Skip to content
Merged
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
418 changes: 340 additions & 78 deletions cms/djangoapps/contentstore/core/course_optimizer_provider.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ def test_update_node_tree_and_dictionary_returns_node_tree(self):
when passed a block level xblock.
"""
expected_tree = {
'chapter_1': {
'sequential_1': {
'vertical_1': {
'block_1': {}
self.mock_section.location: {
self.mock_subsection.location: {
self.mock_unit.location: {
self.mock_block.location: {}
}
}
}
Expand All @@ -81,19 +81,19 @@ def test_update_node_tree_and_dictionary_returns_dictionary(self):
when passed a block level xblock.
"""
expected_dictionary = {
'chapter_1': {
self.mock_section.location: {
'display_name': 'Section Name',
'category': 'chapter'
},
'sequential_1': {
self.mock_subsection.location: {
'display_name': 'Subsection Name',
'category': 'sequential'
},
'vertical_1': {
self.mock_unit.location: {
'display_name': 'Unit Name',
'category': 'vertical'
},
'block_1': {
self.mock_block.location: {
'display_name': 'Block Name',
'category': 'html',
'url': f'/course/{self.course.id}/editor/html/{self.mock_block.location}',
Expand Down Expand Up @@ -274,11 +274,16 @@ def test_returns_unchanged_data_if_sections_missing(self, mock_modulestore):
def test_sorts_sections_correctly(self, mock_modulestore):
"""Test that the function correctly sorts sections based on published course structure."""

# Create mock location objects that will match the section IDs in data
mock_location2 = "section2"
mock_location3 = "section3"
mock_location1 = "section1"

mock_course_block = Mock()
mock_course_block.get_children.return_value = [
Mock(location=Mock(block_id="section2")),
Mock(location=Mock(block_id="section3")),
Mock(location=Mock(block_id="section1")),
Mock(location=mock_location2),
Mock(location=mock_location3),
Mock(location=mock_location1),
]

mock_modulestore_instance = Mock()
Expand All @@ -301,8 +306,7 @@ def test_sorts_sections_correctly(self, mock_modulestore):
{"id": "section3", "name": "Bonus"},
{"id": "section1", "name": "Intro"},
]

assert result["LinkCheckOutput"]["sections"] == expected_sections
self.assertEqual(result["LinkCheckOutput"]["sections"], expected_sections)

def test_prev_run_link_detection(self):
"""Test the core logic of separating previous run links from regular links."""
Expand Down Expand Up @@ -366,64 +370,90 @@ def test_enhanced_url_detection_edge_cases(self):

def test_course_updates_and_custom_pages_structure(self):
"""Test that course_updates and custom_pages are properly structured in the response."""
course_key = self.course.id

# Test data that represents the broken links JSON structure
json_content = [
# Regular course content
[
"course-v1:Test+Course+2024+type@html+block@content1",
str(self.mock_block.location),
"http://content-link.com",
"broken",
LinkState.BROKEN,
],
[
"course-v1:Test+Course+2024+type@vertical+block@unit1",
str(self.mock_unit.location),
"http://unit-link.com",
"locked",
LinkState.LOCKED,
],
# Course updates
[
"course-v1:Test+Course+2024+type@course_info+block@updates",
f"{course_key}+type@course_info+block@updates",
"http://update1.com",
"broken",
LinkState.BROKEN,
],
[
"course-v1:Test+Course+2024+type@course_info+block@updates",
f"{course_key}+type@course_info+block@updates",
"http://update2.com",
"locked",
LinkState.LOCKED,
],
# Handouts (should be merged into course_updates)
[
"course-v1:Test+Course+2024+type@course_info+block@handouts",
f"{course_key}+type@course_info+block@handouts",
"http://handout.com",
"broken",
LinkState.BROKEN,
],
# Custom pages (static tabs)
[
"course-v1:Test+Course+2024+type@static_tab+block@page1",
f"{course_key}+type@static_tab+block@page1",
"http://page1.com",
"broken",
LinkState.BROKEN,
],
[
"course-v1:Test+Course+2024+type@static_tab+block@page2",
f"{course_key}+type@static_tab+block@page2",
"http://page2.com",
"external-forbidden",
LinkState.EXTERNAL_FORBIDDEN,
],
]

with mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider._generate_links_descriptor_for_content"
) as mock_content, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore"
) as mock_modulestore:
) as mock_modulestore, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.create_course_info_usage_key"
) as mock_create_usage_key, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.get_course_update_items"
) as mock_get_update_items, mock.patch(
"cms.djangoapps.contentstore.core.course_optimizer_provider.extract_content_URLs_from_course"
) as mock_extract_urls:

