diff --git a/pyproject.toml b/pyproject.toml index 826eb2d..f0b1136 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.10.5", "qrcode[pil]>=8.2", "pycryptodome>=3.23.0", + "json5>=0.13.0", ] [dependency-groups] diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index fcc4680..b85bfbc 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -9,3 +9,5 @@ class AdminUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + user_stats = State() + user_result_detail = State() diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index b2cc96a..b0eb6e6 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -8,6 +8,9 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminUsersSG 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.timezone import to_msk @inject @@ -84,6 +87,125 @@ async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: Di await manager.done() +async def on_user_stats_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminUsersSG.user_stats) + + +@inject +async def get_user_stats_data( + dialog_manager: DialogManager, + user_dao: FromDishka[UserDAO], + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs, +): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"stats_info": "Пользователь не выбран", "results": [], "count": 0} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"stats_info": "Пользователь не найден", "results": [], "count": 0} + + stats = await attempt_repo.get_user_stats(user_id) + attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id) + + name = user.name or user.first_name + + if stats["total_attempts"] > 0: + accuracy_str = f"📊 Средняя точность: {stats['avg_score']}%" + tests_str = f"📝 Пройдено тестов: {stats['total_attempts']}" + else: + accuracy_str = "📊 Средняя точность: —" + tests_str = "📝 Пройдено тестов: 0" + + stats_info = ( + f"📊 Статистика: {name}\n\n" + f"{tests_str}\n" + f"{accuracy_str}" + ) + + 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 { + "stats_info": stats_info, + "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(AdminUsersSG.user_result_detail) + + +@inject +async def get_user_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 "Неизвестный тест" + + 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}", + "\n📋 Ответы:\n", + ] + + # Загружаем все вопросы за один запрос + question_ids = [answer.question_id for answer in answers] + questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) + + for i, answer in enumerate(answers, 1): + question_data = questions_map.get(answer.question_id) + if not question_data: + continue + + question, options = question_data + 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") + + return {"result_info": "\n".join(lines)} + + admin_users_dialog = Dialog( Window( Format("👥 Пользователи\n\nВсего: {count}"), @@ -114,8 +236,35 @@ admin_users_dialog = Dialog( ), Window( Format("{user_info}"), - SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + Column( + Button(Const("📊 Статистика"), id="stats", on_click=on_user_stats_clicked), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + ), state=AdminUsersSG.user_detail, getter=get_user_detail_data, ), + Window( + Format("{stats_info}"), + 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=5, + ), + SwitchTo(Const("◀️ Назад"), id="back_to_detail", state=AdminUsersSG.user_detail), + state=AdminUsersSG.user_stats, + getter=get_user_stats_data, + ), + Window( + Format("{result_info}"), + SwitchTo(Const("◀️ Назад"), id="back_to_stats", state=AdminUsersSG.user_stats), + state=AdminUsersSG.user_result_detail, + getter=get_user_result_detail, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 749c2f1..7fae2b6 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -9,5 +9,7 @@ class CreatorUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + user_stats = State() + user_result_detail = State() make_admin_confirm = State() remove_admin_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index f71c027..eee5525 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -11,9 +11,12 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorUsersSG 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.database.repo.user import UserRepository from trudex.infrastructure.utils.bot_commands import setup_bot_commands from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.timezone import to_msk @inject @@ -208,6 +211,125 @@ async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: Di await manager.done() +async def on_user_stats_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorUsersSG.user_stats) + + +@inject +async def get_user_stats_data( + dialog_manager: DialogManager, + user_dao: FromDishka[UserDAO], + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs, +): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"stats_info": "Пользователь не выбран", "results": [], "count": 0} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"stats_info": "Пользователь не найден", "results": [], "count": 0} + + stats = await attempt_repo.get_user_stats(user_id) + attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id) + + name = user.name or user.first_name + + if stats["total_attempts"] > 0: + accuracy_str = f"📊 Средняя точность: {stats['avg_score']}%" + tests_str = f"📝 Пройдено тестов: {stats['total_attempts']}" + else: + accuracy_str = "📊 Средняя точность: —" + tests_str = "📝 Пройдено тестов: 0" + + stats_info = ( + f"📊 Статистика: {name}\n\n" + f"{tests_str}\n" + f"{accuracy_str}" + ) + + 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 { + "stats_info": stats_info, + "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(CreatorUsersSG.user_result_detail) + + +@inject +async def get_user_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 "Неизвестный тест" + + 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}", + "\n📋 Ответы:\n", + ] + + # Загружаем все вопросы за один запрос + question_ids = [answer.question_id for answer in answers] + questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) + + for i, answer in enumerate(answers, 1): + question_data = questions_map.get(answer.question_id) + if not question_data: + continue + + question, options = question_data + 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") + + return {"result_info": "\n".join(lines)} + + creator_users_dialog = Dialog( Window( Format("👥 Пользователи\n\nВсего: {count}"), @@ -239,6 +361,7 @@ creator_users_dialog = Dialog( Window( Format("{user_info}"), Column( + Button(Const("📊 Статистика"), id="stats", on_click=on_user_stats_clicked), Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), Button(Const("🚫 Снять администратора"), id="remove_admin", on_click=on_remove_admin_clicked, when="show_remove_admin"), SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list), @@ -246,6 +369,30 @@ creator_users_dialog = Dialog( state=CreatorUsersSG.user_detail, getter=get_user_detail_data, ), + Window( + Format("{stats_info}"), + 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=5, + ), + SwitchTo(Const("◀️ Назад"), id="back_to_detail", state=CreatorUsersSG.user_detail), + state=CreatorUsersSG.user_stats, + getter=get_user_stats_data, + ), + Window( + Format("{result_info}"), + SwitchTo(Const("◀️ Назад"), id="back_to_stats", state=CreatorUsersSG.user_stats), + state=CreatorUsersSG.user_result_detail, + getter=get_user_result_detail, + ), Window( Format("{confirm_text}"), Row( diff --git a/src/trudex/application/bot/shared_dialogs/templates.py b/src/trudex/application/bot/shared_dialogs/templates.py index 9e47330..9849860 100644 --- a/src/trudex/application/bot/shared_dialogs/templates.py +++ b/src/trudex/application/bot/shared_dialogs/templates.py @@ -12,6 +12,7 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO @@ -330,6 +331,7 @@ async def on_import_file( test_dao: FromDishka[TestDAO], question_dao: FromDishka[QuestionDAO], option_dao: FromDishka[OptionDAO], + group_dao: FromDishka[GroupDAO], ) -> None: if not message.document: await message.answer("❌ Отправьте JSON файл") @@ -373,6 +375,13 @@ async def on_import_file( await progress_msg.edit_text("\n".join(error_lines)) return + # Проверяем существование группы + if result.for_group is not None: + group = await group_dao.get_by_number(result.for_group) + if not group: + await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует") + return + await create_test_from_parsed(result, test_dao, question_dao, option_dao) await progress_msg.edit_text( diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index ba77bec..b4bfad8 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -1,4 +1,4 @@ -import json +import json5 from dataclasses import dataclass from datetime import datetime @@ -39,9 +39,9 @@ class TestParser: def parse(self, json_str: str) -> ParsedTest | list[ParseError]: try: - data = json.loads(json_str) - except json.JSONDecodeError as e: - return [ParseError(f"Невалидный JSON: {e.msg}", path=None)] + data = json5.loads(json_str) + except ValueError as e: + return [ParseError(f"Невалидный JSON: {e}", path=None)] if not isinstance(data, dict): return [ParseError("JSON должен быть объектом", path=None)] diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index a780cc5..1b5c816 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -10,7 +10,6 @@ from trudex.domain.schemas import QuestionType class Base(DeclarativeBase): pass - @final class User(Base): __tablename__ = "users" diff --git a/uv.lock b/uv.lock index c3874ba..6ff0ab6 100644 --- a/uv.lock +++ b/uv.lock @@ -380,6 +380,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "json5" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, +] + [[package]] name = "magic-filter" version = "1.0.12" @@ -773,6 +782,7 @@ dependencies = [ { name = "asyncpg" }, { name = "dishka" }, { name = "httpx" }, + { name = "json5" }, { name = "pycryptodome" }, { name = "pydantic" }, { name = "qrcode", extra = ["pil"] }, @@ -795,6 +805,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "dishka", specifier = ">=1.7.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "json5", specifier = ">=0.13.0" }, { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" },