This commit is contained in:
2026-01-06 18:06:51 +03:00
parent 326ced233b
commit efe3f4ab43
71 changed files with 245 additions and 245 deletions
+1
View File
@@ -0,0 +1 @@
+97
View File
@@ -0,0 +1,97 @@
import asyncio
import logging
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram_dialog import setup_dialogs
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dishka import make_async_container
from dishka.integrations.aiogram import setup_dishka
from quizzi.application.bot.admin_dialogs.main_menu import admin_menu_dialog
from quizzi.application.bot.admin_dialogs.users import admin_users_dialog
from quizzi.application.bot.creator_dialogs.main_menu import creator_menu_dialog
from quizzi.application.bot.creator_dialogs.users import creator_users_dialog
from quizzi.application.bot.handlers import router
from quizzi.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware
from quizzi.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware
from quizzi.application.bot.shared_dialogs.broadcast import shared_broadcast_dialog
from quizzi.application.bot.shared_dialogs.create_test import shared_create_test_dialog
from quizzi.application.bot.shared_dialogs.groups import shared_groups_dialog
from quizzi.application.bot.shared_dialogs.templates import shared_templates_dialog
from quizzi.application.bot.shared_dialogs.tests import shared_tests_dialog
from quizzi.application.bot.user_dialogs.deeplink import deeplink_dialog
from quizzi.application.bot.user_dialogs.main_menu import user_menu_dialog
from quizzi.application.bot.user_dialogs.registration import registration_dialog
from quizzi.application.bot.user_dialogs.take_test import take_test_dialog
from quizzi.infrastructure.database.repo.user import UserRepository
from quizzi.infrastructure.di import DatabaseProvider, SchedulerProvider
from quizzi.infrastructure.utils.bot_commands import setup_bot_commands
from quizzi.infrastructure.utils.config import Config
async def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
config = Config.from_toml("config.toml")
bot = Bot(
token=config.bot.token,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
dp = Dispatcher()
dp.include_routers(
router,
user_menu_dialog,
take_test_dialog,
registration_dialog,
deeplink_dialog,
shared_tests_dialog,
shared_groups_dialog,
shared_broadcast_dialog,
shared_templates_dialog,
shared_create_test_dialog,
admin_menu_dialog,
admin_users_dialog,
creator_menu_dialog,
creator_users_dialog,
)
router.message.middleware(RejectNotAdminMiddleware())
router.message.middleware(RejectNotCreatorMiddleware())
container = make_async_container(
DatabaseProvider(),
SchedulerProvider(),
context={Bot: bot, Config: config}
)
setup_dialogs(dp)
setup_dishka(container, dp, auto_inject=True)
async with container() as request_container:
user_repo = await request_container.get(UserRepository)
await setup_bot_commands(bot, config, user_repo)
scheduler = await container.get(AsyncIOScheduler)
scheduler.start()
await bot.delete_webhook(drop_pending_updates=True)
logging.info("Бот запущен")
logging.info("Планировщик задач запущен")
try:
await dp.start_polling(bot)
finally:
scheduler.shutdown()
await bot.session.close()
if __name__ == "__main__":
asyncio.run(main())
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,47 @@
from aiogram.types import CallbackQuery
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.kbd import Button, Column
from aiogram_dialog.widgets.text import Const
from quizzi.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG
from quizzi.application.bot.shared_dialogs.states import (
SharedBroadcastSG,
SharedGroupsSG,
SharedTemplatesSG,
SharedTestsSG,
)
async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedTestsSG.tests_list)
async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(AdminUsersSG.users_list)
async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedGroupsSG.groups_list)
async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedBroadcastSG.broadcast_input)
async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedTemplatesSG.main)
admin_menu_dialog = Dialog(
Window(
Const("🔧 <b>Админ-панель</b>\n\nВыберите раздел:"),
Column(
Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked),
Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked),
Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked),
Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked),
Button(Const("📦 Шаблоны тестов"), id="templates", on_click=on_templates_clicked),
),
state=AdminMenuSG.main,
),
)
@@ -0,0 +1,13 @@
from aiogram.fsm.state import State, StatesGroup
class AdminMenuSG(StatesGroup):
main = State()
class AdminUsersSG(StatesGroup):
users_list = State()
users_input = State()
user_detail = State()
user_stats = State()
user_result_detail = State()
@@ -0,0 +1,269 @@
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo
from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from quizzi.application.bot.admin_dialogs.states import AdminUsersSG
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.timezone import to_msk
@inject
async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs):
users = await user_dao.get_all()
users_sorted = sorted(users, key=lambda u: u.created_at or u.id, reverse=True)
return {
"users": [
(f"{'👑 ' if u.is_admin else ''}{u.name or u.first_name} (@{u.username or 'нет'})", u.id)
for u in users_sorted
],
"count": len(users_sorted),
}
@inject
async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs):
user_id = dialog_manager.dialog_data.get("selected_user_id")
if not user_id:
return {"user_info": "Пользователь не выбран"}
user = await user_dao.get_by_id(user_id)
if not user:
return {"user_info": "Пользователь не найден"}
username_str = f"@{user.username}" if user.username else ""
name_str = user.name or ""
group_str = str(user.group) if user.group else ""
admin_status = "✅ Да" if user.is_admin else "❌ Нет"
user_info = (
f"<b>👤 Информация о пользователе</b>\n\n"
f"<b>ID:</b> <code>{user.id}</code>\n"
f"<b>Ник:</b> {user.first_name}\n"
f"<b>Имя и фамилия:</b> {name_str}\n"
f"<b>Username:</b> {username_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Администратор:</b> {admin_status}"
)
return {"user_info": user_info}
async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_user_id"] = int(item_id)
await manager.switch_to(AdminUsersSG.user_detail)
async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminUsersSG.users_input)
@inject
async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager, user_dao: FromDishka[UserDAO]):
text = (message.text or "").strip()
user = None
if text.startswith("@"):
username = text[1:]
user = await user_dao.get_by_username(username)
elif text.isdigit():
user = await user_dao.get_by_id(int(text))
if not user:
await message.answer("❌ Пользователь не найден в базе данных.")
return
manager.dialog_data["selected_user_id"] = user.id
await manager.switch_to(AdminUsersSG.user_detail)
async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.done()
async def on_user_stats_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(AdminUsersSG.user_stats)
@inject
async def get_user_stats_data(
dialog_manager: DialogManager,
user_dao: FromDishka[UserDAO],
attempt_repo: FromDishka[TestAttemptRepository],
**_kwargs,
):
user_id = dialog_manager.dialog_data.get("selected_user_id")
if not user_id:
return {"stats_info": "Пользователь не выбран", "results": [], "count": 0}
user = await user_dao.get_by_id(user_id)
if not user:
return {"stats_info": "Пользователь не найден", "results": [], "count": 0}
stats = await attempt_repo.get_user_stats(user_id)
attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id)
name = user.name or user.first_name
if stats["total_attempts"] > 0:
accuracy_str = f"📊 Средняя точность: <b>{stats['avg_score']}%</b>"
tests_str = f"📝 Пройдено тестов: <b>{stats['total_attempts']}</b>"
else:
accuracy_str = "📊 Средняя точность: <b>—</b>"
tests_str = "📝 Пройдено тестов: <b>0</b>"
stats_info = (
f"<b>📊 Статистика: {name}</b>\n\n"
f"{tests_str}\n"
f"{accuracy_str}"
)
results = []
for attempt, test_title in attempts_with_tests:
status = "" if attempt.is_passed else ""
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else ""
results.append((f"{status} {test_title}{attempt.score}% ({date_str})", attempt.id))
return {
"stats_info": stats_info,
"results": results,
"count": len(results),
}
async def on_result_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_attempt_id"] = int(item_id)
await manager.switch_to(AdminUsersSG.user_result_detail)
@inject
async def get_user_result_detail(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
test_repo: FromDishka[TestRepository],
**_kwargs
):
attempt_id = dialog_manager.dialog_data.get("selected_attempt_id")
if not attempt_id:
return {"result_info": "❌ Результат не найден"}
attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id)
if not attempt:
return {"result_info": "❌ Результат не найден"}
test, _ = await test_repo.get_test_with_questions(attempt.test_id)
test_title = test.title if test else "Неизвестный тест"
status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден"
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else ""
correct_count = sum(1 for a in answers if a.is_correct)
total_count = len(answers)
lines = [
f"<b>📝 {test_title}</b>\n",
f"📊 <b>Результат:</b> {attempt.score}%",
f"✏️ <b>Правильных ответов:</b> {correct_count} из {total_count}",
f"📅 <b>Дата:</b> {date_str}",
f"🏆 <b>Статус:</b> {status}",
"\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)
for i, answer in enumerate(answers, 1):
question_data = questions_map.get(answer.question_id)
if not question_data:
continue
question, options = question_data
correct_options = [opt for opt in options if opt.is_correct]
correct_texts = [opt.text for opt in correct_options]
status_icon = "" if answer.is_correct else ""
user_answer = answer.text_answer or ""
if "|" in user_answer:
user_answer = ", ".join(user_answer.split("|"))
lines.append(f"{status_icon} <b>Вопрос {i}</b>")
lines.append(f"<blockquote>{question.text}</blockquote>")
lines.append(f"👤 <i>Ответ:</i> {user_answer or ''}")
lines.append(f"✓ <i>Правильно:</i> {', '.join(correct_texts)}\n")
return {"result_info": "\n".join(lines)}
admin_users_dialog = Dialog(
Window(
Format("<b>👥 Пользователи</b>\n\nВсего: {count}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="user_select",
item_id_getter=lambda x: x[1],
items="users",
on_click=on_user_selected,
),
id="users_scroll",
width=1,
height=7,
),
Column(
Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main),
),
state=AdminUsersSG.users_list,
getter=get_users_data,
),
Window(
Const("<b>Введите ID или @username пользователя:</b>"),
MessageInput(on_user_input),
SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list),
state=AdminUsersSG.users_input,
),
Window(
Format("{user_info}"),
Column(
Button(Const("📊 Статистика"), id="stats", on_click=on_user_stats_clicked),
SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list),
),
state=AdminUsersSG.user_detail,
getter=get_user_detail_data,
),
Window(
Format("{stats_info}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="result_select",
item_id_getter=lambda x: x[1],
items="results",
on_click=on_result_selected,
),
id="results_scroll",
width=1,
height=5,
),
SwitchTo(Const("◀️ Назад"), id="back_to_detail", state=AdminUsersSG.user_detail),
state=AdminUsersSG.user_stats,
getter=get_user_stats_data,
),
Window(
Format("{result_info}"),
SwitchTo(Const("◀️ Назад"), id="back_to_stats", state=AdminUsersSG.user_stats),
state=AdminUsersSG.user_result_detail,
getter=get_user_result_detail,
),
)
@@ -0,0 +1,47 @@
from aiogram.types import CallbackQuery
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.kbd import Button, Column
from aiogram_dialog.widgets.text import Const
from quizzi.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG
from quizzi.application.bot.shared_dialogs.states import (
SharedBroadcastSG,
SharedGroupsSG,
SharedTemplatesSG,
SharedTestsSG,
)
async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedTestsSG.tests_list)
async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(CreatorUsersSG.users_list)
async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedGroupsSG.groups_list)
async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedBroadcastSG.broadcast_input)
async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None:
await manager.start(SharedTemplatesSG.main)
creator_menu_dialog = Dialog(
Window(
Const("👑 <b>Панель создателя</b>\n\nВыберите раздел:"),
Column(
Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked),
Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked),
Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked),
Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked),
Button(Const("📦 Шаблоны тестов"), id="templates", on_click=on_templates_clicked),
),
state=CreatorMenuSG.main,
),
)
@@ -0,0 +1,15 @@
from aiogram.fsm.state import State, StatesGroup
class CreatorMenuSG(StatesGroup):
main = State()
class CreatorUsersSG(StatesGroup):
users_list = State()
users_input = State()
user_detail = State()
user_stats = State()
user_result_detail = State()
make_admin_confirm = State()
remove_admin_confirm = State()
@@ -0,0 +1,413 @@
import asyncio
from aiogram import Bot
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo
from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from quizzi.application.bot.creator_dialogs.states import CreatorUsersSG
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.database.repo.user import UserRepository
from quizzi.infrastructure.utils.bot_commands import setup_bot_commands
from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.timezone import to_msk
@inject
async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs):
users = await user_dao.get_all()
users_sorted = sorted(users, key=lambda u: u.created_at or u.id, reverse=True)
return {
"users": [
(f"{'👑 ' if u.is_admin else ''}{u.name or u.first_name} (@{u.username or 'нет'})", u.id)
for u in users_sorted
],
"count": len(users_sorted),
}
@inject
async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs):
user_id = dialog_manager.dialog_data.get("selected_user_id")
if not user_id:
return {"user_info": "Пользователь не выбран", "is_admin": True, "show_make_admin": False}
user = await user_dao.get_by_id(user_id)
if not user:
return {"user_info": "Пользователь не найден", "is_admin": True, "show_make_admin": False}
username_str = f"@{user.username}" if user.username else ""
name_str = user.name or ""
group_str = str(user.group) if user.group else ""
admin_status = "✅ Да" if user.is_admin else "❌ Нет"
user_info = (
f"<b>👤 Информация о пользователе</b>\n\n"
f"<b>ID:</b> <code>{user.id}</code>\n"
f"<b>Ник:</b> {user.first_name}\n"
f"<b>Имя и фамилия:</b> {name_str}\n"
f"<b>Username:</b> {username_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Администратор:</b> {admin_status}"
)
return {
"user_info": user_info,
"is_admin": user.is_admin,
"show_make_admin": not user.is_admin,
"show_remove_admin": user.is_admin,
}
@inject
async def get_make_admin_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs):
user_id = dialog_manager.dialog_data.get("selected_user_id")
if not user_id:
return {"confirm_text": "❌ Пользователь не выбран"}
user = await user_dao.get_by_id(user_id)
if not user:
return {"confirm_text": "❌ Пользователь не найден"}
username_str = f"@{user.username}" if user.username else "нет username"
name_str = user.name or user.first_name
group_str = f"группа {user.group}" if user.group else "без группы"
confirm_text = (
f"<b>👑 Назначение администратора</b>\n\n"
f"Вы собираетесь назначить администратором:\n\n"
f"<blockquote>"
f"👤 <b>{name_str}</b>\n"
f"📱 {username_str}\n"
f"🎓 {group_str}\n"
f"🆔 <code>{user.id}</code>"
f"</blockquote>\n\n"
f"⚠️ <i>Администратор получит доступ к управлению тестами, пользователями и рассылкам.</i>"
)
return {"confirm_text": confirm_text}
@inject
async def get_remove_admin_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs):
user_id = dialog_manager.dialog_data.get("selected_user_id")
if not user_id:
return {"confirm_text": "❌ Пользователь не выбран"}
user = await user_dao.get_by_id(user_id)
if not user:
return {"confirm_text": "❌ Пользователь не найден"}
username_str = f"@{user.username}" if user.username else "нет username"
name_str = user.name or user.first_name
group_str = f"группа {user.group}" if user.group else "без группы"
confirm_text = (
f"<b>🚫 Снятие администратора</b>\n\n"
f"Вы собираетесь снять с должности администратора:\n\n"
f"<blockquote>"
f"👤 <b>{name_str}</b>\n"
f"📱 {username_str}\n"
f"🎓 {group_str}\n"
f"🆔 <code>{user.id}</code>"
f"</blockquote>\n\n"
f"⚠️ <i>Пользователь потеряет доступ к админ-панели.</i>"
)
return {"confirm_text": confirm_text}
async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_user_id"] = int(item_id)
await manager.switch_to(CreatorUsersSG.user_detail)
async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(CreatorUsersSG.users_input)
@inject
async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager, user_dao: FromDishka[UserDAO]):
text = (message.text or "").strip()
user = None
if text.startswith("@"):
username = text[1:]
user = await user_dao.get_by_username(username)
elif text.isdigit():
user = await user_dao.get_by_id(int(text))
if not user:
await message.answer("❌ Пользователь не найден в базе данных.")
return
manager.dialog_data["selected_user_id"] = user.id
await manager.switch_to(CreatorUsersSG.user_detail)
async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(CreatorUsersSG.make_admin_confirm)
async def on_remove_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(CreatorUsersSG.remove_admin_confirm)
@inject
async def on_confirm_yes(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
user_dao: FromDishka[UserDAO],
user_repo: FromDishka[UserRepository],
bot: FromDishka[Bot],
config: FromDishka[Config],
):
user_id = manager.dialog_data.get("selected_user_id")
if not user_id:
await _callback.answer("Ошибка: пользователь не выбран")
return
await user_dao.update(user_id=user_id, is_admin=True)
asyncio.create_task(setup_bot_commands(bot, config, user_repo))
await _callback.answer("✅ Пользователь назначен администратором")
await manager.switch_to(CreatorUsersSG.user_detail)
@inject
async def on_remove_admin_confirm_yes(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
user_dao: FromDishka[UserDAO],
user_repo: FromDishka[UserRepository],
bot: FromDishka[Bot],
config: FromDishka[Config],
):
user_id = manager.dialog_data.get("selected_user_id")
if not user_id:
await _callback.answer("Ошибка: пользователь не выбран")
return
await user_dao.update(user_id=user_id, is_admin=False)
asyncio.create_task(setup_bot_commands(bot, config, user_repo))
await _callback.answer("✅ Пользователь снят с должности администратора")
await manager.switch_to(CreatorUsersSG.user_detail)
async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await _callback.answer("Отменено")
await manager.switch_to(CreatorUsersSG.user_detail)
async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.done()
async def on_user_stats_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(CreatorUsersSG.user_stats)
@inject
async def get_user_stats_data(
dialog_manager: DialogManager,
user_dao: FromDishka[UserDAO],
attempt_repo: FromDishka[TestAttemptRepository],
**_kwargs,
):
user_id = dialog_manager.dialog_data.get("selected_user_id")
if not user_id:
return {"stats_info": "Пользователь не выбран", "results": [], "count": 0}
user = await user_dao.get_by_id(user_id)
if not user:
return {"stats_info": "Пользователь не найден", "results": [], "count": 0}
stats = await attempt_repo.get_user_stats(user_id)
attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id)
name = user.name or user.first_name
if stats["total_attempts"] > 0:
accuracy_str = f"📊 Средняя точность: <b>{stats['avg_score']}%</b>"
tests_str = f"📝 Пройдено тестов: <b>{stats['total_attempts']}</b>"
else:
accuracy_str = "📊 Средняя точность: <b>—</b>"
tests_str = "📝 Пройдено тестов: <b>0</b>"
stats_info = (
f"<b>📊 Статистика: {name}</b>\n\n"
f"{tests_str}\n"
f"{accuracy_str}"
)
results = []
for attempt, test_title in attempts_with_tests:
status = "" if attempt.is_passed else ""
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else ""
results.append((f"{status} {test_title}{attempt.score}% ({date_str})", attempt.id))
return {
"stats_info": stats_info,
"results": results,
"count": len(results),
}
async def on_result_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_attempt_id"] = int(item_id)
await manager.switch_to(CreatorUsersSG.user_result_detail)
@inject
async def get_user_result_detail(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
test_repo: FromDishka[TestRepository],
**_kwargs
):
attempt_id = dialog_manager.dialog_data.get("selected_attempt_id")
if not attempt_id:
return {"result_info": "❌ Результат не найден"}
attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id)
if not attempt:
return {"result_info": "❌ Результат не найден"}
test, _ = await test_repo.get_test_with_questions(attempt.test_id)
test_title = test.title if test else "Неизвестный тест"
status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден"
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else ""
correct_count = sum(1 for a in answers if a.is_correct)
total_count = len(answers)
lines = [
f"<b>📝 {test_title}</b>\n",
f"📊 <b>Результат:</b> {attempt.score}%",
f"✏️ <b>Правильных ответов:</b> {correct_count} из {total_count}",
f"📅 <b>Дата:</b> {date_str}",
f"🏆 <b>Статус:</b> {status}",
"\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)
for i, answer in enumerate(answers, 1):
question_data = questions_map.get(answer.question_id)
if not question_data:
continue
question, options = question_data
correct_options = [opt for opt in options if opt.is_correct]
correct_texts = [opt.text for opt in correct_options]
status_icon = "" if answer.is_correct else ""
user_answer = answer.text_answer or ""
if "|" in user_answer:
user_answer = ", ".join(user_answer.split("|"))
lines.append(f"{status_icon} <b>Вопрос {i}</b>")
lines.append(f"<blockquote>{question.text}</blockquote>")
lines.append(f"👤 <i>Ответ:</i> {user_answer or ''}")
lines.append(f"✓ <i>Правильно:</i> {', '.join(correct_texts)}\n")
return {"result_info": "\n".join(lines)}
creator_users_dialog = Dialog(
Window(
Format("<b>👥 Пользователи</b>\n\nВсего: {count}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="user_select",
item_id_getter=lambda x: x[1],
items="users",
on_click=on_user_selected,
),
id="users_scroll",
width=1,
height=7,
),
Column(
Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main),
),
state=CreatorUsersSG.users_list,
getter=get_users_data,
),
Window(
Const("<b>Введите ID или @username пользователя:</b>"),
MessageInput(on_user_input),
SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list),
state=CreatorUsersSG.users_input,
),
Window(
Format("{user_info}"),
Column(
Button(Const("📊 Статистика"), id="stats", on_click=on_user_stats_clicked),
Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"),
Button(Const("🚫 Снять администратора"), id="remove_admin", on_click=on_remove_admin_clicked, when="show_remove_admin"),
SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list),
),
state=CreatorUsersSG.user_detail,
getter=get_user_detail_data,
),
Window(
Format("{stats_info}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="result_select",
item_id_getter=lambda x: x[1],
items="results",
on_click=on_result_selected,
),
id="results_scroll",
width=1,
height=5,
),
SwitchTo(Const("◀️ Назад"), id="back_to_detail", state=CreatorUsersSG.user_detail),
state=CreatorUsersSG.user_stats,
getter=get_user_stats_data,
),
Window(
Format("{result_info}"),
SwitchTo(Const("◀️ Назад"), id="back_to_stats", state=CreatorUsersSG.user_stats),
state=CreatorUsersSG.user_result_detail,
getter=get_user_result_detail,
),
Window(
Format("{confirm_text}"),
Row(
Button(Const("✅ Подтвердить"), id="confirm_yes", on_click=on_confirm_yes),
Button(Const("◀️ Отмена"), id="confirm_no", on_click=on_confirm_no),
),
state=CreatorUsersSG.make_admin_confirm,
getter=get_make_admin_confirm_data,
),
Window(
Format("{confirm_text}"),
Row(
Button(Const("✅ Подтвердить"), id="confirm_yes", on_click=on_remove_admin_confirm_yes),
Button(Const("◀️ Отмена"), id="confirm_no", on_click=on_confirm_no),
),
state=CreatorUsersSG.remove_admin_confirm,
getter=get_remove_admin_confirm_data,
),
)
+211
View File
@@ -0,0 +1,211 @@
import logging
from aiogram import Router
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.types import ErrorEvent, Message
from aiogram_dialog import DialogManager, StartMode
from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent
from dishka.integrations.aiogram import FromDishka
from quizzi.application.bot.admin_dialogs.states import AdminMenuSG
from quizzi.application.bot.creator_dialogs.states import CreatorMenuSG
from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG
from quizzi.infrastructure.database.dao.group import GroupDAO
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.test_id_to_hash import decode_id
from quizzi.infrastructure.utils.timezone import now_msk_naive
router = Router()
logger = logging.getLogger(__name__)
async def ensure_user_registered(
user_dao: UserDAO,
group_dao: GroupDAO,
message: Message,
dialog_manager: DialogManager,
pending_test_id: int | None = None,
) -> bool:
assert message.from_user is not None
existing_user = await user_dao.get_by_id(message.from_user.id)
groups = await group_dao.get_all()
start_data = {"user_id": message.from_user.id}
if pending_test_id:
start_data["pending_test_id"] = pending_test_id
if existing_user is None:
await user_dao.create(
user_id=message.from_user.id,
first_name=message.from_user.first_name,
username=message.from_user.username,
last_name=message.from_user.last_name,
)
if len(groups) > 0:
await dialog_manager.start(
UserRegistrationSG.input_name,
mode=StartMode.RESET_STACK,
data=start_data
)
return False
return True
if len(groups) > 0 and (existing_user.name is None or existing_user.group is None):
if existing_user.name is None:
await dialog_manager.start(
UserRegistrationSG.input_name,
mode=StartMode.RESET_STACK,
data=start_data
)
else:
await dialog_manager.start(
UserRegistrationSG.select_group,
mode=StartMode.RESET_STACK,
data=start_data
)
return False
await user_dao.upsert(
user_id=message.from_user.id,
first_name=message.from_user.first_name,
username=message.from_user.username,
last_name=message.from_user.last_name,
)
return True
async def validate_deeplink_test(
test_dao: TestDAO,
user_dao: UserDAO,
test_id: int,
user_id: int,
) -> tuple[bool, str]:
test = await test_dao.get_by_id(test_id)
if not test:
return False, "❌ Тест не найден"
if not test.is_active:
return False, "❌ Тест деактивирован"
if test.expires_at and test.expires_at < now_msk_naive():
return False, "❌ Срок действия теста истек"
user = await user_dao.get_by_id(user_id)
if test.for_group and user and user.group != test.for_group:
return False, f"❌ Тест доступен только для группы {test.for_group}"
return True, ""
@router.message(CommandStart(deep_link=True))
async def start_with_deeplink(
message: Message,
command: CommandObject,
dialog_manager: DialogManager,
user_dao: FromDishka[UserDAO],
group_dao: FromDishka[GroupDAO],
test_dao: FromDishka[TestDAO],
config: FromDishka[Config],
) -> None:
assert message.from_user is not None
deeplink = command.args
logger.info(
"Deeplink start: user_id=%d, username=%s, deeplink=%s",
message.from_user.id,
message.from_user.username,
deeplink,
)
if not deeplink:
await start_handler(message, user_dao, group_dao, dialog_manager)
return
try:
test_id = decode_id(deeplink, config.security.encode_key)
except (ValueError, IndexError):
logger.warning("Invalid deeplink: user_id=%d, deeplink=%s", message.from_user.id, deeplink)
await message.answer("❌ Неверная ссылка на тест")
await start_handler(message, user_dao, group_dao, dialog_manager)
return
is_registered = await ensure_user_registered(
user_dao, group_dao, message, dialog_manager, pending_test_id=test_id
)
if not is_registered:
return
is_valid, error = await validate_deeplink_test(
test_dao, user_dao, test_id, message.from_user.id
)
if not is_valid:
logger.info(
"Test validation failed: user_id=%d, test_id=%d, error=%s",
message.from_user.id,
test_id,
error,
)
await dialog_manager.start(
UserDeeplinkSG.test_preview,
mode=StartMode.RESET_STACK,
data={"test_id": test_id, "error": error}
)
return
logger.info("User starting test via deeplink: user_id=%d, test_id=%d", message.from_user.id, test_id)
await dialog_manager.start(
UserDeeplinkSG.test_preview,
mode=StartMode.RESET_STACK,
data={"test_id": test_id}
)
@router.message(CommandStart())
async def start_handler(
message: Message,
dialog_manager: DialogManager,
user_dao: FromDishka[UserDAO],
group_dao: FromDishka[GroupDAO],
) -> None:
assert message.from_user is not None
logger.info(
"Start command: user_id=%d, username=%s",
message.from_user.id,
message.from_user.username,
)
is_registered = await ensure_user_registered(
user_dao, group_dao, message, dialog_manager
)
if is_registered:
await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
@router.message(Command("admin"))
async def admin_command(_message: Message, dialog_manager: DialogManager) -> None:
assert _message.from_user is not None
logger.info("Admin panel access: user_id=%d", _message.from_user.id)
await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK)
@router.message(Command("creator"))
async def creator_command(_message: Message, dialog_manager: DialogManager) -> None:
assert _message.from_user is not None
logger.info("Creator panel access: user_id=%d", _message.from_user.id)
await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK)
@router.error()
async def dialog_error_handler(event: ErrorEvent, dialog_manager: DialogManager) -> None:
if isinstance(event.exception, (UnknownIntent, OutdatedIntent)):
logger.debug("Dialog intent error, resetting to main menu: %s", type(event.exception).__name__)
await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
else:
logger.exception("Unhandled error in dialog: %s", event.exception)
@@ -0,0 +1 @@
@@ -0,0 +1,42 @@
from collections.abc import Awaitable
from typing import Any, Callable
from aiogram import BaseMiddleware
from aiogram.types import Message, TelegramObject
from dishka import AsyncContainer
from quizzi.infrastructure.database.repo import UserRepository
from quizzi.infrastructure.utils.config import Config
class RejectNotAdminMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
if not isinstance(event, Message):
return await handler(event, data)
assert event.from_user is not None
container: AsyncContainer = data["dishka_container"]
user_id = event.from_user.id
admin_commands = ["/admin"]
if event.text and event.text.strip() in admin_commands:
config: Config = await container.get(Config)
if user_id == config.bot.creator_id:
return await handler(event, data)
users_repo: UserRepository = await container.get(UserRepository)
is_admin = await users_repo.is_admin(user_id)
if is_admin:
return await handler(event, data)
return
return await handler(event, data)
@@ -0,0 +1,35 @@
from collections.abc import Awaitable
from typing import Any, Callable
from aiogram import BaseMiddleware
from aiogram.types import Message, TelegramObject
from dishka import AsyncContainer
from quizzi.infrastructure.utils.config import Config
class RejectNotCreatorMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
if not isinstance(event, Message):
return await handler(event, data)
assert event.from_user is not None
container: AsyncContainer = data["dishka_container"]
user_id = event.from_user.id
creator_commands = ["/creator"]
if event.text and event.text.strip() in creator_commands:
config: Config = await container.get(Config)
if user_id == config.bot.creator_id:
return await handler(event, data)
return
return await handler(event, data)
@@ -0,0 +1,73 @@
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Row
from aiogram_dialog.widgets.text import Const
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from quizzi.application.bot.shared_dialogs.states import SharedBroadcastSG
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.utils.broadcast import broadcast_message
async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager):
manager.dialog_data["broadcast_message_id"] = message.message_id
manager.dialog_data["broadcast_chat_id"] = message.chat.id
await manager.switch_to(SharedBroadcastSG.broadcast_confirm)
@inject
async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]):
message_id = manager.dialog_data.get("broadcast_message_id")
chat_id = manager.dialog_data.get("broadcast_chat_id")
if not message_id or not chat_id or not _callback.message:
await _callback.answer("Ошибка: сообщение не найдено")
return
await _callback.message.answer("⏳ Рассылка началась...")
bot = _callback.bot
if not bot:
await _callback.answer("Ошибка: бот не найден")
return
stats = await broadcast_message(bot, message_id, chat_id, user_dao)
stats_text = (
f"✅ <b>Рассылка завершена</b>\n\n"
f"Всего пользователей: {stats.total}\n"
f"Успешно отправлено: {stats.success}\n"
f"Не удалось отправить: {stats.failed}"
)
await _callback.message.answer(stats_text)
await manager.done()
async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await _callback.answer("Рассылка отменена")
await manager.done()
async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.done()
shared_broadcast_dialog = Dialog(
Window(
Const("<b>📢 Рассылка</b>\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"),
MessageInput(on_broadcast_input),
Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_main),
state=SharedBroadcastSG.broadcast_input,
),
Window(
Const("<b>⚠️ Подтверждение рассылки</b>\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"),
Row(
Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm),
Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel),
),
state=SharedBroadcastSG.broadcast_confirm,
),
)
@@ -0,0 +1,571 @@
from datetime import date, datetime, time
from aiogram.types import 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, Calendar, Cancel, Column, 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 SharedCreateTestSG, SharedTestsSG
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
from quizzi.infrastructure.utils.timezone import to_msk
async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager):
if not message.text:
await message.answer("❌ Название не может быть пустым")
return
title = message.text.strip()
if not title:
await message.answer("❌ Название не может быть пустым")
return
if len(title) > 255:
await message.answer("❌ Название слишком длинное (максимум 255 символов)")
return
manager.dialog_data["title"] = title
await manager.switch_to(SharedCreateTestSG.input_description)
async def on_description_input(message: Message, _widget: MessageInput, manager: DialogManager):
if not message.text:
await message.answer("❌ Описание не может быть пустым")
return
description = message.text.strip()
if not description:
await message.answer("❌ Описание не может быть пустым")
return
if len(description) > 2000:
await message.answer("❌ Описание слишком длинное (максимум 2000 символов)")
return
manager.dialog_data["description"] = description
await manager.switch_to(SharedCreateTestSG.input_password)
@inject
async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, _group_dao: FromDishka[GroupDAO]):
if not message.text:
await message.answer("❌ Пароль не может быть пустым")
return
password = message.text.strip()
if not password:
await message.answer("❌ Пароль не может быть пустым")
return
if len(password) > 255:
await message.answer("❌ Пароль слишком длинный (максимум 255 символов)")
return
manager.dialog_data["password"] = password
await manager.switch_to(SharedCreateTestSG.input_attempts)
@inject
async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, _group_dao: FromDishka[GroupDAO]):
manager.dialog_data["password"] = None
await manager.switch_to(SharedCreateTestSG.input_attempts)
async def on_attempts_input(message: Message, _widget: MessageInput, manager: DialogManager):
if not message.text:
await message.answer("❌ Количество попыток не может быть пустым")
return
attempts_str = message.text.strip()
if not attempts_str.isdigit():
await message.answer("❌ Количество попыток должно быть числом")
return
attempts = int(attempts_str)
if attempts < 1:
await message.answer("❌ Количество попыток должно быть больше 0")
return
if attempts > 100:
await message.answer("❌ Количество попыток не может быть больше 100")
return
manager.dialog_data["attempts"] = attempts
await manager.switch_to(SharedCreateTestSG.input_expires_at)
async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager):
manager.dialog_data["attempts"] = None
await manager.switch_to(SharedCreateTestSG.input_expires_at)
async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date):
manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min)
await manager.switch_to(SharedCreateTestSG.input_for_group)
async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager):
manager.dialog_data["expires_at"] = None
await manager.switch_to(SharedCreateTestSG.input_for_group)
@inject
async def get_groups_for_test(group_dao: FromDishka[GroupDAO], **_kwargs):
groups = await group_dao.get_all()
return {"groups": [(str(g.number), str(g.number)) for g in groups]}
async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str):
manager.dialog_data["for_group"] = int(item_id)
await manager.switch_to(SharedCreateTestSG.confirm_test_info)
async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager):
manager.dialog_data["for_group"] = None
await manager.switch_to(SharedCreateTestSG.confirm_test_info)
async def get_test_info(dialog_manager: DialogManager, **_kwargs):
title = dialog_manager.dialog_data.get("title", "")
description = dialog_manager.dialog_data.get("description", "")
password = dialog_manager.dialog_data.get("password")
attempts = dialog_manager.dialog_data.get("attempts")
expires_at = dialog_manager.dialog_data.get("expires_at")
for_group = dialog_manager.dialog_data.get("for_group")
password_str = f"🔒 {password}" if password else "Без пароля"
attempts_str = f"🔄 {attempts}" if attempts else "♾️ Без ограничений"
expires_at_msk = to_msk(expires_at)
expires_str = expires_at_msk.strftime("%d.%m.%Y") if expires_at_msk else "Без срока"
group_str = str(for_group) if for_group else "Для всех"
return {
"info": (
f"<b>📝 Информация о тесте</b>\n\n"
f"<b>Название:</b> {title}\n"
f"<b>Описание:</b> {description}\n"
f"<b>Пароль:</b> {password_str}\n"
f"<b>Попыток:</b> {attempts_str}\n"
f"<b>Истекает:</b> {expires_str}\n"
f"<b>Для группы:</b> {group_str}"
)
}
@inject
async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
title = manager.dialog_data.get("title")
assert isinstance(title, str)
description = manager.dialog_data.get("description")
password = manager.dialog_data.get("password")
attempts = manager.dialog_data.get("attempts")
expires_at = manager.dialog_data.get("expires_at")
for_group = manager.dialog_data.get("for_group")
test = await test_dao.create(
title=title,
description=description,
password=password,
attempts=attempts,
expires_at=expires_at,
for_group=for_group,
)
manager.dialog_data["test_id"] = test.id
manager.dialog_data["questions"] = []
await manager.switch_to(SharedCreateTestSG.add_question)
async def on_add_question(_callback: CallbackQuery, _button: Button, manager: DialogManager):
manager.dialog_data["current_question"] = {}
await manager.switch_to(SharedCreateTestSG.input_question_text)
async def on_question_input(message: Message, _widget: MessageInput, manager: DialogManager):
current_question = manager.dialog_data.get("current_question", {})
if message.content_type == ContentType.PHOTO:
photo = message.photo[-1] if message.photo else None
if photo:
text = (message.caption or "").strip()
if not text:
await message.answer("❌ Изображение должно содержать подпись с текстом вопроса")
return
if len(text) > 2000:
await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)")
return
current_question["tg_file_id"] = photo.file_id
current_question["text"] = text
elif message.content_type == ContentType.TEXT and message.text:
text = message.text.strip()
if not text:
await message.answer("❌ Текст вопроса не может быть пустым")
return
if len(text) > 2000:
await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)")
return
current_question["text"] = text
current_question["tg_file_id"] = None
else:
await message.answer("❌ Отправьте текст или фото с подписью")
return
manager.dialog_data["current_question"] = current_question
await manager.switch_to(SharedCreateTestSG.select_question_type)
async def get_question_type_data(**_kwargs):
return {
"question_types": [
("single", "📌 Один правильный ответ"),
("multiple", "📋 Несколько правильных ответов"),
("input", "✏️ Ввод текста"),
]
}
async def on_question_type_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str):
current_question = manager.dialog_data.get("current_question", {})
current_question["question_type"] = item_id
manager.dialog_data["current_question"] = current_question
if item_id == "input":
await manager.switch_to(SharedCreateTestSG.input_correct_answer)
else:
manager.dialog_data["current_options"] = []
await manager.switch_to(SharedCreateTestSG.input_options)
async def on_correct_answer_input(message: Message, _widget: MessageInput, manager: DialogManager):
if not message.text:
await message.answer("❌ Правильный ответ не может быть пустым")
return
answer = message.text.strip()
if not answer:
await message.answer("❌ Правильный ответ не может быть пустым")
return
if len(answer) > 255:
await message.answer("❌ Ответ слишком длинный (максимум 255 символов)")
return
current_question = manager.dialog_data.get("current_question", {})
current_question["correct_answer"] = answer
manager.dialog_data["current_question"] = current_question
await manager.switch_to(SharedCreateTestSG.confirm_question)
async def on_option_input(message: Message, _widget: MessageInput, manager: DialogManager):
if not message.text:
await message.answer("❌ Вариант ответа не может быть пустым")
return
option_text = message.text.strip()
if not option_text:
await message.answer("❌ Вариант ответа не может быть пустым")
return
if len(option_text) > 255:
await message.answer("❌ Вариант ответа слишком длинный (максимум 255 символов)")
return
current_options = manager.dialog_data.get("current_options", [])
if len(current_options) >= 10:
await message.answer("❌ Максимум 10 вариантов ответа")
return
current_options.append({"text": option_text, "is_correct": False})
manager.dialog_data["current_options"] = current_options
await message.answer(f"✅ Вариант {len(current_options)} добавлен")
async def on_finish_options(_callback: CallbackQuery, _button: Button, manager: DialogManager):
current_options = manager.dialog_data.get("current_options", [])
if len(current_options) < 2:
await _callback.answer("❌ Добавьте минимум 2 варианта ответа", show_alert=True)
return
await manager.switch_to(SharedCreateTestSG.mark_correct_options)
async def get_options_data(dialog_manager: DialogManager, **_kwargs):
current_options = dialog_manager.dialog_data.get("current_options", [])
formatted_options = []
for i, opt in enumerate(current_options):
marker = "" if opt["is_correct"] else ""
formatted_options.append((str(i), f"{marker} {opt['text']}"))
return {
"options": formatted_options,
"options_count": len(current_options),
}
async def on_option_toggle(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str):
current_options = manager.dialog_data.get("current_options", [])
current_question = manager.dialog_data.get("current_question", {})
question_type = current_question.get("question_type", "single")
option_idx = int(item_id)
if question_type == "single":
for opt in current_options:
opt["is_correct"] = False
current_options[option_idx]["is_correct"] = True
else:
current_options[option_idx]["is_correct"] = not current_options[option_idx]["is_correct"]
manager.dialog_data["current_options"] = current_options
await _callback.answer()
async def on_confirm_correct(_callback: CallbackQuery, _button: Button, manager: DialogManager):
current_options = manager.dialog_data.get("current_options", [])
if not any(opt["is_correct"] for opt in current_options):
await _callback.answer("❌ Отметьте хотя бы один правильный ответ", show_alert=True)
return
await manager.switch_to(SharedCreateTestSG.confirm_question)
async def get_question_preview(dialog_manager: DialogManager, **_kwargs):
current_question = dialog_manager.dialog_data.get("current_question", {})
current_options = dialog_manager.dialog_data.get("current_options", [])
text = current_question.get("text", "")
question_type = current_question.get("question_type", "single")
has_image = current_question.get("tg_file_id") is not None
type_names = {
"single": "📌 Один правильный ответ",
"multiple": "📋 Несколько правильных ответов",
"input": "✏️ Ввод текста",
}
preview = "<b>📝 Предпросмотр вопроса</b>\n\n"
preview += f"<b>Текст:</b> {text}\n"
preview += f"<b>Тип:</b> {type_names[question_type]}\n"
preview += f"<b>Изображение:</b> {'✅ Да' if has_image else '❌ Нет'}\n\n"
if question_type == "input":
correct_answer = current_question.get("correct_answer", "")
preview += f"<b>Правильный ответ:</b> <code>{correct_answer}</code>"
else:
preview += "<b>Варианты ответов:</b>\n"
for i, opt in enumerate(current_options, 1):
marker = "" if opt["is_correct"] else ""
preview += f"{i}. {marker} {opt['text']}\n"
return {"preview": preview}
@inject
async def on_save_question(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
question_dao: FromDishka[QuestionDAO],
option_dao: FromDishka[OptionDAO],
test_repo: FromDishka[TestRepository],
):
test_id = manager.dialog_data.get("test_id")
assert isinstance(test_id, int)
current_question = manager.dialog_data.get("current_question", {})
current_options = manager.dialog_data.get("current_options", [])
questions_count = await test_repo.count_questions_in_test(test_id)
question = await question_dao.create(
test_id=test_id,
text=current_question.get("text", ""),
position=questions_count,
question_type=current_question.get("question_type", "single"),
tg_file_id=current_question.get("tg_file_id"),
)
if current_question.get("question_type") == "input":
await option_dao.create(
question_id=question.id,
text=current_question.get("correct_answer", ""),
is_correct=True,
)
else:
for opt in current_options:
await option_dao.create(
question_id=question.id,
text=opt["text"],
is_correct=opt["is_correct"],
)
questions = manager.dialog_data.get("questions", [])
questions.append(question.id)
manager.dialog_data["questions"] = questions
manager.dialog_data.pop("current_question", None)
manager.dialog_data.pop("current_options", None)
await _callback.answer("✅ Вопрос добавлен")
await manager.switch_to(SharedCreateTestSG.add_question)
async def on_cancel_question(_callback: CallbackQuery, _button: Button, manager: DialogManager):
manager.dialog_data.pop("current_question", None)
manager.dialog_data.pop("current_options", None)
await manager.switch_to(SharedCreateTestSG.add_question)
async def get_questions_count(dialog_manager: DialogManager, **_kwargs):
questions = dialog_manager.dialog_data.get("questions", [])
return {"questions_count": len(questions)}
async def on_finish_test(_callback: CallbackQuery, _button: Button, manager: DialogManager):
questions = manager.dialog_data.get("questions", [])
if len(questions) == 0:
await _callback.answer("❌ Добавьте хотя бы один вопрос", show_alert=True)
return
await _callback.answer("✅ Тест создан")
await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK)
async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK)
shared_create_test_dialog = Dialog(
Window(
Const("<b>📝 Создание теста</b>\n\n💬 <b>Введите название теста:</b>\n<i>(максимум 255 символов)</i>"),
MessageInput(on_title_input),
Cancel(Const("◀️ Отмена")),
state=SharedCreateTestSG.input_title,
),
Window(
Const("<b>📝 Создание теста</b>\n\n📄 <b>Введите описание теста:</b>\n<i>(максимум 2000 символов)</i>"),
MessageInput(on_description_input),
state=SharedCreateTestSG.input_description,
),
Window(
Const("<b>🔒 Пароль</b>\n\n🔑 <b>Введите пароль для доступа к тесту</b> или пропустите этот шаг:\n<i>(максимум 255 символов)</i>"),
MessageInput(on_password_input),
Button(Const("⏭️ Без пароля"), id="skip_password", on_click=on_skip_password),
state=SharedCreateTestSG.input_password,
),
Window(
Const("<b>🔄 Количество попыток</b>\n\n🔢 <b>Введите количество попыток</b> (1-100) или пропустите для неограниченного количества:"),
MessageInput(on_attempts_input),
Button(Const("⏭️ Без ограничений"), id="skip_attempts", on_click=on_skip_attempts),
state=SharedCreateTestSG.input_attempts,
),
Window(
Const("<b>📅 Срок действия</b>\n\n🗓 <b>Выберите дату истечения теста</b> или пропустите:"),
Calendar(id="calendar", on_click=on_date_selected),
Button(Const("⏭️ Без срока"), id="skip_expires", on_click=on_skip_expires),
state=SharedCreateTestSG.input_expires_at,
),
Window(
Const("<b>👥 Группа</b>\n\n🎓 <b>Выберите группу</b> или пропустите для всех:"),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_group_selected,
),
id="groups_scroll",
width=2,
height=7,
),
Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group),
state=SharedCreateTestSG.input_for_group,
getter=get_groups_for_test,
),
Window(
Format("{info}\n\n<b>✅ Подтвердите создание теста:</b>"),
Row(
Button(Const("✅ Создать"), id="confirm", on_click=on_confirm_test),
Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel),
),
state=SharedCreateTestSG.confirm_test_info,
getter=get_test_info,
),
Window(
Format("<b>➕ Добавление вопросов</b>\n\n📊 <b>Вопросов добавлено:</b> {questions_count}\n\n💡 Добавьте вопросы к тесту:"),
Column(
Button(Const(" Добавить вопрос"), id="add_question", on_click=on_add_question),
Button(Const("✅ Завершить создание"), id="finish", on_click=on_finish_test),
),
state=SharedCreateTestSG.add_question,
getter=get_questions_count,
),
Window(
Const("<b>❓ Текст вопроса</b>\n\n📝 <b>Отправьте текст вопроса</b> или 📷 <b>фото с подписью:</b>\n<i>(максимум 2000 символов)</i>"),
MessageInput(on_question_input, content_types=[ContentType.TEXT, ContentType.PHOTO]),
Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
state=SharedCreateTestSG.input_question_text,
),
Window(
Const("<b>📋 Тип вопроса</b>\n\n🎯 <b>Выберите тип вопроса:</b>"),
Column(Select(
Format("{item[1]}"),
id="question_type",
item_id_getter=lambda x: x[0],
items="question_types",
on_click=on_question_type_selected,
)),
Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
state=SharedCreateTestSG.select_question_type,
getter=get_question_type_data,
),
Window(
Const("<b>✏️ Правильный ответ</b>\n\n💬 <b>Введите правильный ответ</b> (регистр и пробелы игнорируются):\n<i>(максимум 255 символов)</i>"),
MessageInput(on_correct_answer_input),
Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
state=SharedCreateTestSG.input_correct_answer,
),
Window(
Format("<b>📝 Варианты ответов</b>\n\n📊 <b>Добавлено вариантов:</b> {options_count}/10\n\n💬 <b>Введите вариант ответа:</b>\n<i>(максимум 255 символов)</i>"),
MessageInput(on_option_input),
Button(Const("✅ Завершить добавление вариантов"), id="finish_options", on_click=on_finish_options),
Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
state=SharedCreateTestSG.input_options,
getter=get_options_data,
),
Window(
Const("<b>✅ Правильные ответы</b>\n\n<b>Отметьте правильные варианты ответов:</b>"),
Column(Select(
Format("{item[1]}"),
id="options",
item_id_getter=lambda x: x[0],
items="options",
on_click=on_option_toggle,
)),
Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct),
Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question),
state=SharedCreateTestSG.mark_correct_options,
getter=get_options_data,
),
Window(
Format("{preview}\n\n<b>💾 Сохранить вопрос?</b>"),
Row(
Button(Const("✅ Сохранить"), id="save", on_click=on_save_question),
Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_question),
),
state=SharedCreateTestSG.confirm_question,
getter=get_question_preview,
),
)
@@ -0,0 +1,176 @@
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Column, 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 SharedGroupsSG
from quizzi.infrastructure.database.dao.group import GroupDAO
async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str):
await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'")
@inject
async def get_groups_data(group_dao: FromDishka[GroupDAO], dialog_manager: DialogManager, **_kwargs):
groups = await group_dao.get_all()
success_message = dialog_manager.dialog_data.pop("success_message", None)
message_text = "<b>👥 Управление группами</b>\n\n"
if success_message:
message_text += f"{success_message}\n\n"
message_text += f"📊 <b>Всего групп:</b> {len(groups)}\n\n<b>Список групп:</b>"
return {
"groups": [(str(g.id), str(g.number)) for g in groups],
"groups_count": len(groups),
"message_text": message_text,
}
async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedGroupsSG.add_group_input_number)
async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedGroupsSG.delete_groups_list)
async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.done()
@inject
async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager, group_dao: FromDishka[GroupDAO]):
if not message.text:
await message.answer("❌ Номер группы не может быть пустым")
return
number_str = message.text.strip()
if not number_str.isdigit():
await message.answer("❌ Номер группы должен содержать только цифры")
return
number = int(number_str)
if number < 1000 or number > 9999:
await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)")
return
existing = await group_dao.get_by_number(number)
if existing:
await message.answer(f"❌ Группа с номером {number} уже существует")
return
await group_dao.create(number=number)
manager.dialog_data["success_message"] = f"✅ Группа {number} создана"
await manager.switch_to(SharedGroupsSG.groups_list)
async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedGroupsSG.groups_list)
@inject
async def get_delete_groups_data(group_dao: FromDishka[GroupDAO], **_kwargs):
groups = await group_dao.get_all()
return {
"groups": [(str(g.id), str(g.number)) for g in groups],
"groups_count": len(groups),
}
@inject
async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, group_dao: FromDishka[GroupDAO]):
group = await group_dao.get_by_id(int(item_id))
if not group:
await _callback.answer("❌ Группа не найдена", show_alert=True)
return
manager.dialog_data["delete_group_id"] = group.id
manager.dialog_data["delete_group_number"] = group.number
await manager.switch_to(SharedGroupsSG.delete_confirm)
async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs):
number = dialog_manager.dialog_data.get("delete_group_number", "")
return {"group_info": str(number)}
@inject
async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager, group_dao: FromDishka[GroupDAO]):
group_id = manager.dialog_data.get("delete_group_id")
assert isinstance(group_id, int)
await group_dao.delete(group_id)
manager.dialog_data["success_message"] = "✅ Группа удалена"
await manager.switch_to(SharedGroupsSG.groups_list)
async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedGroupsSG.delete_groups_list)
shared_groups_dialog = Dialog(
Window(
Format("{message_text}"),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_group_click,
),
id="groups_scroll",
width=2,
height=7,
),
Column(
Button(Const(" Добавить группу"), id="add", on_click=on_add_group),
Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu),
),
state=SharedGroupsSG.groups_list,
getter=get_groups_data,
),
Window(
Const("<b>➕ Добавление группы</b>\n\n🔢 <b>Введите номер группы</b> (четырехзначное число 1000-9999):"),
MessageInput(on_group_number_input),
Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add),
state=SharedGroupsSG.add_group_input_number,
),
Window(
Format("<b>🗑 Удаление группы</b>\n\n<b>Выберите группу для удаления:</b>\n\n📊 <b>Всего групп:</b> {groups_count}"),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="delete_groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_select_group_to_delete,
),
id="delete_groups_scroll",
width=2,
height=7,
),
Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add),
state=SharedGroupsSG.delete_groups_list,
getter=get_delete_groups_data,
),
Window(
Format("<b>⚠️ Подтверждение удаления</b>\n\n<b>Точно хотите удалить группу?</b>\n\n👥 {group_info}"),
Row(
Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete),
Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete),
),
state=SharedGroupsSG.delete_confirm,
getter=get_delete_confirm_data,
),
)
@@ -0,0 +1,51 @@
from aiogram.fsm.state import State, StatesGroup
class SharedTemplatesSG(StatesGroup):
main = State()
export_list = State()
spec = State()
import_file = State()
class SharedTestsSG(StatesGroup):
tests_list = State()
test_detail = State()
share_test = State()
edit_menu = State()
edit_password = State()
edit_attempts = State()
edit_group = State()
edit_expires = State()
statistics = State()
attempt_detail = State()
class SharedBroadcastSG(StatesGroup):
broadcast_input = State()
broadcast_confirm = State()
class SharedGroupsSG(StatesGroup):
groups_list = State()
add_group_input_number = State()
delete_groups_list = State()
delete_confirm = State()
class SharedCreateTestSG(StatesGroup):
input_title = State()
input_description = State()
input_password = State()
input_attempts = State()
input_expires_at = State()
input_for_group = State()
confirm_test_info = State()
add_question = State()
input_question_text = State()
select_question_type = State()
input_correct_answer = State()
input_options = State()
mark_correct_options = State()
confirm_question = State()
test_created = State()
@@ -0,0 +1,497 @@
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 = (
"<b>📦 Шаблоны тестов</b>\n\n"
"Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n"
"🔹 <b>Экспорт</b> — сохраните тест как файл для резервной копии или передачи\n"
"🔹 <b>Импорт</b> — загрузите тест из файла\n"
"🔹 <b>Спецификация</b> — описание формата JSON для создания тестов вручную"
)
SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
<b>Структура файла:</b>
<code>{
"title": "Название теста",
"description": "Описание теста",
"password": null,
"attempts": null,
"expires_at": null,
"for_group": null,
"questions": [...]
}</code>
<b>Поля теста:</b>
• <code>title</code> — название (обязательно, до 255 символов)
• <code>description</code> — описание (до 2000 символов)
• <code>password</code> — пароль для доступа или <code>null</code>
• <code>attempts</code> — лимит попыток (1-100) или <code>null</code>
• <code>expires_at</code> — срок действия в ISO формате или <code>null</code>
• <code>for_group</code> — номер группы или <code>null</code> для всех
<b>Типы вопросов:</b>
• <code>single</code> — один правильный ответ
• <code>multiple</code> — несколько правильных ответов
• <code>input</code> — ввод текста (регистр и пробелы игнорируются)
<b>Формат вопроса (single/multiple):</b>
<code>{
"question_type": "single",
"question": "Текст вопроса",
"image_url": "https://...",
"answers": [
{"option": "Вариант 1", "is_correct": true},
{"option": "Вариант 2", "is_correct": false}
]
}</code>
<b>Формат вопроса (input):</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
• <code>image_url</code> — опционально, URL изображения к вопросу"""
TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════
// УЛЬТИМАТИВНЫЙ ШАБЛОН ТЕСТА
// ═══════════════════════════════════════════════════════════════
//
// 📝 Название: Ультимативный пример теста
// 📄 Описание: Полная демонстрация всех возможностей формата
//
// ⚙️ НАСТРОЙКИ:
// • Пароль: test2024
// • Попыток: 5
// • Срок действия: 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
// • for_group - номер группы или null для всех пользователей
// • image_url - URL изображения к вопросу (опционально)
//
// ═══════════════════════════════════════════════════════════════
{
"title": "Ультимативный пример теста",
"description": "Полная демонстрация всех возможностей формата: разные типы вопросов, настройки доступа, ограничения по времени и группам",
"password": "test2024",
"attempts": 5,
"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,
"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"📤 <b>Экспорт теста:</b> {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"📄 <b>Шаблон:</b> {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,
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 = ["❌ <b>Ошибки валидации:</b>\n"]
for err in result[:10]:
path_str = f" (<code>{err.path}</code>)" 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"✅ <b>Тест импортирован!</b>\n\n"
f"📝 <b>Название:</b> {result.title}\n"
f"❓ <b>Вопросов:</b> {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("<b>📤 Экспорт теста</b>\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("<b>📥 Импорт теста</b>\n\nОтправьте JSON файл с тестом.\n\n<i>Формат файла описан в разделе «Спецификация»</i>"),
MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates),
state=SharedTemplatesSG.import_file,
),
)
@@ -0,0 +1,645 @@
import asyncio
import functools
import json
from datetime import date, datetime, time
from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Calendar, Column, 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 SharedCreateTestSG, SharedTestsSG
from quizzi.domain.schemas import QuestionType
from quizzi.infrastructure.database.dao.group import GroupDAO
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.qr_generator import generate_qr_bytes
from quizzi.infrastructure.utils.test_id_to_hash import encode_id
from quizzi.infrastructure.utils.timezone import to_msk
@inject
async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs):
tests = await test_dao.get_all()
return {
"tests": [
(f"{'🟢' if t.is_active else '🔴'} {t.title}", t.id)
for t in tests
],
"count": len(tests),
}
async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_test_id"] = int(item_id)
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], dialog_manager: DialogManager, **_kwargs):
test_id = dialog_manager.dialog_data.get("selected_test_id")
if not test_id:
return {
"test_info": "Тест не найден",
"is_active": False,
"button_text": "◀️ Назад",
"results_button_text": "👁 Показать результаты",
}
test = await test_dao.get_by_id(test_id)
questions_count = await test_repo.count_questions_in_test(test_id)
if not test:
return {
"test_info": "Тест не найден",
"is_active": False,
"button_text": "◀️ Назад",
"results_button_text": "👁 Показать результаты",
}
status = "🟢 Активен" if test.is_active else "🔴 Деактивирован"
password_str = f"🔒 {test.password}" if test.password else "🔓 Без пароля"
attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений"
expires_str = f"📅 {to_msk(test.expires_at).strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока"
group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех"
results_str = "👁 Результаты видны" if test.are_results_viewable else "🔒 Результаты скрыты"
test_info = (
f"<b>📝 Информация о тесте</b>\n\n"
f"<b>Название:</b>\n<blockquote>{test.title}</blockquote>\n"
f"<b>Описание:</b>\n<blockquote>{test.description or ''}</blockquote>\n\n"
f"<b>Статус:</b> {status}\n"
f"<b>Вопросов:</b> {questions_count}\n"
f"<b>Пароль:</b> {password_str}\n"
f"<b>Попытки:</b> {attempts_str}\n"
f"<b>Срок:</b> {expires_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Видимость:</b> {results_str}\n\n"
f"<b>Создан:</b> {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else ''}"
)
button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать"
results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты"
return {
"test_info": test_info,
"is_active": test.is_active,
"button_text": button_text,
"results_button_text": results_button_text,
}
@inject
async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
test = await test_dao.get_by_id(test_id)
if test:
await test_dao.update(test_id, is_active=not test.is_active)
action = "деактивирован" if test.is_active else "активирован"
await _callback.answer(f"✅ Тест {action}")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_toggle_results_viewable(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
test = await test_dao.get_by_id(test_id)
if test:
await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable)
action = "скрыты" if test.are_results_viewable else "видны"
await _callback.answer(f"✅ Результаты теперь {action}")
await manager.switch_to(SharedTestsSG.test_detail)
async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.tests_list)
async def on_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.statistics)
@inject
async def get_statistics_data(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
**_kwargs
):
test_id = dialog_manager.dialog_data.get("selected_test_id")
if not test_id:
return {"attempts": [], "count": 0}
attempts_with_users = await attempt_repo.get_test_attempts_with_users(test_id)
results = []
for attempt, user_name in attempts_with_users:
status = "" if attempt.is_passed else ""
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else ""
results.append((f"{status} {user_name}{attempt.score}% ({date_str})", attempt.id))
return {
"attempts": results,
"count": len(results),
}
async def on_attempt_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_attempt_id"] = int(item_id)
await manager.switch_to(SharedTestsSG.attempt_detail)
async def on_back_to_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.statistics)
@inject
async def get_attempt_detail(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
test_repo: FromDishka[TestRepository],
**_kwargs
):
attempt_id = dialog_manager.dialog_data.get("selected_attempt_id")
if not attempt_id:
return {"attempt_info": "❌ Результат не найден"}
attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id)
if not attempt:
return {"attempt_info": "❌ Результат не найден"}
status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден"
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else ""
lines = [
"<b>📊 Результат прохождения</b>\n",
f"📈 <b>Результат:</b> {attempt.score}%",
f"📅 <b>Дата:</b> {date_str}",
f"🏆 <b>Статус:</b> {status}\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)
for i, answer in enumerate(answers, 1):
question_data = questions_map.get(answer.question_id)
if not question_data:
continue
question, options = question_data
correct_options = [opt for opt in options if opt.is_correct]
correct_texts = [opt.text for opt in correct_options]
status_icon = "" if answer.is_correct else ""
user_answer = answer.text_answer or ""
if "|" in user_answer:
user_answer = ", ".join(user_answer.split("|"))
lines.append(f"{status_icon} <b>Вопрос {i}</b>")
lines.append(f"<blockquote>{question.text}</blockquote>")
lines.append(f"👤 <i>Ответ:</i> {user_answer or ''}")
lines.append(f"✓ <i>Правильно:</i> {', '.join(correct_texts)}\n")
return {"attempt_info": "\n".join(lines)}
@inject
async def on_export_test(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
test_repo: FromDishka[TestRepository],
) -> None:
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
assert _callback.message is not None
await _callback.answer("⏳ Экспортирую тест...")
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,
"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.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"📤 <b>Экспорт теста:</b> {test.title}",
)
@inject
async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
return {
"share_link": "Ошибка: тест не найден"
}
test_hash = encode_id(
test_id,
config.security.encode_key,
config.security.encoded_string_length
)
bot_info = await bot_inst.get_me()
bot_username = bot_info.username or "your_bot"
share_link = f"https://t.me/{bot_username}?start={test_hash}"
loop = asyncio.get_running_loop()
qr_bytes = await loop.run_in_executor(
None,
functools.partial(generate_qr_bytes, share_link)
)
assert _callback.message is not None
await _callback.message.answer_photo(
photo=BufferedInputFile(qr_bytes, filename="qr.png"),
caption=f"<b>🔗 Поделиться тестом</b>\n\n📎 <b>Ссылка на тест:</b>\n<code>{share_link}</code>\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"
)
async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.edit_menu)
async def on_back_to_detail(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.test_detail)
async def on_back_to_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.edit_menu)
async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.edit_password)
async def on_edit_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.edit_attempts)
async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.edit_group)
async def on_edit_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(SharedTestsSG.edit_expires)
@inject
async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await message.answer("❌ Тест не найден")
return
if not message.text:
await message.answer("❌ Пароль не может быть пустым")
return
password = message.text.strip()
if len(password) > 255:
await message.answer("❌ Пароль слишком длинный (максимум 255 символов)")
return
await test_dao.update(test_id, password=password)
await message.answer("✅ Пароль обновлен")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_remove_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, password=None)
await _callback.answer("✅ Пароль удален")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_attempts_input_edit(message: Message, _widget: MessageInput, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await message.answer("❌ Тест не найден")
return
if not message.text:
await message.answer("❌ Количество попыток не может быть пустым")
return
attempts_str = message.text.strip()
if not attempts_str.isdigit():
await message.answer("❌ Количество попыток должно быть числом")
return
attempts = int(attempts_str)
if attempts < 1:
await message.answer("❌ Количество попыток должно быть больше 0")
return
if attempts > 100:
await message.answer("❌ Количество попыток не может быть больше 100")
return
await test_dao.update(test_id, attempts=attempts)
await message.answer("✅ Количество попыток обновлено")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_remove_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, attempts=None)
await _callback.answer("✅ Ограничение попыток удалено")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def get_groups_for_edit(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs):
groups = await group_dao.get_all()
return {
"groups": [(str(g.number), str(g.number)) for g in groups],
}
@inject
async def on_group_selected_for_test(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, for_group=int(item_id))
await _callback.answer("✅ Группа обновлена")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_remove_group(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, for_group=None)
await _callback.answer("✅ Тест теперь доступен для всех групп")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, selected_date: date, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
expires_at = datetime.combine(selected_date, time.min)
await test_dao.update(test_id, expires_at=expires_at)
await _callback.answer("✅ Срок действия обновлен")
await manager.switch_to(SharedTestsSG.test_detail)
@inject
async def on_remove_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("❌ Тест не найден")
return
await test_dao.update(test_id, expires_at=None)
await _callback.answer("✅ Срок действия удален")
await manager.switch_to(SharedTestsSG.test_detail)
async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.start(SharedCreateTestSG.input_title, mode=StartMode.RESET_STACK)
async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.done()
shared_tests_dialog = Dialog(
Window(
Format("<b>📝 Тесты</b>\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,
),
id="tests_scroll",
width=1,
height=7,
),
Column(
Button(Const(" Добавить тест"), id="add_test", on_click=on_add_test_clicked),
Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked),
),
state=SharedTestsSG.tests_list,
getter=get_tests_data,
),
Window(
Format("{test_info}"),
Column(
Button(
Format("{button_text}"),
id="toggle_active",
on_click=on_toggle_active
),
Button(
Format("{results_button_text}"),
id="toggle_results",
on_click=on_toggle_results_viewable
),
Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics),
Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test),
Button(Const("📤 Экспорт"), id="export", on_click=on_export_test),
Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list),
),
state=SharedTestsSG.test_detail,
getter=get_test_detail,
),
Window(
Const("<b>✏️ Изменить тест</b>\n\nВыберите, что хотите изменить:"),
Column(
Button(Const("🔑 Пароль"), id="edit_password", on_click=on_edit_password),
Button(Const("🔄 Попытки"), id="edit_attempts", on_click=on_edit_attempts),
Button(Const("👥 Группа"), id="edit_group", on_click=on_edit_group),
Button(Const("📅 Срок действия"), id="edit_expires", on_click=on_edit_expires),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail),
),
state=SharedTestsSG.edit_menu,
),
Window(
Const("<b>🔑 Изменение пароля</b>\n\n💬 <b>Введите новый пароль</b> или удалите текущий:\n<i>(максимум 255 символов)</i>"),
MessageInput(on_password_input),
Column(
Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=SharedTestsSG.edit_password,
),
Window(
Const("<b>🔄 Изменение количества попыток</b>\n\n🔢 <b>Введите новое количество попыток</b> (1-100) или удалите ограничение:"),
MessageInput(on_attempts_input_edit),
Column(
Button(Const("🗑 Без ограничений"), id="remove_attempts", on_click=on_remove_attempts),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=SharedTestsSG.edit_attempts,
),
Window(
Const("<b>👥 Изменение группы</b>\n\n🎓 <b>Выберите группу</b> или удалите привязку:"),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_group_selected_for_test,
),
id="groups_scroll",
width=2,
height=7,
),
Column(
Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=SharedTestsSG.edit_group,
getter=get_groups_for_edit,
),
Window(
Const("<b>📅 Изменение срока действия</b>\n\n🗓 <b>Выберите новую дату</b> или удалите срок:"),
Calendar(id="calendar", on_click=on_date_selected_for_test),
Column(
Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu),
),
state=SharedTestsSG.edit_expires,
),
Window(
Format("<b>📊 Статистика теста</b>\n\nПрошли тест: {count}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="attempt_select",
item_id_getter=lambda x: x[1],
items="attempts",
on_click=on_attempt_selected,
),
id="attempts_scroll",
width=1,
height=7,
),
Column(
Button(Const("🔄 Обновить"), id="refresh", on_click=on_statistics),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail),
),
state=SharedTestsSG.statistics,
getter=get_statistics_data,
),
Window(
Format("{attempt_info}"),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_statistics),
state=SharedTestsSG.attempt_detail,
getter=get_attempt_detail,
),
)
@@ -0,0 +1,207 @@
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button
from aiogram_dialog.widgets.text import Const, Format
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserTestSG
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.database.models import QuestionType
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter
@inject
async def get_deeplink_test_data(
dialog_manager: DialogManager,
test_dao: FromDishka[TestDAO],
test_repo: FromDishka[TestRepository],
**_kwargs,
):
start_data = dialog_manager.start_data or {}
assert isinstance(start_data, dict)
test_id = start_data.get("test_id")
error = start_data.get("error")
if error:
return {"test_info": error, "can_start": False}
if not test_id:
return {"test_info": "❌ Тест не найден", "can_start": False}
test = await test_dao.get_by_id(test_id)
if not test:
return {"test_info": "❌ Тест не найден", "can_start": False}
questions_count = await test_repo.count_questions_in_test(test_id)
password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля"
attempts_str = f"🔄 Попыток: {test.attempts}" if test.attempts else "🔄 Попыток: ♾️"
test_info = (
f"<b>📝 {test.title}</b>\n\n"
f"<blockquote>{test.description or ''}</blockquote>\n\n"
f"<b>Вопросов:</b> {questions_count}\n"
f"{password_str}\n"
f"{attempts_str}"
)
return {"test_info": test_info, "can_start": True, "has_password": bool(test.password)}
@inject
async def on_start_deeplink_test(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
test_dao: FromDishka[TestDAO],
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
rate_limiter: FromDishka[PasswordRateLimiter],
):
assert _callback.from_user is not None
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
test_id = start_data.get("test_id")
user_id = _callback.from_user.id
if not test_id:
await _callback.answer("❌ Тест не найден")
return
test = await test_dao.get_by_id(test_id)
if not test:
await _callback.answer("❌ Тест не найден")
return
if test.attempts:
attempts = await attempt_repo.get_user_test_attempts(user_id, test_id)
finished_attempts = [a for a in attempts if a.finished_at]
if len(finished_attempts) >= test.attempts:
await _callback.answer("❌ Вы исчерпали все попытки")
return
active_attempt = await attempt_repo.get_active_attempt(user_id, test_id)
if active_attempt:
await attempt_repo.attempt_dao.delete(active_attempt.id)
if test.password:
allowed, wait_time = await rate_limiter.check(user_id)
if not allowed:
minutes = int(wait_time // 60) + 1
await _callback.answer(f"⏳ Слишком много попыток. Подождите {minutes} мин.", show_alert=True)
return
await manager.switch_to(UserDeeplinkSG.password_input)
else:
await start_test_without_password(manager, test_repo, attempt_repo, test_id, user_id)
async def start_test_without_password(
manager: DialogManager,
test_repo: TestRepository,
attempt_repo: TestAttemptRepository,
test_id: int,
user_id: int,
):
_, questions = await test_repo.get_test_with_questions(test_id)
if not questions:
return
attempt = await attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id)
first_question, _ = await test_repo.get_question_with_options(questions[0].id)
if first_question:
if first_question.question_type == QuestionType.SINGLE:
first_state = UserTestSG.question_single
elif first_question.question_type == QuestionType.MULTIPLE:
first_state = UserTestSG.question_multiple
else:
first_state = UserTestSG.question_input
else:
first_state = UserTestSG.question_single
await manager.start(
first_state,
mode=StartMode.RESET_STACK,
data={
"test_id": test_id,
"attempt_id": attempt.id,
"questions": [q.id for q in questions],
"current_question_index": 0,
"user_answers": {},
}
)
@inject
async def on_deeplink_password_input(
message: Message,
_widget: MessageInput,
manager: DialogManager,
test_dao: FromDishka[TestDAO],
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
rate_limiter: FromDishka[PasswordRateLimiter],
):
assert message.from_user is not None
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
test_id = start_data.get("test_id")
if not test_id:
await message.answer("❌ Тест не найден")
return
test = await test_dao.get_by_id(test_id)
if not test or not test.password:
await message.answer("❌ Ошибка проверки пароля")
return
if message.text and message.text.strip() == test.password:
await message.answer("✅ Пароль верный")
await start_test_without_password(
manager, test_repo, attempt_repo, test_id, message.from_user.id
)
else:
allowed, wait_time = await rate_limiter.check(message.from_user.id)
if not allowed:
minutes = int(wait_time // 60) + 1
await message.answer(f"❌ Неверный пароль\n⏳ Слишком много попыток. Подождите {minutes} мин.")
await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
else:
await message.answer("❌ Неверный пароль")
async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
deeplink_dialog = Dialog(
Window(
Format("{test_info}"),
Button(
Const("▶️ Пройти тест"),
id="start_test",
on_click=on_start_deeplink_test,
when="can_start"
),
Button(Const("◀️ В главное меню"), id="back", on_click=on_back_to_menu),
state=UserDeeplinkSG.test_preview,
getter=get_deeplink_test_data,
),
Window(
Const("<b>🔑 Введите пароль для доступа к тесту:</b>"),
MessageInput(on_deeplink_password_input),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu),
state=UserDeeplinkSG.password_input,
),
)
@@ -0,0 +1,484 @@
import asyncio
import functools
from datetime import datetime, timedelta
from aiogram import Bot
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import Button, Column, 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.user_dialogs.states import UserMenuSG
from quizzi.application.bot.user_dialogs.take_test import on_start_test
from quizzi.infrastructure.database.dao.group import GroupDAO
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.qr_generator import generate_qr_bytes
from quizzi.infrastructure.utils.test_id_to_hash import encode_id
from quizzi.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk
def can_edit_field(updated_at: datetime | None) -> bool:
if updated_at is None:
return True
updated_at_msk = to_msk(updated_at)
assert updated_at_msk is not None
return now_msk() - updated_at_msk >= timedelta(hours=24)
def get_remaining_time(updated_at: datetime) -> str:
updated_at_msk = to_msk(updated_at)
assert updated_at_msk is not None
remaining = timedelta(hours=24) - (now_msk() - updated_at_msk)
hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60)
return f"{hours}ч {minutes}м"
@inject
async def get_user_data(
dialog_manager: DialogManager,
user_dao: FromDishka[UserDAO],
attempt_repo: FromDishka[TestAttemptRepository],
**_kwargs,
):
assert dialog_manager.event.from_user is not None
user_id = dialog_manager.event.from_user.id
user = await user_dao.get_by_id(user_id)
stats = await attempt_repo.get_user_stats(user_id)
if not user:
return {"user_info": "❌ Пользователь не найден"}
name = user.name or user.first_name
group_str = f"🎓 Группа {user.group}" if user.group else "👤 Группа не указана"
if stats["total_attempts"] > 0:
accuracy_str = f"📊 Средняя точность: <b>{stats['avg_score']}%</b>"
tests_str = f"📝 Пройдено тестов: <b>{stats['total_attempts']}</b>"
else:
accuracy_str = "📊 Средняя точность: <b>—</b>"
tests_str = "📝 Пройдено тестов: <b>0</b>"
user_info = (
f"<b>👋 Привет, {name}!</b>\n\n"
f"<blockquote>{group_str}</blockquote>\n\n"
f"{tests_str}\n"
f"{accuracy_str}"
)
return {"user_info": user_info}
@inject
async def on_edit_name_clicked(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
user_dao: FromDishka[UserDAO],
):
assert _callback.from_user is not None
user = await user_dao.get_by_id(_callback.from_user.id)
if not user:
await _callback.answer("❌ Пользователь не найден")
return
if not can_edit_field(user.name_updated_at):
assert user.name_updated_at is not None
remaining = get_remaining_time(user.name_updated_at)
await _callback.answer(f"⏳ Изменить можно через {remaining}")
return
await manager.switch_to(UserMenuSG.edit_name)
@inject
async def on_edit_group_clicked(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
user_dao: FromDishka[UserDAO],
):
assert _callback.from_user is not None
user = await user_dao.get_by_id(_callback.from_user.id)
if not user:
await _callback.answer("❌ Пользователь не найден")
return
if not can_edit_field(user.group_updated_at):
assert user.group_updated_at is not None
remaining = get_remaining_time(user.group_updated_at)
await _callback.answer(f"⏳ Изменить можно через {remaining}")
return
await manager.switch_to(UserMenuSG.edit_group)
async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserMenuSG.available_tests)
async def on_results_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserMenuSG.my_results)
async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserMenuSG.main)
@inject
async def on_name_input(
message: Message,
_widget: MessageInput,
manager: DialogManager,
user_dao: FromDishka[UserDAO],
):
assert message.from_user is not None
if not message.text or len(message.text.strip()) < 2:
await message.answer("❌ Имя должно содержать минимум 2 символа")
return
name = message.text.strip()[:128]
result = await user_dao.update(
user_id=message.from_user.id,
name=name,
name_updated_at=now_msk_naive(),
)
if result:
await message.answer("✅ Имя обновлено")
else:
await message.answer("❌ Не удалось обновить имя")
await manager.switch_to(UserMenuSG.main)
@inject
async def get_groups_data(group_dao: FromDishka[GroupDAO], **_kwargs):
groups = await group_dao.get_all()
return {"groups": [(str(g.number), str(g.number)) for g in groups]}
@inject
async def on_group_selected(
_callback: CallbackQuery,
_widget,
manager: DialogManager,
item_id: str,
user_dao: FromDishka[UserDAO],
):
assert _callback.from_user is not None
result = await user_dao.update(
user_id=_callback.from_user.id,
group=int(item_id),
group_updated_at=now_msk_naive(),
)
if result:
await _callback.answer("✅ Группа обновлена")
else:
await _callback.answer("❌ Не удалось обновить группу")
await manager.switch_to(UserMenuSG.main)
@inject
async def get_available_tests(
dialog_manager: DialogManager,
user_dao: FromDishka[UserDAO],
test_repo: FromDishka[TestRepository],
**_kwargs,
):
assert dialog_manager.event.from_user is not None
user_id = dialog_manager.event.from_user.id
user = await user_dao.get_by_id(user_id)
if not user:
return {"tests": [], "count": 0}
tests = await test_repo.get_available_tests_for_user(user_id, user.group)
return {
"tests": [(f"📝 {t.title}", t.id) for t in tests],
"count": len(tests),
}
async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_test_id"] = int(item_id)
await manager.switch_to(UserMenuSG.test_detail)
async def on_back_to_tests(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserMenuSG.available_tests)
@inject
async def on_share_test(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
config: FromDishka[Config],
bot_inst: FromDishka[Bot],
):
test_id = manager.dialog_data.get("selected_test_id")
if not test_id:
await _callback.answer("Ошибка: тест не найден")
return
test_hash = encode_id(
test_id,
config.security.encode_key,
config.security.encoded_string_length,
)
bot_info = await bot_inst.get_me()
bot_username = bot_info.username or "your_bot"
share_link = f"https://t.me/{bot_username}?start={test_hash}"
loop = asyncio.get_running_loop()
qr_bytes = await loop.run_in_executor(
None,
functools.partial(generate_qr_bytes, share_link),
)
assert _callback.message is not None
await _callback.message.answer_photo(
photo=BufferedInputFile(qr_bytes, filename="qr.png"),
caption=f"<b>🔗 Поделиться тестом</b>\n\n📎 <b>Ссылка на тест:</b>\n<code>{share_link}</code>\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста",
)
@inject
async def get_test_detail(
dialog_manager: DialogManager,
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
**_kwargs,
):
assert dialog_manager.event.from_user is not None
test_id = dialog_manager.dialog_data.get("selected_test_id")
user_id = dialog_manager.event.from_user.id
if not test_id:
return {"test_info": "❌ Тест не найден"}
test, questions = await test_repo.get_test_with_questions(test_id)
if not test:
return {"test_info": "❌ Тест не найден"}
attempts = await attempt_repo.get_user_test_attempts(user_id, test_id)
finished_attempts = [a for a in attempts if a.finished_at]
password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля"
attempts_str = f"🔄 Попыток: {len(finished_attempts)}/{test.attempts}" if test.attempts else f"🔄 Попыток: {len(finished_attempts)}/♾️"
expires_at_msk = to_msk(test.expires_at)
expires_str = f"📅 До {expires_at_msk.strftime('%d.%m.%Y %H:%M')}" if expires_at_msk else "📅 Без срока"
group_str = f"🎓 Для группы {test.for_group}" if test.for_group else "👥 Для всех"
test_info = (
f"<b>📝 {test.title}</b>\n\n"
f"<blockquote>{test.description or ''}</blockquote>\n\n"
f"<b>Вопросов:</b> {len(questions)}\n"
f"{password_str}\n"
f"{attempts_str}\n"
f"{expires_str}\n"
f"{group_str}"
)
return {"test_info": test_info}
@inject
async def get_my_results(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
**_kwargs,
):
assert dialog_manager.event.from_user is not None
user_id = dialog_manager.event.from_user.id
attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id)
results = []
for attempt, test_title in attempts_with_tests:
status = "" if attempt.is_passed else ""
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else ""
results.append((f"{status} {test_title}{attempt.score}% ({date_str})", attempt.id))
return {
"results": results,
"count": len(results),
}
async def on_result_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str):
manager.dialog_data["selected_attempt_id"] = int(item_id)
await manager.switch_to(UserMenuSG.result_detail)
async def on_back_to_results(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserMenuSG.my_results)
@inject
async def get_result_detail(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
test_repo: FromDishka[TestRepository],
**_kwargs
):
attempt_id = dialog_manager.dialog_data.get("selected_attempt_id")
if not attempt_id:
return {"result_info": "❌ Результат не найден"}
attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id)
if not attempt:
return {"result_info": "❌ Результат не найден"}
test, _ = await test_repo.get_test_with_questions(attempt.test_id)
test_title = test.title if test else "Неизвестный тест"
are_results_viewable = test.are_results_viewable if test else False
status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден"
finished_at_msk = to_msk(attempt.finished_at)
date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else ""
correct_count = sum(1 for a in answers if a.is_correct)
total_count = len(answers)
lines = [
f"<b>📝 {test_title}</b>\n",
f"📊 <b>Результат:</b> {attempt.score}%",
f"✏️ <b>Правильных ответов:</b> {correct_count} из {total_count}",
f"📅 <b>Дата:</b> {date_str}",
f"🏆 <b>Статус:</b> {status}",
]
if are_results_viewable:
lines.append("\n<b>📋 Ответы:</b>\n")
for i, answer in enumerate(answers, 1):
question, options = await test_repo.get_question_with_options(answer.question_id)
if not question:
continue
correct_options = [opt for opt in options if opt.is_correct]
correct_texts = [opt.text for opt in correct_options]
status_icon = "" if answer.is_correct else ""
user_answer = answer.text_answer or ""
if "|" in user_answer:
user_answer = ", ".join(user_answer.split("|"))
lines.append(f"{status_icon} <b>Вопрос {i}</b>")
lines.append(f"<blockquote>{question.text}</blockquote>")
lines.append(f"👤 <i>Ваш ответ:</i> {user_answer or ''}")
lines.append(f"✓ <i>Правильно:</i> {', '.join(correct_texts)}\n")
else:
lines.append("\n<i>🔒 Подробные результаты скрыты</i>")
return {"result_info": "\n".join(lines)}
user_menu_dialog = Dialog(
Window(
Format("{user_info}"),
Column(
Button(Const("📝 Доступные тесты"), id="tests", on_click=on_tests_clicked),
Button(Const("📊 Мои результаты"), id="results", on_click=on_results_clicked),
),
Row(
Button(Const("✏️ Имя"), id="edit_name", on_click=on_edit_name_clicked),
Button(Const("🎓 Группа"), id="edit_group", on_click=on_edit_group_clicked),
),
state=UserMenuSG.main,
getter=get_user_data,
),
Window(
Format("<b>📝 Доступные тесты</b>\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,
),
id="tests_scroll",
width=1,
height=7,
),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main),
state=UserMenuSG.available_tests,
getter=get_available_tests,
),
Window(
Format("{test_info}"),
Column(
Button(Const("▶️ Пройти тест"), id="start_test", on_click=on_start_test),
Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_tests),
),
state=UserMenuSG.test_detail,
getter=get_test_detail,
),
Window(
Const("<b>✏️ Изменение имени</b>\n\nВведите новое имя:"),
MessageInput(on_name_input),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main),
state=UserMenuSG.edit_name,
),
Window(
Const("<b>🎓 Изменение группы</b>\n\nВыберите группу:"),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_group_selected,
),
id="groups_scroll",
width=2,
height=7,
),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main),
state=UserMenuSG.edit_group,
getter=get_groups_data,
),
Window(
Format("<b>📊 Мои результаты</b>\n\nВсего: {count}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="result_select",
item_id_getter=lambda x: x[1],
items="results",
on_click=on_result_selected,
),
id="results_scroll",
width=1,
height=7,
),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main),
state=UserMenuSG.my_results,
getter=get_my_results,
),
Window(
Format("{result_info}"),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_results),
state=UserMenuSG.result_detail,
getter=get_result_detail,
),
)
@@ -0,0 +1,111 @@
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import Dialog, DialogManager, StartMode, Window
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import 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.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG
from quizzi.infrastructure.database.dao.group import GroupDAO
from quizzi.infrastructure.database.dao.user import UserDAO
@inject
async def on_name_input(
message: Message,
_widget: MessageInput,
manager: DialogManager,
user_dao: FromDishka[UserDAO],
):
assert message.from_user is not None
if not message.text:
await message.answer("❌ Имя и фамилия не могут быть пустыми")
return
name = message.text.strip()
if not name:
await message.answer("❌ Имя и фамилия не могут быть пустыми")
return
if len(name) > 128:
await message.answer("❌ Имя и фамилия слишком длинные (максимум 128 символов)")
return
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
user_id = start_data.get("user_id")
if user_id:
await user_dao.update(user_id=user_id, name=name)
manager.dialog_data["name"] = name
await manager.switch_to(UserRegistrationSG.select_group)
@inject
async def get_groups_for_registration(group_dao: FromDishka[GroupDAO], **_kwargs):
groups = await group_dao.get_all()
return {
"groups": [(str(g.number), str(g.number)) for g in groups],
}
@inject
async def on_group_selected(
_callback: CallbackQuery,
_widget,
manager: DialogManager,
item_id: str,
user_dao: FromDishka[UserDAO],
):
assert _callback.from_user is not None
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
user_id = start_data.get("user_id")
pending_test_id = start_data.get("pending_test_id")
if user_id:
await user_dao.update(user_id=user_id, group=int(item_id))
if pending_test_id:
await manager.start(
UserDeeplinkSG.test_preview,
mode=StartMode.RESET_STACK,
data={"test_id": pending_test_id}
)
else:
await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
registration_dialog = Dialog(
Window(
Const(
"<b>👋 Добро пожаловать!</b>\n\n"
"✏️ <b>Введите ваше имя и фамилию:</b>\n\n"
"⚠️ <b>Внимание:</b> Изменить данные можно будет только через 24 часа!"
),
MessageInput(on_name_input),
state=UserRegistrationSG.input_name,
),
Window(
Const(
"<b>🎓 Выберите вашу группу:</b>\n\n"
"⚠️ <b>Внимание:</b> Изменить группу можно будет только через 24 часа!"
),
ScrollingGroup(
Select(
Format("{item[1]}"),
id="groups",
item_id_getter=lambda x: x[0],
items="groups",
on_click=on_group_selected,
),
id="groups_scroll",
width=2,
height=7,
),
state=UserRegistrationSG.select_group,
getter=get_groups_for_registration,
),
)
@@ -0,0 +1,30 @@
from aiogram.fsm.state import State, StatesGroup
class UserMenuSG(StatesGroup):
main = State()
available_tests = State()
test_detail = State()
edit_name = State()
edit_group = State()
my_results = State()
result_detail = State()
class UserTestSG(StatesGroup):
password_input = State()
question_single = State()
question_multiple = State()
question_input = State()
results = State()
detailed_results = State()
class UserDeeplinkSG(StatesGroup):
test_preview = State()
password_input = State()
class UserRegistrationSG(StatesGroup):
input_name = State()
select_group = State()
@@ -0,0 +1,564 @@
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
from quizzi.application.bot.user_dialogs.states import UserMenuSG, UserTestSG
from quizzi.domain.schemas import QuestionType
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter
from quizzi.infrastructure.utils.timezone import now_msk_naive
async def get_state_for_question_type(question_type: str):
if question_type == QuestionType.SINGLE:
return UserTestSG.question_single
elif question_type == QuestionType.MULTIPLE:
return UserTestSG.question_multiple
else:
return UserTestSG.question_input
@inject
async def on_start_test(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
test_dao: FromDishka[TestDAO],
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
rate_limiter: FromDishka[PasswordRateLimiter],
):
assert _callback.from_user is not None
test_id = manager.dialog_data.get("selected_test_id")
user_id = _callback.from_user.id
if not test_id:
await _callback.answer("❌ Тест не найден")
return
test = await test_dao.get_by_id(test_id)
if not test:
await _callback.answer("❌ Тест не найден")
return
if not test.is_active:
await _callback.answer("❌ Тест деактивирован")
return
if test.expires_at and test.expires_at < now_msk_naive():
await _callback.answer("❌ Срок действия теста истек")
return
if test.attempts:
attempts = await attempt_repo.get_user_test_attempts(user_id, test_id)
finished_attempts = [a for a in attempts if a.finished_at]
if len(finished_attempts) >= test.attempts:
await _callback.answer("❌ Вы исчерпали все попытки")
return
active_attempt = await attempt_repo.get_active_attempt(user_id, test_id)
if active_attempt:
await attempt_repo.attempt_dao.delete(active_attempt.id)
if test.password:
allowed, wait_time = await rate_limiter.check(user_id)
if not allowed:
minutes = int(wait_time // 60) + 1
await _callback.answer(f"⏳ Слишком много попыток. Подождите {minutes} мин.", show_alert=True)
return
await manager.start(UserTestSG.password_input, mode=StartMode.NORMAL, data={"test_id": test_id})
else:
_, questions = await test_repo.get_test_with_questions(test_id)
if not questions:
await _callback.answer("❌ В тесте нет вопросов")
return
attempt = await attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id)
first_question, _ = await test_repo.get_question_with_options(questions[0].id)
first_state = await get_state_for_question_type(first_question.question_type if first_question else QuestionType.SINGLE)
await manager.start(
first_state,
mode=StartMode.NORMAL,
data={
"test_id": test_id,
"attempt_id": attempt.id,
"questions": [q.id for q in questions],
"current_question_index": 0,
"user_answers": {},
}
)
@inject
async def on_password_input(
message: Message,
_widget: MessageInput,
manager: DialogManager,
test_dao: FromDishka[TestDAO],
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
rate_limiter: FromDishka[PasswordRateLimiter],
):
assert message.from_user is not None
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
test_id = start_data.get("test_id")
if not test_id:
await message.answer("❌ Тест не найден")
return
test = await test_dao.get_by_id(test_id)
if not test or not test.password:
await message.answer("❌ Ошибка проверки пароля")
return
if message.text and message.text.strip() == test.password:
await message.answer("✅ Пароль верный, начинаем тест")
_, questions = await test_repo.get_test_with_questions(test_id)
if not questions:
await message.answer("❌ В тесте нет вопросов")
return
attempt = await attempt_repo.attempt_dao.create(user_id=message.from_user.id, test_id=test_id)
first_question, _ = await test_repo.get_question_with_options(questions[0].id)
first_state = await get_state_for_question_type(first_question.question_type if first_question else QuestionType.SINGLE)
manager.dialog_data["attempt_id"] = attempt.id
manager.dialog_data["questions"] = [q.id for q in questions]
manager.dialog_data["current_question_index"] = 0
manager.dialog_data["user_answers"] = {}
await manager.switch_to(first_state)
else:
allowed, wait_time = await rate_limiter.check(message.from_user.id)
if not allowed:
minutes = int(wait_time // 60) + 1
await message.answer(f"❌ Неверный пароль\n⏳ Слишком много попыток. Подождите {minutes} мин.")
await manager.done()
else:
await message.answer("❌ Неверный пароль")
@inject
async def on_cancel_test(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
):
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id")
if attempt_id:
await attempt_repo.attempt_dao.delete(attempt_id)
await _callback.answer("Тест отменен")
await manager.start(UserMenuSG.available_tests, mode=StartMode.RESET_STACK)
@inject
async def get_question_data(
dialog_manager: DialogManager,
test_repo: FromDishka[TestRepository],
**_kwargs,
):
start_data = dialog_manager.start_data or {}
assert isinstance(start_data, dict)
current_index = dialog_manager.dialog_data.get("current_question_index")
if current_index is None:
current_index = start_data.get("current_question_index", 0)
questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", [])
if not questions or current_index >= len(questions):
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": [], "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,
}
async def on_single_answer_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str):
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
current_index = manager.dialog_data.get("current_question_index")
if current_index is None:
current_index = start_data.get("current_question_index", 0)
questions = manager.dialog_data.get("questions") or start_data.get("questions", [])
if not questions or current_index >= len(questions):
return
question_id = questions[current_index]
user_answers = manager.dialog_data.get("user_answers", {})
user_answers[str(question_id)] = {"type": "single", "answer": int(item_id)}
manager.dialog_data["user_answers"] = user_answers
async def on_multiple_answer_changed(_event, widget, manager: DialogManager, _data: str):
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
current_index = manager.dialog_data.get("current_question_index")
if current_index is None:
current_index = start_data.get("current_question_index", 0)
questions = manager.dialog_data.get("questions") or start_data.get("questions", [])
if not questions or current_index >= len(questions):
return
question_id = questions[current_index]
selected = widget.get_checked()
user_answers = manager.dialog_data.get("user_answers", {})
user_answers[str(question_id)] = {"type": "multiple", "answer": [int(x) for x in selected]}
manager.dialog_data["user_answers"] = user_answers
@inject
async def on_text_answer_input(
message: Message,
_widget: MessageInput,
manager: DialogManager,
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
answer_dao: FromDishka[UserAnswerDAO],
test_dao: FromDishka[TestDAO],
):
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
current_index = manager.dialog_data.get("current_question_index")
if current_index is None:
current_index = start_data.get("current_question_index", 0)
questions = manager.dialog_data.get("questions") or start_data.get("questions", [])
if not questions or current_index >= len(questions):
return
question_id = questions[current_index]
text_answer = message.text.strip() if message.text else ""
attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id")
test_id = manager.dialog_data.get("test_id") or start_data.get("test_id")
if not attempt_id:
await message.answer("❌ Ошибка попытки")
return
question, options = await test_repo.get_question_with_options(question_id)
if not question:
await message.answer("❌ Вопрос не найден")
return
correct_options = [opt for opt in options if opt.is_correct]
user_normalized = text_answer.lower().replace(" ", "")
is_correct = any(opt.text.lower().replace(" ", "") == user_normalized for opt in correct_options)
await answer_dao.create(
attempt_id=attempt_id,
question_id=question_id,
text_answer=text_answer,
is_correct=is_correct,
)
if current_index + 1 >= len(questions):
test = await test_dao.get_by_id(test_id) if test_id else None
are_results_viewable = test.are_results_viewable if test else False
await finish_test(manager, attempt_repo, attempt_id, len(questions), are_results_viewable)
else:
next_index = current_index + 1
manager.dialog_data["current_question_index"] = next_index
next_question_id = questions[next_index]
next_question, _ = await test_repo.get_question_with_options(next_question_id)
next_state = await get_state_for_question_type(next_question.question_type if next_question else QuestionType.SINGLE)
await manager.switch_to(next_state)
@inject
async def on_next_question(
_callback: CallbackQuery,
_button: Button,
manager: DialogManager,
test_repo: FromDishka[TestRepository],
attempt_repo: FromDishka[TestAttemptRepository],
answer_dao: FromDishka[UserAnswerDAO],
test_dao: FromDishka[TestDAO],
):
start_data = manager.start_data or {}
assert isinstance(start_data, dict)
current_index = manager.dialog_data.get("current_question_index")
if current_index is None:
current_index = start_data.get("current_question_index", 0)
questions = manager.dialog_data.get("questions") or start_data.get("questions", [])
if not questions or current_index >= len(questions):
await _callback.answer("❌ Ошибка")
return
question_id = questions[current_index]
user_answers = manager.dialog_data.get("user_answers", {})
if str(question_id) not in user_answers:
await _callback.answer("❌ Выберите ответ")
return
answer_data = user_answers[str(question_id)]
attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id")
test_id = manager.dialog_data.get("test_id") or start_data.get("test_id")
if not attempt_id:
await _callback.answer("❌ Ошибка попытки")
return
question, options = await test_repo.get_question_with_options(question_id)
if not question:
await _callback.answer("❌ Вопрос не найден")
return
if answer_data["type"] == "single":
selected_option_id = answer_data["answer"]
correct_options = [opt for opt in options if opt.is_correct]
is_correct = any(opt.id == selected_option_id for opt in correct_options)
selected_text = next((opt.text for opt in options if opt.id == selected_option_id), "")
await answer_dao.create(
attempt_id=attempt_id,
question_id=question_id,
selected_option_id=selected_option_id,
text_answer=selected_text,
is_correct=is_correct,
)
elif answer_data["type"] == "multiple":
selected_option_ids = set(answer_data["answer"])
selected_texts = sorted([opt.text for opt in options if opt.id in selected_option_ids])
correct_texts = sorted([opt.text for opt in options if opt.is_correct])
is_correct = selected_texts == correct_texts
await answer_dao.create(
attempt_id=attempt_id,
question_id=question_id,
text_answer="|".join(selected_texts),
is_correct=is_correct,
)
if current_index + 1 >= len(questions):
test = await test_dao.get_by_id(test_id) if test_id else None
are_results_viewable = test.are_results_viewable if test else False
await finish_test(manager, attempt_repo, attempt_id, len(questions), are_results_viewable)
else:
next_index = current_index + 1
manager.dialog_data["current_question_index"] = next_index
next_question_id = questions[next_index]
next_question, _ = await test_repo.get_question_with_options(next_question_id)
next_state = await get_state_for_question_type(next_question.question_type if next_question else QuestionType.SINGLE)
await manager.switch_to(next_state)
async def finish_test(
manager: DialogManager,
attempt_repo: TestAttemptRepository,
attempt_id: int,
total_questions: int,
are_results_viewable: bool = False,
):
correct_count = await attempt_repo.calculate_attempt_score(attempt_id)
score = round((correct_count / total_questions * 100)) if total_questions > 0 else 0
is_passed = score >= 50
await attempt_repo.finish_attempt(attempt_id, score, is_passed)
manager.dialog_data["score"] = score
manager.dialog_data["correct_count"] = correct_count
manager.dialog_data["total_questions"] = total_questions
manager.dialog_data["is_passed"] = is_passed
manager.dialog_data["are_results_viewable"] = are_results_viewable
await manager.switch_to(UserTestSG.results)
async def get_results_data(dialog_manager: DialogManager, **_kwargs):
score = dialog_manager.dialog_data.get("score", 0)
correct_count = dialog_manager.dialog_data.get("correct_count", 0)
total_questions = dialog_manager.dialog_data.get("total_questions", 0)
is_passed = dialog_manager.dialog_data.get("is_passed", False)
are_results_viewable = dialog_manager.dialog_data.get("are_results_viewable", False)
if is_passed:
status = "✅ <b>Тест пройден!</b>"
else:
status = "❌ <b>Тест не пройден</b>"
results_text = (
f"{status}\n\n"
f"📊 <b>Результат:</b> {score}%\n"
f"✏️ <b>Правильных ответов:</b> {correct_count} из {total_questions}"
)
return {"results_text": results_text, "are_results_viewable": are_results_viewable}
async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
async def on_show_detailed_results(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserTestSG.detailed_results)
async def on_back_to_results(_callback: CallbackQuery, _button: Button, manager: DialogManager):
await manager.switch_to(UserTestSG.results)
@inject
async def get_detailed_results_data(
dialog_manager: DialogManager,
attempt_repo: FromDishka[TestAttemptRepository],
test_repo: FromDishka[TestRepository],
**_kwargs,
):
start_data = dialog_manager.start_data or {}
assert isinstance(start_data, dict)
attempt_id = dialog_manager.dialog_data.get("attempt_id") or start_data.get("attempt_id")
if not attempt_id:
return {"detailed_text": "Ошибка загрузки результатов"}
answers = await attempt_repo.get_answers_for_attempt(attempt_id)
lines = ["<b>📋 Подробные результаты</b>\n"]
for i, answer in enumerate(answers, 1):
question, options = await test_repo.get_question_with_options(answer.question_id)
if not question:
continue
correct_options = [opt for opt in options if opt.is_correct]
correct_texts = [opt.text for opt in correct_options]
status = "" if answer.is_correct else ""
user_answer = answer.text_answer or ""
if "|" in user_answer:
user_answer = ", ".join(user_answer.split("|"))
lines.append(f"{status} <b>Вопрос {i}</b>")
lines.append(f"<blockquote>{question.text}</blockquote>")
lines.append(f"👤 <i>Ваш ответ:</i> {user_answer or ''}")
lines.append(f"✓ <i>Правильно:</i> {', '.join(correct_texts)}\n")
return {"detailed_text": "\n".join(lines)}
take_test_dialog = Dialog(
Window(
Const("<b>🔑 Введите пароль для доступа к тесту:</b>"),
MessageInput(on_password_input),
Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test),
state=UserTestSG.password_input,
),
Window(
DynamicMedia("media", when="media"),
Format("{question_text}\n\n<i>Выберите один вариант ответа:</i>"),
Column(
Radio(
Format("🔘 {item[0]}"),
Format("{item[0]}"),
id="single_answer",
item_id_getter=lambda x: x[1],
items="options",
on_click=on_single_answer_selected,
),
),
Button(Const("➡️ Далее"), id="next", on_click=on_next_question),
state=UserTestSG.question_single,
getter=get_question_data,
),
Window(
DynamicMedia("media", when="media"),
Format("{question_text}\n\n<i>Выберите несколько вариантов ответа:</i>"),
Column(
Multiselect(
Format("{item[0]}"),
Format("{item[0]}"),
id="multiple_answer",
item_id_getter=lambda x: x[1],
items="options",
on_state_changed=on_multiple_answer_changed,
),
),
Button(Const("➡️ Далее"), id="next", on_click=on_next_question),
state=UserTestSG.question_multiple,
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,
getter=get_question_data,
),
Window(
Format("{results_text}"),
Column(
Button(
Const("📋 Подробные результаты"),
id="detailed",
on_click=on_show_detailed_results,
when="are_results_viewable",
),
Button(Const("◀️ В главное меню"), id="back", on_click=on_back_to_menu),
),
state=UserTestSG.results,
getter=get_results_data,
),
Window(
Format("{detailed_text}"),
Button(Const("◀️ Назад"), id="back", on_click=on_back_to_results),
state=UserTestSG.detailed_results,
getter=get_detailed_results_data,
),
)