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