This commit is contained in:
2026-01-03 23:40:40 +03:00
parent 02b6ad48bb
commit 13b4597bbc
5 changed files with 378 additions and 59 deletions
+2
View File
@@ -35,6 +35,7 @@ from trudex.application.bot.middlewares.reject_not_admin import \
RejectNotAdminMiddleware RejectNotAdminMiddleware
from trudex.application.bot.middlewares.reject_not_creator import \ from trudex.application.bot.middlewares.reject_not_creator import \
RejectNotCreatorMiddleware 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.main_menu import user_menu_dialog
from trudex.application.bot.user_dialogs.registration import \ from trudex.application.bot.user_dialogs.registration import \
registration_dialog registration_dialog
@@ -65,6 +66,7 @@ async def main() -> None:
user_menu_dialog, user_menu_dialog,
take_test_dialog, take_test_dialog,
registration_dialog, registration_dialog,
deeplink_dialog,
admin_menu_dialog, admin_menu_dialog,
admin_users_dialog, admin_users_dialog,
admin_tests_dialog, admin_tests_dialog,
+145 -52
View File
@@ -1,5 +1,7 @@
from datetime import datetime
from aiogram import Router 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.types import ErrorEvent, Message
from aiogram_dialog import DialogManager, StartMode from aiogram_dialog import DialogManager, StartMode
from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent 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.admin_dialogs.states import AdminMenuSG
from trudex.application.bot.creator_dialogs.states import CreatorMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG
from trudex.application.bot.user_dialogs.states import (UserMenuSG, from trudex.application.bot.user_dialogs.states import (
UserRegistrationSG) UserDeeplinkSG,
UserMenuSG,
UserRegistrationSG,
)
from trudex.infrastructure.database.dao.group import GroupDAO 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.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() 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()) @router.message(CommandStart())
async def start_handler( async def start_handler(
message: Message, message: Message,
@@ -22,56 +160,11 @@ async def start_handler(
group_dao: FromDishka[GroupDAO], group_dao: FromDishka[GroupDAO],
dialog_manager: DialogManager dialog_manager: DialogManager
) -> None: ) -> 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 is_registered:
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) 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 import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
from trudex.application.bot.user_dialogs.states import (UserMenuSG, from trudex.application.bot.user_dialogs.states import (
UserRegistrationSG) UserDeeplinkSG,
UserMenuSG,
UserRegistrationSG,
)
from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.group import GroupDAO
from trudex.infrastructure.database.dao.user import UserDAO 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 символов)") await message.answer("❌ Имя и фамилия слишком длинные (максимум 128 символов)")
return 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) await user_dao.update(user_id=user_id, name=name)
manager.dialog_data["name"] = name manager.dialog_data["name"] = name
@@ -35,7 +39,7 @@ async def on_name_input(message: Message, _widget: MessageInput, manager: Dialog
@inject @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() groups = await group_dao.get_all()
return { return {
@@ -45,8 +49,19 @@ async def get_groups_for_registration(dialog_manager: DialogManager, group_dao:
@inject @inject
async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, user_dao: FromDishka[UserDAO]): 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 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) await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK)
@@ -20,6 +20,11 @@ class UserTestSG(StatesGroup):
detailed_results = State() detailed_results = State()
class UserDeeplinkSG(StatesGroup):
test_preview = State()
password_input = State()
class UserRegistrationSG(StatesGroup): class UserRegistrationSG(StatesGroup):
input_name = State() input_name = State()
select_group = State() select_group = State()