From 13b4597bbc94c4c8f31f991d6bec821c11ff3421 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:40:40 +0300 Subject: [PATCH] commit --- src/trudex/application/__main__.py | 2 + src/trudex/application/bot/handlers.py | 199 ++++++++++++----- .../application/bot/user_dialogs/deeplink.py | 204 ++++++++++++++++++ .../bot/user_dialogs/registration.py | 27 ++- .../application/bot/user_dialogs/states.py | 5 + 5 files changed, 378 insertions(+), 59 deletions(-) create mode 100644 src/trudex/application/bot/user_dialogs/deeplink.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index e6ada8d..727f465 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -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, diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 9476abd..a9bb513 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -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,57 +160,12 @@ 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, - ) - await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + if is_registered: + await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("admin")) diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/trudex/application/bot/user_dialogs/deeplink.py new file mode 100644 index 0000000..8f634f2 --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/deeplink.py @@ -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"📝 {test.title}\n\n" + f"
{test.description or '—'}
\n\n" + f"Вопросов: {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("🔑 Введите пароль для доступа к тесту:"), + MessageInput(on_deeplink_password_input), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), + state=UserDeeplinkSG.password_input, + ), +) diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index b7ba040..5e837cd 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -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,9 +49,20 @@ 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)) - await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + + 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( diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index e485906..9f77f72 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -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()