diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py
index d9b1e88..e346506 100644
--- a/src/trudex/application/bot/admin_dialogs/states.py
+++ b/src/trudex/application/bot/admin_dialogs/states.py
@@ -9,6 +9,7 @@ class AdminTemplatesSG(StatesGroup):
main = State()
export_list = State()
spec = State()
+ import_file = State()
class AdminUsersSG(StatesGroup):
diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py
index e861db4..644ab3a 100644
--- a/src/trudex/application/bot/admin_dialogs/templates.py
+++ b/src/trudex/application/bot/admin_dialogs/templates.py
@@ -1,13 +1,18 @@
import json
-from aiogram.types import BufferedInputFile, CallbackQuery
+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.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG
+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
@@ -164,8 +169,8 @@ async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager:
await manager.switch_to(AdminTemplatesSG.export_list)
-async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
- await _callback.answer("🚧 В разработке", show_alert=True)
+async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
+ await manager.switch_to(AdminTemplatesSG.import_file)
async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
@@ -273,6 +278,98 @@ async def on_template_full(_callback: CallbackQuery, _button: Button, _manager:
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,
+ )
+
+ 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):
+ error_lines = ["❌ Ошибки валидации:\n"]
+ for err in result[:10]:
+ path_str = f" ({err.path})" 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
+
+ test_id = await create_test_from_parsed(result, test_dao, question_dao, option_dao)
+
+ await message.answer(
+ f"✅ Тест импортирован!\n\n"
+ f"📝 Название: {result.title}\n"
+ f"❓ Вопросов: {len(result.questions)}\n\n"
+ f"Тест создан в деактивированном состоянии."
+ )
+
+ await manager.switch_to(AdminTemplatesSG.main)
+
+
templates_dialog = Dialog(
Window(
Const(TEMPLATES_INFO),
@@ -315,4 +412,10 @@ templates_dialog = Dialog(
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
state=AdminTemplatesSG.spec,
),
+ Window(
+ Const("📥 Импорт теста\n\nОтправьте JSON файл с тестом.\n\nФормат файла описан в разделе «Спецификация»"),
+ MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]),
+ Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
+ state=AdminTemplatesSG.import_file,
+ ),
)
diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py
index eff6b23..6b21c34 100644
--- a/src/trudex/application/bot/creator_dialogs/states.py
+++ b/src/trudex/application/bot/creator_dialogs/states.py
@@ -9,6 +9,7 @@ class CreatorTemplatesSG(StatesGroup):
main = State()
export_list = State()
spec = State()
+ import_file = State()
class CreatorUsersSG(StatesGroup):
diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py
index 570b878..b9dbc6c 100644
--- a/src/trudex/application/bot/creator_dialogs/templates.py
+++ b/src/trudex/application/bot/creator_dialogs/templates.py
@@ -1,13 +1,18 @@
import json
-from aiogram.types import BufferedInputFile, CallbackQuery
+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.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG
+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
@@ -164,8 +169,8 @@ async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager:
await manager.switch_to(CreatorTemplatesSG.export_list)
-async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None:
- await _callback.answer("🚧 В разработке", show_alert=True)
+async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
+ await manager.switch_to(CreatorTemplatesSG.import_file)
async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
@@ -273,6 +278,98 @@ async def on_template_full(_callback: CallbackQuery, _button: Button, _manager:
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,
+ )
+
+ 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):
+ error_lines = ["❌ Ошибки валидации:\n"]
+ for err in result[:10]:
+ path_str = f" ({err.path})" 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
+
+ test_id = await create_test_from_parsed(result, test_dao, question_dao, option_dao)
+
+ await message.answer(
+ f"✅ Тест импортирован!\n\n"
+ f"📝 Название: {result.title}\n"
+ f"❓ Вопросов: {len(result.questions)}\n\n"
+ f"Тест создан в деактивированном состоянии."
+ )
+
+ await manager.switch_to(CreatorTemplatesSG.main)
+
+
templates_dialog = Dialog(
Window(
Const(TEMPLATES_INFO),
@@ -315,4 +412,10 @@ templates_dialog = Dialog(
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
state=CreatorTemplatesSG.spec,
),
+ Window(
+ Const("📥 Импорт теста\n\nОтправьте JSON файл с тестом.\n\nФормат файла описан в разделе «Спецификация»"),
+ MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]),
+ Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
+ state=CreatorTemplatesSG.import_file,
+ ),
)
diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py
new file mode 100644
index 0000000..682c786
--- /dev/null
+++ b/src/trudex/domain/test_parser.py
@@ -0,0 +1,333 @@
+import json
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass
+class ParsedOption:
+ text: str
+ is_correct: bool
+
+
+@dataclass
+class ParsedQuestion:
+ text: str
+ question_type: str
+ options: list[ParsedOption]
+ correct_answer: str | None = None
+
+
+@dataclass
+class ParsedTest:
+ title: str
+ description: str | None
+ password: str | None
+ attempts: int | None
+ expires_at: datetime | None
+ for_group: int | None
+ questions: list[ParsedQuestion]
+
+
+@dataclass
+class ParseError:
+ message: str
+ path: str | None = None
+
+
+class TestParser:
+ VALID_QUESTION_TYPES = {"single", "multiple", "input"}
+
+ def parse(self, json_str: str) -> ParsedTest | list[ParseError]:
+ try:
+ data = json.loads(json_str)
+ except json.JSONDecodeError as e:
+ return [ParseError(f"Невалидный JSON: {e.msg}", path=None)]
+
+ if not isinstance(data, dict):
+ return [ParseError("JSON должен быть объектом", path=None)]
+
+ errors: list[ParseError] = []
+
+ title = self._parse_string(data, "title", required=True, max_length=255, errors=errors)
+ description = self._parse_string(data, "description", required=False, max_length=2000, errors=errors)
+ password = self._parse_string(data, "password", required=False, max_length=255, errors=errors)
+ attempts = self._parse_int(data, "attempts", required=False, min_val=1, max_val=100, errors=errors)
+ expires_at = self._parse_datetime(data, "expires_at", required=False, errors=errors)
+ for_group = self._parse_int(data, "for_group", required=False, errors=errors)
+
+ questions = self._parse_questions(data, errors)
+
+ if errors:
+ return errors
+
+ assert title is not None
+
+ return ParsedTest(
+ title=title,
+ description=description,
+ password=password,
+ attempts=attempts,
+ expires_at=expires_at,
+ for_group=for_group,
+ questions=questions,
+ )
+
+ def _parse_string(
+ self,
+ data: dict,
+ key: str,
+ required: bool,
+ max_length: int | None = None,
+ errors: list[ParseError] | None = None,
+ ) -> str | None:
+ errors = errors or []
+ value = data.get(key)
+
+ if value is None:
+ if required:
+ errors.append(ParseError(f"Поле '{key}' обязательно", path=key))
+ return None
+
+ if not isinstance(value, str):
+ errors.append(ParseError(f"Поле '{key}' должно быть строкой", path=key))
+ return None
+
+ value = value.strip()
+ if not value and required:
+ errors.append(ParseError(f"Поле '{key}' не может быть пустым", path=key))
+ return None
+
+ if max_length and len(value) > max_length:
+ errors.append(ParseError(f"Поле '{key}' слишком длинное (максимум {max_length})", path=key))
+ return None
+
+ return value if value else None
+
+ def _parse_int(
+ self,
+ data: dict,
+ key: str,
+ required: bool,
+ min_val: int | None = None,
+ max_val: int | None = None,
+ errors: list[ParseError] | None = None,
+ ) -> int | None:
+ errors = errors or []
+ value = data.get(key)
+
+ if value is None:
+ if required:
+ errors.append(ParseError(f"Поле '{key}' обязательно", path=key))
+ return None
+
+ if not isinstance(value, int) or isinstance(value, bool):
+ errors.append(ParseError(f"Поле '{key}' должно быть числом", path=key))
+ return None
+
+ if min_val is not None and value < min_val:
+ errors.append(ParseError(f"Поле '{key}' должно быть >= {min_val}", path=key))
+ return None
+
+ if max_val is not None and value > max_val:
+ errors.append(ParseError(f"Поле '{key}' должно быть <= {max_val}", path=key))
+ return None
+
+ return value
+
+ def _parse_datetime(
+ self,
+ data: dict,
+ key: str,
+ required: bool,
+ errors: list[ParseError] | None = None,
+ ) -> datetime | None:
+ errors = errors or []
+ value = data.get(key)
+
+ if value is None:
+ if required:
+ errors.append(ParseError(f"Поле '{key}' обязательно", path=key))
+ return None
+
+ if not isinstance(value, str):
+ errors.append(ParseError(f"Поле '{key}' должно быть строкой в ISO формате", path=key))
+ return None
+
+ try:
+ return datetime.fromisoformat(value)
+ except ValueError:
+ errors.append(ParseError(f"Поле '{key}' должно быть в ISO формате (например 2026-12-31T23:59:59)", path=key))
+ return None
+
+ def _parse_questions(self, data: dict, errors: list[ParseError]) -> list[ParsedQuestion]:
+ questions_data = data.get("questions")
+
+ if questions_data is None:
+ errors.append(ParseError("Поле 'questions' обязательно", path="questions"))
+ return []
+
+ if not isinstance(questions_data, list):
+ errors.append(ParseError("Поле 'questions' должно быть массивом", path="questions"))
+ return []
+
+ if len(questions_data) == 0:
+ errors.append(ParseError("Тест должен содержать хотя бы один вопрос", path="questions"))
+ return []
+
+ questions: list[ParsedQuestion] = []
+
+ for i, q_data in enumerate(questions_data):
+ path = f"questions[{i}]"
+
+ if not isinstance(q_data, dict):
+ errors.append(ParseError("Вопрос должен быть объектом", path=path))
+ continue
+
+ question = self._parse_question(q_data, path, errors)
+ if question:
+ questions.append(question)
+
+ return questions
+
+ def _parse_question(self, data: dict, path: str, errors: list[ParseError]) -> ParsedQuestion | None:
+ text = data.get("text")
+ if not text or not isinstance(text, str):
+ errors.append(ParseError("Поле 'text' обязательно и должно быть строкой", path=f"{path}.text"))
+ return None
+
+ text = text.strip()
+ if not text:
+ errors.append(ParseError("Текст вопроса не может быть пустым", path=f"{path}.text"))
+ return None
+
+ if len(text) > 2000:
+ errors.append(ParseError("Текст вопроса слишком длинный (максимум 2000)", path=f"{path}.text"))
+ return None
+
+ question_type = data.get("question_type")
+ if not question_type or not isinstance(question_type, str):
+ errors.append(ParseError("Поле 'question_type' обязательно", path=f"{path}.question_type"))
+ return None
+
+ if question_type not in self.VALID_QUESTION_TYPES:
+ errors.append(ParseError(
+ f"Неизвестный тип вопроса '{question_type}'. Допустимые: single, multiple, input",
+ path=f"{path}.question_type"
+ ))
+ return None
+
+ if question_type == "input":
+ return self._parse_input_question(data, path, text, errors)
+ else:
+ return self._parse_choice_question(data, path, text, question_type, errors)
+
+ def _parse_input_question(
+ self,
+ data: dict,
+ path: str,
+ text: str,
+ errors: list[ParseError],
+ ) -> ParsedQuestion | None:
+ correct_answer = data.get("correct_answer")
+
+ if not correct_answer or not isinstance(correct_answer, str):
+ errors.append(ParseError(
+ "Для типа 'input' поле 'correct_answer' обязательно",
+ path=f"{path}.correct_answer"
+ ))
+ return None
+
+ correct_answer = correct_answer.strip()
+ if not correct_answer:
+ errors.append(ParseError("Правильный ответ не может быть пустым", path=f"{path}.correct_answer"))
+ return None
+
+ if len(correct_answer) > 255:
+ errors.append(ParseError("Правильный ответ слишком длинный (максимум 255)", path=f"{path}.correct_answer"))
+ return None
+
+ return ParsedQuestion(
+ text=text,
+ question_type="input",
+ options=[ParsedOption(text=correct_answer, is_correct=True)],
+ correct_answer=correct_answer,
+ )
+
+ def _parse_choice_question(
+ self,
+ data: dict,
+ path: str,
+ text: str,
+ question_type: str,
+ errors: list[ParseError],
+ ) -> ParsedQuestion | None:
+ options_data = data.get("options")
+
+ if not options_data or not isinstance(options_data, list):
+ errors.append(ParseError(
+ f"Для типа '{question_type}' поле 'options' обязательно и должно быть массивом",
+ path=f"{path}.options"
+ ))
+ return None
+
+ if len(options_data) < 2:
+ errors.append(ParseError("Минимум 2 варианта ответа", path=f"{path}.options"))
+ return None
+
+ if len(options_data) > 10:
+ errors.append(ParseError("Максимум 10 вариантов ответа", path=f"{path}.options"))
+ return None
+
+ options: list[ParsedOption] = []
+ correct_count = 0
+
+ for j, opt_data in enumerate(options_data):
+ opt_path = f"{path}.options[{j}]"
+
+ if not isinstance(opt_data, dict):
+ errors.append(ParseError("Вариант ответа должен быть объектом", path=opt_path))
+ continue
+
+ opt_text = opt_data.get("text")
+ if not opt_text or not isinstance(opt_text, str):
+ errors.append(ParseError("Поле 'text' обязательно", path=f"{opt_path}.text"))
+ continue
+
+ opt_text = opt_text.strip()
+ if not opt_text:
+ errors.append(ParseError("Текст варианта не может быть пустым", path=f"{opt_path}.text"))
+ continue
+
+ if len(opt_text) > 255:
+ errors.append(ParseError("Текст варианта слишком длинный (максимум 255)", path=f"{opt_path}.text"))
+ continue
+
+ is_correct = opt_data.get("is_correct")
+ if not isinstance(is_correct, bool):
+ errors.append(ParseError("Поле 'is_correct' должно быть true или false", path=f"{opt_path}.is_correct"))
+ continue
+
+ if is_correct:
+ correct_count += 1
+
+ options.append(ParsedOption(text=opt_text, is_correct=is_correct))
+
+ if len(options) < 2:
+ return None
+
+ if correct_count == 0:
+ errors.append(ParseError("Должен быть хотя бы один правильный ответ", path=f"{path}.options"))
+ return None
+
+ if question_type == "single" and correct_count > 1:
+ errors.append(ParseError(
+ f"Для типа 'single' должен быть ровно один правильный ответ (найдено {correct_count})",
+ path=f"{path}.options"
+ ))
+ return None
+
+ return ParsedQuestion(
+ text=text,
+ question_type=question_type,
+ options=options,
+ )