diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index ff8d583..730d3a2 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -52,16 +52,13 @@ async def main() -> None: take_test_dialog, registration_dialog, deeplink_dialog, - # Shared dialogs shared_tests_dialog, shared_groups_dialog, shared_broadcast_dialog, shared_templates_dialog, shared_create_test_dialog, - # Admin dialogs admin_menu_dialog, admin_users_dialog, - # Creator dialogs creator_menu_dialog, creator_users_dialog, ) diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index b0eb6e6..3ea52bc 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -179,7 +179,6 @@ async def get_user_result_detail( "\n📋 Ответы:\n", ] - # Загружаем все вопросы за один запрос question_ids = [answer.question_id for answer in answers] questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index eee5525..7e493bd 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -303,7 +303,6 @@ async def get_user_result_detail( "\n📋 Ответы:\n", ] - # Загружаем все вопросы за один запрос question_ids = [answer.question_id for answer in answers] questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) diff --git a/src/trudex/application/bot/shared_dialogs/templates.py b/src/trudex/application/bot/shared_dialogs/templates.py index 9849860..5ac977c 100644 --- a/src/trudex/application/bot/shared_dialogs/templates.py +++ b/src/trudex/application/bot/shared_dialogs/templates.py @@ -1,5 +1,6 @@ import json +import httpx from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window @@ -57,6 +58,7 @@ SPEC_INFO = """📋 Спецификация формата JSON { "question_type": "single", "question": "Текст вопроса", + "image_url": "https://...", "answers": [ {"option": "Вариант 1", "is_correct": true}, {"option": "Вариант 2", "is_correct": false} @@ -67,13 +69,15 @@ SPEC_INFO = """📋 Спецификация формата JSON { "question_type": "input", "question": "Текст вопроса", + "image_url": "https://...", "correct_answer": "правильный ответ" } ⚠️ Важно: • Для single — ровно один is_correct: true • Для multiple — один или более is_correct: true -• Минимум 2 варианта ответа для single/multiple""" +• Минимум 2 варианта ответа для single/multiple +• image_url — опционально, URL изображения к вопросу""" TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════ @@ -91,7 +95,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ // // ❓ ВОПРОСЫ (всего 6): // 1. [single] - Один правильный ответ (3 варианта) -// 2. [single] - Один правильный ответ (4 варианта) +// 2. [single] - Один правильный ответ (4 варианта) + изображение // 3. [multiple] - Несколько правильных (4 варианта, 2 верных) // 4. [multiple] - Несколько правильных (5 вариантов, 3 верных) // 5. [input] - Ввод текста (точный ответ) @@ -101,6 +105,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ // • null означает "не задано" / "без ограничений" // • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS // • for_group - номер группы или null для всех пользователей +// • image_url - URL изображения к вопросу (опционально) // // ═══════════════════════════════════════════════════════════════ @@ -124,6 +129,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ { "question_type": "single", "question": "Сколько байт в одном килобайте?", + "image_url": "https://example.com/kilobyte.png", "answers": [ {"option": "100", "is_correct": false}, {"option": "1000", "is_correct": false}, @@ -232,6 +238,9 @@ async def on_test_selected_for_export( "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: @@ -246,7 +255,6 @@ async def on_test_selected_for_export( json_str = json.dumps(export_data, ensure_ascii=False, indent=2) - # Build comment header 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) @@ -288,11 +296,44 @@ async def on_template_ultimate(_callback: CallbackQuery, _button: Button, _manag 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, @@ -305,11 +346,19 @@ async def create_test_from_parsed( ) 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: @@ -375,14 +424,24 @@ async def on_import_file( 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 - await create_test_from_parsed(result, test_dao, question_dao, option_dao) + 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" diff --git a/src/trudex/application/bot/shared_dialogs/tests.py b/src/trudex/application/bot/shared_dialogs/tests.py index 021098e..8b689d1 100644 --- a/src/trudex/application/bot/shared_dialogs/tests.py +++ b/src/trudex/application/bot/shared_dialogs/tests.py @@ -201,7 +201,6 @@ async def get_attempt_detail( "📋 Ответы:\n", ] - # Загружаем все вопросы с опциями за один запрос question_ids = [answer.question_id for answer in answers] questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/trudex/application/bot/user_dialogs/deeplink.py index b990ae5..8f24726 100644 --- a/src/trudex/application/bot/user_dialogs/deeplink.py +++ b/src/trudex/application/bot/user_dialogs/deeplink.py @@ -91,7 +91,6 @@ async def on_start_deeplink_test( await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: - # Проверяем rate limit перед показом экрана ввода пароля allowed, wait_time = await rate_limiter.check(user_id) if not allowed: minutes = int(wait_time // 60) + 1 @@ -173,7 +172,6 @@ async def on_deeplink_password_input( manager, test_repo, attempt_repo, test_id, message.from_user.id ) else: - # Проверяем rate limit при неверном пароле allowed, wait_time = await rate_limiter.check(message.from_user.id) if not allowed: minutes = int(wait_time // 60) + 1 diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index e618084..c0cae97 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -1,7 +1,10 @@ +from aiogram.enums import ContentType as AiogramContentType from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.api.entities import MediaAttachment, MediaId from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Radio +from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -68,7 +71,6 @@ async def on_start_test( await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: - # Проверяем rate limit перед показом экрана ввода пароля allowed, wait_time = await rate_limiter.check(user_id) if not allowed: minutes = int(wait_time // 60) + 1 @@ -146,7 +148,6 @@ async def on_password_input( await manager.switch_to(first_state) else: - # Проверяем rate limit при неверном пароле allowed, wait_time = await rate_limiter.check(message.from_user.id) if not allowed: minutes = int(wait_time // 60) + 1 @@ -190,19 +191,27 @@ async def get_question_data( questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", []) if not questions or current_index >= len(questions): - return {"question_text": "Ошибка", "options": []} + return {"question_text": "Ошибка", "options": [], "media": None} question_id = questions[current_index] question, options = await test_repo.get_question_with_options(question_id) if not question: - return {"question_text": "Ошибка", "options": []} + return {"question_text": "Ошибка", "options": [], "media": None} progress = f"{current_index + 1}/{len(questions)}" + media = None + if question.tg_file_id: + media = MediaAttachment( + type=AiogramContentType.PHOTO, + file_id=MediaId(question.tg_file_id), + ) + return { "question_text": f"📝 Вопрос {progress}\n\n
{question.text}
", "options": [(opt.text, str(opt.id)) for opt in options], + "media": media, } @@ -492,6 +501,7 @@ take_test_dialog = Dialog( state=UserTestSG.password_input, ), Window( + DynamicMedia("media", when="media"), Format("{question_text}\n\nВыберите один вариант ответа:"), Column( Radio( @@ -508,6 +518,7 @@ take_test_dialog = Dialog( getter=get_question_data, ), Window( + DynamicMedia("media", when="media"), Format("{question_text}\n\nВыберите несколько вариантов ответа:"), Column( Multiselect( @@ -524,6 +535,7 @@ take_test_dialog = Dialog( getter=get_question_data, ), Window( + DynamicMedia("media", when="media"), Format("{question_text}\n\nВведите ответ:"), MessageInput(on_text_answer_input), state=UserTestSG.question_input, diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index b4bfad8..fa72f6f 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -15,6 +15,7 @@ class ParsedQuestion: question_type: str options: list[ParsedOption] correct_answer: str | None = None + image_url: str | None = None @dataclass @@ -219,16 +220,48 @@ class TestParser: )) return None + image_url = self._parse_image_url(data, path, errors) + if question_type == "input": - return self._parse_input_question(data, path, text, errors) + return self._parse_input_question(data, path, text, image_url, errors) else: - return self._parse_choice_question(data, path, text, question_type, errors) + return self._parse_choice_question(data, path, text, question_type, image_url, errors) + + def _parse_image_url( + self, + data: dict, + path: str, + errors: list[ParseError], + ) -> str | None: + image_url = data.get("image_url") + + if image_url is None: + return None + + if not isinstance(image_url, str): + errors.append(ParseError("Поле 'image_url' должно быть строкой", path=f"{path}.image_url")) + return None + + image_url = image_url.strip() + if not image_url: + return None + + if not image_url.startswith(("http://", "https://")): + errors.append(ParseError("Поле 'image_url' должно быть URL (http:// или https://)", path=f"{path}.image_url")) + return None + + if len(image_url) > 2000: + errors.append(ParseError("URL изображения слишком длинный (максимум 2000)", path=f"{path}.image_url")) + return None + + return image_url def _parse_input_question( self, data: dict, path: str, text: str, + image_url: str | None, errors: list[ParseError], ) -> ParsedQuestion | None: correct_answer = data.get("correct_answer") @@ -254,6 +287,7 @@ class TestParser: question_type="input", options=[ParsedOption(text=correct_answer, is_correct=True)], correct_answer=correct_answer, + image_url=image_url, ) def _parse_choice_question( @@ -262,6 +296,7 @@ class TestParser: path: str, text: str, question_type: str, + image_url: str | None, errors: list[ParseError], ) -> ParsedQuestion | None: options_data = data.get("answers") @@ -333,4 +368,5 @@ class TestParser: text=text, question_type=question_type, options=options, + image_url=image_url, ) diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py index 0059c81..8e775d7 100644 --- a/src/trudex/infrastructure/utils/broadcast.py +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -37,7 +37,6 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U except TelegramRetryAfter as e: logger.warning("Rate limited, waiting %d seconds", e.retry_after) await asyncio.sleep(e.retry_after) - # Retry after waiting try: await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) success += 1