From 5728f8aff7db9acb82fa55875c4e86f3f530222b Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 20 Mar 2026 11:02:33 +0500 Subject: [PATCH] =?UTF-8?q?fix(courses):=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=20=D0=B2=20xlsx-?= =?UTF-8?q?=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=B0=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- courses/services/export_course_results.py | 63 ++++- courses/tests/test_export.py | 278 ++++++++++++++++++++++ 2 files changed, 330 insertions(+), 11 deletions(-) diff --git a/courses/services/export_course_results.py b/courses/services/export_course_results.py index 57aee4a8..734b5aad 100644 --- a/courses/services/export_course_results.py +++ b/courses/services/export_course_results.py @@ -9,6 +9,7 @@ from core.utils import build_xlsx_download_response, sanitize_excel_value from courses.models import ( Course, + CourseLesson, CourseLessonContentStatus, CourseModuleContentStatus, CourseTask, @@ -105,6 +106,24 @@ def _lesson_progresses_by_user(user_ids: list[int], course: Course) -> dict[int, return progress_map +def _published_lessons_with_tasks(course: Course) -> list[CourseLesson]: + published_tasks_qs = CourseTask.objects.filter( + status=CourseTaskContentStatus.PUBLISHED, + ).order_by("order", "id") + return list( + CourseLesson.objects.filter( + module__course=course, + module__status=CourseModuleContentStatus.PUBLISHED, + status=CourseLessonContentStatus.PUBLISHED, + ) + .select_related("module") + .prefetch_related( + Prefetch("tasks", queryset=published_tasks_qs, to_attr="_published_tasks") + ) + .order_by("module__order", "module__id", "order", "id") + ) + + def _answers_by_user_and_task( user_ids: list[int], task_ids: list[int], @@ -143,34 +162,55 @@ def _task_header(task: CourseTask) -> str: ) +def _lesson_stage_header(lesson: CourseLesson) -> str: + return ( + f"Модуль {lesson.module.order}: {lesson.module.title} / " + f"Урок {lesson.order}: {lesson.title}" + ) + + def _format_stage( course_progress: UserCourseProgress, lesson_progresses: list[UserLessonProgress], + published_lessons: list[CourseLesson], ) -> str: if course_progress.status == ProgressStatus.COMPLETED: return "Курс завершён" + lesson_progress_by_lesson_id = { + progress.lesson_id: progress for progress in lesson_progresses + } + current_progress = next( - (progress for progress in lesson_progresses if progress.current_task_id), + ( + progress + for progress in lesson_progresses + if progress.current_task_id + and progress.current_task is not None + and progress.current_task.status == CourseTaskContentStatus.PUBLISHED + ), None, ) if current_progress is not None: current_task = current_progress.current_task - return ( - f"Модуль {current_progress.lesson.module.order}: {current_progress.lesson.module.title} / " - f"Урок {current_progress.lesson.order}: {current_progress.lesson.title} / " - f"Задание {current_task.order}: {current_task.title}" - ) + return _task_header(current_task) in_progress_lesson = next( (progress for progress in lesson_progresses if progress.status == ProgressStatus.IN_PROGRESS), None, ) if in_progress_lesson is not None: - return ( - f"Модуль {in_progress_lesson.lesson.module.order}: {in_progress_lesson.lesson.module.title} / " - f"Урок {in_progress_lesson.lesson.order}: {in_progress_lesson.lesson.title}" - ) + return _lesson_stage_header(in_progress_lesson.lesson) + + for lesson in published_lessons: + lesson_progress = lesson_progress_by_lesson_id.get(lesson.id) + if lesson_progress and lesson_progress.status == ProgressStatus.COMPLETED: + continue + + published_tasks = getattr(lesson, "_published_tasks", []) + if published_tasks: + return _task_header(published_tasks[0]) + return _lesson_stage_header(lesson) return "Этап не определён" @@ -204,6 +244,7 @@ def _build_headers(tasks: list[CourseTask]) -> list[str]: def build_course_results_workbook_bytes(course: Course) -> bytes: tasks = _export_tasks(course) + published_lessons = _published_lessons_with_tasks(course) course_progresses = _started_course_progresses(course) user_ids = [progress.user_id for progress in course_progresses] task_ids = [task.id for task in tasks] @@ -223,7 +264,7 @@ def build_course_results_workbook_bytes(course: Course) -> bytes: course_progress.percent, _format_msk_datetime(course_progress.started_at), course.title, - _format_stage(course_progress, lesson_progresses), + _format_stage(course_progress, lesson_progresses, published_lessons), ] for task in tasks: row.append( diff --git a/courses/tests/test_export.py b/courses/tests/test_export.py index b0903a87..b4d307c2 100644 --- a/courses/tests/test_export.py +++ b/courses/tests/test_export.py @@ -4,6 +4,20 @@ from django.urls import reverse from openpyxl import load_workbook +from courses.models import ( + CourseLessonContentStatus, + CourseModuleContentStatus, + CourseTask, + CourseTaskContentStatus, + CourseTaskInformationalType, + CourseTaskKind, + CourseTaskQuestionType, + CourseTaskAnswerType, + CourseTaskCheckType, + ProgressStatus, + UserCourseProgress, + UserLessonProgress, +) from courses.services.answers import TaskAnswerSubmitPayload, submit_user_task_answer from courses.services.progress import recalculate_user_progresses_for_lesson @@ -33,6 +47,24 @@ def _read_workbook_rows(self, response): worksheet = workbook[workbook.sheetnames[0]] return list(worksheet.iter_rows(values_only=True)) + def _export_rows(self, course): + response = self.client.get(reverse("admin:courses_export_results", args=[course.id])) + self.assertEqual(response.status_code, 200) + return self._read_workbook_rows(response) + + def _stage_from_export(self, course): + rows = self._export_rows(course) + self.assertEqual(len(rows), 2) + return rows[1][5] + + def _mark_course_started(self, user, course, *, percent: int = 1): + return UserCourseProgress.objects.create( + user=user, + course=course, + status=ProgressStatus.IN_PROGRESS, + percent=percent, + ) + def test_course_admin_export_contains_dynamic_task_columns_and_answers(self): course = create_course(title="Экспорт SQL") module = create_module(course, title="SQL basics", order=1) @@ -174,3 +206,249 @@ def test_course_admin_export_marks_completed_course_stage(self): self.assertEqual(rows[1][2], 100) self.assertEqual(rows[1][5], "Курс завершён") self.assertEqual(rows[1][6], "Retention отражает возврат пользователей.") + + def test_course_admin_export_resolves_next_stage_for_not_started_next_module(self): + course = create_course(title="Экспорт этапа") + module_one = create_module(course, title="Основы", order=1) + module_two = create_module(course, title="Практика", order=2) + lesson_one = create_lesson(module_one, title="Денежный поток", order=1) + lesson_two = create_lesson(module_one, title="Runway", order=2) + lesson_three = create_lesson(module_two, title="Финальный блок", order=1) + student = create_user(prefix="export-next-stage") + + module_one_tasks = [ + create_informational_task(lesson_one, title="Введение в cash flow", order=1), + create_text_question_task(lesson_one, title="Что такое cash flow", order=2), + create_informational_task(lesson_two, title="Что такое runway", order=1), + create_text_question_task(lesson_two, title="Как считать runway", order=2), + ] + module_two_first_task = create_informational_task( + lesson_three, + title="Финальная инструкция", + order=1, + ) + + submit_user_task_answer(student, module_one_tasks[0], TaskAnswerSubmitPayload()) + submit_user_task_answer( + student, + module_one_tasks[1], + TaskAnswerSubmitPayload(answer_text="Cash flow показывает движение денег."), + ) + recalculate_user_progresses_for_lesson(student, lesson_one) + + submit_user_task_answer(student, module_one_tasks[2], TaskAnswerSubmitPayload()) + submit_user_task_answer( + student, + module_one_tasks[3], + TaskAnswerSubmitPayload(answer_text="Runway считают по burn rate."), + ) + recalculate_user_progresses_for_lesson(student, lesson_two) + + response = self.client.get( + reverse("admin:courses_export_results", args=[course.id]) + ) + rows = self._read_workbook_rows(response) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(rows), 2) + self.assertEqual( + rows[1][5], + ( + f"Модуль 2: Практика / Урок 1: Финальный блок / " + f"Задание 1: {module_two_first_task.title}" + ), + ) + + def test_course_admin_export_resolves_next_stage_for_not_started_next_lesson(self): + course = create_course(title="Экспорт следующего урока") + module = create_module(course, title="Основной модуль", order=1) + lesson_one = create_lesson(module, title="Урок 1", order=1) + lesson_two = create_lesson(module, title="Урок 2", order=2) + student = create_user(prefix="export-next-lesson") + + info_task = create_informational_task(lesson_one, title="Введение", order=1) + create_text_question_task(lesson_one, title="Ответ по первому уроку", order=2) + next_task = create_informational_task(lesson_two, title="Старт второго урока", order=1) + + submit_user_task_answer(student, info_task, TaskAnswerSubmitPayload()) + submit_user_task_answer( + student, + lesson_one.tasks.get(order=2), + TaskAnswerSubmitPayload(answer_text="Первый урок завершён."), + ) + recalculate_user_progresses_for_lesson(student, lesson_one) + + self.assertEqual( + self._stage_from_export(course), + f"Модуль 1: Основной модуль / Урок 2: Урок 2 / Задание 1: {next_task.title}", + ) + + def test_course_admin_export_uses_first_published_task_when_only_course_progress_exists(self): + course = create_course(title="Экспорт старта без уроков") + module = create_module(course, title="Структура", order=1) + lesson = create_lesson(module, title="Первый урок", order=1) + first_task = create_informational_task(lesson, title="Первое действие", order=1) + create_text_question_task(lesson, title="Следующий вопрос", order=2) + student = create_user(prefix="export-only-course-progress") + + self._mark_course_started(student, course) + + self.assertEqual( + self._stage_from_export(course), + f"Модуль 1: Структура / Урок 1: Первый урок / Задание 1: {first_task.title}", + ) + + def test_course_admin_export_uses_lesson_header_when_first_unfinished_lesson_has_no_tasks(self): + course = create_course(title="Экспорт урока без заданий") + module = create_module(course, title="Только теория", order=1) + create_lesson(module, title="Пустой урок", order=1) + student = create_user(prefix="export-empty-lesson") + + self._mark_course_started(student, course) + + self.assertEqual( + self._stage_from_export(course), + "Модуль 1: Только теория / Урок 1: Пустой урок", + ) + + def test_course_admin_export_uses_lesson_header_for_in_progress_lesson_without_current_task(self): + course = create_course(title="Экспорт in_progress без задания") + module = create_module(course, title="Модуль", order=1) + lesson = create_lesson(module, title="Урок", order=1) + student = create_user(prefix="export-in-progress-lesson") + + self._mark_course_started(student, course, percent=50) + UserLessonProgress.objects.create( + user=student, + lesson=lesson, + status=ProgressStatus.IN_PROGRESS, + percent=50, + current_task=None, + ) + + self.assertEqual( + self._stage_from_export(course), + "Модуль 1: Модуль / Урок 1: Урок", + ) + + def test_course_admin_export_ignores_unpublished_current_task_and_falls_back_to_lesson(self): + course = create_course(title="Экспорт draft current task") + module = create_module(course, title="Модуль", order=1) + lesson = create_lesson(module, title="Урок", order=1) + student = create_user(prefix="export-draft-current-task") + + draft_task = CourseTask.objects.create( + lesson=lesson, + title="Черновик", + status=CourseTaskContentStatus.DRAFT, + task_kind=CourseTaskKind.INFORMATIONAL, + informational_type=CourseTaskInformationalType.TEXT, + body_text="Черновик", + order=1, + ) + + self._mark_course_started(student, course, percent=30) + UserLessonProgress.objects.create( + user=student, + lesson=lesson, + status=ProgressStatus.IN_PROGRESS, + percent=30, + current_task=draft_task, + ) + + self.assertEqual( + self._stage_from_export(course), + "Модуль 1: Модуль / Урок 1: Урок", + ) + + def test_course_admin_export_is_undefined_when_all_published_lessons_are_completed_but_course_is_not(self): + course = create_course(title="Экспорт broken completed lessons") + module = create_module(course, title="Модуль", order=1) + lesson = create_lesson(module, title="Урок", order=1) + student = create_user(prefix="export-broken-completed") + + self._mark_course_started(student, course, percent=99) + UserLessonProgress.objects.create( + user=student, + lesson=lesson, + status=ProgressStatus.COMPLETED, + percent=100, + ) + + self.assertEqual(self._stage_from_export(course), "Этап не определён") + + def test_course_admin_export_is_undefined_when_course_has_no_published_lessons(self): + course = create_course(title="Экспорт без опубликованных уроков") + draft_module = create_module( + course, + title="Черновой модуль", + order=1, + status=CourseModuleContentStatus.DRAFT, + ) + create_lesson( + draft_module, + title="Черновой урок", + order=1, + status=CourseLessonContentStatus.DRAFT, + ) + student = create_user(prefix="export-no-published-lessons") + + self._mark_course_started(student, course) + + self.assertEqual(self._stage_from_export(course), "Этап не определён") + + def test_course_admin_export_skips_draft_modules_in_stage_fallback(self): + course = create_course(title="Экспорт пропуска draft модуля") + draft_module = create_module( + course, + title="Черновик", + order=1, + status=CourseModuleContentStatus.DRAFT, + ) + create_lesson(draft_module, title="Черновой урок", order=1) + module = create_module(course, title="Публикация", order=2) + lesson = create_lesson(module, title="Старт", order=1) + first_task = create_text_question_task(lesson, title="Первый вопрос", order=1) + student = create_user(prefix="export-skip-draft-module") + + self._mark_course_started(student, course) + + self.assertEqual( + self._stage_from_export(course), + f"Модуль 2: Публикация / Урок 1: Старт / Задание 1: {first_task.title}", + ) + + def test_course_admin_export_skips_draft_lessons_in_stage_fallback(self): + course = create_course(title="Экспорт пропуска draft урока") + module = create_module(course, title="Модуль", order=1) + draft_lesson = create_lesson( + module, + title="Черновой урок", + order=1, + status=CourseLessonContentStatus.DRAFT, + ) + CourseTask.objects.create( + lesson=draft_lesson, + title="Черновое задание", + status=CourseTaskContentStatus.DRAFT, + task_kind=CourseTaskKind.QUESTION, + question_type=CourseTaskQuestionType.TEXT, + answer_type=CourseTaskAnswerType.TEXT, + check_type=CourseTaskCheckType.WITHOUT_REVIEW, + body_text="Черновик", + order=1, + ) + published_lesson = create_lesson(module, title="Рабочий урок", order=2) + first_task = create_text_question_task( + published_lesson, + title="Первое опубликованное задание", + order=1, + ) + student = create_user(prefix="export-skip-draft-lesson") + + self._mark_course_started(student, course) + + self.assertEqual( + self._stage_from_export(course), + f"Модуль 1: Модуль / Урок 2: Рабочий урок / Задание 1: {first_task.title}", + )