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
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,
+146 -53
View File
@@ -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"))
@@ -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,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(
@@ -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()