mock_content.return_value = {"sections": []}
mock_course = self.mock_course
mock_tab1 = StaticTab(name="Page1", url_slug="page1")
mock_tab2 = StaticTab(name="Page2", url_slug="page2")
mock_tab1 = StaticTab(name="Test Page 1", url_slug="page1")
mock_tab2 = StaticTab(name="Test Page 2", url_slug="page2")
mock_course.tabs = [mock_tab1, mock_tab2]
mock_course.id = CourseKey.from_string("course-v1:Test+Course+2024")
mock_course.id = course_key
mock_modulestore.return_value.get_course.return_value = mock_course

course_key = CourseKey.from_string("course-v1:Test+Course+2024")
mock_updates_usage_key = Mock()
mock_handouts_usage_key = Mock()
mock_create_usage_key.side_effect = lambda course, info_type: (
mock_updates_usage_key if info_type == "updates" else mock_handouts_usage_key
)
mock_updates_block = Mock()
mock_updates_block.data = "Check out <a href='http://update1.com'>this update</a>"
mock_handouts_block = Mock()
mock_handouts_block.data = "Download <a href='http://handout.com'>handout</a>"
mock_get_item_mapping = {
mock_updates_usage_key: mock_updates_block,
mock_handouts_usage_key: mock_handouts_block,
}
mock_modulestore.return_value.get_item.side_effect = (
lambda usage_key: mock_get_item_mapping.get(usage_key, Mock())
)
mock_get_update_items.return_value = [
{"id": "update1", "date": "2024-01-01", "content": "Update content 1", "status": "visible"},
{"id": "update2", "date": "2024-01-02", "content": "Update content 2", "status": "visible"}
]
mock_extract_urls.return_value = ["http://update1.com", "http://update2.com"]
result = generate_broken_links_descriptor(
json_content, self.user, course_key
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,62 @@ class LinkCheckSerializer(serializers.Serializer):
LinkCheckCreatedAt = serializers.DateTimeField(required=False)
LinkCheckOutput = LinkCheckOutputSerializer(required=False)
LinkCheckError = serializers.CharField(required=False)


class CourseRerunLinkDataSerializer(serializers.Serializer):
""" Serializer for individual course rerun link data """
url = serializers.CharField(required=True, allow_null=False, allow_blank=False)
type = serializers.CharField(required=True, allow_null=False, allow_blank=False)
id = serializers.CharField(required=True, allow_null=False, allow_blank=False)


class CourseRerunLinkUpdateRequestSerializer(serializers.Serializer):
"""Serializer for course rerun link update request."""

ACTION_CHOICES = ("all", "single")

action = serializers.ChoiceField(choices=ACTION_CHOICES, required=True)
data = CourseRerunLinkDataSerializer(many=True, required=False)

def validate(self, attrs):
"""
Validate that 'data' is provided when action is 'single'.
"""
action = attrs.get("action")
data = attrs.get("data")

if action == "single" and not data:
raise serializers.ValidationError(
{"data": "This field is required when action is 'single'."}
)

return attrs


class CourseRerunLinkUpdateResultSerializer(serializers.Serializer):
""" Serializer for individual course rerun link update result """
new_url = serializers.CharField(required=True, allow_null=False, allow_blank=False)
original_url = serializers.CharField(required=False, allow_null=True, allow_blank=True)
type = serializers.CharField(required=True, allow_null=False, allow_blank=True)
id = serializers.CharField(required=True, allow_null=False, allow_blank=False)
success = serializers.BooleanField(required=True)
error_message = serializers.CharField(required=False, allow_null=True, allow_blank=True)

def to_representation(self, instance):
"""
Override to exclude error_message field when success is True or error_message is null/empty
"""
data = super().to_representation(instance)
if data.get('success') is True or not data.get('error_message'):
data.pop('error_message', None)

return data


class CourseRerunLinkUpdateStatusSerializer(serializers.Serializer):
""" Serializer for course rerun link update status """
status = serializers.ChoiceField(
choices=['pending', 'in_progress', 'completed', 'failed', 'uninitiated'],
required=True
)
results = CourseRerunLinkUpdateResultSerializer(many=True, required=False)
Loading
Loading