mirror of
https://github.com/koloideal/Quizzi.git
synced 2026-06-10 10:25:28 +03:00
update
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class QuestionType(str, Enum):
|
||||
SINGLE = "single"
|
||||
MULTIPLE = "multiple"
|
||||
INPUT = "input"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: int
|
||||
first_name: str
|
||||
username: str | None = None
|
||||
last_name: str | None = None
|
||||
name: str | None = None
|
||||
group: int | None = None
|
||||
is_admin: bool = False
|
||||
name_updated_at: datetime | None = None
|
||||
group_updated_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Group:
|
||||
id: int
|
||||
number: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Test:
|
||||
id: int
|
||||
title: str
|
||||
description: str | None = None
|
||||
for_group: int | None = None
|
||||
password: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
attempts: int | None = None
|
||||
is_active: bool = True
|
||||
are_results_viewable: bool = False
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Question:
|
||||
id: int
|
||||
test_id: int
|
||||
text: str
|
||||
position: int = 0
|
||||
question_type: QuestionType = QuestionType.SINGLE
|
||||
tg_file_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Option:
|
||||
id: int
|
||||
question_id: int
|
||||
text: str
|
||||
is_correct: bool = False
|
||||
explanation: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestAttempt:
|
||||
id: int
|
||||
user_id: int
|
||||
test_id: int
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
score: int = 0
|
||||
is_passed: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserAnswer:
|
||||
id: int
|
||||
attempt_id: int
|
||||
question_id: int
|
||||
selected_option_id: int | None = None
|
||||
text_answer: str | None = None
|
||||
is_correct: bool = False
|
||||
@@ -0,0 +1,372 @@
|
||||
import json5
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedOption:
|
||||
text: str
|
||||
is_correct: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedQuestion:
|
||||
text: str
|
||||
question_type: str
|
||||
options: list[ParsedOption]
|
||||
correct_answer: str | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedTest:
|
||||
title: str
|
||||
description: str | None
|
||||
password: str | None
|
||||
attempts: int | None
|
||||
expires_at: datetime | None
|
||||
for_group: int | None
|
||||
questions: list[ParsedQuestion]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseError:
|
||||
message: str
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class TestParser:
|
||||
VALID_QUESTION_TYPES = {"single", "multiple", "input"}
|
||||
|
||||
def parse(self, json_str: str) -> ParsedTest | list[ParseError]:
|
||||
try:
|
||||
data = json5.loads(json_str)
|
||||
except ValueError as e:
|
||||
return [ParseError(f"Невалидный JSON: {e}", path=None)]
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return [ParseError("JSON должен быть объектом", path=None)]
|
||||
|
||||
errors: list[ParseError] = []
|
||||
|
||||
title = self._parse_string(data, "title", required=True, max_length=255, errors=errors)
|
||||
description = self._parse_string(data, "description", required=False, max_length=2000, errors=errors)
|
||||
password = self._parse_string(data, "password", required=False, max_length=255, errors=errors)
|
||||
attempts = self._parse_int(data, "attempts", required=False, min_val=1, max_val=100, errors=errors)
|
||||
expires_at = self._parse_datetime(data, "expires_at", required=False, errors=errors)
|
||||
for_group = self._parse_int(data, "for_group", required=False, errors=errors)
|
||||
|
||||
questions = self._parse_questions(data, errors)
|
||||
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
assert title is not None
|
||||
|
||||
return ParsedTest(
|
||||
title=title,
|
||||
description=description,
|
||||
password=password,
|
||||
attempts=attempts,
|
||||
expires_at=expires_at,
|
||||
for_group=for_group,
|
||||
questions=questions,
|
||||
)
|
||||
|
||||
def _parse_string(
|
||||
self,
|
||||
data: dict,
|
||||
key: str,
|
||||
required: bool,
|
||||
max_length: int | None = None,
|
||||
errors: list[ParseError] | None = None,
|
||||
) -> str | None:
|
||||
if errors is None:
|
||||
errors = []
|
||||
value = data.get(key)
|
||||
|
||||
if value is None:
|
||||
if required:
|
||||
errors.append(ParseError(f"Поле '{key}' обязательно", path=key))
|
||||
return None
|
||||
|
||||
if not isinstance(value, str):
|
||||
errors.append(ParseError(f"Поле '{key}' должно быть строкой", path=key))
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
if not value and required:
|
||||
errors.append(ParseError(f"Поле '{key}' не может быть пустым", path=key))
|
||||
return None
|
||||
|
||||
if max_length and len(value) > max_length:
|
||||
errors.append(ParseError(f"Поле '{key}' слишком длинное (максимум {max_length})", path=key))
|
||||
return None
|
||||
|
||||
return value if value else None
|
||||
|
||||
def _parse_int(
|
||||
self,
|
||||
data: dict,
|
||||
key: str,
|
||||
required: bool,
|
||||
min_val: int | None = None,
|
||||
max_val: int | None = None,
|
||||
errors: list[ParseError] | None = None,
|
||||
) -> int | None:
|
||||
if errors is None:
|
||||
errors = []
|
||||
value = data.get(key)
|
||||
|
||||
if value is None:
|
||||
if required:
|
||||
errors.append(ParseError(f"Поле '{key}' обязательно", path=key))
|
||||
return None
|
||||
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
errors.append(ParseError(f"Поле '{key}' должно быть числом", path=key))
|
||||
return None
|
||||
|
||||
if min_val is not None and value < min_val:
|
||||
errors.append(ParseError(f"Поле '{key}' должно быть не меньше {min_val}", path=key))
|
||||
return None
|
||||
|
||||
if max_val is not None and value > max_val:
|
||||
errors.append(ParseError(f"Поле '{key}' должно быть не больше {max_val}", path=key))
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
def _parse_datetime(
|
||||
self,
|
||||
data: dict,
|
||||
key: str,
|
||||
required: bool,
|
||||
errors: list[ParseError] | None = None,
|
||||
) -> datetime | None:
|
||||
if errors is None:
|
||||
errors = []
|
||||
value = data.get(key)
|
||||
|
||||
if value is None:
|
||||
if required:
|
||||
errors.append(ParseError(f"Поле '{key}' обязательно", path=key))
|
||||
return None
|
||||
|
||||
if not isinstance(value, str):
|
||||
errors.append(ParseError(f"Поле '{key}' должно быть строкой в ISO формате", path=key))
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
errors.append(ParseError(f"Поле '{key}' должно быть в ISO формате (например 2026-12-31T23:59:59)", path=key))
|
||||
return None
|
||||
|
||||
def _parse_questions(self, data: dict, errors: list[ParseError]) -> list[ParsedQuestion]:
|
||||
questions_data = data.get("questions")
|
||||
|
||||
if questions_data is None:
|
||||
errors.append(ParseError("Поле 'questions' обязательно", path="questions"))
|
||||
return []
|
||||
|
||||
if not isinstance(questions_data, list):
|
||||
errors.append(ParseError("Поле 'questions' должно быть массивом", path="questions"))
|
||||
return []
|
||||
|
||||
if len(questions_data) == 0:
|
||||
errors.append(ParseError("Тест должен содержать хотя бы один вопрос", path="questions"))
|
||||
return []
|
||||
|
||||
questions: list[ParsedQuestion] = []
|
||||
|
||||
for i, q_data in enumerate(questions_data):
|
||||
path = f"questions[{i}]"
|
||||
|
||||
if not isinstance(q_data, dict):
|
||||
errors.append(ParseError("Вопрос должен быть объектом", path=path))
|
||||
continue
|
||||
|
||||
question = self._parse_question(q_data, path, errors)
|
||||
if question:
|
||||
questions.append(question)
|
||||
|
||||
return questions
|
||||
|
||||
def _parse_question(self, data: dict, path: str, errors: list[ParseError]) -> ParsedQuestion | None:
|
||||
text = data.get("question")
|
||||
if not text or not isinstance(text, str):
|
||||
errors.append(ParseError("Поле 'question' обязательно и должно быть строкой", path=f"{path}.question"))
|
||||
return None
|
||||
|
||||
text = text.strip()
|
||||
if not text:
|
||||
errors.append(ParseError("Текст вопроса не может быть пустым", path=f"{path}.question"))
|
||||
return None
|
||||
|
||||
if len(text) > 2000:
|
||||
errors.append(ParseError("Текст вопроса слишком длинный (максимум 2000)", path=f"{path}.question"))
|
||||
return None
|
||||
|
||||
question_type = data.get("question_type")
|
||||
if not question_type or not isinstance(question_type, str):
|
||||
errors.append(ParseError("Поле 'question_type' обязательно", path=f"{path}.question_type"))
|
||||
return None
|
||||
|
||||
if question_type not in self.VALID_QUESTION_TYPES:
|
||||
errors.append(ParseError(
|
||||
f"Неизвестный тип вопроса '{question_type}'. Допустимые: single, multiple, input",
|
||||
path=f"{path}.question_type"
|
||||
))
|
||||
return None
|
||||
|
||||
image_url = self._parse_image_url(data, path, errors)
|
||||
|
||||
if question_type == "input":
|
||||
return self._parse_input_question(data, path, text, image_url, errors)
|
||||
else:
|
||||
return self._parse_choice_question(data, path, text, question_type, image_url, errors)
|
||||
|
||||
def _parse_image_url(
|
||||
self,
|
||||
data: dict,
|
||||
path: str,
|
||||
errors: list[ParseError],
|
||||
) -> str | None:
|
||||
image_url = data.get("image_url")
|
||||
|
||||
if image_url is None:
|
||||
return None
|
||||
|
||||
if not isinstance(image_url, str):
|
||||
errors.append(ParseError("Поле 'image_url' должно быть строкой", path=f"{path}.image_url"))
|
||||
return None
|
||||
|
||||
image_url = image_url.strip()
|
||||
if not image_url:
|
||||
return None
|
||||
|
||||
if not image_url.startswith(("http://", "https://")):
|
||||
errors.append(ParseError("Поле 'image_url' должно быть URL (http:// или https://)", path=f"{path}.image_url"))
|
||||
return None
|
||||
|
||||
if len(image_url) > 2000:
|
||||
errors.append(ParseError("URL изображения слишком длинный (максимум 2000)", path=f"{path}.image_url"))
|
||||
return None
|
||||
|
||||
return image_url
|
||||
|
||||
def _parse_input_question(
|
||||
self,
|
||||
data: dict,
|
||||
path: str,
|
||||
text: str,
|
||||
image_url: str | None,
|
||||
errors: list[ParseError],
|
||||
) -> ParsedQuestion | None:
|
||||
correct_answer = data.get("correct_answer")
|
||||
|
||||
if not correct_answer or not isinstance(correct_answer, str):
|
||||
errors.append(ParseError(
|
||||
"Для типа 'input' поле 'correct_answer' обязательно",
|
||||
path=f"{path}.correct_answer"
|
||||
))
|
||||
return None
|
||||
|
||||
correct_answer = correct_answer.strip()
|
||||
if not correct_answer:
|
||||
errors.append(ParseError("Правильный ответ не может быть пустым", path=f"{path}.correct_answer"))
|
||||
return None
|
||||
|
||||
if len(correct_answer) > 255:
|
||||
errors.append(ParseError("Правильный ответ слишком длинный (максимум 255)", path=f"{path}.correct_answer"))
|
||||
return None
|
||||
|
||||
return ParsedQuestion(
|
||||
text=text,
|
||||
question_type="input",
|
||||
options=[ParsedOption(text=correct_answer, is_correct=True)],
|
||||
correct_answer=correct_answer,
|
||||
image_url=image_url,
|
||||
)
|
||||
|
||||
def _parse_choice_question(
|
||||
self,
|
||||
data: dict,
|
||||
path: str,
|
||||
text: str,
|
||||
question_type: str,
|
||||
image_url: str | None,
|
||||
errors: list[ParseError],
|
||||
) -> ParsedQuestion | None:
|
||||
options_data = data.get("answers")
|
||||
|
||||
if not options_data or not isinstance(options_data, list):
|
||||
errors.append(ParseError(
|
||||
f"Для типа '{question_type}' поле 'answers' обязательно и должно быть массивом",
|
||||
path=f"{path}.answers"
|
||||
))
|
||||
return None
|
||||
|
||||
if len(options_data) < 2:
|
||||
errors.append(ParseError("Минимум 2 варианта ответа", path=f"{path}.answers"))
|
||||
return None
|
||||
|
||||
if len(options_data) > 10:
|
||||
errors.append(ParseError("Максимум 10 вариантов ответа", path=f"{path}.answers"))
|
||||
return None
|
||||
|
||||
options: list[ParsedOption] = []
|
||||
correct_count = 0
|
||||
|
||||
for j, opt_data in enumerate(options_data):
|
||||
opt_path = f"{path}.answers[{j}]"
|
||||
|
||||
if not isinstance(opt_data, dict):
|
||||
errors.append(ParseError("Вариант ответа должен быть объектом", path=opt_path))
|
||||
continue
|
||||
|
||||
opt_text = opt_data.get("option")
|
||||
if not opt_text or not isinstance(opt_text, str):
|
||||
errors.append(ParseError("Поле 'option' обязательно", path=f"{opt_path}.option"))
|
||||
continue
|
||||
|
||||
opt_text = opt_text.strip()
|
||||
if not opt_text:
|
||||
errors.append(ParseError("Текст варианта не может быть пустым", path=f"{opt_path}.option"))
|
||||
continue
|
||||
|
||||
if len(opt_text) > 255:
|
||||
errors.append(ParseError("Текст варианта слишком длинный (максимум 255)", path=f"{opt_path}.option"))
|
||||
continue
|
||||
|
||||
is_correct = opt_data.get("is_correct")
|
||||
if not isinstance(is_correct, bool):
|
||||
errors.append(ParseError("Поле 'is_correct' должно быть true или false", path=f"{opt_path}.is_correct"))
|
||||
continue
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
|
||||
options.append(ParsedOption(text=opt_text, is_correct=is_correct))
|
||||
|
||||
if len(options) < 2:
|
||||
return None
|
||||
|
||||
if correct_count == 0:
|
||||
errors.append(ParseError("Должен быть хотя бы один правильный ответ", path=f"{path}.answers"))
|
||||
return None
|
||||
|
||||
if question_type == "single" and correct_count > 1:
|
||||
errors.append(ParseError(
|
||||
f"Для типа 'single' должен быть ровно один правильный ответ (найдено {correct_count})",
|
||||
path=f"{path}.answers"
|
||||
))
|
||||
return None
|
||||
|
||||
return ParsedQuestion(
|
||||
text=text,
|
||||
question_type=question_type,
|
||||
options=options,
|
||||
image_url=image_url,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]:
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
pool_size=15,
|
||||
max_overflow=15,
|
||||
connect_args={
|
||||
"timeout": 5,
|
||||
},
|
||||
)
|
||||
return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False)
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
from .option import OptionDAO as OptionDAO
|
||||
from .question import QuestionDAO as QuestionDAO
|
||||
from .test import TestDAO as TestDAO
|
||||
from .user import UserDAO as UserDAO
|
||||
@@ -0,0 +1,73 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import Group as DomainGroup
|
||||
from quizzi.infrastructure.database.dto.group import GroupDTO
|
||||
from quizzi.infrastructure.database.models import Group
|
||||
|
||||
|
||||
class GroupDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, group_id: int) -> DomainGroup | None:
|
||||
result = await self.session.execute(
|
||||
select(Group).where(Group.id == group_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return GroupDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_by_number(self, number: int) -> DomainGroup | None:
|
||||
result = await self.session.execute(
|
||||
select(Group).where(Group.number == number)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return GroupDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainGroup]:
|
||||
result = await self.session.execute(select(Group))
|
||||
models = list(result.scalars().all())
|
||||
return [GroupDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
number: int,
|
||||
) -> DomainGroup:
|
||||
group = Group(
|
||||
number=number,
|
||||
)
|
||||
self.session.add(group)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(group)
|
||||
return GroupDTO(group).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
group_id: int,
|
||||
number: int | None = None
|
||||
) -> DomainGroup | None:
|
||||
result = await self.session.execute(
|
||||
select(Group).where(Group.id == group_id)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if number is not None:
|
||||
group.number = number
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(group)
|
||||
return GroupDTO(group).to_domain()
|
||||
|
||||
async def delete(self, group_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(Group).where(Group.id == group_id)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
return False
|
||||
|
||||
await self.session.delete(group)
|
||||
await self.session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,78 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import Option as DomainOption
|
||||
from quizzi.infrastructure.database.dto.option import OptionDTO
|
||||
from quizzi.infrastructure.database.models import Option
|
||||
|
||||
|
||||
class OptionDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, option_id: int) -> DomainOption | None:
|
||||
result = await self.session.execute(
|
||||
select(Option).where(Option.id == option_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return OptionDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainOption]:
|
||||
result = await self.session.execute(select(Option))
|
||||
models = list(result.scalars().all())
|
||||
return [OptionDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
question_id: int,
|
||||
text: str,
|
||||
is_correct: bool = False,
|
||||
explanation: str | None = None,
|
||||
) -> DomainOption:
|
||||
option = Option(
|
||||
question_id=question_id,
|
||||
text=text,
|
||||
is_correct=is_correct,
|
||||
explanation=explanation,
|
||||
)
|
||||
self.session.add(option)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(option)
|
||||
return OptionDTO(option).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
option_id: int,
|
||||
text: str | None = None,
|
||||
is_correct: bool | None = None,
|
||||
explanation: str | None = None,
|
||||
) -> DomainOption | None:
|
||||
result = await self.session.execute(
|
||||
select(Option).where(Option.id == option_id)
|
||||
)
|
||||
option = result.scalar_one_or_none()
|
||||
if not option:
|
||||
return None
|
||||
|
||||
if text is not None:
|
||||
option.text = text
|
||||
if is_correct is not None:
|
||||
option.is_correct = is_correct
|
||||
if explanation is not None:
|
||||
option.explanation = explanation
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(option)
|
||||
return OptionDTO(option).to_domain()
|
||||
|
||||
async def delete(self, option_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(Option).where(Option.id == option_id)
|
||||
)
|
||||
option = result.scalar_one_or_none()
|
||||
if not option:
|
||||
return False
|
||||
|
||||
await self.session.delete(option)
|
||||
await self.session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,88 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import Question as DomainQuestion
|
||||
from quizzi.domain.schemas import QuestionType
|
||||
from quizzi.infrastructure.database.dto.question import QuestionDTO
|
||||
from quizzi.infrastructure.database.models import Question
|
||||
|
||||
|
||||
class QuestionDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, question_id: int) -> DomainQuestion | None:
|
||||
result = await self.session.execute(
|
||||
select(Question).where(Question.id == question_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return QuestionDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainQuestion]:
|
||||
result = await self.session.execute(select(Question))
|
||||
models = list(result.scalars().all())
|
||||
return [QuestionDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
test_id: int,
|
||||
text: str,
|
||||
position: int = 0,
|
||||
question_type: str | QuestionType = QuestionType.SINGLE,
|
||||
tg_file_id: str | None = None,
|
||||
) -> DomainQuestion:
|
||||
if isinstance(question_type, str):
|
||||
question_type = QuestionType(question_type)
|
||||
question = Question(
|
||||
test_id=test_id,
|
||||
text=text,
|
||||
position=position,
|
||||
question_type=question_type,
|
||||
tg_file_id=tg_file_id,
|
||||
)
|
||||
self.session.add(question)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(question)
|
||||
return QuestionDTO(question).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
question_id: int,
|
||||
text: str | None = None,
|
||||
position: int | None = None,
|
||||
question_type: str | QuestionType | None = None,
|
||||
tg_file_id: str | None = None,
|
||||
) -> DomainQuestion | None:
|
||||
result = await self.session.execute(
|
||||
select(Question).where(Question.id == question_id)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if not question:
|
||||
return None
|
||||
|
||||
if text is not None:
|
||||
question.text = text
|
||||
if position is not None:
|
||||
question.position = position
|
||||
if question_type is not None:
|
||||
if isinstance(question_type, str):
|
||||
question_type = QuestionType(question_type)
|
||||
question.question_type = question_type
|
||||
if tg_file_id is not None:
|
||||
question.tg_file_id = tg_file_id
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(question)
|
||||
return QuestionDTO(question).to_domain()
|
||||
|
||||
async def delete(self, question_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(Question).where(Question.id == question_id)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if not question:
|
||||
return False
|
||||
|
||||
await self.session.delete(question)
|
||||
await self.session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,135 @@
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict, Unpack
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import Test as DomainTest
|
||||
from quizzi.infrastructure.database.dto.test import TestDTO
|
||||
from quizzi.infrastructure.database.models import Test
|
||||
|
||||
|
||||
class _UNSET:
|
||||
"""Sentinel для различения None и "не передано"."""
|
||||
pass
|
||||
|
||||
|
||||
UNSET = _UNSET()
|
||||
|
||||
|
||||
class TestUpdateFields(TypedDict, total=False):
|
||||
title: str
|
||||
description: str | None
|
||||
for_group: int | None
|
||||
password: str | None
|
||||
expires_at: datetime | None
|
||||
attempts: int | None
|
||||
is_active: bool
|
||||
are_results_viewable: bool
|
||||
|
||||
|
||||
class TestDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, test_id: int) -> DomainTest | None:
|
||||
result = await self.session.execute(
|
||||
select(Test).where(Test.id == test_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return TestDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainTest]:
|
||||
result = await self.session.execute(
|
||||
select(Test).order_by(Test.created_at.desc())
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_expired_active_tests(self, now: datetime) -> list[DomainTest]:
|
||||
result = await self.session.execute(
|
||||
select(Test)
|
||||
.where(Test.is_active == True)
|
||||
.where(Test.expires_at.isnot(None))
|
||||
.where(Test.expires_at < now)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
for_group: int | None = None,
|
||||
password: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
attempts: int | None = None,
|
||||
is_active: bool = True,
|
||||
are_results_viewable: bool = False,
|
||||
) -> DomainTest:
|
||||
test = Test(
|
||||
title=title,
|
||||
description=description,
|
||||
for_group=for_group,
|
||||
password=password,
|
||||
expires_at=expires_at,
|
||||
attempts=attempts,
|
||||
is_active=is_active,
|
||||
are_results_viewable=are_results_viewable,
|
||||
)
|
||||
self.session.add(test)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(test)
|
||||
return TestDTO(test).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
test_id: int,
|
||||
title: str | _UNSET = UNSET,
|
||||
description: str | None | _UNSET = UNSET,
|
||||
for_group: int | None | _UNSET = UNSET,
|
||||
password: str | None | _UNSET = UNSET,
|
||||
expires_at: datetime | None | _UNSET = UNSET,
|
||||
attempts: int | None | _UNSET = UNSET,
|
||||
is_active: bool | _UNSET = UNSET,
|
||||
are_results_viewable: bool | _UNSET = UNSET,
|
||||
) -> DomainTest | None:
|
||||
result = await self.session.execute(
|
||||
select(Test).where(Test.id == test_id)
|
||||
)
|
||||
test = result.scalar_one_or_none()
|
||||
if not test:
|
||||
return None
|
||||
|
||||
if not isinstance(title, _UNSET):
|
||||
test.title = title
|
||||
if not isinstance(description, _UNSET):
|
||||
test.description = description
|
||||
if not isinstance(for_group, _UNSET):
|
||||
test.for_group = for_group
|
||||
if not isinstance(password, _UNSET):
|
||||
test.password = password
|
||||
if not isinstance(expires_at, _UNSET):
|
||||
test.expires_at = expires_at
|
||||
if not isinstance(attempts, _UNSET):
|
||||
test.attempts = attempts
|
||||
if not isinstance(is_active, _UNSET):
|
||||
test.is_active = is_active
|
||||
if not isinstance(are_results_viewable, _UNSET):
|
||||
test.are_results_viewable = are_results_viewable
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(test)
|
||||
return TestDTO(test).to_domain()
|
||||
|
||||
async def delete(self, test_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(Test).where(Test.id == test_id)
|
||||
)
|
||||
test = result.scalar_one_or_none()
|
||||
if not test:
|
||||
return False
|
||||
|
||||
await self.session.delete(test)
|
||||
await self.session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,87 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import TestAttempt as DomainTestAttempt
|
||||
from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO
|
||||
from quizzi.infrastructure.database.models import TestAttempt
|
||||
|
||||
|
||||
class TestAttemptDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, attempt_id: int) -> DomainTestAttempt | None:
|
||||
result = await self.session.execute(
|
||||
select(TestAttempt).where(TestAttempt.id == attempt_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return TestAttemptDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainTestAttempt]:
|
||||
result = await self.session.execute(select(TestAttempt))
|
||||
models = list(result.scalars().all())
|
||||
return [TestAttemptDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: int,
|
||||
test_id: int,
|
||||
score: int = 0,
|
||||
is_passed: bool = False,
|
||||
) -> DomainTestAttempt:
|
||||
attempt = TestAttempt(
|
||||
user_id=user_id,
|
||||
test_id=test_id,
|
||||
score=score,
|
||||
is_passed=is_passed,
|
||||
)
|
||||
self.session.add(attempt)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(attempt)
|
||||
return TestAttemptDTO(attempt).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
attempt_id: int,
|
||||
finished_at: datetime | None = None,
|
||||
score: int | None = None,
|
||||
is_passed: bool | None = None,
|
||||
) -> DomainTestAttempt | None:
|
||||
result = await self.session.execute(
|
||||
select(TestAttempt).where(TestAttempt.id == attempt_id)
|
||||
)
|
||||
attempt = result.scalar_one_or_none()
|
||||
if not attempt:
|
||||
return None
|
||||
|
||||
if finished_at is not None:
|
||||
attempt.finished_at = finished_at
|
||||
if score is not None:
|
||||
attempt.score = score
|
||||
if is_passed is not None:
|
||||
attempt.is_passed = is_passed
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(attempt)
|
||||
return TestAttemptDTO(attempt).to_domain()
|
||||
|
||||
async def delete(self, attempt_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(TestAttempt).where(TestAttempt.id == attempt_id)
|
||||
)
|
||||
attempt = result.scalar_one_or_none()
|
||||
if not attempt:
|
||||
return False
|
||||
|
||||
await self.session.delete(attempt)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def get_by_user_id(self, user_id: int) -> list[DomainTestAttempt]:
|
||||
result = await self.session.execute(
|
||||
select(TestAttempt).where(TestAttempt.user_id == user_id)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestAttemptDTO(model).to_domain() for model in models]
|
||||
@@ -0,0 +1,157 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import User as DomainUser
|
||||
from quizzi.infrastructure.database.dto.user import UserDTO
|
||||
from quizzi.infrastructure.database.models import User
|
||||
|
||||
|
||||
class _UNSET:
|
||||
"""Sentinel для различения None и "не передано"."""
|
||||
pass
|
||||
|
||||
|
||||
UNSET = _UNSET()
|
||||
|
||||
|
||||
class UserDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, user_id: int) -> DomainUser | None:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return UserDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_by_username(self, username: str) -> DomainUser | None:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return UserDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainUser]:
|
||||
result = await self.session.execute(
|
||||
select(User).order_by(User.created_at.desc())
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [UserDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: int,
|
||||
first_name: str,
|
||||
username: str | None = None,
|
||||
last_name: str | None = None,
|
||||
name: str | None = None,
|
||||
group: int | None = None,
|
||||
is_admin: bool = False,
|
||||
) -> DomainUser:
|
||||
user = User(
|
||||
id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
name=name,
|
||||
group=group,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(user)
|
||||
return UserDTO(user).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
user_id: int,
|
||||
username: str | None | _UNSET = UNSET,
|
||||
first_name: str | _UNSET = UNSET,
|
||||
last_name: str | None | _UNSET = UNSET,
|
||||
name: str | None | _UNSET = UNSET,
|
||||
group: int | None | _UNSET = UNSET,
|
||||
is_admin: bool | _UNSET = UNSET,
|
||||
name_updated_at: datetime | None | _UNSET = UNSET,
|
||||
group_updated_at: datetime | None | _UNSET = UNSET,
|
||||
) -> DomainUser | None:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not isinstance(username, _UNSET):
|
||||
user.username = username
|
||||
if not isinstance(first_name, _UNSET):
|
||||
user.first_name = first_name
|
||||
if not isinstance(last_name, _UNSET):
|
||||
user.last_name = last_name
|
||||
if not isinstance(name, _UNSET):
|
||||
user.name = name
|
||||
if not isinstance(group, _UNSET):
|
||||
user.group = group
|
||||
if not isinstance(is_admin, _UNSET):
|
||||
user.is_admin = is_admin
|
||||
if not isinstance(name_updated_at, _UNSET):
|
||||
user.name_updated_at = name_updated_at
|
||||
if not isinstance(group_updated_at, _UNSET):
|
||||
user.group_updated_at = group_updated_at
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(user)
|
||||
return UserDTO(user).to_domain()
|
||||
|
||||
async def delete(self, user_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
await self.session.delete(user)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
user_id: int,
|
||||
first_name: str,
|
||||
username: str | None = None,
|
||||
last_name: str | None = None,
|
||||
name: str | None | _UNSET = UNSET,
|
||||
group: int | None | _UNSET = UNSET,
|
||||
is_admin: bool | _UNSET = UNSET,
|
||||
) -> DomainUser:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
user.username = username
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
if not isinstance(name, _UNSET):
|
||||
user.name = name
|
||||
if not isinstance(group, _UNSET):
|
||||
user.group = group
|
||||
if not isinstance(is_admin, _UNSET):
|
||||
user.is_admin = is_admin
|
||||
await self.session.flush()
|
||||
await self.session.refresh(user)
|
||||
return UserDTO(user).to_domain()
|
||||
|
||||
return await self.create(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
name=name if not isinstance(name, _UNSET) else None,
|
||||
group=group if not isinstance(group, _UNSET) else None,
|
||||
is_admin=is_admin if not isinstance(is_admin, _UNSET) else False,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import UserAnswer as DomainUserAnswer
|
||||
from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO
|
||||
from quizzi.infrastructure.database.models import UserAnswer
|
||||
|
||||
|
||||
class UserAnswerDAO:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session: AsyncSession = session
|
||||
|
||||
async def get_by_id(self, answer_id: int) -> DomainUserAnswer | None:
|
||||
result = await self.session.execute(
|
||||
select(UserAnswer).where(UserAnswer.id == answer_id)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return UserAnswerDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_all(self) -> list[DomainUserAnswer]:
|
||||
result = await self.session.execute(select(UserAnswer))
|
||||
models = list(result.scalars().all())
|
||||
return [UserAnswerDTO(model).to_domain() for model in models]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
attempt_id: int,
|
||||
question_id: int,
|
||||
selected_option_id: int | None = None,
|
||||
text_answer: str | None = None,
|
||||
is_correct: bool = False,
|
||||
) -> DomainUserAnswer:
|
||||
answer = UserAnswer(
|
||||
attempt_id=attempt_id,
|
||||
question_id=question_id,
|
||||
selected_option_id=selected_option_id,
|
||||
text_answer=text_answer,
|
||||
is_correct=is_correct,
|
||||
)
|
||||
self.session.add(answer)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(answer)
|
||||
return UserAnswerDTO(answer).to_domain()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
answer_id: int,
|
||||
selected_option_id: int | None = None,
|
||||
text_answer: str | None = None,
|
||||
is_correct: bool | None = None,
|
||||
) -> DomainUserAnswer | None:
|
||||
result = await self.session.execute(
|
||||
select(UserAnswer).where(UserAnswer.id == answer_id)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if not answer:
|
||||
return None
|
||||
|
||||
if selected_option_id is not None:
|
||||
answer.selected_option_id = selected_option_id
|
||||
if text_answer is not None:
|
||||
answer.text_answer = text_answer
|
||||
if is_correct is not None:
|
||||
answer.is_correct = is_correct
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(answer)
|
||||
return UserAnswerDTO(answer).to_domain()
|
||||
|
||||
async def delete(self, answer_id: int) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(UserAnswer).where(UserAnswer.id == answer_id)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if not answer:
|
||||
return False
|
||||
|
||||
await self.session.delete(answer)
|
||||
await self.session.flush()
|
||||
return True
|
||||
@@ -0,0 +1,15 @@
|
||||
from quizzi.domain.schemas import Group as DomainGroup
|
||||
from quizzi.infrastructure.database.models import Group as GroupModel
|
||||
|
||||
|
||||
class GroupDTO:
|
||||
def __init__(self, model: GroupModel) -> None:
|
||||
self.model: GroupModel = model
|
||||
|
||||
def to_domain(self) -> DomainGroup:
|
||||
return DomainGroup(
|
||||
id=self.model.id,
|
||||
number=self.model.number,
|
||||
created_at=self.model.created_at,
|
||||
updated_at=self.model.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
from quizzi.domain.schemas import Option as DomainOption
|
||||
from quizzi.infrastructure.database.models import Option as OptionModel
|
||||
|
||||
|
||||
class OptionDTO:
|
||||
def __init__(self, model: OptionModel) -> None:
|
||||
self.model: OptionModel = model
|
||||
|
||||
def to_domain(self) -> DomainOption:
|
||||
return DomainOption(
|
||||
id=self.model.id,
|
||||
question_id=self.model.question_id,
|
||||
text=self.model.text,
|
||||
is_correct=self.model.is_correct,
|
||||
explanation=self.model.explanation,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from quizzi.domain.schemas import Question as DomainQuestion
|
||||
from quizzi.infrastructure.database.models import Question as QuestionModel
|
||||
|
||||
|
||||
class QuestionDTO:
|
||||
def __init__(self, model: QuestionModel) -> None:
|
||||
self.model: QuestionModel = model
|
||||
|
||||
def to_domain(self) -> DomainQuestion:
|
||||
return DomainQuestion(
|
||||
id=self.model.id,
|
||||
test_id=self.model.test_id,
|
||||
text=self.model.text,
|
||||
position=self.model.position,
|
||||
question_type=self.model.question_type,
|
||||
tg_file_id=self.model.tg_file_id,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from quizzi.domain.schemas import Test as DomainTest
|
||||
from quizzi.infrastructure.database.models import Test as TestModel
|
||||
|
||||
|
||||
class TestDTO:
|
||||
def __init__(self, model: TestModel) -> None:
|
||||
self.model: TestModel = model
|
||||
|
||||
def to_domain(self) -> DomainTest:
|
||||
return DomainTest(
|
||||
id=self.model.id,
|
||||
title=self.model.title,
|
||||
description=self.model.description,
|
||||
for_group=self.model.for_group,
|
||||
password=self.model.password,
|
||||
expires_at=self.model.expires_at,
|
||||
attempts=self.model.attempts,
|
||||
is_active=self.model.is_active,
|
||||
are_results_viewable=self.model.are_results_viewable,
|
||||
created_at=self.model.created_at,
|
||||
updated_at=self.model.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from quizzi.domain.schemas import TestAttempt as DomainTestAttempt
|
||||
from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel
|
||||
|
||||
|
||||
class TestAttemptDTO:
|
||||
def __init__(self, model: TestAttemptModel) -> None:
|
||||
self.model: TestAttemptModel = model
|
||||
|
||||
def to_domain(self) -> DomainTestAttempt:
|
||||
return DomainTestAttempt(
|
||||
id=self.model.id,
|
||||
user_id=self.model.user_id,
|
||||
test_id=self.model.test_id,
|
||||
started_at=self.model.started_at,
|
||||
finished_at=self.model.finished_at,
|
||||
score=self.model.score,
|
||||
is_passed=self.model.is_passed,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from quizzi.domain.schemas import User as DomainUser
|
||||
from quizzi.infrastructure.database.models import User as UserModel
|
||||
|
||||
|
||||
class UserDTO:
|
||||
def __init__(self, model: UserModel) -> None:
|
||||
self.model: UserModel = model
|
||||
|
||||
def to_domain(self) -> DomainUser:
|
||||
return DomainUser(
|
||||
id=self.model.id,
|
||||
username=self.model.username,
|
||||
first_name=self.model.first_name,
|
||||
last_name=self.model.last_name,
|
||||
name=self.model.name,
|
||||
group=self.model.group,
|
||||
is_admin=self.model.is_admin,
|
||||
name_updated_at=self.model.name_updated_at,
|
||||
group_updated_at=self.model.group_updated_at,
|
||||
created_at=self.model.created_at,
|
||||
updated_at=self.model.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from quizzi.domain.schemas import UserAnswer as DomainUserAnswer
|
||||
from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel
|
||||
|
||||
|
||||
class UserAnswerDTO:
|
||||
def __init__(self, model: UserAnswerModel) -> None:
|
||||
self.model: UserAnswerModel = model
|
||||
|
||||
def to_domain(self) -> DomainUserAnswer:
|
||||
return DomainUserAnswer(
|
||||
id=self.model.id,
|
||||
attempt_id=self.model.attempt_id,
|
||||
question_id=self.model.question_id,
|
||||
selected_option_id=self.model.selected_option_id,
|
||||
text_answer=self.model.text_answer,
|
||||
is_correct=self.model.is_correct,
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
from datetime import datetime
|
||||
from typing import final
|
||||
|
||||
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
from quizzi.domain.schemas import QuestionType
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
@final
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
username: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
first_name: Mapped[str] = mapped_column(String(64))
|
||||
last_name: Mapped[str | None] = mapped_column(String(64))
|
||||
name: Mapped[str | None] = mapped_column(String(128))
|
||||
group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999"), index=True)
|
||||
is_admin: Mapped[bool] = mapped_column(default=False)
|
||||
name_updated_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
group_updated_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@final
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
number: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"),
|
||||
)
|
||||
|
||||
|
||||
@final
|
||||
class Test(Base):
|
||||
__tablename__ = "tests"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
for_group: Mapped[int | None] = mapped_column(default=None)
|
||||
password: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
attempts: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
is_active: Mapped[bool] = mapped_column(default=True)
|
||||
are_results_viewable: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
|
||||
questions: Mapped[list["Question"]] = relationship(
|
||||
back_populates="test",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Question.position"
|
||||
)
|
||||
|
||||
|
||||
@final
|
||||
class Question(Base):
|
||||
__tablename__ = "questions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
question_type: Mapped[QuestionType] = mapped_column(default=QuestionType.SINGLE)
|
||||
tg_file_id: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
test: Mapped["Test"] = relationship(back_populates="questions")
|
||||
options: Mapped[list["Option"]] = relationship(
|
||||
back_populates="question",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
@final
|
||||
class Option(Base):
|
||||
__tablename__ = "options"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
question_id: Mapped[int] = mapped_column(ForeignKey("questions.id"), index=True)
|
||||
text: Mapped[str] = mapped_column(String(255))
|
||||
is_correct: Mapped[bool] = mapped_column(default=False)
|
||||
explanation: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
question: Mapped["Question"] = relationship(back_populates="options")
|
||||
|
||||
|
||||
@final
|
||||
class TestAttempt(Base):
|
||||
__tablename__ = "test_attempts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), index=True)
|
||||
test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_passed: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
test: Mapped["Test"] = relationship()
|
||||
answers: Mapped[list["UserAnswer"]] = relationship(
|
||||
back_populates="attempt",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
@final
|
||||
class UserAnswer(Base):
|
||||
__tablename__ = "user_answers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
attempt_id: Mapped[int] = mapped_column(ForeignKey("test_attempts.id"), index=True)
|
||||
question_id: Mapped[int] = mapped_column(ForeignKey("questions.id"), index=True)
|
||||
selected_option_id: Mapped[int | None] = mapped_column(ForeignKey("options.id"), default=None)
|
||||
text_answer: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
is_correct: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
attempt: Mapped["TestAttempt"] = relationship(back_populates="answers")
|
||||
question: Mapped["Question"] = relationship()
|
||||
selected_option: Mapped["Option | None"] = relationship()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
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
|
||||
|
||||
__all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"]
|
||||
@@ -0,0 +1,209 @@
|
||||
from typing import final
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from quizzi.domain.schemas import Option, Question, Test
|
||||
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.dto.option import OptionDTO
|
||||
from quizzi.infrastructure.database.dto.question import QuestionDTO
|
||||
from quizzi.infrastructure.database.dto.test import TestDTO
|
||||
from quizzi.infrastructure.database.models import Option as OptionModel
|
||||
from quizzi.infrastructure.database.models import Question as QuestionModel
|
||||
from quizzi.infrastructure.database.models import Test as TestModel
|
||||
|
||||
|
||||
@final
|
||||
class TestRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.test_dao = TestDAO(session)
|
||||
self.question_dao = QuestionDAO(session)
|
||||
self.option_dao = OptionDAO(session)
|
||||
|
||||
async def get_active_tests(self) -> list[Test]:
|
||||
result = await self.session.execute(
|
||||
select(TestModel).where(TestModel.is_active == True)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_tests_by_group(self, group: int) -> list[Test]:
|
||||
result = await self.session.execute(
|
||||
select(TestModel).where(TestModel.for_group == group)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_active_tests_by_group(self, group: int) -> list[Test]:
|
||||
result = await self.session.execute(
|
||||
select(TestModel)
|
||||
.where(TestModel.for_group == group)
|
||||
.where(TestModel.is_active == True)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_test_with_questions(self, test_id: int) -> tuple[Test | None, list[Question]]:
|
||||
test = await self.test_dao.get_by_id(test_id)
|
||||
if not test:
|
||||
return None, []
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TestModel)
|
||||
.where(TestModel.id == test_id)
|
||||
.options(selectinload(TestModel.questions))
|
||||
)
|
||||
test_model = result.scalar_one_or_none()
|
||||
if not test_model:
|
||||
return test, []
|
||||
|
||||
questions = [QuestionDTO(q).to_domain() for q in sorted(test_model.questions, key=lambda x: x.position)]
|
||||
return test, questions
|
||||
|
||||
async def get_question_with_options(self, question_id: int) -> tuple[Question | None, list[Option]]:
|
||||
question = await self.question_dao.get_by_id(question_id)
|
||||
if not question:
|
||||
return None, []
|
||||
|
||||
result = await self.session.execute(
|
||||
select(QuestionModel)
|
||||
.where(QuestionModel.id == question_id)
|
||||
.options(selectinload(QuestionModel.options))
|
||||
)
|
||||
question_model = result.scalar_one_or_none()
|
||||
if not question_model:
|
||||
return question, []
|
||||
|
||||
options = [OptionDTO(o).to_domain() for o in question_model.options]
|
||||
return question, options
|
||||
|
||||
async def get_correct_options_for_question(self, question_id: int) -> list[Option]:
|
||||
result = await self.session.execute(
|
||||
select(OptionModel)
|
||||
.where(OptionModel.question_id == question_id)
|
||||
.where(OptionModel.is_correct == True)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [OptionDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_full_test(self, test_id: int) -> tuple[Test | None, list[tuple[Question, list[Option]]]]:
|
||||
test = await self.test_dao.get_by_id(test_id)
|
||||
if not test:
|
||||
return None, []
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TestModel)
|
||||
.where(TestModel.id == test_id)
|
||||
.options(
|
||||
selectinload(TestModel.questions).selectinload(QuestionModel.options)
|
||||
)
|
||||
)
|
||||
test_model = result.scalar_one_or_none()
|
||||
if not test_model:
|
||||
return test, []
|
||||
|
||||
questions_with_options: list[tuple[Question, list[Option]]] = []
|
||||
for question_model in sorted(test_model.questions, key=lambda x: x.position):
|
||||
question = QuestionDTO(question_model).to_domain()
|
||||
options = [OptionDTO(o).to_domain() for o in question_model.options]
|
||||
questions_with_options.append((question, options))
|
||||
|
||||
return test, questions_with_options
|
||||
|
||||
async def count_questions_in_test(self, test_id: int) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count(QuestionModel.id))
|
||||
.where(QuestionModel.test_id == test_id)
|
||||
)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
|
||||
async def get_questions_with_options_by_ids(
|
||||
self, question_ids: list[int]
|
||||
) -> dict[int, tuple[Question, list[Option]]]:
|
||||
"""Загружает вопросы с опциями по списку ID за один запрос."""
|
||||
if not question_ids:
|
||||
return {}
|
||||
|
||||
result = await self.session.execute(
|
||||
select(QuestionModel)
|
||||
.where(QuestionModel.id.in_(question_ids))
|
||||
.options(selectinload(QuestionModel.options))
|
||||
)
|
||||
question_models = list(result.scalars().all())
|
||||
|
||||
questions_dict: dict[int, tuple[Question, list[Option]]] = {}
|
||||
for qm in question_models:
|
||||
question = QuestionDTO(qm).to_domain()
|
||||
options = [OptionDTO(o).to_domain() for o in qm.options]
|
||||
questions_dict[qm.id] = (question, options)
|
||||
|
||||
return questions_dict
|
||||
|
||||
async def duplicate_test(self, test_id: int, new_title: str) -> Test | None:
|
||||
test, questions_with_options = await self.get_full_test(test_id)
|
||||
if not test:
|
||||
return None
|
||||
|
||||
new_test = await self.test_dao.create(
|
||||
title=new_title,
|
||||
description=test.description,
|
||||
for_group=test.for_group,
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
for question, options in questions_with_options:
|
||||
new_question = await self.question_dao.create(
|
||||
test_id=new_test.id,
|
||||
text=question.text,
|
||||
position=question.position,
|
||||
question_type=question.question_type,
|
||||
tg_file_id=question.tg_file_id,
|
||||
)
|
||||
|
||||
for option in options:
|
||||
await self.option_dao.create(
|
||||
question_id=new_question.id,
|
||||
text=option.text,
|
||||
is_correct=option.is_correct,
|
||||
explanation=option.explanation,
|
||||
)
|
||||
|
||||
return new_test
|
||||
|
||||
async def get_available_tests_for_user(self, user_id: int, user_group: int | None) -> list[Test]:
|
||||
from quizzi.infrastructure.database.models import TestAttempt
|
||||
|
||||
subquery = (
|
||||
select(
|
||||
TestAttempt.test_id,
|
||||
func.count(TestAttempt.id).label("attempts_count")
|
||||
)
|
||||
.where(TestAttempt.user_id == user_id)
|
||||
.where(TestAttempt.finished_at.isnot(None))
|
||||
.group_by(TestAttempt.test_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(TestModel)
|
||||
.outerjoin(subquery, TestModel.id == subquery.c.test_id)
|
||||
.where(TestModel.is_active == True)
|
||||
.where(
|
||||
(TestModel.for_group == user_group) | (TestModel.for_group.is_(None))
|
||||
)
|
||||
.where(
|
||||
(TestModel.attempts.is_(None)) |
|
||||
(subquery.c.attempts_count.is_(None)) |
|
||||
(subquery.c.attempts_count < TestModel.attempts)
|
||||
)
|
||||
.order_by(TestModel.created_at.desc())
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
models = list(result.scalars().all())
|
||||
return [TestDTO(model).to_domain() for model in models]
|
||||
@@ -0,0 +1,235 @@
|
||||
from typing import final
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from quizzi.domain.schemas import TestAttempt, UserAnswer
|
||||
from quizzi.infrastructure.database.dao.test_attempt import TestAttemptDAO
|
||||
from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO
|
||||
from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO
|
||||
from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO
|
||||
from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel
|
||||
from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel
|
||||
from quizzi.infrastructure.utils.timezone import now_msk_naive
|
||||
|
||||
|
||||
@final
|
||||
class TestAttemptRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.attempt_dao = TestAttemptDAO(session)
|
||||
self.answer_dao = UserAnswerDAO(session)
|
||||
|
||||
async def get_user_attempts(self, user_id: int) -> list[TestAttempt]:
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.order_by(TestAttemptModel.started_at.desc())
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestAttemptDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_test_attempts(self, test_id: int) -> list[TestAttempt]:
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.test_id == test_id)
|
||||
.order_by(TestAttemptModel.started_at.desc())
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestAttemptDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_user_test_attempts(self, user_id: int, test_id: int) -> list[TestAttempt]:
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.where(TestAttemptModel.test_id == test_id)
|
||||
.order_by(TestAttemptModel.started_at.desc())
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [TestAttemptDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_active_attempt(self, user_id: int, test_id: int) -> TestAttempt | None:
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.where(TestAttemptModel.test_id == test_id)
|
||||
.where(TestAttemptModel.finished_at == None)
|
||||
.order_by(TestAttemptModel.started_at.desc())
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return TestAttemptDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_attempt_with_answers(self, attempt_id: int) -> tuple[TestAttempt | None, list[UserAnswer]]:
|
||||
attempt = await self.attempt_dao.get_by_id(attempt_id)
|
||||
if not attempt:
|
||||
return None, []
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.id == attempt_id)
|
||||
.options(selectinload(TestAttemptModel.answers))
|
||||
)
|
||||
attempt_model = result.scalar_one_or_none()
|
||||
if not attempt_model:
|
||||
return attempt, []
|
||||
|
||||
answers = [UserAnswerDTO(answer).to_domain() for answer in attempt_model.answers]
|
||||
return attempt, answers
|
||||
|
||||
async def get_answers_for_attempt(self, attempt_id: int) -> list[UserAnswer]:
|
||||
result = await self.session.execute(
|
||||
select(UserAnswerModel)
|
||||
.where(UserAnswerModel.attempt_id == attempt_id)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [UserAnswerDTO(model).to_domain() for model in models]
|
||||
|
||||
async def count_user_attempts(self, user_id: int, test_id: int | None = None) -> int:
|
||||
query = select(func.count(TestAttemptModel.id)).where(TestAttemptModel.user_id == user_id)
|
||||
if test_id is not None:
|
||||
query = query.where(TestAttemptModel.test_id == test_id)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
|
||||
async def count_passed_attempts(self, user_id: int, test_id: int | None = None) -> int:
|
||||
query = (
|
||||
select(func.count(TestAttemptModel.id))
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.where(TestAttemptModel.is_passed == True)
|
||||
)
|
||||
if test_id is not None:
|
||||
query = query.where(TestAttemptModel.test_id == test_id)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
|
||||
async def get_best_attempt(self, user_id: int, test_id: int) -> TestAttempt | None:
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.where(TestAttemptModel.test_id == test_id)
|
||||
.where(TestAttemptModel.finished_at != None)
|
||||
.order_by(TestAttemptModel.score.desc(), TestAttemptModel.started_at.asc())
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return TestAttemptDTO(model).to_domain() if model else None
|
||||
|
||||
async def get_latest_attempt(self, user_id: int, test_id: int) -> TestAttempt | None:
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel)
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.where(TestAttemptModel.test_id == test_id)
|
||||
.order_by(TestAttemptModel.started_at.desc())
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return TestAttemptDTO(model).to_domain() if model else None
|
||||
|
||||
async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None:
|
||||
return await self.attempt_dao.update(
|
||||
attempt_id=attempt_id,
|
||||
finished_at=now_msk_naive(),
|
||||
score=score,
|
||||
is_passed=is_passed
|
||||
)
|
||||
|
||||
async def calculate_attempt_score(self, attempt_id: int) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count(UserAnswerModel.id))
|
||||
.where(UserAnswerModel.attempt_id == attempt_id)
|
||||
.where(UserAnswerModel.is_correct == True)
|
||||
)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
|
||||
async def get_incorrect_answers(self, attempt_id: int) -> list[UserAnswer]:
|
||||
result = await self.session.execute(
|
||||
select(UserAnswerModel)
|
||||
.where(UserAnswerModel.attempt_id == attempt_id)
|
||||
.where(UserAnswerModel.is_correct == False)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [UserAnswerDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_question_statistics(self, question_id: int) -> dict[str, int]:
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
func.count(UserAnswerModel.id).label("total"),
|
||||
func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)).label("correct")
|
||||
)
|
||||
.where(UserAnswerModel.question_id == question_id)
|
||||
)
|
||||
row = result.one()
|
||||
total = row.total or 0
|
||||
correct = row.correct or 0
|
||||
|
||||
return {
|
||||
"total_answers": total,
|
||||
"correct_answers": correct,
|
||||
"incorrect_answers": total - correct,
|
||||
}
|
||||
|
||||
async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]:
|
||||
from quizzi.infrastructure.database.models import Question as QuestionModel
|
||||
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
UserAnswerModel.question_id,
|
||||
func.count(UserAnswerModel.id).label("total"),
|
||||
func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)).label("correct")
|
||||
)
|
||||
.join(QuestionModel, UserAnswerModel.question_id == QuestionModel.id)
|
||||
.where(QuestionModel.test_id == test_id)
|
||||
.group_by(UserAnswerModel.question_id)
|
||||
.having(func.count(UserAnswerModel.id) > 0)
|
||||
.order_by((func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)) / func.count(UserAnswerModel.id)).asc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = result.all()
|
||||
return [(row.question_id, row.correct / row.total if row.total > 0 else 0.0) for row in rows]
|
||||
|
||||
async def get_user_stats(self, user_id: int) -> dict:
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
func.count(TestAttemptModel.id).label("total_attempts"),
|
||||
func.avg(TestAttemptModel.score).label("avg_score"),
|
||||
).where(
|
||||
TestAttemptModel.user_id == user_id,
|
||||
TestAttemptModel.finished_at.isnot(None)
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
return {
|
||||
"total_attempts": row.total_attempts or 0,
|
||||
"avg_score": round(row.avg_score, 1) if row.avg_score else 0,
|
||||
}
|
||||
|
||||
async def get_finished_attempts_with_tests(self, user_id: int) -> list[tuple[TestAttempt, str]]:
|
||||
from quizzi.infrastructure.database.models import Test as TestModel
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel, TestModel.title)
|
||||
.join(TestModel, TestAttemptModel.test_id == TestModel.id)
|
||||
.where(TestAttemptModel.user_id == user_id)
|
||||
.where(TestAttemptModel.finished_at.isnot(None))
|
||||
.order_by(TestAttemptModel.finished_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
return [(TestAttemptDTO(row[0]).to_domain(), row[1]) for row in rows]
|
||||
|
||||
async def get_test_attempts_with_users(self, test_id: int) -> list[tuple[TestAttempt, str]]:
|
||||
from quizzi.infrastructure.database.models import User as UserModel
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TestAttemptModel, UserModel.name, UserModel.first_name)
|
||||
.join(UserModel, TestAttemptModel.user_id == UserModel.id)
|
||||
.where(TestAttemptModel.test_id == test_id)
|
||||
.where(TestAttemptModel.finished_at.isnot(None))
|
||||
.order_by(TestAttemptModel.finished_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
return [(TestAttemptDTO(row[0]).to_domain(), row[1] or row[2]) for row in rows]
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import final
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from quizzi.domain.schemas import User
|
||||
from quizzi.infrastructure.database.dao.user import UserDAO
|
||||
from quizzi.infrastructure.database.dto.user import UserDTO
|
||||
from quizzi.infrastructure.database.models import User as UserModel
|
||||
|
||||
|
||||
@final
|
||||
class UserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.user_dao = UserDAO(session)
|
||||
|
||||
async def get_admins(self) -> list[User]:
|
||||
result = await self.session.execute(
|
||||
select(UserModel).where(UserModel.is_admin == True)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [UserDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_users_by_group(self, group: int) -> list[User]:
|
||||
result = await self.session.execute(
|
||||
select(UserModel).where(UserModel.group == group)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [UserDTO(model).to_domain() for model in models]
|
||||
|
||||
async def get_users_without_group(self) -> list[User]:
|
||||
result = await self.session.execute(
|
||||
select(UserModel).where(UserModel.group == None)
|
||||
)
|
||||
models = list(result.scalars().all())
|
||||
return [UserDTO(model).to_domain() for model in models]
|
||||
|
||||
async def is_admin(self, user_id: int) -> bool:
|
||||
user = await self.user_dao.get_by_id(user_id)
|
||||
return user.is_admin if user else False
|
||||
|
||||
async def has_group(self, user_id: int) -> bool:
|
||||
user = await self.user_dao.get_by_id(user_id)
|
||||
return user.group is not None if user else False
|
||||
|
||||
async def count_users_by_group(self, group: int) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count(UserModel.id))
|
||||
.where(UserModel.group == group)
|
||||
)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
|
||||
async def count_admins(self) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count(UserModel.id))
|
||||
.where(UserModel.is_admin == True)
|
||||
)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from dishka import AsyncContainer, Provider, Scope, provide
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from quizzi.infrastructure.database.config import new_session_maker
|
||||
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.dao.test_attempt import TestAttemptDAO
|
||||
from quizzi.infrastructure.database.dao.user import UserDAO
|
||||
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.database.repo.user import UserRepository
|
||||
from quizzi.infrastructure.scheduling.tasks import deactivate_expired_tests
|
||||
from quizzi.infrastructure.utils.config import Config
|
||||
from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter
|
||||
|
||||
|
||||
class DatabaseProvider(Provider):
|
||||
@provide(scope=Scope.APP)
|
||||
def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]:
|
||||
return new_session_maker(config.database.url)
|
||||
|
||||
@provide(scope=Scope.APP)
|
||||
def get_password_rate_limiter(self) -> PasswordRateLimiter:
|
||||
return PasswordRateLimiter()
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
async def get_session(
|
||||
self, session_maker: async_sessionmaker[AsyncSession]
|
||||
) -> AsyncIterable[AsyncSession]:
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
await session.commit()
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_user_dao(self, session: AsyncSession) -> UserDAO:
|
||||
return UserDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_group_dao(self, session: AsyncSession) -> GroupDAO:
|
||||
return GroupDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_test_dao(self, session: AsyncSession) -> TestDAO:
|
||||
return TestDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_question_dao(self, session: AsyncSession) -> QuestionDAO:
|
||||
return QuestionDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_option_dao(self, session: AsyncSession) -> OptionDAO:
|
||||
return OptionDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_test_attempt_dao(self, session: AsyncSession) -> TestAttemptDAO:
|
||||
return TestAttemptDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_user_answer_dao(self, session: AsyncSession) -> UserAnswerDAO:
|
||||
return UserAnswerDAO(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_user_repository(self, session: AsyncSession) -> UserRepository:
|
||||
return UserRepository(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_test_repository(self, session: AsyncSession) -> TestRepository:
|
||||
return TestRepository(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_test_attempt_repository(self, session: AsyncSession) -> TestAttemptRepository:
|
||||
return TestAttemptRepository(session)
|
||||
|
||||
|
||||
class SchedulerProvider(Provider):
|
||||
@provide(scope = Scope.APP)
|
||||
def get_scheduler(self, container: AsyncContainer) -> AsyncIOScheduler:
|
||||
logging.getLogger('apscheduler').setLevel(logging.WARNING)
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
scheduler.add_job(
|
||||
deactivate_expired_tests,
|
||||
'interval',
|
||||
minutes=5,
|
||||
args=[container],
|
||||
id='deactivate_expired_tests',
|
||||
)
|
||||
|
||||
return scheduler
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
|
||||
from dishka import AsyncContainer
|
||||
|
||||
from quizzi.infrastructure.database.dao.test import TestDAO
|
||||
from quizzi.infrastructure.utils.timezone import now_msk_naive
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def deactivate_expired_tests(container: AsyncContainer) -> None:
|
||||
async with container() as request_container:
|
||||
test_dao = await request_container.get(TestDAO)
|
||||
|
||||
expired_tests = await test_dao.get_expired_active_tests(now_msk_naive())
|
||||
|
||||
for test in expired_tests:
|
||||
await test_dao.update(test.id, is_active=False)
|
||||
logger.info("Деактивирован истёкший тест: id=%d, title=%s", test.id, test.title)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from aiogram import Bot
|
||||
from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat
|
||||
|
||||
from quizzi.infrastructure.database.repo.user import UserRepository
|
||||
from quizzi.infrastructure.utils.config import Config
|
||||
|
||||
|
||||
async def setup_bot_commands(bot: Bot, config: Config, user_repo: UserRepository) -> None:
|
||||
await bot.set_my_commands(
|
||||
commands=[
|
||||
BotCommand(command="start", description="Главное меню"),
|
||||
],
|
||||
scope=BotCommandScopeAllPrivateChats(),
|
||||
)
|
||||
|
||||
admins = await user_repo.get_admins()
|
||||
for admin in admins:
|
||||
await bot.set_my_commands(
|
||||
commands=[
|
||||
BotCommand(command="start", description="Главное меню"),
|
||||
BotCommand(command="admin", description="Админ-панель"),
|
||||
],
|
||||
scope=BotCommandScopeChat(chat_id=admin.id),
|
||||
)
|
||||
|
||||
await bot.set_my_commands(
|
||||
commands=[
|
||||
BotCommand(command="start", description="Главное меню"),
|
||||
BotCommand(command="creator", description="Панель создателя"),
|
||||
],
|
||||
scope=BotCommandScopeChat(chat_id=config.bot.creator_id),
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import (
|
||||
TelegramAPIError,
|
||||
TelegramBadRequest,
|
||||
TelegramForbiddenError,
|
||||
TelegramNetworkError,
|
||||
TelegramRetryAfter,
|
||||
)
|
||||
|
||||
from quizzi.infrastructure.database.dao.user import UserDAO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BroadcastStats:
|
||||
success: int
|
||||
failed: int
|
||||
total: int
|
||||
|
||||
|
||||
async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: UserDAO) -> BroadcastStats:
|
||||
users = await user_dao.get_all()
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
logger.info("Starting broadcast: message_id=%d, total_users=%d", message_id, len(users))
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
|
||||
success += 1
|
||||
except TelegramRetryAfter as e:
|
||||
logger.warning("Rate limited, waiting %d seconds", e.retry_after)
|
||||
await asyncio.sleep(e.retry_after)
|
||||
try:
|
||||
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
|
||||
success += 1
|
||||
except TelegramAPIError:
|
||||
failed += 1
|
||||
except TelegramForbiddenError:
|
||||
logger.debug("Broadcast failed (forbidden): user_id=%d", user.id)
|
||||
failed += 1
|
||||
except TelegramBadRequest as e:
|
||||
logger.debug("Broadcast failed (bad request): user_id=%d, error=%s", user.id, e)
|
||||
failed += 1
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning("Network error during broadcast: user_id=%d, error=%s", user.id, e)
|
||||
failed += 1
|
||||
except TelegramAPIError as e:
|
||||
logger.warning("Telegram API error during broadcast: user_id=%d, error=%s", user.id, e)
|
||||
failed += 1
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
logger.info("Broadcast completed: success=%d, failed=%d, total=%d", success, failed, len(users))
|
||||
return BroadcastStats(success=success, failed=failed, total=len(users))
|
||||
@@ -0,0 +1,63 @@
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotConfig:
|
||||
token: str
|
||||
creator_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
encode_key: str
|
||||
encoded_string_length: int = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
host: str
|
||||
port: int | str
|
||||
user: str
|
||||
password: str
|
||||
database: str
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
bot: BotConfig
|
||||
database: DatabaseConfig
|
||||
security: SecurityConfig
|
||||
|
||||
@classmethod
|
||||
def from_toml(cls, path: str | Path) -> Self:
|
||||
with open(path, "rb") as f:
|
||||
data: dict[str, dict[str, str | int]] = tomllib.load(f)
|
||||
|
||||
bot_data: dict[str, str | int] = data["bot"]
|
||||
db_data: dict[str, str | int] = data["database"]
|
||||
security_data: dict[str, str | int] = data["security"]
|
||||
|
||||
return cls(
|
||||
bot=BotConfig(
|
||||
token=str(bot_data["token"]),
|
||||
creator_id=int(bot_data["creator_id"])
|
||||
),
|
||||
database=DatabaseConfig(
|
||||
host=str(db_data["host"]),
|
||||
port=db_data["port"],
|
||||
user=str(db_data["user"]),
|
||||
password=str(db_data["password"]),
|
||||
database=str(db_data["database"])
|
||||
),
|
||||
security=SecurityConfig(
|
||||
encode_key=str(security_data["encode_key"]),
|
||||
encoded_string_length=int(security_data.get("encoded_string_length", 8))
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
|
||||
|
||||
def generate_qr_bytes(text: str) -> bytes:
|
||||
img = qrcode.make(text)
|
||||
with io.BytesIO() as buffer:
|
||||
img.save(buffer)
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,57 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserBucket:
|
||||
tokens: float
|
||||
last_updated: float
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, rate: int, period: int):
|
||||
self.rate = rate
|
||||
self.period = period
|
||||
self.fill_rate = rate / period
|
||||
self.buckets: dict[int, UserBucket] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def check(self, user_id: int) -> tuple[bool, float]:
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
|
||||
if user_id not in self.buckets:
|
||||
self.buckets[user_id] = UserBucket(
|
||||
tokens=self.rate - 1,
|
||||
last_updated=now
|
||||
)
|
||||
return True, 0.0
|
||||
|
||||
bucket = self.buckets[user_id]
|
||||
|
||||
elapsed = now - bucket.last_updated
|
||||
added_tokens = elapsed * self.fill_rate
|
||||
bucket.tokens = min(self.rate, bucket.tokens + added_tokens)
|
||||
bucket.last_updated = now
|
||||
|
||||
if bucket.tokens >= 1:
|
||||
bucket.tokens -= 1
|
||||
return True, 0.0
|
||||
else:
|
||||
wait_time = (1 - bucket.tokens) / self.fill_rate
|
||||
return False, wait_time
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
async with self._lock:
|
||||
full_buckets = [
|
||||
user_id for user_id, bucket in self.buckets.items()
|
||||
if bucket.tokens >= self.rate
|
||||
]
|
||||
for user_id in full_buckets:
|
||||
del self.buckets[user_id]
|
||||
|
||||
|
||||
class PasswordRateLimiter(RateLimiter):
|
||||
def __init__(self):
|
||||
super().__init__(rate=5, period=3600)
|
||||
@@ -0,0 +1,69 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import string
|
||||
|
||||
ALPHABET = string.ascii_letters
|
||||
|
||||
def _feistel_round(val: int, key: bytes, rounds: int) -> int:
|
||||
msg = f"{val}:{rounds}".encode()
|
||||
h = hmac.new(key, msg, hashlib.sha256).digest()
|
||||
return int.from_bytes(h[:4], 'big')
|
||||
|
||||
def permute_id(n: int, key_str: str, bits: int) -> int:
|
||||
key = key_str.encode()
|
||||
split = bits // 2
|
||||
mask = (1 << split) - 1
|
||||
|
||||
left = (n >> split) & mask
|
||||
right = n & mask
|
||||
|
||||
for i in range(6):
|
||||
new_left = right
|
||||
f_val = _feistel_round(right, key, i)
|
||||
new_right = left ^ (f_val & mask)
|
||||
left, right = new_left, new_right
|
||||
|
||||
return (left << split) | right
|
||||
|
||||
def unpermute_id(n: int, key_str: str, bits: int) -> int:
|
||||
key = key_str.encode()
|
||||
split = bits // 2
|
||||
mask = (1 << split) - 1
|
||||
|
||||
left = (n >> split) & mask
|
||||
right = n & mask
|
||||
|
||||
for i in reversed(range(6)):
|
||||
new_right = left
|
||||
f_val = _feistel_round(left, key, i)
|
||||
new_left = right ^ (f_val & mask)
|
||||
left, right = new_left, new_right
|
||||
|
||||
return (left << split) | right
|
||||
|
||||
|
||||
def encode_id(n: int, key: str, length: int = 8) -> str:
|
||||
bits = length * 5
|
||||
if length >= 8: bits = 44
|
||||
elif length == 7: bits = 38
|
||||
|
||||
permuted = permute_id(n, key, bits=bits)
|
||||
|
||||
chars = []
|
||||
for _ in range(length):
|
||||
permuted, rem = divmod(permuted, 52)
|
||||
chars.append(ALPHABET[rem])
|
||||
|
||||
return "".join(chars)
|
||||
|
||||
def decode_id(s: str, key: str) -> int:
|
||||
num = 0
|
||||
for char in reversed(s):
|
||||
num = num * 52 + ALPHABET.index(char)
|
||||
|
||||
length = len(s)
|
||||
bits = length * 5
|
||||
if length >= 8: bits = 44
|
||||
elif length == 7: bits = 38
|
||||
|
||||
return unpermute_id(num, key, bits=bits)
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
MSK_TZ = ZoneInfo("Europe/Moscow")
|
||||
|
||||
|
||||
def now_msk() -> datetime:
|
||||
return datetime.now(MSK_TZ)
|
||||
|
||||
|
||||
def now_msk_naive() -> datetime:
|
||||
"""Возвращает текущее время в МСК без timezone info (для сохранения в БД)."""
|
||||
return datetime.now(MSK_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
def to_msk(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=MSK_TZ)
|
||||
return dt.astimezone(MSK_TZ)
|
||||
Reference in New Issue
Block a user