From 60d9220b518e7b10a9b3ff7a58082581ea342602 Mon Sep 17 00:00:00 2001 From: Samuel Allan Date: Thu, 13 Nov 2025 15:52:03 +1030 Subject: [PATCH] feat: Add a csv report of xblocks used in courses Private-ref: https://tasks.opencraft.com/browse/BB-10225 --- lms/djangoapps/instructor/views/api.py | 38 ++++++++ lms/djangoapps/instructor/views/api_urls.py | 1 + .../instructor/views/instructor_dashboard.py | 1 + lms/djangoapps/instructor_task/api.py | 15 ++++ lms/djangoapps/instructor_task/data.py | 1 + lms/djangoapps/instructor_task/tasks.py | 14 +++ .../tasks_helper/components.py | 88 +++++++++++++++++++ .../js/instructor_dashboard/data_download.js | 26 ++++++ .../instructor_dashboard_2/data_download.html | 4 + 9 files changed, 188 insertions(+) create mode 100644 lms/djangoapps/instructor_task/tasks_helper/components.py diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index eae657ed19e7..306127fcaadb 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1562,6 +1562,44 @@ def post(self, request, course_id, csv=False): # pylint: disable=redefined-oute return JsonResponse({"status": success_status}) +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetXblocksList(DeveloperErrorViewMixin, APIView): + """ + Respond with a csv which contains a summary of all xblocks in all courses. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): # pylint: disable=redefined-outer-name + """ + Handle POST requests + + Args: + request: The HTTP request object. + course_id: The ID of the course for which to retrieve student information. + + Returns: + Response: A CSV response containing xblocks information. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + report_type = _('xblocks list') + + try: + task_api.submit_xblocks_list_csv( + request, + course_key, + ) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + except Exception as e: + raise self.api_error(status.HTTP_400_BAD_REQUEST, str(e), 'Requested task is already running') + + return JsonResponse({"status": success_status}) + + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @method_decorator(transaction.non_atomic_requests, name='dispatch') class GetStudentsWhoMayEnroll(DeveloperErrorViewMixin, APIView): diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 7d4db3e3c089..6ba546a96265 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -29,6 +29,7 @@ path('get_problem_responses', api.GetProblemResponses.as_view(), name='get_problem_responses'), path('get_issued_certificates/', api.GetIssuedCertificates.as_view(), name='get_issued_certificates'), re_path(r'^get_students_features(?P/csv)?$', api.GetStudentsFeatures.as_view(), name='get_students_features'), + re_path(r'^get_xblocks_list$', api.GetXblocksList.as_view(), name='get_xblocks_list'), path('get_grading_config', api.GetGradingConfig.as_view(), name='get_grading_config'), path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), path('get_enrolled_students_with_inactive_account', api.GetInactiveEnrolledStudents.as_view(), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 3c47a67b632a..70ca25d35d03 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -670,6 +670,7 @@ def _section_data_download(course, access): 'export_ora2_submission_files', kwargs={'course_id': str(course_key)} ), 'export_ora2_summary_url': reverse('export_ora2_summary', kwargs={'course_id': str(course_key)}), + 'export_xblock_list_url': reverse('get_xblocks_list', kwargs={'course_id': str(course_key)}), } if not access.get('data_researcher'): section_data['is_hidden'] = True diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 6474efc1d374..a5a4bd4b051e 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -37,6 +37,7 @@ calculate_problem_grade_report, calculate_problem_responses_csv, calculate_students_features_csv, + calculate_xblock_list_csv, cohort_students, course_survey_report_csv, delete_problem_state, @@ -395,6 +396,20 @@ def submit_calculate_students_features_csv(request, course_key, features, **task return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_xblocks_list_csv(request, course_key): + """ + Submits a task to generate a CSV containing info about xblocks in courses. + + Raises AlreadyRunningError if said CSV is already being updated. + """ + task_type = InstructorTaskTypes.XBLOCK_LIST_CSV + task_class = calculate_xblock_list_csv + task_input = {} + task_key = "" + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + def submit_calculate_may_enroll_csv(request, course_key, features): """ Submits a task to generate a CSV file containing information about diff --git a/lms/djangoapps/instructor_task/data.py b/lms/djangoapps/instructor_task/data.py index 7a09f7d08227..1ae651a57f45 100644 --- a/lms/djangoapps/instructor_task/data.py +++ b/lms/djangoapps/instructor_task/data.py @@ -29,6 +29,7 @@ class InstructorTaskTypes(str, Enum): PROBLEM_RESPONSES_CSV = "problem_responses_csv" PROCTORED_EXAM_RESULTS_REPORT = "proctored_exam_results_report" PROFILE_INFO_CSV = "profile_info_csv" + XBLOCK_LIST_CSV = "xblock_list_csv" REGENERATE_CERTIFICATES_ALL_STUDENT = "regenerate_certificates_all_student" RESCORE_PROBLEM = "rescore_problem" RESCORE_PROBLEM_IF_HIGHER = "rescore_problem_if_higher" diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index 7a9dabe3d51c..4376ef997303 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -30,6 +30,7 @@ from lms.djangoapps.bulk_email.tasks import perform_delegate_email_batches from lms.djangoapps.instructor_task.tasks_base import BaseInstructorTask from lms.djangoapps.instructor_task.tasks_helper.certs import generate_students_certificates +from lms.djangoapps.instructor_task.tasks_helper.components import upload_xblock_list_csv from lms.djangoapps.instructor_task.tasks_helper.enrollments import ( upload_inactive_enrolled_students_info_csv, upload_may_enroll_csv, @@ -232,6 +233,19 @@ def calculate_students_features_csv(entry_id, xblock_instance_args): return run_main_task(entry_id, task_fn, action_name) +@shared_task(base=BaseInstructorTask) +@set_code_owner_attribute +def calculate_xblock_list_csv(entry_id, xblock_instance_args): + """ + Compute list of components/lxblocks and upload the + CSV to an S3 bucket for download. + """ + # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. + action_name = gettext_noop('generated') + task_fn = partial(upload_xblock_list_csv, xblock_instance_args) + return run_main_task(entry_id, task_fn, action_name) + + @shared_task(base=BaseInstructorTask) @set_code_owner_attribute def course_survey_report_csv(entry_id, xblock_instance_args): diff --git a/lms/djangoapps/instructor_task/tasks_helper/components.py b/lms/djangoapps/instructor_task/tasks_helper/components.py new file mode 100644 index 000000000000..b6c37973d3d9 --- /dev/null +++ b/lms/djangoapps/instructor_task/tasks_helper/components.py @@ -0,0 +1,88 @@ +""" +Instructor tasks related to components and xblocks in the course. +""" + +from datetime import datetime +from time import time +from pytz import UTC +from lms.djangoapps.instructor_analytics.csvs import format_dictlist +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.modulestore.django import modulestore + +from .runner import TaskProgress +from .utils import upload_csv_to_report_store + + +def upload_xblock_list_csv( + _xblock_instance_args, _entry_id, course_id, task_input, action_name +): + """ + Generate a csv containing all components. + """ + start_time = time() + start_date = datetime.now(UTC) + + overviews = CourseOverview.objects.filter(id=course_id).order_by("id") + # NOTE: if we want to report on all courses at once, use this line below instead of the one above + # overviews = CourseOverview.objects.all().order_by('id') + + total_courses = overviews.count() + task_progress = TaskProgress(action_name, total_courses, start_time) + + current_step = {"step": "Calculating XBlock Info"} + task_progress.update_task_state(extra_meta=current_step) + + data = [] + succeeded_count = 0 + for overview in overviews: + try: + course = modulestore().get_course(overview.id) + data.extend( + { + "Course ID": course.id, + "Course Name": course.display_name, + "Section Name": section.display_name, + "Subsection Name": subsection.display_name, + "Unit Name": unit.display_name, + "Component Name": component.display_name, + "Xblock Type": component.location.block_type, + } + for section in course.get_children() + for subsection in section.get_children() + for unit in subsection.get_children() + for component in unit.get_children() + ) + succeeded_count += 1 + except: # pylint: disable=bare-except + print(f"FAILED GETTING COURSE {overview.id} FROM MODULESTORE") + + header, rows = format_dictlist( + data, + [ + "Course ID", + "Course Name", + "Section Name", + "Subsection Name", + "Unit Name", + "Component Name", + "Xblock Type", + ], + ) + + task_progress.attempted = total_courses + task_progress.succeeded = succeeded_count + task_progress.skipped = task_progress.failed = total_courses - succeeded_count + + rows.insert(0, header) + + current_step = {"step": "Uploading CSV"} + task_progress.update_task_state(extra_meta=current_step) + + # Perform the upload + upload_parent_dir = task_input.get("upload_parent_dir", "") + upload_filename = task_input.get("filename", "xblocks_list") + upload_csv_to_report_store( + rows, upload_filename, course_id, start_date, parent_dir=upload_parent_dir + ) + + return task_progress.update_task_state(extra_meta=current_step) diff --git a/lms/static/js/instructor_dashboard/data_download.js b/lms/static/js/instructor_dashboard/data_download.js index 94f406b4f301..fcc57beb5919 100644 --- a/lms/static/js/instructor_dashboard/data_download.js +++ b/lms/static/js/instructor_dashboard/data_download.js @@ -109,6 +109,7 @@ this.$calculate_grades_csv_btn = this.$section.find("input[name='calculate-grades-csv']"); this.$problem_grade_report_csv_btn = this.$section.find("input[name='problem-grade-report']"); this.$async_report_btn = this.$section.find("input[class='async-report-btn']"); + this.$export_xblocks_csv_btn = this.$section.find("input[name='export-xblocks-csv']"); this.$download = this.$section.find('.data-download-container'); this.$download_display_text = this.$download.find('.data-display-text'); this.$download_request_response_error = this.$download.find('.request-response-error'); @@ -400,6 +401,31 @@ } }); }); + this.$export_xblocks_csv_btn.click(function() { + var url = dataDownloadObj.$export_xblocks_csv_btn.data('endpoint'); + var errorMessage = gettext('Error generating xblocks information. Please try again.'); + dataDownloadObj.clear_display(); + return $.ajax({ + type: 'POST', + dataType: 'json', + url: url, + error: function(error) { + if (error.responseText) { + errorMessage = JSON.parse(error.responseText); + } + dataDownloadObj.$reports_request_response_error.text(errorMessage); + return dataDownloadObj.$reports_request_response_error.css({ + display: 'block' + }); + }, + success: function(data) { + dataDownloadObj.$reports_request_response.text(data.status); + return $('.msg-confirm').css({ + display: 'block' + }); + } + }); + }); } InstructorDashboardDataDownload.prototype.onClickTitle = function() { diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index b3c3b6a9b4ae..bf6cc0909987 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -109,6 +109,10 @@

${_("Reports")}

+

${_("Click to generate a CSV file of all components with their XBlock types for the course.")}

+ +

+ %endif