From 68d33728278eb744d5fbe9daf8f0b7e6bd681fac Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 29 May 2026 15:39:23 +0700 Subject: [PATCH 1/2] feat: Management command to generate a CSV report of xblocks used in all courses or specific courses --- .../commands/tests/test_xblock_list_csv.py | 142 ++++++++++++++++++ .../management/commands/xblock_list_csv.py | 106 +++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py create mode 100644 lms/djangoapps/courseware/management/commands/xblock_list_csv.py diff --git a/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py b/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py new file mode 100644 index 000000000000..6c6bec61cccc --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py @@ -0,0 +1,142 @@ +""" +Tests for the xblock_list_csv management command. +""" + +import csv +from io import StringIO +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from lms.djangoapps.courseware.management.commands.xblock_list_csv import generate_xblocks_csv + + +OVERVIEWS_PATH = "lms.djangoapps.courseware.management.commands.xblock_list_csv.CourseOverview.objects" +MODULESTORE_PATH = "lms.djangoapps.courseware.management.commands.xblock_list_csv.modulestore" + + +class GenerateCSVCommandTestCase(TestCase): + """ + Test case for the xblock_list_csv management command + """ + + COURSE_ID = "course-v1:edX+Test101+2024" + + @staticmethod + def _make_block(display_name, block_type): + """ + Creates a mock block + """ + component = MagicMock() + component.display_name = display_name + component.location.block_type = block_type + return component + + @staticmethod + def _make_container(display_name, children): + """ + Creates a mock container + """ + block = MagicMock() + block.display_name = display_name + block.get_children.return_value = children + return block + + @staticmethod + def _make_course(course_id, display_name, sections): + """ + Creates a mock course + """ + course = MagicMock() + course.id = course_id + course.display_name = display_name + course.get_children.return_value = sections + return course + + @staticmethod + def _make_overview(course_id): + """ + Creates a mock overview for a course + """ + overview = MagicMock() + overview.id = course_id + return overview + + @classmethod + def setUpClass(cls): + super().setUpClass() + HTML_COMPONENT = cls._make_block("My HTML", "html") + VIDEO_COMPONENT = cls._make_block("My Video", "video") + PROBLEM_COMPONENT = cls._make_block("My Problem", "problem") + DRAG_COMPONENT = cls._make_block("My Drag Drop", "drag-and-drop-v2") + unit = cls._make_container("Unit 1", [HTML_COMPONENT, VIDEO_COMPONENT, PROBLEM_COMPONENT, DRAG_COMPONENT]) + subsection = cls._make_container("Subsection 1", [unit]) + section = cls._make_container("Section 1", [subsection]) + cls.MOCK_COURSE = cls._make_course(cls.COURSE_ID, "Test Course", [section]) + cls.MOCK_OVERVIEW = cls._make_overview(cls.COURSE_ID) + + def _run_generate(self, overviews, exclude_core_xblocks=False, courses=None): + """Helper: run generate_xblocks_csv with mocked DB/modulestore, return parsed CSV rows.""" + output = StringIO() + with patch(OVERVIEWS_PATH) as mock_overviews, patch(MODULESTORE_PATH) as mock_modulestore: + mock_overviews.all.return_value.order_by.return_value = overviews + mock_overviews.filter.return_value.order_by.return_value = overviews + mock_modulestore.return_value.get_course.return_value = self.MOCK_COURSE + generate_xblocks_csv(output, exclude_core_xblocks, courses) + output.seek(0) + return list(csv.reader(output)) + + def test_header_row(self): + rows = self._run_generate([]) + assert rows[0] == [ + "Course ID", + "Course Name", + "Section Name", + "Subsection Name", + "Unit Name", + "Component Name", + "Xblock Type", + ] + + def test_all_components_included_by_default(self): + rows = self._run_generate([self.MOCK_OVERVIEW]) + # 1 header + 4 components + assert len(rows) == 5 + + # Checking data in the first row + row = rows[1] + assert row[0] == str(self.COURSE_ID) + assert row[1] == "Test Course" + assert row[2] == "Section 1" + assert row[3] == "Subsection 1" + assert row[4] == "Unit 1" + assert row[5] == "My HTML" + assert row[6] == "html" + + def test_exclude_core_xblocks(self): + rows = self._run_generate([self.MOCK_OVERVIEW], exclude_core_xblocks=True) + # Only drag-and-drop-v2 survives; html/video/problem are filtered out + assert len(rows) == 2 + assert rows[1][6] == "drag-and-drop-v2" + + def test_courses_filter_uses_filter_queryset(self): + output = StringIO() + with patch(OVERVIEWS_PATH) as mock_overviews, patch(MODULESTORE_PATH) as mock_modulestore: + mock_overviews.filter.return_value.order_by.return_value = [] + mock_modulestore.return_value.get_course.return_value = self.MOCK_COURSE + + generate_xblocks_csv(output, False, [self.COURSE_ID]) + + mock_overviews.filter.assert_called_once_with(id__in=[self.COURSE_ID]) + mock_overviews.all.assert_not_called() + + def test_no_courses_filter_uses_all_queryset(self): + output = StringIO() + with patch(OVERVIEWS_PATH) as mock_overviews, patch(MODULESTORE_PATH) as mock_modulestore: + mock_overviews.all.return_value.order_by.return_value = [] + mock_modulestore.return_value.get_course.return_value = self.MOCK_COURSE + + generate_xblocks_csv(output, False, None) + + mock_overviews.all.assert_called_once() + mock_overviews.filter.assert_not_called() diff --git a/lms/djangoapps/courseware/management/commands/xblock_list_csv.py b/lms/djangoapps/courseware/management/commands/xblock_list_csv.py new file mode 100644 index 000000000000..3b6977b1516c --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/xblock_list_csv.py @@ -0,0 +1,106 @@ +""" +Script for generating CSV data on all components, +to audit the XBlocks used. +""" + +import csv +import sys +from typing import TextIO + +from django.core.management.base import BaseCommand + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.modulestore.django import modulestore + + + +CORE_XBLOCKS = ["html", "problem", "video"] + + +class Command(BaseCommand): + """ + Generate a CSV file with all published components used in courses with their xblock type. + """ + + help = "Generate a CSV file with all published components used in courses with their xblock type." + + def add_arguments(self, parser): + parser.add_argument( + "filename", help="Path to the CSV output file. Use '-' to print to stdout." + ) + parser.add_argument( + "--exclude-core-xblocks", + action="store_true", + help=f"Exclude components using core XBlocks: ({', '.join(CORE_XBLOCKS)}). Default: false", + ) + parser.add_argument( + "--courses", + nargs="+", + metavar="COURSE_ID", + help="Filter the report by one or more course IDs", + ) + + + def handle(self, *args, **options): + filename = options["filename"] + exclude_core_xblocks = options["exclude_core_xblocks"] + courses = options["courses"] + + if filename == "-": + generate_xblocks_csv(sys.stdout, exclude_core_xblocks, courses) + else: + with open(filename, "w") as file_handle: + generate_xblocks_csv(file_handle, exclude_core_xblocks, courses) + + +def generate_xblocks_csv( + file_handle: TextIO, + exclude_core_xblocks: bool, + courses: list[str] | None, +): + """ + Generate the CSV and write it to `file_handle`. + """ + if courses: + overviews = CourseOverview.objects.filter(id__in=courses).order_by("id") + else: + overviews = CourseOverview.objects.all().order_by("id") + + writer = csv.writer(file_handle) + writer.writerow( + ( + "Course ID", + "Course Name", + "Section Name", + "Subsection Name", + "Unit Name", + "Component Name", + "Xblock Type", + ) + ) + + for overview in overviews: + try: + course = modulestore().get_course(overview.id) + writer.writerows( + ( + course.id, + course.display_name, + section.display_name, + subsection.display_name, + unit.display_name, + component.display_name, + 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() + if not exclude_core_xblocks + or component.location.block_type not in CORE_XBLOCKS + ) + except: # pylint: disable=bare-except + print( + f"Failed retrieving course {overview.id} from modulestore", + file=sys.stderr + ) From d753fa396c93321c48e2a1d5d91b9402cea82774 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 29 May 2026 16:01:50 +0700 Subject: [PATCH 2/2] fix: Issues from the linter --- .../management/commands/tests/test_xblock_list_csv.py | 1 - .../courseware/management/commands/xblock_list_csv.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py b/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py index 6c6bec61cccc..20dd351b45c9 100644 --- a/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py +++ b/lms/djangoapps/courseware/management/commands/tests/test_xblock_list_csv.py @@ -10,7 +10,6 @@ from lms.djangoapps.courseware.management.commands.xblock_list_csv import generate_xblocks_csv - OVERVIEWS_PATH = "lms.djangoapps.courseware.management.commands.xblock_list_csv.CourseOverview.objects" MODULESTORE_PATH = "lms.djangoapps.courseware.management.commands.xblock_list_csv.modulestore" diff --git a/lms/djangoapps/courseware/management/commands/xblock_list_csv.py b/lms/djangoapps/courseware/management/commands/xblock_list_csv.py index 3b6977b1516c..ca5896063c0c 100644 --- a/lms/djangoapps/courseware/management/commands/xblock_list_csv.py +++ b/lms/djangoapps/courseware/management/commands/xblock_list_csv.py @@ -12,8 +12,6 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.django import modulestore - - CORE_XBLOCKS = ["html", "problem", "video"]