Fix account enrollment and operator callback flows with Redis scenarios
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -26,6 +26,7 @@ steps:
|
||||
BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
||||
DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test"
|
||||
TEST_DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test"
|
||||
TEST_REDIS_URL: "redis://redis:6379/15"
|
||||
commands:
|
||||
- pip install -r requirements-dev.txt
|
||||
- python scripts/wait_database.py
|
||||
@@ -61,6 +62,9 @@ steps:
|
||||
status: [success]
|
||||
|
||||
services:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
|
||||
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
|
||||
[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
|
||||
[Проверка счетов и сценариев администратора, кассира и клиента](docs/OPERATOR_SCENARIO_AUDIT.md).
|
||||
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
|
||||
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
|
||||
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
- Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
|
||||
- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
|
||||
|
||||
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
|
||||
Для быстрого добавления отправьте в личный диалог `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`, затем «Добавить в розыгрыш» и нужный розыгрыш. Если карта известна боту, новый счёт будет привязан к клиенту одновременно с добавлением участия. Ведущие нули карты сохраняются. Если счёт уже принадлежит другому клиенту или деактивирован, бот откажет в добавлении. Повторное добавление покажет пропуск без создания второго билета.
|
||||
|
||||
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
|
||||
|
||||
## Проведение и выдача призов
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
3. За один ввод допускается до 1000 распознанных счетов.
|
||||
4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
|
||||
|
||||
Чтобы сразу привязать новый счёт клиенту, отправьте строку `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`. Карта должна быть известна боту. Создание счёта и добавление участия сохраняются вместе. Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору.
|
||||
|
||||
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
|
||||
|
||||
Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю.
|
||||
|
||||
29
docs/OPERATOR_SCENARIO_AUDIT.md
Normal file
29
docs/OPERATOR_SCENARIO_AUDIT.md
Normal 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 и новые ошибки контейнера.
|
||||
@@ -472,16 +472,19 @@ class ParticipationService:
|
||||
results["errors"].append(f"Пользователь {telegram_id} не найден")
|
||||
continue
|
||||
|
||||
first_name, username = user.first_name, user.username
|
||||
# Пробуем добавить
|
||||
if await ParticipationService.add_participant(session, lottery_id, user.id):
|
||||
results["added"] += 1
|
||||
results["details"].append(f"Добавлен: {user.first_name} (@{user.username or 'no_username'})")
|
||||
results["details"].append(f"Добавлен: {first_name} (@{username or 'no_username'})")
|
||||
else:
|
||||
results["skipped"] += 1
|
||||
results["details"].append(f"Уже участвует: {user.first_name}")
|
||||
results["details"].append(f"Не добавлен (повтор или розыгрыш закрыт): {first_name}")
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
|
||||
await session.rollback()
|
||||
from src.utils.errors import public_error
|
||||
results["errors"].append(f"Ошибка с {telegram_id}: {public_error(e)}")
|
||||
|
||||
return results
|
||||
|
||||
@@ -503,15 +506,18 @@ class ParticipationService:
|
||||
results["details"].append(f"Не найден: {telegram_id}")
|
||||
continue
|
||||
|
||||
first_name = user.first_name
|
||||
if await ParticipationService.remove_participant(session, lottery_id, user.id):
|
||||
results["removed"] += 1
|
||||
results["details"].append(f"Удален: {user.first_name}")
|
||||
results["details"].append(f"Удален: {first_name}")
|
||||
else:
|
||||
results["not_found"] += 1
|
||||
results["details"].append(f"Не участвовал: {user.first_name}")
|
||||
results["details"].append(f"Не удалён (нет участия или розыгрыш закрыт): {first_name}")
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
|
||||
await session.rollback()
|
||||
from src.utils.errors import public_error
|
||||
results["errors"].append(f"Ошибка с {telegram_id}: {public_error(e)}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
21
src/filters/numeric_callback.py
Normal file
21
src/filters/numeric_callback.py
Normal 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)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Обработчики для работы со счетами в розыгрышах
|
||||
"""
|
||||
from src.filters.numeric_callback import NumericCallback
|
||||
from src.core.access import is_admin
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
@@ -15,6 +16,9 @@ from ..core.services import LotteryService
|
||||
from .account_services import AccountParticipationService
|
||||
from ..utils.account_utils import parse_accounts_from_message, validate_account_number
|
||||
from typing import List
|
||||
from html import escape
|
||||
from ..utils.text_output import answer_plain_pages, edit_plain_pages
|
||||
from ..utils.account_input import parse_account_records
|
||||
|
||||
|
||||
# Состояния FSM для работы со счетами
|
||||
@@ -47,11 +51,14 @@ async def detect_account_input(message: Message, state: FSMContext):
|
||||
raise SkipHandler()
|
||||
|
||||
# Парсим счета из сообщения
|
||||
accounts = parse_accounts_from_message(message.text)
|
||||
accounts, errors = parse_account_records(message.text)
|
||||
|
||||
if not accounts:
|
||||
from aiogram.dispatcher.event.bases import SkipHandler
|
||||
raise SkipHandler()
|
||||
if errors:
|
||||
await answer_plain_pages(message, "Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
|
||||
return
|
||||
|
||||
# Извлекаем номера клубных карт и определяем владельцев
|
||||
from ..core.services import UserService
|
||||
@@ -119,9 +126,9 @@ async def detect_account_input(message: Message, state: FSMContext):
|
||||
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
|
||||
|
||||
text = (
|
||||
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
|
||||
f"Найдено: <b>{count}</b>\n"
|
||||
f"Владельцев определено: <b>{owners_found}</b>\n\n"
|
||||
f"🔍 Обнаружен ввод счет{'а' if count == 1 else 'ов'}\n\n"
|
||||
f"Найдено: {count}\n"
|
||||
f"Владельцев определено: {owners_found}\n\n"
|
||||
f"{accounts_text}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
@@ -142,7 +149,8 @@ async def detect_account_input(message: Message, state: FSMContext):
|
||||
)]
|
||||
])
|
||||
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
# Owner names and long pasted lists must not break Telegram markup/limits.
|
||||
await answer_plain_pages(message, text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@account_router.callback_query(F.data == "account_action:cancel")
|
||||
@@ -159,6 +167,9 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||
return
|
||||
if not (await state.get_data()).get("detected_accounts"):
|
||||
await callback.answer("Данные устарели. Отправьте счета заново.", show_alert=True)
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем активные розыгрыши
|
||||
@@ -197,7 +208,7 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@account_router.callback_query(F.data.startswith("add_accounts_to:"))
|
||||
@account_router.callback_query(NumericCallback('add_accounts_to:'))
|
||||
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавление счетов в выбранный розыгрыш"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -229,29 +240,31 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# A skipped account rolls back the session and expires ORM objects.
|
||||
lottery_title = lottery.title
|
||||
# Добавляем счета
|
||||
results = await AccountParticipationService.add_accounts_bulk(
|
||||
session, lottery_id, accounts
|
||||
)
|
||||
|
||||
# Формируем результат
|
||||
text = f"<b>Результаты добавления в розыгрыш:</b>\n<i>{lottery.title}</i>\n\n"
|
||||
text += f"✅ Добавлено: <b>{results['added']}</b>\n"
|
||||
text += f"⚠️ Пропущено: <b>{results['skipped']}</b>\n\n"
|
||||
await state.clear()
|
||||
text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n"
|
||||
text += f"✅ Добавлено: {results['added']}\n"
|
||||
text += f"⚠️ Пропущено: {results['skipped']}\n\n"
|
||||
|
||||
if results['details']:
|
||||
text += "<b>Детали:</b>\n"
|
||||
text += "Детали:\n"
|
||||
text += "\n".join(results['details'][:20]) # Показываем первые 20
|
||||
|
||||
if len(results['details']) > 20:
|
||||
text += f"\n... и ещё {len(results['details']) - 20}"
|
||||
|
||||
if results['errors']:
|
||||
text += f"\n\n<b>Ошибки:</b>\n"
|
||||
text += f"\n\nОшибки:\n"
|
||||
text += "\n".join(results['errors'][:10])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML")
|
||||
await state.clear()
|
||||
await edit_plain_pages(callback.message, text)
|
||||
await callback.answer("✅ Готово!")
|
||||
|
||||
|
||||
@@ -276,8 +289,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем все розыгрыши (активные и завершенные)
|
||||
lotteries = await LotteryService.get_all_lotteries(session, limit=30)
|
||||
lotteries = await LotteryService.get_active_lotteries(session, limit=30)
|
||||
|
||||
if not lotteries:
|
||||
await callback.message.edit_text(
|
||||
@@ -316,7 +328,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@account_router.callback_query(F.data.startswith("winner_lottery:"))
|
||||
@account_router.callback_query(NumericCallback('winner_lottery:'))
|
||||
async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор места для победителя"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -324,6 +336,10 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
return
|
||||
|
||||
lottery_id = int(callback.data.split(":")[1])
|
||||
accounts = (await state.get_data()).get("detected_accounts", [])
|
||||
if len(accounts) != 1:
|
||||
await callback.answer("Данные устарели. Отправьте один счёт заново.", show_alert=True)
|
||||
return
|
||||
|
||||
# Сохраняем ID розыгрыша
|
||||
await state.update_data(winner_lottery_id=lottery_id)
|
||||
@@ -369,7 +385,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👑 <b>Установка победителя</b>\n\n"
|
||||
f"Розыгрыш: <i>{lottery.title}</i>\n"
|
||||
f"Розыгрыш: <i>{escape(lottery.title)}</i>\n"
|
||||
f"Счет: <code>{account}</code>\n\n"
|
||||
f"Выберите место:",
|
||||
reply_markup=keyboard,
|
||||
@@ -379,7 +395,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@account_router.callback_query(F.data.startswith("winner_place:"))
|
||||
@account_router.callback_query(F.data.regexp(r"^winner_place:[0-9]{1,3}$"))
|
||||
async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
||||
"""Установка счета как победителя"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -390,7 +406,8 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
# Получаем данные
|
||||
data = await state.get_data()
|
||||
account = data.get("detected_accounts", [])[0]
|
||||
accounts = data.get("detected_accounts", [])
|
||||
account = accounts[0] if len(accounts) == 1 else None
|
||||
lottery_id = data.get("winner_lottery_id")
|
||||
|
||||
if not account or not lottery_id:
|
||||
@@ -404,6 +421,14 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer("Розыгрыш не найден", show_alert=True)
|
||||
return
|
||||
if not 1 <= place <= len(lottery.prizes or [None]):
|
||||
await callback.answer("Некорректное призовое место", show_alert=True)
|
||||
return
|
||||
lottery_title = lottery.title
|
||||
|
||||
# Получаем приз для этого места
|
||||
prize = None
|
||||
@@ -416,20 +441,20 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
||||
session, lottery_id, account, place, prize
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
if result["success"]:
|
||||
text = (
|
||||
f"✅ <b>Победитель установлен!</b>\n\n"
|
||||
f"Розыгрыш: <i>{lottery.title}</i>\n"
|
||||
f"Розыгрыш: <i>{escape(lottery_title)}</i>\n"
|
||||
f"Счет: <code>{account}</code>\n"
|
||||
f"Место: <b>{place}</b>\n"
|
||||
)
|
||||
if prize:
|
||||
text += f"Приз: <i>{prize}</i>"
|
||||
text += f"Приз: <i>{escape(prize)}</i>"
|
||||
|
||||
await callback.answer("✅ Победитель установлен!", show_alert=True)
|
||||
else:
|
||||
text = f"❌ {result['message']}"
|
||||
text = f"❌ {escape(result['message'])}"
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML")
|
||||
await state.clear()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from ..core.models import Lottery, Participation, Winner
|
||||
from ..core.models import Account, Lottery, Participation, User, Winner
|
||||
from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..core.transactions import lock_open_lottery
|
||||
@@ -78,15 +78,22 @@ class AccountParticipationService:
|
||||
"account_number": formatted_account
|
||||
}
|
||||
|
||||
# Получаем запись Account и владельца
|
||||
from ..core.registration_services import AccountService
|
||||
from ..core.models import Account
|
||||
|
||||
user = await AccountService.get_account_owner(session, formatted_account)
|
||||
account_record = await session.execute(
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
account_record = account_record.scalar_one_or_none()
|
||||
# Register a new ticket for the supplied card in the same transaction.
|
||||
# Conflict handling also protects two cashiers adding it to different draws.
|
||||
account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account))
|
||||
if card_number and not account_record:
|
||||
owner_id = await session.scalar(select(User.id).where(User.club_card_number == card_number))
|
||||
if owner_id is None:
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Владелец клубной карты не найден. Сначала зарегистрируйте клиента.",
|
||||
"account_number": formatted_account}
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
await session.execute(insert(Account).values(account_number=formatted_account, owner_id=owner_id, is_active=True)
|
||||
.on_conflict_do_nothing(index_elements=[Account.account_number]))
|
||||
account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account))
|
||||
user = await session.get(User, account_record.owner_id) if account_record else None
|
||||
if account_record and not account_record.is_active:
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Счет неактивен", "account_number": formatted_account}
|
||||
@@ -298,6 +305,17 @@ class AccountParticipationService:
|
||||
"success": False,
|
||||
"message": f"❌ Счет {formatted_account}{card_info} не участвует в розыгрыше"
|
||||
}
|
||||
if card_number:
|
||||
from ..core.registration_services import AccountService
|
||||
owner = await AccountService.get_account_owner(session, formatted_account)
|
||||
if not owner or owner.club_card_number != card_number:
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Карта не соответствует владельцу счета"}
|
||||
if await session.scalar(select(Winner.id).where(
|
||||
Winner.lottery_id == lottery_id, Winner.account_number == formatted_account, Winner.place != place
|
||||
).limit(1)):
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Счет уже назначен на другое призовое место"}
|
||||
|
||||
# Проверяем, не занято ли уже это место
|
||||
existing_winner = await session.execute(
|
||||
@@ -311,7 +329,7 @@ class AccountParticipationService:
|
||||
if existing_winner:
|
||||
# Обновляем существующего победителя
|
||||
existing_winner.account_number = formatted_account
|
||||
existing_winner.user_id = None
|
||||
existing_winner.user_id = participation.user_id
|
||||
existing_winner.is_manual = True
|
||||
if prize:
|
||||
existing_winner.prize = prize
|
||||
@@ -320,7 +338,7 @@ class AccountParticipationService:
|
||||
winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
account_number=formatted_account,
|
||||
user_id=None,
|
||||
user_id=participation.user_id,
|
||||
place=place,
|
||||
prize=prize,
|
||||
is_manual=True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Админские обработчики для управления счетами и верификации"""
|
||||
from src.filters.numeric_callback import NumericCallback
|
||||
from src.utils.account_input import parse_account_records
|
||||
from src.utils.text_output import answer_plain_pages
|
||||
from src.utils.text_output import answer_plain_pages, edit_plain_pages
|
||||
from src.utils.input_validation import numeric_id
|
||||
from src.filters.dialog_input import NotCommand
|
||||
from src.utils.errors import public_error
|
||||
@@ -332,7 +333,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("add_to_lottery_"))
|
||||
@router.callback_query(NumericCallback('add_to_lottery_'))
|
||||
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить счета в выбранный розыгрыш"""
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
@@ -356,12 +357,14 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
lottery_title = lottery.title
|
||||
from .account_services import AccountParticipationService
|
||||
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts])
|
||||
success_count = result['added']
|
||||
errors = result['errors']
|
||||
|
||||
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
|
||||
await state.clear()
|
||||
text = f"📊 Добавление в розыгрыш '{lottery_title}'\n\n"
|
||||
|
||||
if success_count:
|
||||
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
||||
@@ -371,8 +374,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
for error in errors[:3]:
|
||||
text += f"• {error}\n"
|
||||
|
||||
await callback.message.edit_text(text)
|
||||
await state.clear()
|
||||
await edit_plain_pages(callback.message, text)
|
||||
await callback.answer("Готово")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "skip_lottery_add")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Расширенная админ-панель для управления розыгрышами
|
||||
"""
|
||||
from src.filters.numeric_callback import NumericCallback
|
||||
from src.utils.input_validation import lottery_field, numeric_id
|
||||
from src.utils.text_output import answer_plain_pages
|
||||
from html import escape
|
||||
@@ -33,6 +34,14 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _winner_name(winner):
|
||||
if winner.account_number:
|
||||
return f"Счет: {winner.account_number}"
|
||||
if winner.user:
|
||||
return f"@{winner.user.username}" if winner.user.username else (winner.user.first_name or str(winner.user_id))
|
||||
return f"ID: {winner.user_id or 'не привязан'}"
|
||||
|
||||
|
||||
async def safe_edit_message(
|
||||
callback: CallbackQuery,
|
||||
text: str,
|
||||
@@ -524,7 +533,7 @@ async def list_all_lotteries(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_lottery_detail_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_lottery_detail_'))
|
||||
async def show_lottery_detail(callback: CallbackQuery):
|
||||
"""Детальная информация о розыгрыше"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -578,7 +587,7 @@ async def show_lottery_detail(callback: CallbackQuery):
|
||||
|
||||
# Безопасная обработка победителя - может быть без user_id
|
||||
if winner.user:
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
else:
|
||||
# Победитель по номеру счета без связанного пользователя
|
||||
username = f"Счет: {winner.account_number}"
|
||||
@@ -621,7 +630,7 @@ async def show_participant_management(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=get_participant_management_keyboard())
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_participants_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_participants_'))
|
||||
async def show_lottery_participants(callback: CallbackQuery):
|
||||
"""Показать участников конкретного розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -738,7 +747,7 @@ async def start_add_participant(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_add_part_to_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_add_part_to_'))
|
||||
async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор пользователя для добавления"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -750,6 +759,10 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
|
||||
text = f"➕ Добавление в: {lottery.title}\n\n"
|
||||
text += "Введите Telegram ID или username пользователя:\n\n"
|
||||
@@ -794,9 +807,14 @@ async def process_add_participant(message: Message, state: FSMContext):
|
||||
)
|
||||
return
|
||||
|
||||
user_first_name = user.first_name
|
||||
# Добавляем участника
|
||||
success = await ParticipationService.add_participant(session, lottery_id, user.id)
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await message.answer('Розыгрыш не найден. Откройте меню заново.')
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -814,7 +832,7 @@ async def process_add_participant(message: Message, state: FSMContext):
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
f"⚠️ Пользователь {user.first_name} уже участвует в этом розыгрыше",
|
||||
f"⚠️ Пользователь {user_first_name} уже участвует либо розыгрыш закрыт",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")]
|
||||
])
|
||||
@@ -856,7 +874,7 @@ async def remove_participant_start(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_remove_part_from_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_remove_part_from_'))
|
||||
async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор розыгрыша для удаления участника"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -926,12 +944,12 @@ async def process_remove_participant(message: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user_first_name = user.first_name
|
||||
username = f"@{user.username}" if user.username else "Нет username"
|
||||
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
|
||||
|
||||
await state.clear()
|
||||
|
||||
username = f"@{user.username}" if user.username else "Нет username"
|
||||
|
||||
if removed:
|
||||
await message.answer(
|
||||
f"✅ Участник удалён из розыгрыша!\n\n"
|
||||
@@ -945,7 +963,7 @@ async def process_remove_participant(message: Message, state: FSMContext):
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
f"⚠️ Пользователь {user.first_name} не участвует в этом розыгрыше",
|
||||
f"⚠️ Пользователь {user_first_name} не участвует либо розыгрыш закрыт",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")]
|
||||
])
|
||||
@@ -1254,7 +1272,7 @@ async def start_bulk_add_participant(callback: CallbackQuery, state: FSMContext)
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_bulk_add_to_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_bulk_add_to_'))
|
||||
async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор пользователей для массового добавления"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -1266,6 +1284,10 @@ async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
|
||||
text = f"📥 Массовое добавление в: {lottery.title}\n\n"
|
||||
text += "Введите список Telegram ID или username через запятую:\n\n"
|
||||
@@ -1319,6 +1341,10 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
|
||||
return
|
||||
results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await message.answer('Розыгрыш не найден. Откройте меню заново.')
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -1391,7 +1417,7 @@ async def start_bulk_remove_participant(callback: CallbackQuery, state: FSMConte
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_bulk_remove_from_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_bulk_remove_from_'))
|
||||
async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор пользователей для массового удаления"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -1403,6 +1429,10 @@ async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
|
||||
text = f"📤 Массовое удаление из: {lottery.title}\n\n"
|
||||
text += "Введите список Telegram ID или username через запятую:\n\n"
|
||||
@@ -1456,6 +1486,10 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
||||
return
|
||||
results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await message.answer('Розыгрыш не найден. Откройте меню заново.')
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -1484,7 +1518,7 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
||||
# ДОБАВЛЕНИЕ/УДАЛЕНИЕ УЧАСТНИКОВ В КОНКРЕТНОМ РОЗЫГРЫШЕ
|
||||
# ======================
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_add_to_'))
|
||||
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавление участника в конкретный розыгрыш"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -1565,36 +1599,15 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
lottery_title = lottery.title
|
||||
# Добавляем участника
|
||||
if user:
|
||||
success = await ParticipationService.add_participant(session, lottery_id, user.id)
|
||||
name = f"@{user.username}" if user.username else f"{user.first_name} (ID: {user.telegram_id})"
|
||||
success = await ParticipationService.add_participant(session, lottery_id, user.id)
|
||||
else:
|
||||
# Добавление по номеру счета
|
||||
from sqlalchemy import select
|
||||
from ..core.models import Participation
|
||||
|
||||
# Проверяем, не добавлен ли уже этот счет
|
||||
existing = await session.execute(
|
||||
select(Participation).where(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_number == account_number
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
await message.answer(
|
||||
f"⚠️ Счет {account_number} уже участвует в этом розыгрыше",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
participation = Participation(lottery_id=lottery_id, account_number=account_number)
|
||||
session.add(participation)
|
||||
await session.commit()
|
||||
success = True
|
||||
from .account_services import AccountParticipationService
|
||||
result = await AccountParticipationService.add_account_to_lottery(session, lottery_id, account_number)
|
||||
success = result["success"]
|
||||
name = f"Счет: {account_number}"
|
||||
|
||||
await state.clear()
|
||||
@@ -1603,7 +1616,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
await message.answer(
|
||||
f"✅ Участник добавлен!\n\n"
|
||||
f"👤 {name}\n"
|
||||
f"🎯 Розыгрыш: {lottery.title}",
|
||||
f"🎯 Розыгрыш: {lottery_title}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ Добавить ещё", callback_data=f"admin_add_to_{lottery_id}")],
|
||||
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")]
|
||||
@@ -1611,14 +1624,14 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
f"⚠️ Участник уже добавлен в этот розыгрыш",
|
||||
result["message"] if account_number else "⚠️ Участник уже добавлен либо розыгрыш закрыт",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_remove_from_'))
|
||||
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Удаление участника из конкретного розыгрыша"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -1671,6 +1684,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
lottery_title = lottery.title
|
||||
removed = False
|
||||
name = user_input
|
||||
|
||||
@@ -1679,36 +1693,25 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
username = user_input[1:]
|
||||
user = await UserService.get_user_by_username(session, username)
|
||||
if user:
|
||||
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
|
||||
name = f"@{user.username}" if user.username else f"{user.first_name}"
|
||||
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
|
||||
elif user_input.isdigit():
|
||||
# Telegram ID
|
||||
telegram_id = int(user_input)
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
if user:
|
||||
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
|
||||
name = f"@{user.username}" if user and user.username else f"ID: {telegram_id}"
|
||||
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
|
||||
elif '-' in user_input:
|
||||
# Номер счета
|
||||
from sqlalchemy import select, delete
|
||||
from ..core.models import Participation
|
||||
|
||||
from src.utils.account_utils import parse_accounts_from_message
|
||||
accounts = parse_accounts_from_message(user_input)
|
||||
if accounts:
|
||||
account_number = accounts[0]
|
||||
result = await session.execute(
|
||||
select(Participation).where(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_number == account_number
|
||||
)
|
||||
)
|
||||
participation = result.scalar_one_or_none()
|
||||
if participation:
|
||||
await session.delete(participation)
|
||||
await session.commit()
|
||||
removed = True
|
||||
name = f"Счет: {account_number}"
|
||||
account_number = accounts[0].split()[-1]
|
||||
from .account_services import AccountParticipationService
|
||||
result = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, account_number)
|
||||
removed = result["success"]
|
||||
name = f"Счет: {account_number}"
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -1716,7 +1719,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
await message.answer(
|
||||
f"✅ Участник удалён!\n\n"
|
||||
f"👤 {name}\n"
|
||||
f"🎯 Розыгрыш: {lottery.title}",
|
||||
f"🎯 Розыгрыш: {lottery_title}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➖ Удалить ещё", callback_data=f"admin_remove_from_{lottery_id}")],
|
||||
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")]
|
||||
@@ -1724,7 +1727,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
f"⚠️ Участник не найден в этом розыгрыше",
|
||||
"⚠️ Участник не найден либо розыгрыш закрыт",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
|
||||
])
|
||||
@@ -1735,7 +1738,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
# ПРОВЕРКА ПОБЕДИТЕЛЕЙ И ПОВТОРНЫЙ РОЗЫГРЫШ
|
||||
# ======================
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_check_winners_'))
|
||||
async def check_winners(callback: CallbackQuery):
|
||||
"""Проверка подтверждения победителей"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -1781,7 +1784,7 @@ async def check_winners(callback: CallbackQuery):
|
||||
if winner.account_number:
|
||||
name = f"Счет: {winner.account_number}"
|
||||
elif winner.user:
|
||||
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
name = _winner_name(winner)
|
||||
else:
|
||||
name = f"ID: {winner.user_id}"
|
||||
|
||||
@@ -1804,7 +1807,7 @@ async def check_winners(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_redraw_'))
|
||||
async def redraw_lottery(callback: CallbackQuery):
|
||||
"""Повторный розыгрыш для неподтверждённых призов"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -1837,7 +1840,7 @@ async def redraw_lottery(callback: CallbackQuery):
|
||||
if winner.account_number:
|
||||
name = f"Счет: {winner.account_number}"
|
||||
elif winner.user:
|
||||
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
name = _winner_name(winner)
|
||||
else:
|
||||
name = f"ID: {winner.user_id}"
|
||||
|
||||
@@ -1855,7 +1858,7 @@ async def redraw_lottery(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_redraw_confirm_'))
|
||||
async def confirm_redraw(callback: CallbackQuery):
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
@@ -1880,7 +1883,7 @@ async def confirm_redraw(callback: CallbackQuery):
|
||||
# УДАЛЕНИЕ РОЗЫГРЫША
|
||||
# ======================
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_del_lottery_'))
|
||||
async def delete_lottery_confirm(callback: CallbackQuery):
|
||||
"""Подтверждение удаления розыгрыша"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -1914,7 +1917,7 @@ async def delete_lottery_confirm(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_del_lottery_yes_'))
|
||||
async def delete_lottery_execute(callback: CallbackQuery):
|
||||
"""Выполнение удаления розыгрыша"""
|
||||
if not await is_admin(callback.from_user.id):
|
||||
@@ -1987,7 +1990,7 @@ async def start_bulk_add_accounts(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_bulk_add_accounts_to_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_bulk_add_accounts_to_'))
|
||||
async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор номеров счетов для массового добавления"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -1999,6 +2002,10 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
|
||||
text = f"🏦 Массовое добавление в: {lottery.title}\n\n"
|
||||
text += "Введите список номеров счетов через запятую или новую строку:\n\n"
|
||||
@@ -2039,6 +2046,10 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
|
||||
# Массовое добавление по номерам счетов
|
||||
results = await ParticipationService.add_participants_by_accounts_bulk(session, lottery_id, account_inputs)
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await message.answer('Розыгрыш не найден. Откройте меню заново.')
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -2116,7 +2127,7 @@ async def start_bulk_remove_accounts(callback: CallbackQuery, state: FSMContext)
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_bulk_remove_accounts_from_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_bulk_remove_accounts_from_'))
|
||||
async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор номеров счетов для массового удаления"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2128,6 +2139,10 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
|
||||
text = f"🏦 Массовое удаление из: {lottery.title}\n\n"
|
||||
text += "Введите список номеров счетов через запятую или новую строку:\n\n"
|
||||
@@ -2167,6 +2182,10 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext):
|
||||
# Массовое удаление по номерам счетов
|
||||
results = await ParticipationService.remove_participants_by_accounts_bulk(session, lottery_id, account_inputs)
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await message.answer('Розыгрыш не найден. Откройте меню заново.')
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -2254,7 +2273,7 @@ async def show_participants_by_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_participants_report")
|
||||
@admin_router.callback_query(F.data == "admin_participants_summary")
|
||||
async def show_participants_report(callback: CallbackQuery):
|
||||
"""Отчет по участникам"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2358,7 +2377,7 @@ async def start_edit_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_edit_field_"))
|
||||
@admin_router.callback_query(NumericCallback("admin_edit_field_", suffixes=("title", "description", "prizes")))
|
||||
async def handle_edit_field(callback: CallbackQuery, state: FSMContext):
|
||||
"""Обработка выбора поля для редактирования"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2399,7 +2418,7 @@ async def handle_edit_field(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_edit_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_edit_'))
|
||||
async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Редирект на редактирование розыгрыша из детального просмотра"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2420,7 +2439,7 @@ async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
await choose_edit_field(callback, state)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_edit_lottery_select_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_edit_lottery_select_'))
|
||||
async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор поля для редактирования"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2466,7 +2485,7 @@ async def show_edit_fields(message: Message, lottery_id: int, edit=False):
|
||||
await (message.edit_text if edit else message.answer)(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_toggle_active_'))
|
||||
async def toggle_lottery_active(callback: CallbackQuery, state: FSMContext):
|
||||
"""Переключить активность розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2530,7 +2549,7 @@ async def start_finish_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_confirm_finish_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_confirm_finish_'))
|
||||
async def confirm_finish_lottery(callback: CallbackQuery):
|
||||
"""Подтвердить завершение розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2541,6 +2560,9 @@ async def confirm_finish_lottery(callback: CallbackQuery):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
count = await ParticipationService.get_participants_count(session, lottery_id)
|
||||
|
||||
text = f"🏁 Завершение розыгрыша\n\n"
|
||||
@@ -2559,7 +2581,7 @@ async def confirm_finish_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_do_finish_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_do_finish_'))
|
||||
async def do_finish_lottery(callback: CallbackQuery):
|
||||
"""Выполнить завершение розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2630,7 +2652,7 @@ async def start_delete_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_confirm_delete_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_confirm_delete_'))
|
||||
async def confirm_delete_lottery(callback: CallbackQuery):
|
||||
"""Подтвердить удаление розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2641,6 +2663,9 @@ async def confirm_delete_lottery(callback: CallbackQuery):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
count = await ParticipationService.get_participants_count(session, lottery_id)
|
||||
|
||||
text = f"🗑️ Удаление розыгрыша\n\n"
|
||||
@@ -2662,7 +2687,7 @@ async def confirm_delete_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_do_delete_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_do_delete_'))
|
||||
async def do_delete_lottery(callback: CallbackQuery):
|
||||
"""Выполнить удаление розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2673,6 +2698,9 @@ async def do_delete_lottery(callback: CallbackQuery):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
lottery_title = lottery.title
|
||||
|
||||
success = await LotteryService.delete_lottery(session, lottery_id)
|
||||
@@ -2757,7 +2785,7 @@ async def start_set_manual_winner(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_set_winner_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_set_winner_'))
|
||||
async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Обработчик для кнопки 'Установить победителя' из карточки розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2771,7 +2799,7 @@ async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMCont
|
||||
await choose_winner_place(callback, state)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_choose_winner_lottery_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_choose_winner_lottery_'))
|
||||
async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор места для победителя"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -2929,12 +2957,16 @@ async def process_winner_user(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
success = await LotteryService.set_manual_winner(
|
||||
session,
|
||||
data['lottery_id'],
|
||||
data['place'],
|
||||
telegram_id
|
||||
)
|
||||
if is_account:
|
||||
from .account_services import AccountParticipationService
|
||||
result = await AccountParticipationService.set_account_as_winner(
|
||||
session, data['lottery_id'], user_input, data['place']
|
||||
)
|
||||
success = result["success"]
|
||||
else:
|
||||
success = await LotteryService.set_manual_winner(
|
||||
session, data['lottery_id'], data['place'], telegram_id
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -3008,7 +3040,7 @@ async def list_all_winners(callback: CallbackQuery):
|
||||
text += f"📅 {lottery.created_at.strftime('%d.%m.%Y')}\n"
|
||||
|
||||
for winner in sorted(winners_list, key=lambda w: w.place):
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
manual_mark = "🔧" if winner.is_manual else "🎲"
|
||||
text += f" {manual_mark} {winner.place} место: {username} - {winner.prize}\n"
|
||||
|
||||
@@ -3072,7 +3104,7 @@ async def edit_winner_start(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_edit_winner_lottery_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_edit_winner_lottery_'))
|
||||
async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор места победителя для редактирования"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3083,6 +3115,10 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
if not winners:
|
||||
@@ -3093,7 +3129,7 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext):
|
||||
buttons = []
|
||||
|
||||
for winner in winners:
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"🏆 {winner.place} место: {username} - {winner.prize}",
|
||||
@@ -3106,7 +3142,7 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_edit_winner_id_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_edit_winner_id_'))
|
||||
async def edit_winner_details(callback: CallbackQuery):
|
||||
"""Показать детали победителя (пока просто информационное сообщение)"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3128,7 +3164,7 @@ async def edit_winner_details(callback: CallbackQuery):
|
||||
await callback.answer("❌ Победитель не найден", show_alert=True)
|
||||
return
|
||||
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
manual_mark = "🔧 Установлен вручную" if winner.is_manual else "🎲 Выбран случайно"
|
||||
|
||||
text = f"📝 Информация о победителе\n\n"
|
||||
@@ -3136,7 +3172,7 @@ async def edit_winner_details(callback: CallbackQuery):
|
||||
text += f"🏆 Место: {winner.place}\n"
|
||||
text += f"💰 Приз: {winner.prize}\n"
|
||||
text += f"👤 Пользователь: {username}\n"
|
||||
text += f"🆔 ID: {winner.user.telegram_id}\n"
|
||||
text += f"🆔 ID: {winner.user.telegram_id if winner.user else '-'}\n"
|
||||
text += f"📊 Тип: {manual_mark}\n"
|
||||
text += f"📅 Дата: {winner.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
text += "ℹ️ Редактирование победителей доступно через удаление и повторное добавление."
|
||||
@@ -3195,7 +3231,7 @@ async def remove_winner_start(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_remove_winner_lottery_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_remove_winner_lottery_'))
|
||||
async def remove_winner_select_place(callback: CallbackQuery):
|
||||
"""Выбор победителя для удаления"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3206,6 +3242,9 @@ async def remove_winner_select_place(callback: CallbackQuery):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
if not winners:
|
||||
@@ -3216,7 +3255,7 @@ async def remove_winner_select_place(callback: CallbackQuery):
|
||||
buttons = []
|
||||
|
||||
for winner in winners:
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"🏆 {winner.place} место: {username} - {winner.prize}",
|
||||
@@ -3229,7 +3268,7 @@ async def remove_winner_select_place(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_confirm_remove_winner_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_confirm_remove_winner_'))
|
||||
async def confirm_remove_winner(callback: CallbackQuery):
|
||||
"""Подтверждение удаления победителя"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3251,7 +3290,7 @@ async def confirm_remove_winner(callback: CallbackQuery):
|
||||
await callback.answer("❌ Победитель не найден", show_alert=True)
|
||||
return
|
||||
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
|
||||
text = f"⚠️ Подтверждение удаления\n\n"
|
||||
text += f"Вы действительно хотите удалить победителя?\n\n"
|
||||
@@ -3271,7 +3310,7 @@ async def confirm_remove_winner(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_do_remove_winner_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_do_remove_winner_'))
|
||||
async def do_remove_winner(callback: CallbackQuery):
|
||||
"""Выполнение удаления победителя"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3296,7 +3335,7 @@ async def do_remove_winner(callback: CallbackQuery):
|
||||
return
|
||||
|
||||
lottery_id = winner.lottery_id
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
|
||||
# Удаляем победителя
|
||||
from src.core.models import WinnerVerification
|
||||
@@ -3358,7 +3397,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.regexp(r"^admin_conduct_\d+$"))
|
||||
@admin_router.callback_query(NumericCallback("admin_conduct_"))
|
||||
async def conduct_lottery_draw_confirm(callback: CallbackQuery):
|
||||
"""Запрос подтверждения проведения розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3413,7 +3452,7 @@ async def conduct_lottery_draw_confirm(callback: CallbackQuery):
|
||||
await safe_edit_message(callback, text, InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_conduct_confirmed_'))
|
||||
async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
"""Проведение розыгрыша после подтверждения"""
|
||||
logger.info(f"🎯 conduct_lottery_draw HANDLER TRIGGERED! data={callback.data}, user={callback.from_user.id}")
|
||||
@@ -3487,7 +3526,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
if winner.account_number:
|
||||
text += f"{winner.place} место: {winner.account_number}\n"
|
||||
elif winner.user:
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
username = _winner_name(winner)
|
||||
text += f"{winner.place} место: {username}\n"
|
||||
else:
|
||||
text += f"{winner.place} место: ID {winner.user_id}\n"
|
||||
@@ -3903,7 +3942,7 @@ async def show_winner_display_settings(callback: CallbackQuery, state: FSMContex
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_set_display_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_set_display_'))
|
||||
async def choose_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор типа отображения для конкретного розыгрыша"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -3915,6 +3954,10 @@ async def choose_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
|
||||
current_type = getattr(lottery, 'winner_display_type', 'username')
|
||||
|
||||
@@ -3937,7 +3980,7 @@ async def choose_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_apply_display_"))
|
||||
@admin_router.callback_query(NumericCallback("admin_apply_display_", suffixes=("username", "chat_id", "account_number")))
|
||||
async def apply_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
"""Применить выбранный тип отображения"""
|
||||
import logging
|
||||
@@ -3967,6 +4010,10 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
logger.info(f"💾 Результат сохранения: {success}")
|
||||
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await state.clear()
|
||||
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
|
||||
return
|
||||
logger.info(f"📋 Получен розыгрыш: {lottery.title if lottery else 'None'}")
|
||||
|
||||
if success:
|
||||
@@ -4078,7 +4125,7 @@ async def show_recent_messages(callback: CallbackQuery, page: int = 0):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_message_view_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_message_view_'))
|
||||
async def view_message(callback: CallbackQuery):
|
||||
"""Просмотр конкретного сообщения"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -4164,7 +4211,7 @@ async def view_message(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_message_delete_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_message_delete_'))
|
||||
async def delete_message(callback: CallbackQuery):
|
||||
"""Удалить сообщение пользователя"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -4230,7 +4277,7 @@ async def delete_message(callback: CallbackQuery):
|
||||
await show_recent_messages(callback, 0)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_messages_user_"))
|
||||
@admin_router.callback_query(NumericCallback('admin_messages_user_'))
|
||||
async def show_user_messages(callback: CallbackQuery):
|
||||
"""Показать все сообщения конкретного пользователя"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -4335,7 +4382,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
if winner.user:
|
||||
nickname = winner.user.nickname
|
||||
if not nickname:
|
||||
display_name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
display_name = _winner_name(winner)
|
||||
elif winner.account_number:
|
||||
from ..core.registration_services import AccountService
|
||||
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||
@@ -4630,7 +4677,7 @@ async def broadcast_type_channel_or_group(callback: CallbackQuery, state: FSMCon
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("broadcast_select_channel_"))
|
||||
@admin_router.callback_query(NumericCallback('broadcast_select_channel_'))
|
||||
async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбран канал/группа - запрос сообщения"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -5315,7 +5362,7 @@ async def admin_users_search_process(message: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_users_list:"))
|
||||
@admin_router.callback_query(NumericCallback('admin_users_list:'))
|
||||
async def admin_users_list(callback: CallbackQuery, state: FSMContext):
|
||||
"""Список всех пользователей с пагинацией"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -5374,7 +5421,7 @@ async def admin_users_list(callback: CallbackQuery, state: FSMContext):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_users_banned:"))
|
||||
@admin_router.callback_query(NumericCallback('admin_users_banned:'))
|
||||
async def admin_users_banned_list(callback: CallbackQuery, state: FSMContext):
|
||||
"""Список заблокированных пользователей"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -5438,7 +5485,7 @@ async def admin_users_banned_list(callback: CallbackQuery, state: FSMContext):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_user_view:"))
|
||||
@admin_router.callback_query(NumericCallback('admin_user_view:'))
|
||||
async def admin_user_view(callback: CallbackQuery, state: FSMContext):
|
||||
"""Просмотр информации о пользователе"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -5482,7 +5529,7 @@ async def admin_user_view(callback: CallbackQuery, state: FSMContext):
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_user_ban:"))
|
||||
@admin_router.callback_query(NumericCallback('admin_user_ban:'))
|
||||
async def admin_user_ban(callback: CallbackQuery, state: FSMContext):
|
||||
"""Заблокировать пользователя в чате"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -5504,7 +5551,7 @@ async def admin_user_ban(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer("❌ Ошибка блокировки", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_user_unban:"))
|
||||
@admin_router.callback_query(NumericCallback('admin_user_unban:'))
|
||||
async def admin_user_unban(callback: CallbackQuery, state: FSMContext):
|
||||
"""Разблокировать пользователя в чате"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
|
||||
from src.filters.numeric_callback import NumericCallback
|
||||
from src.filters.dialog_input import NotCommand
|
||||
from aiogram import F, Router
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||
@@ -49,10 +50,15 @@ async def cashier_menu_callback(callback: CallbackQuery, state: FSMContext):
|
||||
await cashier_menu.__wrapped__(callback.message, state)
|
||||
|
||||
|
||||
@cashier_router.callback_query(F.data.startswith("cash_add:"))
|
||||
@cashier_router.callback_query(NumericCallback('cash_add:'))
|
||||
async def choose_draw(callback: CallbackQuery, state: FSMContext):
|
||||
lottery_id = int(callback.data.split(":")[1])
|
||||
await state.clear()
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery or not lottery.is_active or lottery.is_completed:
|
||||
await callback.answer("Розыгрыш закрыт или удалён. Откройте /cashier заново.", show_alert=True)
|
||||
return
|
||||
await state.update_data(lottery_id=lottery_id)
|
||||
await state.set_state(CashierStates.accounts)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Explain unmatched input instead of silently dropping it or broadcasting it."""
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
|
||||
input_fallback_router = Router(name="input_fallback")
|
||||
|
||||
|
||||
@input_fallback_router.callback_query()
|
||||
async def unmatched_button(callback: CallbackQuery):
|
||||
await callback.answer("Кнопка устарела или недоступна. Откройте меню заново через /start.", show_alert=True)
|
||||
|
||||
|
||||
@input_fallback_router.message(F.text.lstrip().startswith("/"))
|
||||
async def unknown_command(message: Message):
|
||||
await message.answer("Команда не распознана или недоступна на этом шаге. /help — справка, /cancel — отменить текущий ввод.")
|
||||
|
||||
@@ -10,3 +10,10 @@ async def answer_plain_pages(message, text, **kwargs):
|
||||
await message.answer(text[:end], parse_mode=None)
|
||||
text = text[end:]
|
||||
return await message.answer(text, parse_mode=None, **kwargs)
|
||||
|
||||
|
||||
async def edit_plain_pages(message, text, **kwargs):
|
||||
if len(text) <= 2000:
|
||||
return await message.edit_text(text, parse_mode=None, **kwargs)
|
||||
await message.edit_text("Результат операции — в сообщениях ниже.", parse_mode=None)
|
||||
return await answer_plain_pages(message, text, **kwargs)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""All tests use disposable databases and a synthetic Telegram token."""
|
||||
import os
|
||||
import tempfile
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest_asyncio
|
||||
@@ -12,7 +13,7 @@ os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL") or (
|
||||
os.environ["BOT_TOKEN"] = "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
|
||||
os.environ["ADMIN_IDS"] = "900001"
|
||||
os.environ["CASHIER_IDS"] = "900002"
|
||||
os.environ["REDIS_URL"] = ""
|
||||
os.environ["REDIS_URL"] = os.getenv("TEST_REDIS_URL", "")
|
||||
|
||||
from src.core.database import Base, engine, async_session_maker
|
||||
from src.core import models
|
||||
@@ -20,8 +21,18 @@ from src.core import models
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def database():
|
||||
if os.getenv("TEST_REDIS_URL"):
|
||||
from redis.asyncio import Redis
|
||||
redis = Redis.from_url(os.environ["TEST_REDIS_URL"])
|
||||
# Only the synthetic bot's FSM namespace in the disposable test service.
|
||||
keys = [key async for key in redis.scan_iter(match="fsm:123456:*")]
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
await redis.aclose()
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.drop_all)
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
yield async_session_maker
|
||||
if os.getenv("TEST_REDIS_URL") and "main" in sys.modules:
|
||||
await sys.modules["main"].dp.storage.close()
|
||||
await engine.dispose()
|
||||
|
||||
321
tests/test_operator_scenarios.py
Normal file
321
tests/test_operator_scenarios.py
Normal 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
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import select
|
||||
from src.core.access import get_role
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.models import User
|
||||
from src.core.services import UserService
|
||||
from src.core.services import LotteryService, UserService
|
||||
from src.core.staff_service import StaffError, change_role, get_member
|
||||
from test_dispatcher import dispatch, event
|
||||
|
||||
@@ -104,8 +104,11 @@ async def test_actual_ordinary_admin_and_cashier_cannot_forge_staff_callback(act
|
||||
|
||||
|
||||
async def test_revoked_cashier_cannot_continue_open_participant_dialog():
|
||||
await create_user(819, is_cashier=True)
|
||||
await dispatch(event(819, callback_data="cash_add:1"))
|
||||
cashier = await create_user(819, is_cashier=True)
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.create_lottery(session, "Open draw", "", ["Prize"], cashier.id)
|
||||
lottery_id = lottery.id
|
||||
await dispatch(event(819, callback_data=f"cash_add:{lottery_id}"))
|
||||
await change_role(900001, 819, "user", (False, True))
|
||||
calls = await dispatch(event(819, text="11-22-33-44-55-66-77"))
|
||||
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
|
||||
|
||||
Reference in New Issue
Block a user