diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py
index a2484d0..4714fc2 100644
--- a/src/trudex/application/__main__.py
+++ b/src/trudex/application/__main__.py
@@ -13,6 +13,7 @@ from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog
from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog
from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog
from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog
+from trudex.application.bot.creator_dialogs.create_test import create_test_dialog
from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog
from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog
from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog
@@ -52,6 +53,7 @@ async def main() -> None:
creator_users_dialog,
creator_tests_dialog,
creator_broadcast_dialog,
+ create_test_dialog,
)
router.message.middleware(RejectNotAdminMiddleware())
diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py
new file mode 100644
index 0000000..c1e96e8
--- /dev/null
+++ b/src/trudex/application/bot/creator_dialogs/create_test.py
@@ -0,0 +1,505 @@
+from datetime import date, datetime
+
+from aiogram.types import CallbackQuery, ContentType, Message
+from aiogram_dialog import Dialog, DialogManager, Window, StartMode
+from aiogram_dialog.widgets.input import MessageInput
+from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, Select
+from aiogram_dialog.widgets.text import Const, Format
+from dishka.integrations.aiogram import CONTAINER_NAME
+from dishka.integrations.aiogram_dialog import inject
+
+from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG
+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
+
+
+async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ if not message.text:
+ await message.answer("❌ Название не может быть пустым")
+ return
+
+ title = message.text.strip()
+ if not title:
+ await message.answer("❌ Название не может быть пустым")
+ return
+
+ if len(title) > 255:
+ await message.answer("❌ Название слишком длинное (максимум 255 символов)")
+ return
+
+ manager.dialog_data["title"] = title
+ await manager.switch_to(CreateTestSG.input_description)
+
+
+async def on_description_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ if not message.text:
+ await message.answer("❌ Описание не может быть пустым")
+ return
+
+ description = message.text.strip()
+ if not description:
+ await message.answer("❌ Описание не может быть пустым")
+ return
+
+ if len(description) > 2000:
+ await message.answer("❌ Описание слишком длинное (максимум 2000 символов)")
+ return
+
+ manager.dialog_data["description"] = description
+ await manager.switch_to(CreateTestSG.input_password)
+
+
+async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ if not message.text:
+ await message.answer("❌ Пароль не может быть пустым")
+ return
+
+ password = message.text.strip()
+ if not password:
+ await message.answer("❌ Пароль не может быть пустым")
+ return
+
+ if len(password) > 255:
+ await message.answer("❌ Пароль слишком длинный (максимум 255 символов)")
+ return
+
+ manager.dialog_data["password"] = password
+ await manager.switch_to(CreateTestSG.input_expires_at)
+
+
+async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ manager.dialog_data["password"] = None
+ await manager.switch_to(CreateTestSG.input_expires_at)
+
+
+async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date):
+ manager.dialog_data["expires_at"] = datetime.combine(selected_date, datetime.min.time())
+ await manager.switch_to(CreateTestSG.input_for_group)
+
+
+async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ manager.dialog_data["expires_at"] = None
+ await manager.switch_to(CreateTestSG.input_for_group)
+
+
+async def on_group_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ text = (message.text or "").strip()
+ if text.isdigit() and len(text) == 4:
+ manager.dialog_data["for_group"] = int(text)
+ await manager.switch_to(CreateTestSG.confirm_test_info)
+ else:
+ await message.answer("❌ Группа должна быть 4-значным числом")
+
+
+async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ manager.dialog_data["for_group"] = None
+ await manager.switch_to(CreateTestSG.confirm_test_info)
+
+
+async def get_test_info(dialog_manager: DialogManager, **_kwargs):
+ title = dialog_manager.dialog_data.get("title", "—")
+ description = dialog_manager.dialog_data.get("description", "—")
+ password = dialog_manager.dialog_data.get("password")
+ expires_at = dialog_manager.dialog_data.get("expires_at")
+ for_group = dialog_manager.dialog_data.get("for_group")
+
+ password_str = f"🔒 {password}" if password else "Без пароля"
+ expires_str = expires_at.strftime("%d.%m.%Y") if expires_at else "Без срока"
+ group_str = str(for_group) if for_group else "Для всех"
+
+ return {
+ "info": (
+ f"📝 Информация о тесте\n\n"
+ f"Название: {title}\n"
+ f"Описание: {description}\n"
+ f"Пароль: {password_str}\n"
+ f"Истекает: {expires_str}\n"
+ f"Для группы: {group_str}"
+ )
+ }
+
+
+async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ container = manager.middleware_data[CONTAINER_NAME]
+ test_dao = await container.get(TestDAO)
+
+ title = manager.dialog_data.get("title")
+ description = manager.dialog_data.get("description")
+ password = manager.dialog_data.get("password")
+ expires_at = manager.dialog_data.get("expires_at")
+ for_group = manager.dialog_data.get("for_group")
+
+ test = await test_dao.create(
+ title=title,
+ description=description,
+ password=password,
+ expires_at=expires_at,
+ for_group=for_group,
+ )
+
+ manager.dialog_data["test_id"] = test.id
+ manager.dialog_data["questions"] = []
+ await manager.switch_to(CreateTestSG.add_question)
+
+
+async def on_add_question(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ manager.dialog_data["current_question"] = {}
+ await manager.switch_to(CreateTestSG.input_question_text)
+
+
+async def on_question_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ current_question = manager.dialog_data.get("current_question", {})
+
+ if message.content_type == ContentType.PHOTO:
+ photo = message.photo[-1] if message.photo else None
+ if photo:
+ current_question["tg_file_id"] = photo.file_id
+ text = (message.caption or "").strip()
+ if len(text) > 2000:
+ await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)")
+ return
+ current_question["text"] = text
+ elif message.content_type == ContentType.TEXT and message.text:
+ text = message.text.strip()
+ if not text:
+ await message.answer("❌ Текст вопроса не может быть пустым")
+ return
+ if len(text) > 2000:
+ await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)")
+ return
+ current_question["text"] = text
+ current_question["tg_file_id"] = None
+ else:
+ await message.answer("❌ Отправьте текст или фото с подписью")
+ return
+
+ manager.dialog_data["current_question"] = current_question
+ await manager.switch_to(CreateTestSG.select_question_type)
+
+
+async def get_question_type_data(**_kwargs):
+ return {
+ "question_types": [
+ ("single", "📌 Один правильный ответ"),
+ ("multiple", "� Ннесколько правильных ответов"),
+ ("input", "✏️ Ввод текста"),
+ ]
+ }
+
+
+async def on_question_type_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str):
+ current_question = manager.dialog_data.get("current_question", {})
+ current_question["question_type"] = item_id
+ manager.dialog_data["current_question"] = current_question
+
+ if item_id == "input":
+ await manager.switch_to(CreateTestSG.input_correct_answer)
+ else:
+ manager.dialog_data["current_options"] = []
+ await manager.switch_to(CreateTestSG.input_options)
+
+
+async def on_correct_answer_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ if not message.text:
+ await message.answer("❌ Правильный ответ не может быть пустым")
+ return
+
+ answer = message.text.strip()
+ if not answer:
+ await message.answer("❌ Правильный ответ не может быть пустым")
+ return
+
+ if len(answer) > 255:
+ await message.answer("❌ Ответ слишком длинный (максимум 255 символов)")
+ return
+
+ current_question = manager.dialog_data.get("current_question", {})
+ current_question["correct_answer"] = answer
+ manager.dialog_data["current_question"] = current_question
+ await manager.switch_to(CreateTestSG.confirm_question)
+
+
+async def on_option_input(message: Message, _widget: MessageInput, manager: DialogManager):
+ if not message.text:
+ await message.answer("❌ Вариант ответа не может быть пустым")
+ return
+
+ option_text = message.text.strip()
+ if not option_text:
+ await message.answer("❌ Вариант ответа не может быть пустым")
+ return
+
+ if len(option_text) > 255:
+ await message.answer("❌ Вариант ответа слишком длинный (максимум 255 символов)")
+ return
+
+ current_options = manager.dialog_data.get("current_options", [])
+
+ if len(current_options) >= 10:
+ await message.answer("❌ Максимум 10 вариантов ответа")
+ return
+
+ current_options.append({"text": option_text, "is_correct": False})
+ manager.dialog_data["current_options"] = current_options
+
+ await message.answer(f"✅ Вариант {len(current_options)} добавлен")
+
+
+async def on_finish_options(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ current_options = manager.dialog_data.get("current_options", [])
+ if len(current_options) < 2:
+ await _callback.answer("❌ Добавьте минимум 2 варианта ответа", show_alert=True)
+ return
+
+ await manager.switch_to(CreateTestSG.mark_correct_options)
+
+
+async def get_options_data(dialog_manager: DialogManager, **_kwargs):
+ current_options = dialog_manager.dialog_data.get("current_options", [])
+ formatted_options = []
+ for i, opt in enumerate(current_options):
+ marker = "✅" if opt["is_correct"] else "❌"
+ formatted_options.append((str(i), f"{marker} {opt['text']}"))
+ return {
+ "options": formatted_options,
+ "options_count": len(current_options),
+ }
+
+
+async def on_option_toggle(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str):
+ current_options = manager.dialog_data.get("current_options", [])
+ current_question = manager.dialog_data.get("current_question", {})
+ question_type = current_question.get("question_type", "single")
+
+ option_idx = int(item_id)
+
+ if question_type == "single":
+ for opt in current_options:
+ opt["is_correct"] = False
+ current_options[option_idx]["is_correct"] = True
+ else:
+ current_options[option_idx]["is_correct"] = not current_options[option_idx]["is_correct"]
+
+ manager.dialog_data["current_options"] = current_options
+ await _callback.answer()
+
+
+async def on_confirm_correct(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ current_options = manager.dialog_data.get("current_options", [])
+
+ if not any(opt["is_correct"] for opt in current_options):
+ await _callback.answer("❌ Отметьте хотя бы один правильный ответ", show_alert=True)
+ return
+
+ await manager.switch_to(CreateTestSG.confirm_question)
+
+
+async def get_question_preview(dialog_manager: DialogManager, **_kwargs):
+ current_question = dialog_manager.dialog_data.get("current_question", {})
+ current_options = dialog_manager.dialog_data.get("current_options", [])
+
+ text = current_question.get("text", "")
+ question_type = current_question.get("question_type", "single")
+ has_image = current_question.get("tg_file_id") is not None
+
+ type_names = {
+ "single": "📌 Один правильный ответ",
+ "multiple": "📋 Несколько правильных ответов",
+ "input": "✏️ Ввод текста",
+ }
+
+ preview = f"📝 Предпросмотр вопроса\n\n"
+ preview += f"Текст: {text}\n"
+ preview += f"Тип: {type_names[question_type]}\n"
+ preview += f"Изображение: {'✅ Да' if has_image else '❌ Нет'}\n\n"
+
+ if question_type == "input":
+ correct_answer = current_question.get("correct_answer", "")
+ preview += f"Правильный ответ: {correct_answer}"
+ else:
+ preview += "Варианты ответов:\n"
+ for i, opt in enumerate(current_options, 1):
+ marker = "✅" if opt["is_correct"] else "❌"
+ preview += f"{i}. {marker} {opt['text']}\n"
+
+ return {"preview": preview}
+
+
+async def on_save_question(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ container = manager.middleware_data[CONTAINER_NAME]
+ question_dao = await container.get(QuestionDAO)
+ option_dao = await container.get(OptionDAO)
+ test_repo = await container.get(TestRepository)
+
+ test_id = manager.dialog_data.get("test_id")
+ current_question = manager.dialog_data.get("current_question", {})
+ current_options = manager.dialog_data.get("current_options", [])
+
+ questions_count = await test_repo.count_questions_in_test(test_id)
+
+ question = await question_dao.create(
+ test_id=test_id,
+ text=current_question.get("text", ""),
+ position=questions_count,
+ question_type=current_question.get("question_type", "single"),
+ tg_file_id=current_question.get("tg_file_id"),
+ )
+
+ if current_question.get("question_type") == "input":
+ await option_dao.create(
+ question_id=question.id,
+ text=current_question.get("correct_answer", ""),
+ is_correct=True,
+ )
+ else:
+ for opt in current_options:
+ await option_dao.create(
+ question_id=question.id,
+ text=opt["text"],
+ is_correct=opt["is_correct"],
+ )
+
+ questions = manager.dialog_data.get("questions", [])
+ questions.append(question.id)
+ manager.dialog_data["questions"] = questions
+
+ manager.dialog_data.pop("current_question", None)
+ manager.dialog_data.pop("current_options", None)
+
+ await _callback.answer("✅ Вопрос добавлен")
+ await manager.switch_to(CreateTestSG.add_question)
+
+
+async def on_cancel_question(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ manager.dialog_data.pop("current_question", None)
+ manager.dialog_data.pop("current_options", None)
+ await manager.switch_to(CreateTestSG.add_question)
+
+
+async def get_questions_count(dialog_manager: DialogManager, **_kwargs):
+ questions = dialog_manager.dialog_data.get("questions", [])
+ return {"questions_count": len(questions)}
+
+
+async def on_finish_test(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ questions = manager.dialog_data.get("questions", [])
+
+ if len(questions) == 0:
+ await _callback.answer("❌ Добавьте хотя бы один вопрос", show_alert=True)
+ return
+
+ await _callback.answer("✅ Тест создан")
+ await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK)
+
+
+async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK)
+
+
+create_test_dialog = Dialog(
+ Window(
+ Const("📝 Создание теста\n\n💬 Введите название теста:\n(максимум 255 символов)"),
+ MessageInput(on_title_input),
+ Cancel(Const("◀️ Отмена")),
+ state=CreateTestSG.input_title,
+ ),
+ Window(
+ Const("📝 Создание теста\n\n📄 Введите описание теста:\n(максимум 2000 символов)"),
+ MessageInput(on_description_input),
+ state=CreateTestSG.input_description,
+ ),
+ Window(
+ Const("🔒 Пароль\n\n🔑 Введите пароль для доступа к тесту или пропустите этот шаг:\n(максимум 255 символов)"),
+ MessageInput(on_password_input),
+ Button(Const("⏭️ Без пароля"), id="skip_password", on_click=on_skip_password),
+ state=CreateTestSG.input_password,
+ ),
+ Window(
+ Const("📅 Срок действия\n\n🗓 Выберите дату истечения теста или пропустите:"),
+ Calendar(id="calendar", on_click=on_date_selected),
+ Button(Const("⏭️ Без срока"), id="skip_expires", on_click=on_skip_expires),
+ state=CreateTestSG.input_expires_at,
+ ),
+ Window(
+ Const("👥 Группа\n\n🎓 Введите номер группы (4 цифры) или пропустите для всех:"),
+ MessageInput(on_group_input),
+ Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group),
+ state=CreateTestSG.input_for_group,
+ ),
+ Window(
+ Format("{info}\n\n✅ Подтвердите создание теста:"),
+ Row(
+ Button(Const("✅ Создать"), id="confirm", on_click=on_confirm_test),
+ Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel),
+ ),
+ state=CreateTestSG.confirm_test_info,
+ getter=get_test_info,
+ ),
+ Window(
+ Format("➕ Добавление вопросов\n\n📊 Вопросов добавлено: {questions_count}\n\n💡 Добавьте вопросы к тесту:"),
+ Column(
+ Button(Const("➕ Добавить вопрос"), id="add_question", on_click=on_add_question),
+ Button(Const("✅ Завершить создание"), id="finish", on_click=on_finish_test),
+ ),
+ state=CreateTestSG.add_question,
+ getter=get_questions_count,
+ ),
+ Window(
+ Const("❓ Текст вопроса\n\n📝 Отправьте текст вопроса или 📷 фото с подписью:\n(максимум 2000 символов)"),
+ MessageInput(on_question_input, content_types=[ContentType.TEXT, ContentType.PHOTO]),
+ Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
+ state=CreateTestSG.input_question_text,
+ ),
+ Window(
+ Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"),
+ Select(
+ Format("{item[1]}"),
+ id="question_type",
+ item_id_getter=lambda x: x[0],
+ items="question_types",
+ on_click=on_question_type_selected,
+ ),
+ Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
+ state=CreateTestSG.select_question_type,
+ getter=get_question_type_data,
+ ),
+ Window(
+ Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (для проверки будет использоваться точное совпадение):\n(максимум 255 символов)"),
+ MessageInput(on_correct_answer_input),
+ Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
+ state=CreateTestSG.input_correct_answer,
+ ),
+ Window(
+ Format("📝 Варианты ответов\n\n📊 Добавлено вариантов: {options_count}/10\n\n💬 Введите вариант ответа:\n(максимум 255 символов)"),
+ MessageInput(on_option_input),
+ Button(Const("✅ Завершить добавление вариантов"), id="finish_options", on_click=on_finish_options),
+ Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
+ state=CreateTestSG.input_options,
+ getter=get_options_data,
+ ),
+ Window(
+ Const("✅ Правильные ответы\n\nОтметьте правильные варианты ответов:"),
+ Select(
+ Format("{item[1]}"),
+ id="options",
+ item_id_getter=lambda x: x[0],
+ items="options",
+ on_click=on_option_toggle,
+ ),
+ Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct),
+ Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
+ state=CreateTestSG.mark_correct_options,
+ getter=get_options_data,
+ ),
+ Window(
+ Format("{preview}\n\n💾 Сохранить вопрос?"),
+ Row(
+ Button(Const("✅ Сохранить"), id="save", on_click=on_save_question),
+ Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_question),
+ ),
+ state=CreateTestSG.confirm_question,
+ getter=get_question_preview,
+ ),
+)
diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py
index 8db1252..20a2c2e 100644
--- a/src/trudex/application/bot/creator_dialogs/states.py
+++ b/src/trudex/application/bot/creator_dialogs/states.py
@@ -19,3 +19,20 @@ class CreatorTestsSG(StatesGroup):
class CreatorBroadcastSG(StatesGroup):
broadcast_input = State()
broadcast_confirm = State()
+
+
+class CreateTestSG(StatesGroup):
+ input_title = State()
+ input_description = State()
+ input_password = State()
+ input_expires_at = State()
+ input_for_group = State()
+ confirm_test_info = State()
+ add_question = State()
+ input_question_text = State()
+ select_question_type = State()
+ input_correct_answer = State()
+ input_options = State()
+ mark_correct_options = State()
+ confirm_question = State()
+ test_created = State()
diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py
index 403742f..67d29e9 100644
--- a/src/trudex/application/bot/creator_dialogs/tests.py
+++ b/src/trudex/application/bot/creator_dialogs/tests.py
@@ -5,7 +5,7 @@ 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 CreatorTestsSG, CreatorMenuSG
+from trudex.application.bot.creator_dialogs.states import CreatorTestsSG, CreatorMenuSG, CreateTestSG
from trudex.infrastructure.database.dao.test import TestDAO
@@ -27,8 +27,8 @@ async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: D
await _callback.answer("Тест выбран")
-async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager):
- await _callback.answer("Добавление теста")
+async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
+ await manager.start(CreateTestSG.input_title, mode=StartMode.RESET_STACK)
async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):