This commit is contained in:
2026-01-04 03:36:13 +03:00
parent 8d708f2cce
commit 12beb5a986
19 changed files with 226 additions and 2103 deletions
@@ -0,0 +1,427 @@
import json
from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Row, 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.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG
from trudex.domain.schemas import QuestionType
from trudex.domain.test_parser import ParsedTest, TestParser
from trudex.infrastructure.database.dao.option import OptionDAO
from trudex.infrastructure.database.dao.question import QuestionDAO
from trudex.infrastructure.database.dao.test import TestDAO
from trudex.infrastructure.database.repo.test import TestRepository
TEMPLATES_INFO = (
"<b>📦 Шаблоны тестов</b>\n\n"
"Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n"
"🔹 <b>Экспорт</b> — сохраните тест как файл для резервной копии или передачи\n"
"🔹 <b>Импорт</b> — загрузите тест из файла\n"
"🔹 <b>Спецификация</b> — описание формата JSON для создания тестов вручную"
)
SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
<b>Структура файла:</b>
<code>{
"title": "Название теста",
"description": "Описание теста",
"password": null,
"attempts": null,
"expires_at": null,
"for_group": null,
"questions": [...]
}</code>
<b>Поля теста:</b>
• <code>title</code> — название (обязательно, до 255 символов)
• <code>description</code> — описание (до 2000 символов)
• <code>password</code> — пароль для доступа или <code>null</code>
• <code>attempts</code> — лимит попыток (1-100) или <code>null</code>
• <code>expires_at</code> — срок действия в ISO формате или <code>null</code>
• <code>for_group</code> — номер группы или <code>null</code> для всех
<b>Типы вопросов:</b>
• <code>single</code> — один правильный ответ
• <code>multiple</code> — несколько правильных ответов
• <code>input</code> — ввод текста (регистр и пробелы игнорируются)
<b>Формат вопроса (single/multiple):</b>
<code>{
"text": "Текст вопроса",
"question_type": "single",
"options": [
{"text": "Вариант 1", "is_correct": true},
{"text": "Вариант 2", "is_correct": false}
]
}</code>
<b>Формат вопроса (input):</b>
<code>{
"text": "Текст вопроса",
"question_type": "input",
"correct_answer": "правильный ответ"
}</code>
<b>⚠️ Важно:</b>
• Для <code>single</code> — ровно один <code>is_correct: true</code>
• Для <code>multiple</code> — один или более <code>is_correct: true</code>
• Минимум 2 варианта ответа для single/multiple"""
TEMPLATE_SINGLE = {
"title": "Пример теста с одиночным выбором",
"description": "Демонстрация формата single вопросов",
"password": None,
"attempts": None,
"expires_at": None,
"for_group": None,
"questions": [
{
"text": "Какой язык программирования используется для разработки Telegram ботов?",
"question_type": "single",
"options": [
{"text": "Python", "is_correct": True},
{"text": "HTML", "is_correct": False},
{"text": "CSS", "is_correct": False},
],
},
],
}
TEMPLATE_MULTIPLE = {
"title": "Пример теста с множественным выбором",
"description": "Демонстрация формата multiple вопросов",
"password": None,
"attempts": None,
"expires_at": None,
"for_group": None,
"questions": [
{
"text": "Выберите языки программирования:",
"question_type": "multiple",
"options": [
{"text": "Python", "is_correct": True},
{"text": "JavaScript", "is_correct": True},
{"text": "HTML", "is_correct": False},
{"text": "CSS", "is_correct": False},
],
},
],
}
TEMPLATE_INPUT = {
"title": "Пример теста с вводом текста",
"description": "Демонстрация формата input вопросов",
"password": None,
"attempts": None,
"expires_at": None,
"for_group": None,
"questions": [
{
"text": "Как называется библиотека для создания Telegram ботов на Python?",
"question_type": "input",
"correct_answer": "aiogram",
},
],
}
TEMPLATE_FULL = {
"title": "Полный пример теста",
"description": "Тест со всеми типами вопросов и настройками",
"password": "secret123",
"attempts": 3,
"expires_at": "2026-12-31T23:59:59",
"for_group": 1234,
"questions": [
{
"text": "Выберите правильный ответ:",
"question_type": "single",
"options": [
{"text": "Вариант A", "is_correct": False},
{"text": "Вариант B", "is_correct": True},
{"text": "Вариант C", "is_correct": False},
],
},
{
"text": "Выберите все правильные ответы:",
"question_type": "multiple",
"options": [
{"text": "Ответ 1", "is_correct": True},
{"text": "Ответ 2", "is_correct": True},
{"text": "Ответ 3", "is_correct": False},
],
},
{
"text": "Введите ответ:",
"question_type": "input",
"correct_answer": "ответ",
},
],
}
async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.switch_to(SharedTemplatesSG.export_list)
async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.switch_to(SharedTemplatesSG.import_file)
async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.switch_to(SharedTemplatesSG.spec)
async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.done()
async def on_back_to_templates(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.switch_to(SharedTemplatesSG.main)
@inject
async def get_tests_for_export(test_dao: FromDishka[TestDAO], **_kwargs):
tests = await test_dao.get_all()
return {
"tests": [(f"📝 {t.title}", t.id) for t in tests],
"count": len(tests),
}
@inject
async def on_test_selected_for_export(
_callback: CallbackQuery,
_widget: Select, # type: ignore[type-arg]
_manager: DialogManager,
item_id: str,
test_repo: FromDishka[TestRepository],
) -> None:
test_id = int(item_id)
test, questions_with_options = await test_repo.get_full_test(test_id)
if not test:
await _callback.answer("❌ Тест не найден")
return
export_data: dict = {
"title": test.title,
"description": test.description,
"password": test.password,
"attempts": test.attempts,
"expires_at": test.expires_at.isoformat() if test.expires_at else None,
"for_group": test.for_group,
"questions": [],
}
questions_list: list = export_data["questions"]
for question, options in questions_with_options:
question_data: dict = {
"text": question.text,
"question_type": question.question_type.value,
}
if question.question_type == QuestionType.INPUT:
correct_options = [o for o in options if o.is_correct]
if correct_options:
question_data["correct_answer"] = correct_options[0].text
else:
question_data["options"] = [
{"text": o.text, "is_correct": o.is_correct}
for o in options
]
questions_list.append(question_data)
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50]
filename = f"{safe_title}.json"
assert _callback.message is not None
await _callback.message.answer_document(
document=BufferedInputFile(json_str.encode("utf-8"), filename=filename),
caption=f"📤 <b>Экспорт теста:</b> {test.title}",
)
async def send_template(callback: CallbackQuery, template: dict, name: str) -> None:
json_str = json.dumps(template, ensure_ascii=False, indent=2)
filename = f"template_{name}.json"
assert callback.message is not None
await callback.message.answer_document(
document=BufferedInputFile(json_str.encode("utf-8"), filename=filename),
caption=f"📄 <b>Шаблон:</b> {template['title']}",
)
async def on_template_single(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
await send_template(_callback, TEMPLATE_SINGLE, "single")
async def on_template_multiple(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
await send_template(_callback, TEMPLATE_MULTIPLE, "multiple")
async def on_template_input(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
await send_template(_callback, TEMPLATE_INPUT, "input")
async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
await send_template(_callback, TEMPLATE_FULL, "full")
async def create_test_from_parsed(
parsed: ParsedTest,
test_dao: TestDAO,
question_dao: QuestionDAO,
option_dao: OptionDAO,
) -> int:
test = await test_dao.create(
title=parsed.title,
description=parsed.description,
password=parsed.password,
attempts=parsed.attempts,
expires_at=parsed.expires_at,
for_group=parsed.for_group,
is_active=False,
)
for position, q in enumerate(parsed.questions):
question = await question_dao.create(
test_id=test.id,
text=q.text,
position=position,
question_type=q.question_type,
)
for opt in q.options:
await option_dao.create(
question_id=question.id,
text=opt.text,
is_correct=opt.is_correct,
)
return test.id
@inject
async def on_import_file(
message: Message,
_widget: MessageInput,
manager: DialogManager,
bot_inst: FromDishka[Bot],
test_dao: FromDishka[TestDAO],
question_dao: FromDishka[QuestionDAO],
option_dao: FromDishka[OptionDAO],
) -> None:
if not message.document:
await message.answer("❌ Отправьте JSON файл")
return
if message.document.file_size and message.document.file_size > 1024 * 1024:
await message.answer("❌ Файл слишком большой (максимум 1 МБ)")
return
file = await bot_inst.get_file(message.document.file_id)
if not file.file_path:
await message.answer("❌ Не удалось загрузить файл")
return
file_bytes = await bot_inst.download_file(file.file_path)
if not file_bytes:
await message.answer("❌ Не удалось загрузить файл")
return
try:
json_str = file_bytes.read().decode("utf-8")
except UnicodeDecodeError:
await message.answer("❌ Файл должен быть в кодировке UTF-8")
return
parser = TestParser()
result = parser.parse(json_str)
if isinstance(result, list):
if not result:
await message.answer("❌ Неизвестная ошибка валидации")
return
error_lines = ["❌ <b>Ошибки валидации:</b>\n"]
for err in result[:10]:
path_str = f" (<code>{err.path}</code>)" if err.path else ""
error_lines.append(f"{err.message}{path_str}")
if len(result) > 10:
error_lines.append(f"\n... и ещё {len(result) - 10} ошибок")
await message.answer("\n".join(error_lines))
return
await create_test_from_parsed(result, test_dao, question_dao, option_dao)
await message.answer(
f"✅ <b>Тест импортирован!</b>\n\n"
f"📝 <b>Название:</b> {result.title}\n"
f"❓ <b>Вопросов:</b> {len(result.questions)}\n\n"
f"Тест создан в деактивированном состоянии."
)
await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK)
shared_templates_dialog = Dialog(
Window(
Const(TEMPLATES_INFO),
Row(
Button(Const("📤 Экспорт"), id="export", on_click=on_export_clicked),
Button(Const("📥 Импорт"), id="import", on_click=on_import_clicked),
),
Button(Const("📋 Спецификация"), id="spec", on_click=on_spec_clicked),
Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked),
state=SharedTemplatesSG.main,
),
Window(
Format("<b>📤 Экспорт теста</b>\n\nВыберите тест для экспорта:\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_for_export, # type: ignore[arg-type]
),
id="tests_scroll",
width=1,
height=7,
),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
state=SharedTemplatesSG.export_list,
getter=get_tests_for_export,
),
Window(
Const(SPEC_INFO),
Row(
Button(Const("📌 Single"), id="tpl_single", on_click=on_template_single),
Button(Const("📋 Multiple"), id="tpl_multiple", on_click=on_template_multiple),
),
Row(
Button(Const("✏️ Input"), id="tpl_input", on_click=on_template_input),
Button(Const("📦 Полный"), id="tpl_full", on_click=on_template_full),
),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
state=SharedTemplatesSG.spec,
),
Window(
Const("<b>📥 Импорт теста</b>\n\nОтправьте JSON файл с тестом.\n\n<i>Формат файла описан в разделе «Спецификация»</i>"),
MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
state=SharedTemplatesSG.import_file,
),
)