import json import httpx 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 quizzi.application.bot.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG from quizzi.domain.schemas import QuestionType from quizzi.domain.test_parser import ParsedTest, TestParser from quizzi.infrastructure.database.dao.group import GroupDAO from quizzi.infrastructure.database.dao.option import OptionDAO from quizzi.infrastructure.database.dao.question import QuestionDAO from quizzi.infrastructure.database.dao.test import TestDAO from quizzi.infrastructure.database.repo.test import TestRepository TEMPLATES_INFO = ( "📦 Шаблоны тестов\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" "🔹 Экспорт — сохраните тест как файл для резервной копии или передачи\n" "🔹 Импорт — загрузите тест из файла\n" "🔹 Спецификация — описание формата JSON для создания тестов вручную" ) SPEC_INFO = """📋 Спецификация формата JSON Структура файла: { "title": "Название теста", "description": "Описание теста", "password": null, "attempts": null, "time_limit": null, "expires_at": null, "for_group": null, "questions": [...] } Поля теста:title — название (обязательно, до 255 символов) • description — описание (до 2000 символов) • password — пароль для доступа или nullattempts — лимит попыток (1-100) или nulltime_limit — лимит времени в секундах (1-86400) или nullexpires_at — срок действия в ISO формате или nullfor_group — номер группы или null для всех Типы вопросов:single — один правильный ответ • multiple — несколько правильных ответов • input — ввод текста (регистр и пробелы игнорируются) Формат вопроса (single/multiple): { "question_type": "single", "question": "Текст вопроса", "image_url": "https://...", "answers": [ {"option": "Вариант 1", "is_correct": true}, {"option": "Вариант 2", "is_correct": false} ] } Формат вопроса (input): { "question_type": "input", "question": "Текст вопроса", "image_url": "https://...", "correct_answer": "правильный ответ" } ⚠️ Важно: • Для single — ровно один is_correct: true • Для multiple — один или более is_correct: true • Минимум 2 варианта ответа для single/multiple • image_url — опционально, URL изображения к вопросу""" TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════ // УЛЬТИМАТИВНЫЙ ШАБЛОН ТЕСТА // ═══════════════════════════════════════════════════════════════ // // 📝 Название: Ультимативный пример теста // 📄 Описание: Полная демонстрация всех возможностей формата // // ⚙️ НАСТРОЙКИ: // • Пароль: test2024 // • Попыток: 5 // • Лимит времени: 1800 секунд (30 минут) // • Срок действия: 31 декабря 2026, 23:59 // • Для группы: 2024 (или null для всех) // // ❓ ВОПРОСЫ (всего 6): // 1. [single] - Один правильный ответ (3 варианта) // 2. [single] - Один правильный ответ (4 варианта) + изображение // 3. [multiple] - Несколько правильных (4 варианта, 2 верных) // 4. [multiple] - Несколько правильных (5 вариантов, 3 верных) // 5. [input] - Ввод текста (точный ответ) // 6. [input] - Ввод текста (регистр игнорируется) // // 💡 ПОДСКАЗКИ: // • null означает "не задано" / "без ограничений" // • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS // • time_limit в секундах (1-86400), null для без ограничений // • for_group - номер группы или null для всех пользователей // • image_url - URL изображения к вопросу (опционально) // // ═══════════════════════════════════════════════════════════════ { "title": "Ультимативный пример теста", "description": "Полная демонстрация всех возможностей формата: разные типы вопросов, настройки доступа, ограничения по времени и группам", "password": "test2024", "attempts": 5, "time_limit": 1800, "expires_at": "2026-12-31T23:59:59", "for_group": 2024, "questions": [ { "question_type": "single", "question": "Какой язык программирования чаще всего используется для создания Telegram ботов?", "answers": [ {"option": "Python", "is_correct": true}, {"option": "HTML", "is_correct": false}, {"option": "CSS", "is_correct": false} ] }, { "question_type": "single", "question": "Сколько байт в одном килобайте?", "image_url": "https://example.com/kilobyte.png", "answers": [ {"option": "100", "is_correct": false}, {"option": "1000", "is_correct": false}, {"option": "1024", "is_correct": true}, {"option": "2048", "is_correct": false} ] }, { "question_type": "multiple", "question": "Выберите все языки программирования из списка:", "answers": [ {"option": "Python", "is_correct": true}, {"option": "JavaScript", "is_correct": true}, {"option": "HTML", "is_correct": false}, {"option": "CSS", "is_correct": false} ] }, { "question_type": "multiple", "question": "Какие из перечисленных являются базами данных?", "answers": [ {"option": "PostgreSQL", "is_correct": true}, {"option": "MongoDB", "is_correct": true}, {"option": "Redis", "is_correct": true}, {"option": "React", "is_correct": false}, {"option": "Docker", "is_correct": false} ] }, { "question_type": "input", "question": "Как называется популярная библиотека для создания Telegram ботов на Python? (одно слово)", "correct_answer": "aiogram" }, { "question_type": "input", "question": "Напишите название протокола для безопасной передачи данных в интернете (4 буквы, регистр не важен)", "correct_answer": "HTTPS" } ] } """ 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: assert _callback.message is not None await _callback.answer("⏳ Экспортирую тест...") test_id = int(item_id) test, questions_with_options = await test_repo.get_full_test(test_id) if not test: await _callback.message.answer("❌ Тест не найден") return export_data: dict = { "title": test.title, "description": test.description, "password": test.password, "attempts": test.attempts, "time_limit": test.time_limit, "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 = { "question_type": question.question_type.value, "question": question.text, } if question.tg_file_id: question_data["tg_file_id"] = question.tg_file_id 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["answers"] = [ {"option": 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) created_str = test.created_at.strftime("%d.%m.%Y %H:%M") if test.created_at else "—" updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else "—" questions_count = len(questions_with_options) comment_header = f"""// ═══════════════════════════════════════════════════════════════ // ЭКСПОРТ ТЕСТА: {test.title} // ═══════════════════════════════════════════════════════════════ // // ❓ Вопросов: {questions_count} // 📅 Создан: {created_str} // 🔄 Обновлён: {updated_str} // // ═══════════════════════════════════════════════════════════════ """ full_content = comment_header + json_str safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50] filename = f"{safe_title}.json" await _callback.message.answer_document( document=BufferedInputFile(full_content.encode("utf-8"), filename=filename), caption=f"📤 Экспорт теста: {test.title}", ) async def send_template(callback: CallbackQuery, template_str: str, name: str, title: str) -> None: filename = f"template_{name}.json" assert callback.message is not None await callback.message.answer_document( document=BufferedInputFile(template_str.encode("utf-8"), filename=filename), caption=f"📄 Шаблон: {title}", ) async def on_template_ultimate(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: await send_template(_callback, TEMPLATE_ULTIMATE, "ultimate", "Ультимативный пример теста") async def download_image(url: str) -> bytes | None: try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url) if response.status_code != 200: return None content_type = response.headers.get("content-type", "") if not content_type.startswith("image/"): return None if len(response.content) > 10 * 1024 * 1024: return None return response.content except httpx.HTTPError: return None async def upload_image_to_telegram(bot: Bot, image_data: bytes, chat_id: int) -> str | None: try: msg = await bot.send_photo( chat_id=chat_id, photo=BufferedInputFile(image_data, filename="image.jpg"), disable_notification=True, ) await msg.delete() if msg.photo: return msg.photo[-1].file_id return None except Exception: return None async def create_test_from_parsed( parsed: ParsedTest, test_dao: TestDAO, question_dao: QuestionDAO, option_dao: OptionDAO, bot: Bot | None = None, chat_id: int | None = None, ) -> int: test = await test_dao.create( title=parsed.title, description=parsed.description, password=parsed.password, attempts=parsed.attempts, time_limit=parsed.time_limit, expires_at=parsed.expires_at, for_group=parsed.for_group, is_active=False, ) for position, q in enumerate(parsed.questions): tg_file_id: str | None = None if q.image_url and bot and chat_id: image_data = await download_image(q.image_url) if image_data: tg_file_id = await upload_image_to_telegram(bot, image_data, chat_id) question = await question_dao.create( test_id=test.id, text=q.text, position=position, question_type=q.question_type, tg_file_id=tg_file_id, ) 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], group_dao: FromDishka[GroupDAO], ) -> 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 progress_msg = await message.answer("⏳ Импортирую тест...") file = await bot_inst.get_file(message.document.file_id) if not file.file_path: await progress_msg.edit_text("❌ Не удалось загрузить файл") return file_bytes = await bot_inst.download_file(file.file_path) if not file_bytes: await progress_msg.edit_text("❌ Не удалось загрузить файл") return try: json_str = file_bytes.read().decode("utf-8") except UnicodeDecodeError: await progress_msg.edit_text("❌ Файл должен быть в кодировке UTF-8") return parser = TestParser() result = parser.parse(json_str) if isinstance(result, list): if not result: await progress_msg.edit_text("❌ Неизвестная ошибка валидации") return 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 progress_msg.edit_text("\n".join(error_lines)) return if result.for_group is not None: group = await group_dao.get_by_number(result.for_group) if not group: await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует") return has_images = any(q.image_url for q in result.questions) if has_images: await progress_msg.edit_text("⏳ Загружаю изображения...") await create_test_from_parsed( result, test_dao, question_dao, option_dao, bot=bot_inst if has_images else None, chat_id=message.chat.id if has_images else None, ) await progress_msg.edit_text( f"✅ Тест импортирован!\n\n" f"📝 Название: {result.title}\n" f"❓ Вопросов: {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("📤 Экспорт теста\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), Button(Const("📦 Ультимативный шаблон"), id="tpl_ultimate", on_click=on_template_ultimate), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), state=SharedTemplatesSG.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=SharedTemplatesSG.import_file, ), )