import asyncio import functools from datetime import 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 trudex.application.bot.user_dialogs.states import UserMenuSG from trudex.application.bot.user_dialogs.take_test import on_start_test from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id from trudex.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk from datetime import datetime 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"📊 Средняя точность: {stats['avg_score']}%" tests_str = f"📝 Пройдено тестов: {stats['total_attempts']}" else: accuracy_str = "📊 Средняя точность: " tests_str = "📝 Пройдено тестов: 0" user_info = ( f"👋 Привет, {name}!\n\n" f"
{group_str}
\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"🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\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"📝 {test.title}\n\n" f"
{test.description or '—'}
\n\n" f"Вопросов: {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"📝 {test_title}\n", f"📊 Результат: {attempt.score}%", f"✏️ Правильных ответов: {correct_count} из {total_count}", f"📅 Дата: {date_str}", f"🏆 Статус: {status}", ] if are_results_viewable: lines.append("\n📋 Ответы:\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} Вопрос {i}") lines.append(f"
{question.text}
") lines.append(f"👤 Ваш ответ: {user_answer or '—'}") lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") else: lines.append("\n🔒 Подробные результаты скрыты") 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("📝 Доступные тесты\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("✏️ Изменение имени\n\nВведите новое имя:"), MessageInput(on_name_input), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), state=UserMenuSG.edit_name, ), Window( Const("🎓 Изменение группы\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("📊 Мои результаты\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, ), )