Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""
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()
104 changes: 104 additions & 0 deletions lms/djangoapps/courseware/management/commands/xblock_list_csv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
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
)
Loading