diff --git a/cms/djangoapps/contentstore/core/__init__.py b/cms/djangoapps/contentstore/core/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/contentstore/core/course_optimizer_provider.py b/cms/djangoapps/contentstore/core/course_optimizer_provider.py index b4121642a96d..3389a0f3a367 100644 --- a/cms/djangoapps/contentstore/core/course_optimizer_provider.py +++ b/cms/djangoapps/contentstore/core/course_optimizer_provider.py @@ -12,8 +12,14 @@ def generate_broken_links_descriptor(json_content, request_user): """ Returns a Data Transfer Object for frontend given a list of broken links. - json_content contains a list of [block_id, link, is_locked] - is_locked is true if the link is a studio link and returns 403 on request + ** Example json_content structure ** + Note: is_locked is true if the link is a studio link and returns 403 + [ + ['block_id_1', 'link_1', is_locked], + ['block_id_1', 'link_2', is_locked], + ['block_id_2', 'link_3', is_locked], + ... + ] ** Example DTO structure ** { @@ -62,7 +68,7 @@ def generate_broken_links_descriptor(json_content, request_user): usage_key = usage_key_with_run(block_id) block = get_xblock(usage_key, request_user) - _update_node_tree_and_dictionary( + xblock_node_tree, xblock_dictionary = _update_node_tree_and_dictionary( block=block, link=link, is_locked=is_locked_flag, @@ -70,7 +76,7 @@ def generate_broken_links_descriptor(json_content, request_user): dictionary=xblock_dictionary ) - return _create_dto_from_node_tree_recursive(xblock_node_tree, xblock_dictionary) + return _create_dto_recursive(xblock_node_tree, xblock_dictionary) def _update_node_tree_and_dictionary(block, link, is_locked, node_tree, dictionary): @@ -100,20 +106,29 @@ def _update_node_tree_and_dictionary(block, link, is_locked, node_tree, dictiona ** Example dictionary structure ** { 'xblock_id: { - 'display_name': 'xblock name' - 'category': 'html' + 'display_name': 'xblock name', + 'category': 'chapter' }, + 'html_block_id': { + 'display_name': 'xblock name', + 'category': 'chapter', + 'url': 'url_1', + 'locked_links': [...], + 'broken_links': [...] + } ..., } """ + updated_tree, updated_dictionary = node_tree, dictionary + path = _get_node_path(block) - current_node = node_tree + current_node = updated_tree xblock_id = '' # Traverse the path and build the tree structure for xblock in path: xblock_id = xblock.location.block_id - dictionary.setdefault(xblock_id, + updated_dictionary.setdefault(xblock_id, { 'display_name': xblock.display_name, 'category': getattr(xblock, 'category', ''), @@ -123,18 +138,20 @@ def _update_node_tree_and_dictionary(block, link, is_locked, node_tree, dictiona current_node = current_node.setdefault(xblock_id, {}) # Add block-level details for the last xblock in the path (URL and broken/locked links) - dictionary[xblock_id].setdefault('url', + updated_dictionary[xblock_id].setdefault('url', f'/course/{block.course_id}/editor/{block.category}/{block.location}' ) if is_locked: - dictionary[xblock_id].setdefault('locked_links', []).append(link) + updated_dictionary[xblock_id].setdefault('locked_links', []).append(link) else: - dictionary[xblock_id].setdefault('broken_links', []).append(link) + updated_dictionary[xblock_id].setdefault('broken_links', []).append(link) + + return updated_tree, updated_dictionary def _get_node_path(block): """ - Retrieves the path frmo the course root node to a specific block, excluding the root. + Retrieves the path from the course root node to a specific block, excluding the root. ** Example Path structure ** [chapter_node, sequential_node, vertical_node, html_node] @@ -156,9 +173,10 @@ def _get_node_path(block): } -def _create_dto_from_node_tree_recursive(xblock_node, xblock_dictionary): +def _create_dto_recursive(xblock_node, xblock_dictionary): """ - Recursively build the Data Transfer Object from the node tree and dictionary. + Recursively build the Data Transfer Object by using + the structure from the node tree and data from the dictionary. """ # Exit condition when there are no more child nodes (at block level) if not xblock_node: @@ -168,7 +186,7 @@ def _create_dto_from_node_tree_recursive(xblock_node, xblock_dictionary): xblock_children = [] for xblock_id, node in xblock_node.items(): - child_blocks = _create_dto_from_node_tree_recursive(node, xblock_dictionary) + child_blocks = _create_dto_recursive(node, xblock_dictionary) xblock_data = xblock_dictionary.get(xblock_id, {}) xblock_entry = { diff --git a/cms/djangoapps/contentstore/core/tests/__init__.py b/cms/djangoapps/contentstore/core/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py new file mode 100644 index 000000000000..621306ed7777 --- /dev/null +++ b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py @@ -0,0 +1,228 @@ +""" +Tests for course optimizer +""" + +import unittest +from unittest.mock import Mock, patch + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.core.course_optimizer_provider import ( + generate_broken_links_descriptor, + _update_node_tree_and_dictionary, + _get_node_path, + _create_dto_recursive +) + +class TestLinkCheckProvider(CourseTestCase): + """ + Tests for functions that generate a json structure of locked and broken links + to send to the frontend. + """ + def setUp(self): + """Setup course blocks for tests""" + super().setUp() + self.mock_course = Mock() + self.mock_section = Mock( + location=Mock(block_id='chapter_1'), + display_name='Section Name', + category='chapter' + ) + self.mock_subsection = Mock( + location=Mock(block_id='sequential_1'), + display_name='Subsection Name', + category='sequential' + ) + self.mock_unit = Mock( + location=Mock(block_id='vertical_1'), + display_name='Unit Name', + category='vertical' + ) + self.mock_block = Mock( + location=Mock(block_id='block_1'), + display_name='Block Name', + course_id=self.course.id, + category='html' + ) + self.mock_course.get_parent.return_value = None + self.mock_section.get_parent.return_value = self.mock_course + self.mock_subsection.get_parent.return_value = self.mock_section + self.mock_unit.get_parent.return_value = self.mock_subsection + self.mock_block.get_parent.return_value = self.mock_unit + + + def test_update_node_tree_and_dictionary_returns_node_tree(self): + """ + Verify _update_node_tree_and_dictionary creates a node tree structure + when passed a block level xblock. + """ + expected_tree = { + 'chapter_1': { + 'sequential_1': { + 'vertical_1': { + 'block_1': {} + } + } + } + } + result_tree, result_dictionary = _update_node_tree_and_dictionary( + self.mock_block, 'example_link', True, {}, {} + ) + + self.assertEqual(expected_tree, result_tree) + + + def test_update_node_tree_and_dictionary_returns_dictionary(self): + """ + Verify _update_node_tree_and_dictionary creates a dictionary of parent xblock entries + when passed a block level xblock. + """ + expected_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'category': 'html', + 'url': f'/course/{self.course.id}/editor/html/{self.mock_block.location}', + 'locked_links': ['example_link'] + } + } + result_tree, result_dictionary = _update_node_tree_and_dictionary( + self.mock_block, 'example_link', True, {}, {} + ) + + self.assertEqual(expected_dictionary, result_dictionary) + + + def test_create_dto_recursive_returns_for_empty_node(self): + """ + Test _create_dto_recursive behavior at the end of recursion. + Function should return None when given empty node tree and empty dictionary. + """ + expected = _create_dto_recursive({}, {}) + self.assertEqual(None, expected) + + + def test_create_dto_recursive_returns_for_leaf_node(self): + """ + Test _create_dto_recursive behavior at the step before the end of recursion. + When evaluating a leaf node in the node tree, the function should return broken links + and locked links data from the leaf node. + """ + expected_result = { + 'blocks': [ + { + 'id': 'block_1', + 'displayName': 'Block Name', + 'url': '/block/1', + 'brokenLinks': ['broken_link_1', 'broken_link_2'], + 'lockedLinks': ['locked_link'] + } + ] + } + + mock_node_tree = { + 'block_1': {} + } + mock_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'url': '/block/1', + 'broken_links': ['broken_link_1', 'broken_link_2'], + 'locked_links': ['locked_link'] + } + } + expected = _create_dto_recursive(mock_node_tree, mock_dictionary) + self.assertEqual(expected_result, expected) + + + def test_create_dto_recursive_returns_for_full_tree(self): + """ + Test _create_dto_recursive behavior when recursing many times. + When evaluating a fully mocked node tree and dictionary, the function should return + a full json DTO prepared for frontend. + """ + expected_result = { + 'sections': [ + { + 'id': 'chapter_1', + 'displayName': 'Section Name', + 'subsections': [ + { + 'id': 'sequential_1', + 'displayName': 'Subsection Name', + 'units': [ + { + 'id': 'vertical_1', + 'displayName': 'Unit Name', + 'blocks': [ + { + 'id': 'block_1', + 'displayName': 'Block Name', + 'url': '/block/1', + 'brokenLinks': ['broken_link_1', 'broken_link_2'], + 'lockedLinks': ['locked_link'] + } + ] + } + ] + } + ] + } + ] + } + + mock_node_tree = { + 'chapter_1': { + 'sequential_1': { + 'vertical_1': { + 'block_1': {} + } + } + } + } + mock_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'url': '/block/1', + 'broken_links': ['broken_link_1', 'broken_link_2'], + 'locked_links': ['locked_link'] + } + } + expected = _create_dto_recursive(mock_node_tree, mock_dictionary) + + self.assertEqual(expected_result, expected) + \ No newline at end of file