Files
Quizzi/src/trudex/application/bot/admin_dialogs/tests.py
T
2026-01-04 03:00:44 +03:00

557 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"<b>📝 Информация о тесте</b>\n\n"
f"<b>Название:</b>\n<blockquote>{test.title}</blockquote>\n"
f"<b>Описание:</b>\n<blockquote>{test.description or ''}</blockquote>\n\n"
f"<b>Статус:</b> {status}\n"
f"<b>Вопросов:</b> {questions_count}\n"
f"<b>Пароль:</b> {password_str}\n"
f"<b>Попытки:</b> {attempts_str}\n"
f"<b>Срок:</b> {expires_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Видимость:</b> {results_str}\n\n"
f"<b>Создан:</b> {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"<b>📊 Результат прохождения</b>\n",
f"📈 <b>Результат:</b> {attempt.score}%",
f"📅 <b>Дата:</b> {date_str}",
f"🏆 <b>Статус:</b> {status}\n",
"<b>📋 Ответы:</b>\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} <b>Вопрос {i}</b>")
lines.append(f"<blockquote>{question.text}</blockquote>")
lines.append(f"👤 <i>Ответ:</i> {user_answer or ''}")
lines.append(f"✓ <i>Правильно:</i> {', '.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"<b>🔗 Поделиться тестом</b>\n\n📎 <b>Ссылка на тест:</b>\n<code>{share_link}</code>\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("<b>📝 Тесты</b>\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("<b>✏️ Изменить тест</b>\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("<b>🔑 Изменение пароля</b>\n\n💬 <b>Введите новый пароль</b> или удалите текущий:\n<i>(максимум 255 символов)</i>"),
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("<b>🔄 Изменение количества попыток</b>\n\n🔢 <b>Введите новое количество попыток</b> (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("<b>👥 Изменение группы</b>\n\n🎓 <b>Выберите группу</b> или удалите привязку:"),
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("<b>📅 Изменение срока действия</b>\n\n🗓 <b>Выберите новую дату</b> или удалите срок:"),
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("<b>📊 Статистика теста</b>\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,
),
)