From 1a8da5c070601f12eb5ee009e65b25cbd5f8227f Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:39:35 +0300 Subject: [PATCH] commit --- ...7720a4_add_are_results_viewable_to_test.py | 29 ++++++++++++ .../application/bot/admin_dialogs/tests.py | 29 +++++++++++- .../application/bot/creator_dialogs/tests.py | 29 +++++++++++- .../application/bot/user_dialogs/main_menu.py | 47 +++++++++++-------- .../application/bot/user_dialogs/take_test.py | 42 +++++++++++------ src/trudex/domain/schemas.py | 1 + .../infrastructure/database/dao/test.py | 7 ++- .../infrastructure/database/dao/user.py | 4 +- .../infrastructure/database/dto/test.py | 1 + src/trudex/infrastructure/database/models.py | 1 + 10 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py diff --git a/alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py b/alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py new file mode 100644 index 0000000..bfffd6c --- /dev/null +++ b/alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py @@ -0,0 +1,29 @@ +"""add_are_results_viewable_to_test + +Revision ID: 40f5317720a4 +Revises: e002f2b802ec +Create Date: 2026-01-04 01:29:18.257105 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '40f5317720a4' +down_revision: str | None = 'e002f2b802ec' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tests', sa.Column('are_results_viewable', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tests', 'are_results_viewable') + # ### end Alembic commands ### diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index fb2faf3..8363be1 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -52,6 +52,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } test = await test_dao.get_by_id(test_id) @@ -62,6 +63,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" @@ -69,6 +71,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" expires_str = f"📅 {to_msk(test.expires_at).strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" + results_str = "👁 Результаты видны" if test.are_results_viewable else "🔒 Результаты скрыты" test_info = ( f"📝 Информация о тесте\n\n" @@ -79,16 +82,19 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Пароль: {password_str}\n" f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" - f"Группа: {group_str}\n\n" + f"Группа: {group_str}\n" + f"Видимость: {results_str}\n\n" f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" + results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты" return { "test_info": test_info, "is_active": test.is_active, "button_text": button_text, + "results_button_text": results_button_text, } @@ -108,6 +114,22 @@ async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(AdminTestsSG.test_detail) +@inject +async def on_toggle_results_viewable(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + test = await test_dao.get_by_id(test_id) + + if test: + await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) + action = "скрыты" if test.are_results_viewable else "видны" + await _callback.answer(f"✅ Результаты теперь {action}") + await manager.switch_to(AdminTestsSG.test_detail) + + async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.tests_list) @@ -435,6 +457,11 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button( + Format("{results_button_text}"), + id="toggle_results", + on_click=on_toggle_results_viewable + ), Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 524a95a..42969ab 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -55,6 +55,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } test = await test_dao.get_by_id(test_id) @@ -65,6 +66,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" @@ -72,6 +74,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" expires_str = f"📅 {to_msk(test.expires_at).strftime('%d.%m.%Y %H:%M')}" if test.expires_at else "📅 Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" + results_str = "👁 Результаты видны" if test.are_results_viewable else "🔒 Результаты скрыты" test_info = ( f"📝 Информация о тесте\n\n" @@ -82,16 +85,19 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Пароль: {password_str}\n" f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" - f"Группа: {group_str}\n\n" + f"Группа: {group_str}\n" + f"Видимость: {results_str}\n\n" f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" + results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты" return { "test_info": test_info, "is_active": test.is_active, "button_text": button_text, + "results_button_text": results_button_text, } @@ -111,6 +117,22 @@ async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(CreatorTestsSG.test_detail) +@inject +async def on_toggle_results_viewable(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + test = await test_dao.get_by_id(test_id) + + if test: + await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) + action = "скрыты" if test.are_results_viewable else "видны" + await _callback.answer(f"✅ Результаты теперь {action}") + await manager.switch_to(CreatorTestsSG.test_detail) + + async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(CreatorTestsSG.tests_list) @@ -439,6 +461,11 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button( + Format("{results_button_text}"), + id="toggle_results", + on_click=on_toggle_results_viewable + ), Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index 122e8de..21da133 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -348,37 +348,46 @@ async def get_result_detail( 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}\n", - "📋 Ответы:\n", + f"🏆 Статус: {status}", ] - for i, answer in enumerate(answers, 1): - question, options = await test_repo.get_question_with_options(answer.question_id) - if not question: - continue + if are_results_viewable: + lines.append("\n📋 Ответы:\n") - 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") + 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)} diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 8a8a16d..6479df6 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -234,6 +234,7 @@ async def on_text_answer_input( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], answer_dao: FromDishka[UserAnswerDAO], + test_dao: FromDishka[TestDAO], ): start_data = manager.start_data or {} assert isinstance(start_data, dict) @@ -248,6 +249,7 @@ async def on_text_answer_input( question_id = questions[current_index] text_answer = message.text.strip() if message.text else "" attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + test_id = manager.dialog_data.get("test_id") or start_data.get("test_id") if not attempt_id: await message.answer("❌ Ошибка попытки") @@ -270,7 +272,9 @@ async def on_text_answer_input( ) if current_index + 1 >= len(questions): - await finish_test(manager, attempt_repo, attempt_id, len(questions)) + test = await test_dao.get_by_id(test_id) if test_id else None + are_results_viewable = test.are_results_viewable if test else False + await finish_test(manager, attempt_repo, attempt_id, len(questions), are_results_viewable) else: next_index = current_index + 1 manager.dialog_data["current_question_index"] = next_index @@ -290,6 +294,7 @@ async def on_next_question( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], answer_dao: FromDishka[UserAnswerDAO], + test_dao: FromDishka[TestDAO], ): start_data = manager.start_data or {} assert isinstance(start_data, dict) @@ -311,6 +316,7 @@ async def on_next_question( answer_data = user_answers[str(question_id)] attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + test_id = manager.dialog_data.get("test_id") or start_data.get("test_id") if not attempt_id: await _callback.answer("❌ Ошибка попытки") @@ -352,7 +358,9 @@ async def on_next_question( ) if current_index + 1 >= len(questions): - await finish_test(manager, attempt_repo, attempt_id, len(questions)) + test = await test_dao.get_by_id(test_id) if test_id else None + are_results_viewable = test.are_results_viewable if test else False + await finish_test(manager, attempt_repo, attempt_id, len(questions), are_results_viewable) else: next_index = current_index + 1 manager.dialog_data["current_question_index"] = next_index @@ -364,7 +372,13 @@ async def on_next_question( await manager.switch_to(next_state) -async def finish_test(manager: DialogManager, attempt_repo: TestAttemptRepository, attempt_id: int, total_questions: int): +async def finish_test( + manager: DialogManager, + attempt_repo: TestAttemptRepository, + attempt_id: int, + total_questions: int, + are_results_viewable: bool = False, +): correct_count = await attempt_repo.calculate_attempt_score(attempt_id) score = round((correct_count / total_questions * 100)) if total_questions > 0 else 0 @@ -376,6 +390,7 @@ async def finish_test(manager: DialogManager, attempt_repo: TestAttemptRepositor manager.dialog_data["correct_count"] = correct_count manager.dialog_data["total_questions"] = total_questions manager.dialog_data["is_passed"] = is_passed + manager.dialog_data["are_results_viewable"] = are_results_viewable await manager.switch_to(UserTestSG.results) @@ -385,6 +400,7 @@ async def get_results_data(dialog_manager: DialogManager, **_kwargs): correct_count = dialog_manager.dialog_data.get("correct_count", 0) total_questions = dialog_manager.dialog_data.get("total_questions", 0) is_passed = dialog_manager.dialog_data.get("is_passed", False) + are_results_viewable = dialog_manager.dialog_data.get("are_results_viewable", False) if is_passed: status = "✅ Тест пройден!" @@ -397,7 +413,7 @@ async def get_results_data(dialog_manager: DialogManager, **_kwargs): f"✏️ Правильных ответов: {correct_count} из {total_questions}" ) - return {"results_text": results_text} + return {"results_text": results_text, "are_results_viewable": are_results_viewable} async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -471,10 +487,7 @@ take_test_dialog = Dialog( on_click=on_single_answer_selected, ), ), - Column( - Button(Const("➡️ Далее"), id="next", on_click=on_next_question), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), - ), + Button(Const("➡️ Далее"), id="next", on_click=on_next_question), state=UserTestSG.question_single, getter=get_question_data, ), @@ -490,24 +503,25 @@ take_test_dialog = Dialog( on_state_changed=on_multiple_answer_changed, ), ), - Column( - Button(Const("➡️ Далее"), id="next", on_click=on_next_question), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), - ), + Button(Const("➡️ Далее"), id="next", on_click=on_next_question), state=UserTestSG.question_multiple, getter=get_question_data, ), Window( Format("{question_text}\n\nВведите ответ:"), MessageInput(on_text_answer_input), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), state=UserTestSG.question_input, getter=get_question_data, ), Window( Format("{results_text}"), Column( - Button(Const("📋 Подробные результаты"), id="detailed", on_click=on_show_detailed_results), + Button( + Const("📋 Подробные результаты"), + id="detailed", + on_click=on_show_detailed_results, + when="are_results_viewable", + ), Button(Const("◀️ В главное меню"), id="back", on_click=on_back_to_menu), ), state=UserTestSG.results, diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index a4b1515..46878d1 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -35,6 +35,7 @@ class Test: expires_at: datetime | None = None attempts: int | None = None is_active: bool = True + are_results_viewable: bool = False created_at: datetime | None = None updated_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 505a90c..6e3d326 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -21,7 +21,7 @@ class TestDAO: async def get_all(self) -> list[DomainTest]: result = await self.session.execute( - select(Test).order_by(Test.id) + select(Test).order_by(Test.created_at.desc()) ) models = list(result.scalars().all()) return [TestDTO(model).to_domain() for model in models] @@ -35,6 +35,7 @@ class TestDAO: expires_at: datetime | None = None, attempts: int | None = None, is_active: bool = True, + are_results_viewable: bool = False, ) -> DomainTest: test = Test( title=title, @@ -44,6 +45,7 @@ class TestDAO: expires_at=expires_at, attempts=attempts, is_active=is_active, + are_results_viewable=are_results_viewable, ) self.session.add(test) await self.session.flush() @@ -60,6 +62,7 @@ class TestDAO: expires_at: datetime | None = None, attempts: int | None = None, is_active: bool | None = None, + are_results_viewable: bool | None = None, ) -> DomainTest | None: result = await self.session.execute( select(Test).where(Test.id == test_id) @@ -82,6 +85,8 @@ class TestDAO: test.attempts = attempts if is_active is not None: test.is_active = is_active + if are_results_viewable is not None: + test.are_results_viewable = are_results_viewable await self.session.flush() await self.session.refresh(test) diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 1d08789..712e2f9 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -20,7 +20,9 @@ class UserDAO: return UserDTO(model).to_domain() if model else None async def get_all(self) -> list[DomainUser]: - result = await self.session.execute(select(User)) + result = await self.session.execute( + select(User).order_by(User.created_at.desc()) + ) models = list(result.scalars().all()) return [UserDTO(model).to_domain() for model in models] diff --git a/src/trudex/infrastructure/database/dto/test.py b/src/trudex/infrastructure/database/dto/test.py index 0be7a2c..05d20bc 100644 --- a/src/trudex/infrastructure/database/dto/test.py +++ b/src/trudex/infrastructure/database/dto/test.py @@ -16,6 +16,7 @@ class TestDTO: expires_at=self.model.expires_at, attempts=self.model.attempts, is_active=self.model.is_active, + are_results_viewable=self.model.are_results_viewable, created_at=self.model.created_at, updated_at=self.model.updated_at, ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 04914ae..890dd7a 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -63,6 +63,7 @@ class Test(Base): expires_at: Mapped[datetime | None] = mapped_column(default=None) attempts: Mapped[int | None] = mapped_column(Integer, default=None) is_active: Mapped[bool] = mapped_column(default=True) + are_results_viewable: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())