This commit is contained in:
2026-01-04 19:22:06 +03:00
parent f3c7f3d10a
commit 326ced233b
9 changed files with 118 additions and 20 deletions
-3
View File
@@ -52,16 +52,13 @@ async def main() -> None:
take_test_dialog, take_test_dialog,
registration_dialog, registration_dialog,
deeplink_dialog, deeplink_dialog,
# Shared dialogs
shared_tests_dialog, shared_tests_dialog,
shared_groups_dialog, shared_groups_dialog,
shared_broadcast_dialog, shared_broadcast_dialog,
shared_templates_dialog, shared_templates_dialog,
shared_create_test_dialog, shared_create_test_dialog,
# Admin dialogs
admin_menu_dialog, admin_menu_dialog,
admin_users_dialog, admin_users_dialog,
# Creator dialogs
creator_menu_dialog, creator_menu_dialog,
creator_users_dialog, creator_users_dialog,
) )
@@ -179,7 +179,6 @@ async def get_user_result_detail(
"\n<b>📋 Ответы:</b>\n", "\n<b>📋 Ответы:</b>\n",
] ]
# Загружаем все вопросы за один запрос
question_ids = [answer.question_id for answer in answers] question_ids = [answer.question_id for answer in answers]
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
@@ -303,7 +303,6 @@ async def get_user_result_detail(
"\n<b>📋 Ответы:</b>\n", "\n<b>📋 Ответы:</b>\n",
] ]
# Загружаем все вопросы за один запрос
question_ids = [answer.question_id for answer in answers] question_ids = [answer.question_id for answer in answers]
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
@@ -1,5 +1,6 @@
import json import json
import httpx
from aiogram import Bot from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog import Dialog, DialogManager, StartMode, Window
@@ -57,6 +58,7 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
<code>{ <code>{
"question_type": "single", "question_type": "single",
"question": "Текст вопроса", "question": "Текст вопроса",
"image_url": "https://...",
"answers": [ "answers": [
{"option": "Вариант 1", "is_correct": true}, {"option": "Вариант 1", "is_correct": true},
{"option": "Вариант 2", "is_correct": false} {"option": "Вариант 2", "is_correct": false}
@@ -67,13 +69,15 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
<code>{ <code>{
"question_type": "input", "question_type": "input",
"question": "Текст вопроса", "question": "Текст вопроса",
"image_url": "https://...",
"correct_answer": "правильный ответ" "correct_answer": "правильный ответ"
}</code> }</code>
<b>⚠️ Важно:</b> <b>⚠️ Важно:</b>
• Для <code>single</code> — ровно один <code>is_correct: true</code> • Для <code>single</code> — ровно один <code>is_correct: true</code>
• Для <code>multiple</code> — один или более <code>is_correct: true</code> • Для <code>multiple</code> — один или более <code>is_correct: true</code>
• Минимум 2 варианта ответа для single/multiple""" • Минимум 2 варианта ответа для single/multiple
• <code>image_url</code> — опционально, URL изображения к вопросу"""
TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════ TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════
@@ -91,7 +95,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════
// //
// ❓ ВОПРОСЫ (всего 6): // ❓ ВОПРОСЫ (всего 6):
// 1. [single] - Один правильный ответ (3 варианта) // 1. [single] - Один правильный ответ (3 варианта)
// 2. [single] - Один правильный ответ (4 варианта) // 2. [single] - Один правильный ответ (4 варианта) + изображение
// 3. [multiple] - Несколько правильных (4 варианта, 2 верных) // 3. [multiple] - Несколько правильных (4 варианта, 2 верных)
// 4. [multiple] - Несколько правильных (5 вариантов, 3 верных) // 4. [multiple] - Несколько правильных (5 вариантов, 3 верных)
// 5. [input] - Ввод текста (точный ответ) // 5. [input] - Ввод текста (точный ответ)
@@ -101,6 +105,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════
// • null означает "не задано" / "без ограничений" // • null означает "не задано" / "без ограничений"
// • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS // • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS
// • for_group - номер группы или null для всех пользователей // • for_group - номер группы или null для всех пользователей
// • image_url - URL изображения к вопросу (опционально)
// //
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -124,6 +129,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════
{ {
"question_type": "single", "question_type": "single",
"question": "Сколько байт в одном килобайте?", "question": "Сколько байт в одном килобайте?",
"image_url": "https://example.com/kilobyte.png",
"answers": [ "answers": [
{"option": "100", "is_correct": false}, {"option": "100", "is_correct": false},
{"option": "1000", "is_correct": false}, {"option": "1000", "is_correct": false},
@@ -232,6 +238,9 @@ async def on_test_selected_for_export(
"question": question.text, "question": question.text,
} }
if question.tg_file_id:
question_data["tg_file_id"] = question.tg_file_id
if question.question_type == QuestionType.INPUT: if question.question_type == QuestionType.INPUT:
correct_options = [o for o in options if o.is_correct] correct_options = [o for o in options if o.is_correct]
if correct_options: 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) 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 "" 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 "" updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else ""
questions_count = len(questions_with_options) 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", "Ультимативный пример теста") 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( async def create_test_from_parsed(
parsed: ParsedTest, parsed: ParsedTest,
test_dao: TestDAO, test_dao: TestDAO,
question_dao: QuestionDAO, question_dao: QuestionDAO,
option_dao: OptionDAO, option_dao: OptionDAO,
bot: Bot | None = None,
chat_id: int | None = None,
) -> int: ) -> int:
test = await test_dao.create( test = await test_dao.create(
title=parsed.title, title=parsed.title,
@@ -305,11 +346,19 @@ async def create_test_from_parsed(
) )
for position, q in enumerate(parsed.questions): 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( question = await question_dao.create(
test_id=test.id, test_id=test.id,
text=q.text, text=q.text,
position=position, position=position,
question_type=q.question_type, question_type=q.question_type,
tg_file_id=tg_file_id,
) )
for opt in q.options: for opt in q.options:
@@ -375,14 +424,24 @@ async def on_import_file(
await progress_msg.edit_text("\n".join(error_lines)) await progress_msg.edit_text("\n".join(error_lines))
return return
# Проверяем существование группы
if result.for_group is not None: if result.for_group is not None:
group = await group_dao.get_by_number(result.for_group) group = await group_dao.get_by_number(result.for_group)
if not group: if not group:
await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует") await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует")
return 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( await progress_msg.edit_text(
f"✅ <b>Тест импортирован!</b>\n\n" f"✅ <b>Тест импортирован!</b>\n\n"
@@ -201,7 +201,6 @@ async def get_attempt_detail(
"<b>📋 Ответы:</b>\n", "<b>📋 Ответы:</b>\n",
] ]
# Загружаем все вопросы с опциями за один запрос
question_ids = [answer.question_id for answer in answers] question_ids = [answer.question_id for answer in answers]
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
@@ -91,7 +91,6 @@ async def on_start_deeplink_test(
await attempt_repo.attempt_dao.delete(active_attempt.id) await attempt_repo.attempt_dao.delete(active_attempt.id)
if test.password: if test.password:
# Проверяем rate limit перед показом экрана ввода пароля
allowed, wait_time = await rate_limiter.check(user_id) allowed, wait_time = await rate_limiter.check(user_id)
if not allowed: if not allowed:
minutes = int(wait_time // 60) + 1 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 manager, test_repo, attempt_repo, test_id, message.from_user.id
) )
else: else:
# Проверяем rate limit при неверном пароле
allowed, wait_time = await rate_limiter.check(message.from_user.id) allowed, wait_time = await rate_limiter.check(message.from_user.id)
if not allowed: if not allowed:
minutes = int(wait_time // 60) + 1 minutes = int(wait_time // 60) + 1
@@ -1,7 +1,10 @@
from aiogram.enums import ContentType as AiogramContentType
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window 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.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Radio 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 aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject 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) await attempt_repo.attempt_dao.delete(active_attempt.id)
if test.password: if test.password:
# Проверяем rate limit перед показом экрана ввода пароля
allowed, wait_time = await rate_limiter.check(user_id) allowed, wait_time = await rate_limiter.check(user_id)
if not allowed: if not allowed:
minutes = int(wait_time // 60) + 1 minutes = int(wait_time // 60) + 1
@@ -146,7 +148,6 @@ async def on_password_input(
await manager.switch_to(first_state) await manager.switch_to(first_state)
else: else:
# Проверяем rate limit при неверном пароле
allowed, wait_time = await rate_limiter.check(message.from_user.id) allowed, wait_time = await rate_limiter.check(message.from_user.id)
if not allowed: if not allowed:
minutes = int(wait_time // 60) + 1 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", []) questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", [])
if not questions or current_index >= len(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_id = questions[current_index]
question, options = await test_repo.get_question_with_options(question_id) question, options = await test_repo.get_question_with_options(question_id)
if not question: if not question:
return {"question_text": "Ошибка", "options": []} return {"question_text": "Ошибка", "options": [], "media": None}
progress = f"{current_index + 1}/{len(questions)}" 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 { return {
"question_text": f"<b>📝 Вопрос {progress}</b>\n\n<blockquote>{question.text}</blockquote>", "question_text": f"<b>📝 Вопрос {progress}</b>\n\n<blockquote>{question.text}</blockquote>",
"options": [(opt.text, str(opt.id)) for opt in options], "options": [(opt.text, str(opt.id)) for opt in options],
"media": media,
} }
@@ -492,6 +501,7 @@ take_test_dialog = Dialog(
state=UserTestSG.password_input, state=UserTestSG.password_input,
), ),
Window( Window(
DynamicMedia("media", when="media"),
Format("{question_text}\n\n<i>Выберите один вариант ответа:</i>"), Format("{question_text}\n\n<i>Выберите один вариант ответа:</i>"),
Column( Column(
Radio( Radio(
@@ -508,6 +518,7 @@ take_test_dialog = Dialog(
getter=get_question_data, getter=get_question_data,
), ),
Window( Window(
DynamicMedia("media", when="media"),
Format("{question_text}\n\n<i>Выберите несколько вариантов ответа:</i>"), Format("{question_text}\n\n<i>Выберите несколько вариантов ответа:</i>"),
Column( Column(
Multiselect( Multiselect(
@@ -524,6 +535,7 @@ take_test_dialog = Dialog(
getter=get_question_data, getter=get_question_data,
), ),
Window( Window(
DynamicMedia("media", when="media"),
Format("{question_text}\n\n<i>Введите ответ:</i>"), Format("{question_text}\n\n<i>Введите ответ:</i>"),
MessageInput(on_text_answer_input), MessageInput(on_text_answer_input),
state=UserTestSG.question_input, state=UserTestSG.question_input,
+38 -2
View File
@@ -15,6 +15,7 @@ class ParsedQuestion:
question_type: str question_type: str
options: list[ParsedOption] options: list[ParsedOption]
correct_answer: str | None = None correct_answer: str | None = None
image_url: str | None = None
@dataclass @dataclass
@@ -219,16 +220,48 @@ class TestParser:
)) ))
return None return None
image_url = self._parse_image_url(data, path, errors)
if question_type == "input": 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: 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( def _parse_input_question(
self, self,
data: dict, data: dict,
path: str, path: str,
text: str, text: str,
image_url: str | None,
errors: list[ParseError], errors: list[ParseError],
) -> ParsedQuestion | None: ) -> ParsedQuestion | None:
correct_answer = data.get("correct_answer") correct_answer = data.get("correct_answer")
@@ -254,6 +287,7 @@ class TestParser:
question_type="input", question_type="input",
options=[ParsedOption(text=correct_answer, is_correct=True)], options=[ParsedOption(text=correct_answer, is_correct=True)],
correct_answer=correct_answer, correct_answer=correct_answer,
image_url=image_url,
) )
def _parse_choice_question( def _parse_choice_question(
@@ -262,6 +296,7 @@ class TestParser:
path: str, path: str,
text: str, text: str,
question_type: str, question_type: str,
image_url: str | None,
errors: list[ParseError], errors: list[ParseError],
) -> ParsedQuestion | None: ) -> ParsedQuestion | None:
options_data = data.get("answers") options_data = data.get("answers")
@@ -333,4 +368,5 @@ class TestParser:
text=text, text=text,
question_type=question_type, question_type=question_type,
options=options, options=options,
image_url=image_url,
) )
@@ -37,7 +37,6 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U
except TelegramRetryAfter as e: except TelegramRetryAfter as e:
logger.warning("Rate limited, waiting %d seconds", e.retry_after) logger.warning("Rate limited, waiting %d seconds", e.retry_after)
await asyncio.sleep(e.retry_after) await asyncio.sleep(e.retry_after)
# Retry after waiting
try: try:
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
success += 1 success += 1