import asyncio import functools from datetime import date, datetime, time from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Calendar, Column, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminMenuSG, AdminTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id from trudex.infrastructure.utils.timezone import to_msk @inject async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): tests = await test_dao.get_all() return { "tests": [ (f"{'🟢' if t.is_active else '🔴'} {t.title}", t.id) for t in tests ], "count": len(tests), } async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): manager.dialog_data["selected_test_id"] = int(item_id) await manager.switch_to(AdminTestsSG.test_detail) @inject async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], dialog_manager: DialogManager, **_kwargs): test_id = dialog_manager.dialog_data.get("selected_test_id") if not test_id: return { "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", "results_button_text": "👁 Показать результаты", } test = await test_dao.get_by_id(test_id) questions_count = await test_repo.count_questions_in_test(test_id) if not test: return { "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", "results_button_text": "👁 Показать результаты", } status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" password_str = f"🔒 {test.password}" if test.password else "🔓 Без пароля" attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" expires_str = f"📅 {to_msk(test.expires_at).strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" results_str = "👁 Результаты видны" if test.are_results_viewable else "🔒 Результаты скрыты" test_info = ( f"📝 Информация о тесте\n\n" f"Название:\n
{test.title}\n" f"Описание:\n
{test.description or '—'}\n\n" f"Статус: {status}\n" f"Вопросов: {questions_count}\n" f"Пароль: {password_str}\n" f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" f"Группа: {group_str}\n" f"Видимость: {results_str}\n\n" f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты" return { "test_info": test_info, "is_active": test.is_active, "button_text": button_text, "results_button_text": results_button_text, } @inject async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): test_id = manager.dialog_data.get("selected_test_id") if not test_id: await _callback.answer("❌ Тест не найден") return test = await test_dao.get_by_id(test_id) if test: await test_dao.update(test_id, is_active=not test.is_active) action = "деактивирован" if test.is_active else "активирован" await _callback.answer(f"✅ Тест {action}") await manager.switch_to(AdminTestsSG.test_detail) @inject async def on_toggle_results_viewable(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): test_id = manager.dialog_data.get("selected_test_id") if not test_id: await _callback.answer("❌ Тест не найден") return test = await test_dao.get_by_id(test_id) if test: await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) action = "скрыты" if test.are_results_viewable else "видны" await _callback.answer(f"✅ Результаты теперь {action}") await manager.switch_to(AdminTestsSG.test_detail) async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.tests_list) async def on_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.statistics) @inject async def get_statistics_data( dialog_manager: DialogManager, attempt_repo: FromDishka[TestAttemptRepository], **_kwargs ): test_id = dialog_manager.dialog_data.get("selected_test_id") if not test_id: return {"attempts": [], "count": 0} attempts_with_users = await attempt_repo.get_test_attempts_with_users(test_id) results = [] for attempt, user_name in attempts_with_users: status = "✅" if attempt.is_passed else "❌" finished_at_msk = to_msk(attempt.finished_at) date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "" results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) return { "attempts": results, "count": len(results), } async def on_attempt_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): manager.dialog_data["selected_attempt_id"] = int(item_id) await manager.switch_to(AdminTestsSG.attempt_detail) async def on_back_to_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.statistics) @inject async def get_attempt_detail( dialog_manager: DialogManager, attempt_repo: FromDishka[TestAttemptRepository], test_repo: FromDishka[TestRepository], **_kwargs ): attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") if not attempt_id: return {"attempt_info": "❌ Результат не найден"} attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) if not attempt: return {"attempt_info": "❌ Результат не найден"} status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" finished_at_msk = to_msk(attempt.finished_at) date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📊 Результат прохождения\n", f"📈 Результат: {attempt.score}%", f"📅 Дата: {date_str}", f"🏆 Статус: {status}\n", "📋 Ответы:\n", ] for i, answer in enumerate(answers, 1): question, options = await test_repo.get_question_with_options(answer.question_id) if not question: continue correct_options = [opt for opt in options if opt.is_correct] correct_texts = [opt.text for opt in correct_options] status_icon = "✅" if answer.is_correct else "❌" user_answer = answer.text_answer or "" if "|" in user_answer: user_answer = ", ".join(user_answer.split("|")) lines.append(f"{status_icon} Вопрос {i}") lines.append(f"
{question.text}") lines.append(f"👤 Ответ: {user_answer or '—'}") lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") return {"attempt_info": "\n".join(lines)} @inject async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]): test_id = manager.dialog_data.get("selected_test_id") if not test_id: await _callback.answer("Ошибка: тест не найден") return test_hash = encode_id( test_id, config.security.encode_key, config.security.encoded_string_length ) bot_info = await bot_inst.get_me() bot_username = bot_info.username or "your_bot" share_link = f"https://t.me/{bot_username}?start={test_hash}" loop = asyncio.get_running_loop() qr_bytes = await loop.run_in_executor( None, functools.partial(generate_qr_bytes, share_link) ) assert _callback.message is not None await _callback.message.answer_photo( photo=BufferedInputFile(qr_bytes, filename="qr.png"), caption=f"🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n
{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"
)
async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.edit_menu)
async def on_back_to_detail(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.test_detail)
async def on_back_to_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.edit_menu)
async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.edit_password)
async def on_edit_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.edit_attempts)
async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.edit_group)
async def on_edit_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminTestsSG.edit_expires)
@inject
async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await message.answer("❌ Тест не найден")
return
if not message.text:
await message.answer("❌ Пароль не может быть пустым")
return
password = message.text.strip()
if len(password) > 255:
await message.answer("❌ Пароль слишком длинный (максимум 255 символов)")
return
await test_dao.update(test_id, password=password)
await message.answer("✅ Пароль обновлен")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def on_remove_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, password=None)
await _callback.answer("✅ Пароль удален")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def on_attempts_input_edit(message: Message, _widget: MessageInput, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await message.answer("❌ Тест не найден")
return
if not message.text:
await message.answer("❌ Количество попыток не может быть пустым")
return
attempts_str = message.text.strip()
if not attempts_str.isdigit():
await message.answer("❌ Количество попыток должно быть числом")
return
attempts = int(attempts_str)
if attempts < 1:
await message.answer("❌ Количество попыток должно быть больше 0")
return
if attempts > 100:
await message.answer("❌ Количество попыток не может быть больше 100")
return
await test_dao.update(test_id, attempts=attempts)
await message.answer("✅ Количество попыток обновлено")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def on_remove_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, attempts=None)
await _callback.answer("✅ Ограничение попыток удалено")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def get_groups_for_edit(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs):
groups = await group_dao.get_all()
return {
"groups": [(str(g.number), str(g.number)) for g in groups],
}
@inject
async def on_group_selected_for_test(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, for_group=int(item_id))
await _callback.answer("✅ Группа обновлена")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def on_remove_group(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, for_group=None)
await _callback.answer("✅ Тест теперь доступен для всех групп")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, selected_date: date, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
expires_at = datetime.combine(selected_date, time.min)
await test_dao.update(test_id, expires_at=expires_at)
await _callback.answer("✅ Срок действия обновлен")
await manager.switch_to(AdminTestsSG.test_detail)
@inject
async def on_remove_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, expires_at=None)
await _callback.answer("✅ Срок действия удален")
await manager.switch_to(AdminTestsSG.test_detail)
async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.start(AdminCreateTestSG.input_title, mode=StartMode.RESET_STACK)
async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK)
tests_dialog = Dialog(
Window(
Format("📝 Тесты\n\nВсего: {count}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="test_select",
item_id_getter=lambda x: x[1],
items="tests",
on_click=on_test_selected,
),
id="tests_scroll",
width=1,
height=7,
),
Column(
Button(Const("➕ Добавить тест"), id="add_test", on_click=on_add_test_clicked),
Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked),
),
state=AdminTestsSG.tests_list,
getter=get_tests_data,
),
Window(
Format("{test_info}"),
Column(
Button(
Format("{button_text}"),
id="toggle_active",
on_click=on_toggle_active
),
Button(
Format("{results_button_text}"),
id="toggle_results",
on_click=on_toggle_results_viewable
),
Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics),
Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test),
Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list),
),
state=AdminTestsSG.test_detail,
getter=get_test_detail,
),
Window(
Const("✏️ Изменить тест\n\nВыберите, что хотите изменить:"),
Column(
Button(Const("🔑 Пароль"), id="edit_password", on_click=on_edit_password),
Button(Const("🔄 Попытки"), id="edit_attempts", on_click=on_edit_attempts),
Button(Const("👥 Группа"), id="edit_group", on_click=on_edit_group),
Button(Const("📅 Срок действия"), id="edit_expires", on_click=on_edit_expires),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail),
),
state=AdminTestsSG.edit_menu,
),
Window(
Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"),
MessageInput(on_password_input),
Column(
Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=AdminTestsSG.edit_password,
),
Window(
Const("🔄 Изменение количества попыток\n\n🔢 Введите новое количество попыток (1-100) или удалите ограничение:"),
MessageInput(on_attempts_input_edit),
Column(
Button(Const("🗑 Без ограничений"), id="remove_attempts", on_click=on_remove_attempts),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=AdminTestsSG.edit_attempts,
),
Window(
Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_group_selected_for_test,
),
id="groups_scroll",
width=2,
height=7,
),
Column(
Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=AdminTestsSG.edit_group,
getter=get_groups_for_edit,
),
Window(
Const("📅 Изменение срока действия\n\n🗓 Выберите новую дату или удалите срок:"),
Calendar(id="calendar", on_click=on_date_selected_for_test),
Column(
Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=AdminTestsSG.edit_expires,
),
Window(
Format("📊 Статистика теста\n\nПрошли тест: {count}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="attempt_select",
item_id_getter=lambda x: x[1],
items="attempts",
on_click=on_attempt_selected,
),
id="attempts_scroll",
width=1,
height=7,
),
Column(
Button(Const("🔄 Обновить"), id="refresh", on_click=on_statistics),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail),
),
state=AdminTestsSG.statistics,
getter=get_statistics_data,
),
Window(
Format("{attempt_info}"),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_statistics),
state=AdminTestsSG.attempt_detail,
getter=get_attempt_detail,
),
)