mirror of
https://github.com/koloideal/Quizzi.git
synced 2026-06-10 10:25:28 +03:00
commit
This commit is contained in:
@@ -35,6 +35,7 @@ from trudex.application.bot.middlewares.reject_not_admin import \
|
||||
RejectNotAdminMiddleware
|
||||
from trudex.application.bot.middlewares.reject_not_creator import \
|
||||
RejectNotCreatorMiddleware
|
||||
from trudex.application.bot.user_dialogs.deeplink import deeplink_dialog
|
||||
from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog
|
||||
from trudex.application.bot.user_dialogs.registration import \
|
||||
registration_dialog
|
||||
@@ -65,6 +66,7 @@ async def main() -> None:
|
||||
user_menu_dialog,
|
||||
take_test_dialog,
|
||||
registration_dialog,
|
||||
deeplink_dialog,
|
||||
admin_menu_dialog,
|
||||
admin_users_dialog,
|
||||
admin_tests_dialog,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command, CommandStart
|
||||
from aiogram.filters import Command, CommandStart, CommandObject
|
||||
from aiogram.types import ErrorEvent, Message
|
||||
from aiogram_dialog import DialogManager, StartMode
|
||||
from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent
|
||||
@@ -7,14 +9,150 @@ from dishka.integrations.aiogram import FromDishka
|
||||
|
||||
from trudex.application.bot.admin_dialogs.states import AdminMenuSG
|
||||
from trudex.application.bot.creator_dialogs.states import CreatorMenuSG
|
||||
from trudex.application.bot.user_dialogs.states import (UserMenuSG,
|
||||
UserRegistrationSG)
|
||||
from trudex.application.bot.user_dialogs.states import (
|
||||
UserDeeplinkSG,
|
||||
UserMenuSG,
|
||||
UserRegistrationSG,
|
||||
)
|
||||
from trudex.infrastructure.database.dao.group import GroupDAO
|
||||
from trudex.infrastructure.database.dao.test import TestDAO
|
||||
from trudex.infrastructure.database.dao.user import UserDAO
|
||||
from trudex.infrastructure.utils.config import Config
|
||||
from trudex.infrastructure.utils.test_id_to_hash import decode_id
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
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 < datetime.utcnow():
|
||||
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,
|
||||
user_dao: FromDishka[UserDAO],
|
||||
group_dao: FromDishka[GroupDAO],
|
||||
test_dao: FromDishka[TestDAO],
|
||||
config: FromDishka[Config],
|
||||
dialog_manager: DialogManager,
|
||||
) -> None:
|
||||
assert message.from_user is not None
|
||||
|
||||
deeplink = command.args
|
||||
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):
|
||||
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:
|
||||
await dialog_manager.start(
|
||||
UserDeeplinkSG.test_preview,
|
||||
mode=StartMode.RESET_STACK,
|
||||
data={"test_id": test_id, "error": error}
|
||||
)
|
||||
return
|
||||
|
||||
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,
|
||||
@@ -22,56 +160,11 @@ async def start_handler(
|
||||
group_dao: FromDishka[GroupDAO],
|
||||
dialog_manager: DialogManager
|
||||
) -> None:
|
||||
assert message.from_user is not None
|
||||
is_registered = await ensure_user_registered(
|
||||
user_dao, group_dao, message, dialog_manager
|
||||
)
|
||||
|
||||
existing_user = await user_dao.get_by_id(message.from_user.id)
|
||||
|
||||
if existing_user is None:
|
||||
groups = await group_dao.get_all()
|
||||
|
||||
if len(groups) > 0:
|
||||
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,
|
||||
)
|
||||
await dialog_manager.start(
|
||||
UserRegistrationSG.input_name,
|
||||
mode=StartMode.RESET_STACK,
|
||||
data={"user_id": message.from_user.id}
|
||||
)
|
||||
else:
|
||||
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,
|
||||
)
|
||||
await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
|
||||
else:
|
||||
groups = await group_dao.get_all()
|
||||
|
||||
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={"user_id": message.from_user.id}
|
||||
)
|
||||
else:
|
||||
await dialog_manager.start(
|
||||
UserRegistrationSG.select_group,
|
||||
mode=StartMode.RESET_STACK,
|
||||
data={"user_id": message.from_user.id}
|
||||
)
|
||||
else:
|
||||
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,
|
||||
)
|
||||
if is_registered:
|
||||
await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
from datetime import datetime
|
||||
|
||||
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 trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserTestSG
|
||||
from trudex.domain.schemas import Test, User
|
||||
from trudex.infrastructure.database.dao.test import TestDAO
|
||||
from trudex.infrastructure.database.models import QuestionType
|
||||
from trudex.infrastructure.database.repo.test import TestRepository
|
||||
from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository
|
||||
|
||||
|
||||
async def validate_test_access(test: Test | None, user: User | None) -> tuple[bool, str]:
|
||||
if not test:
|
||||
return False, "❌ Тест не найден"
|
||||
|
||||
if not test.is_active:
|
||||
return False, "❌ Тест деактивирован"
|
||||
|
||||
if test.expires_at and test.expires_at < datetime.utcnow():
|
||||
return False, "❌ Срок действия теста истек"
|
||||
|
||||
if test.for_group and user and user.group != test.for_group:
|
||||
return False, f"❌ Тест доступен только для группы {test.for_group}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
@inject
|
||||
async def get_deeplink_test_data(
|
||||
dialog_manager: DialogManager,
|
||||
test_dao: FromDishka[TestDAO],
|
||||
test_repo: FromDishka[TestRepository],
|
||||
**_kwargs
|
||||
):
|
||||
test_id = dialog_manager.start_data.get("test_id") if dialog_manager.start_data else None
|
||||
error = dialog_manager.start_data.get("error") if dialog_manager.start_data else None
|
||||
|
||||
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],
|
||||
):
|
||||
start_data = manager.start_data or {}
|
||||
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:
|
||||
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],
|
||||
):
|
||||
start_data = manager.start_data or {}
|
||||
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:
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -6,8 +6,11 @@ from aiogram_dialog.widgets.text import Const, Format
|
||||
from dishka import FromDishka
|
||||
from dishka.integrations.aiogram_dialog import inject
|
||||
|
||||
from trudex.application.bot.user_dialogs.states import (UserMenuSG,
|
||||
UserRegistrationSG)
|
||||
from trudex.application.bot.user_dialogs.states import (
|
||||
UserDeeplinkSG,
|
||||
UserMenuSG,
|
||||
UserRegistrationSG,
|
||||
)
|
||||
from trudex.infrastructure.database.dao.group import GroupDAO
|
||||
from trudex.infrastructure.database.dao.user import UserDAO
|
||||
|
||||
@@ -27,7 +30,8 @@ async def on_name_input(message: Message, _widget: MessageInput, manager: Dialog
|
||||
await message.answer("❌ Имя и фамилия слишком длинные (максимум 128 символов)")
|
||||
return
|
||||
|
||||
user_id = manager.start_data.get("user_id")
|
||||
start_data = manager.start_data or {}
|
||||
user_id = start_data.get("user_id")
|
||||
await user_dao.update(user_id=user_id, name=name)
|
||||
|
||||
manager.dialog_data["name"] = name
|
||||
@@ -35,7 +39,7 @@ async def on_name_input(message: Message, _widget: MessageInput, manager: Dialog
|
||||
|
||||
|
||||
@inject
|
||||
async def get_groups_for_registration(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs):
|
||||
async def get_groups_for_registration(group_dao: FromDishka[GroupDAO], **_kwargs):
|
||||
groups = await group_dao.get_all()
|
||||
|
||||
return {
|
||||
@@ -45,8 +49,19 @@ async def get_groups_for_registration(dialog_manager: DialogManager, group_dao:
|
||||
|
||||
@inject
|
||||
async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, user_dao: FromDishka[UserDAO]):
|
||||
user_id = manager.start_data.get("user_id")
|
||||
start_data = manager.start_data or {}
|
||||
user_id = start_data.get("user_id")
|
||||
pending_test_id = start_data.get("pending_test_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)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ class UserTestSG(StatesGroup):
|
||||
detailed_results = State()
|
||||
|
||||
|
||||
class UserDeeplinkSG(StatesGroup):
|
||||
test_preview = State()
|
||||
password_input = State()
|
||||
|
||||
|
||||
class UserRegistrationSG(StatesGroup):
|
||||
input_name = State()
|
||||
select_group = State()
|
||||
|
||||
Reference in New Issue
Block a user