mirror of
https://github.com/koloideal/Quizzi.git
synced 2026-06-10 10:25:28 +03:00
commit
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -179,7 +179,6 @@ async def get_user_result_detail(
|
||||
"\n<b>📋 Ответы:</b>\n",
|
||||
]
|
||||
|
||||
# Загружаем все вопросы за один запрос
|
||||
question_ids = [answer.question_id for answer in answers]
|
||||
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",
|
||||
]
|
||||
|
||||
# Загружаем все вопросы за один запрос
|
||||
question_ids = [answer.question_id for answer in answers]
|
||||
questions_map = await test_repo.get_questions_with_options_by_ids(question_ids)
|
||||
|
||||
|
||||
@@ -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 = """<b>📋 Спецификация формата JSON</b>
|
||||
<code>{
|
||||
"question_type": "single",
|
||||
"question": "Текст вопроса",
|
||||
"image_url": "https://...",
|
||||
"answers": [
|
||||
{"option": "Вариант 1", "is_correct": true},
|
||||
{"option": "Вариант 2", "is_correct": false}
|
||||
@@ -67,13 +69,15 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
|
||||
<code>{
|
||||
"question_type": "input",
|
||||
"question": "Текст вопроса",
|
||||
"image_url": "https://...",
|
||||
"correct_answer": "правильный ответ"
|
||||
}</code>
|
||||
|
||||
<b>⚠️ Важно:</b>
|
||||
• Для <code>single</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 = """// ═══════════════════════════════════════════════════════════════
|
||||
@@ -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"✅ <b>Тест импортирован!</b>\n\n"
|
||||
|
||||
@@ -201,7 +201,6 @@ async def get_attempt_detail(
|
||||
"<b>📋 Ответы:</b>\n",
|
||||
]
|
||||
|
||||
# Загружаем все вопросы с опциями за один запрос
|
||||
question_ids = [answer.question_id for answer in answers]
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -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"<b>📝 Вопрос {progress}</b>\n\n<blockquote>{question.text}</blockquote>",
|
||||
"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<i>Выберите один вариант ответа:</i>"),
|
||||
Column(
|
||||
Radio(
|
||||
@@ -508,6 +518,7 @@ take_test_dialog = Dialog(
|
||||
getter=get_question_data,
|
||||
),
|
||||
Window(
|
||||
DynamicMedia("media", when="media"),
|
||||
Format("{question_text}\n\n<i>Выберите несколько вариантов ответа:</i>"),
|
||||
Column(
|
||||
Multiselect(
|
||||
@@ -524,6 +535,7 @@ take_test_dialog = Dialog(
|
||||
getter=get_question_data,
|
||||
),
|
||||
Window(
|
||||
DynamicMedia("media", when="media"),
|
||||
Format("{question_text}\n\n<i>Введите ответ:</i>"),
|
||||
MessageInput(on_text_answer_input),
|
||||
state=UserTestSG.question_input,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user