diff --git a/lms/djangoapps/course_api/blocks/transformers/navigation.py b/lms/djangoapps/course_api/blocks/transformers/navigation.py new file mode 100644 index 0000000000..3f9906a5fa --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/navigation.py @@ -0,0 +1,92 @@ +""" +TODO +""" +from openedx.core.lib.block_cache.transformer import BlockStructureTransformer +from .block_depth import BlockDepthTransformer + + +class DescendantList(object): + """ + Contain + """ + def __init__(self): + self.items = [] + + +class BlockNavigationTransformer(BlockStructureTransformer): + """ + Creates a table of contents for the course. + + Prerequisites: BlockDepthTransformer must be run before this in the + transform phase. + """ + VERSION = 1 + BLOCK_NAVIGATION = 'block_nav' + BLOCK_NAVIGATION_FOR_CHILDREN = 'children_block_nav' + + def __init__(self, nav_depth): + self.nav_depth = nav_depth + + @classmethod + def name(cls): + return "blocks_api:block_navigation" + + @classmethod + def collect(cls, block_structure): + """ + Collects any information that's necessary to execute this transformer's + transform method. + """ + # collect basic xblock fields + block_structure.request_xblock_fields('hide_from_toc') + + def transform(self, usage_info, block_structure): # pylint: disable=unused-argument + """ + Mutates block_structure based on the given usage_info. + """ + if self.nav_depth is None: + return + + for block_key in block_structure.topological_traversal(): + + parents = block_structure.get_parents(block_key) + parents_descendants_list = set() + for parent_key in parents: + parent_nav = block_structure.get_transformer_block_field( + parent_key, + self, + self.BLOCK_NAVIGATION_FOR_CHILDREN, + ) + if parent_nav is not None: + parents_descendants_list |= parent_nav + + children_descendants_list = None + if ( + not block_structure.get_xblock_field(block_key, 'hide_from_toc', False) and ( + not parents or + any(parent_desc_list is not None for parent_desc_list in parents_descendants_list) + ) + ): + # add self to parent's descendants + for parent_desc_list in parents_descendants_list: + if parent_desc_list is not None: + parent_desc_list.items.append(unicode(block_key)) + + if BlockDepthTransformer.get_block_depth(block_structure, block_key) > self.nav_depth: + children_descendants_list = parents_descendants_list + else: + block_nav_list = DescendantList() + children_descendants_list = {block_nav_list} + block_structure.set_transformer_block_field( + block_key, + self, + self.BLOCK_NAVIGATION, + block_nav_list.items + ) + + block_structure.set_transformer_block_field( + block_key, + self, + self.BLOCK_NAVIGATION_FOR_CHILDREN, + children_descendants_list + ) diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_navigation.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_navigation.py new file mode 100644 index 0000000000..bd860ca980 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_navigation.py @@ -0,0 +1,122 @@ +# pylint: disable=protected-access +""" +Tests for BlockNavigationTransformer. +""" +import ddt +from unittest import TestCase + +from lms.djangoapps.course_api.blocks.transformers.block_depth import BlockDepthTransformer +from lms.djangoapps.course_api.blocks.transformers.navigation import BlockNavigationTransformer +from openedx.core.lib.block_cache.tests.test_utils import ChildrenMapTestMixin +from openedx.core.lib.block_cache.block_structure import BlockStructureModulestoreData +from openedx.core.lib.block_cache.block_structure_factory import BlockStructureFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import SampleCourseFactory +from xmodule.modulestore import ModuleStoreEnum + + +@ddt.ddt +class BlockNavigationTransformerTestCase(TestCase, ChildrenMapTestMixin): + """ + Course-agnostic test class for testing the Navigation transformer. + """ + + @ddt.data( + (0, 0, [], []), + + (0, 0, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[], [], [], []]), + (None, 0, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1, 2, 3], [], [], []]), + (None, 1, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2, 3], [], []]), + (None, 2, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [3], []]), + (None, 3, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [3], []]), + (None, 4, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [3], []]), + (1, 4, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [], [], []]), + (2, 4, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [], []]), + + (0, 0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[], [], [], [], [], [], []]), + (0, 0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[], [], [], [], [], [], []]), + (None, 0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2, 3, 4, 5, 6], [], [], [], [], [], []]), + (None, 1, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3, 5, 6], [3, 4, 5, 6], [], [], [], []]), + (None, 2, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [5, 6], [], [], []]), + (None, 3, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [5, 6], [], [], []]), + (None, 4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [5, 6], [], [], []]), + (1, 4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [], [], [], [], [], []]), + (2, 4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [], [], [], []]), + ) + @ddt.unpack + def test_navigation(self, depth, nav_depth, children_map, expected_nav_map): + + block_structure = self.create_block_structure(BlockStructureModulestoreData, children_map) + BlockDepthTransformer(depth).transform(usage_info=None, block_structure=block_structure) + BlockNavigationTransformer(nav_depth).transform(usage_info=None, block_structure=block_structure) + block_structure._prune_unreachable() + + for block_key, expected_nav in enumerate(expected_nav_map): + self.assertSetEqual( + set(unicode(block) for block in expected_nav), + set( + block_structure.get_transformer_block_field( + block_key, + BlockNavigationTransformer, + BlockNavigationTransformer.BLOCK_NAVIGATION, + [] + ) + ), + ) + + +class BlockNavigationTransformerCourseTestCase(ModuleStoreTestCase): + """ + Uses SampleCourseFactory and Modulestore to test the Navigation transformer, + specifically to test enforcement of the hide_from_toc field + """ + + def test_hide_from_toc(self): + course_key = SampleCourseFactory.create().id + course_usage_key = self.store.make_course_usage_key(course_key) + + # hide chapter_x from TOC + chapter_x_key = course_key.make_usage_key('chapter', 'chapter_x') + chapter_x = self.store.get_item(chapter_x_key) + chapter_x.hide_from_toc = True + self.store.update_item(chapter_x, ModuleStoreEnum.UserID.test) + + block_structure = BlockStructureFactory.create_from_modulestore(course_usage_key, self.store) + + # collect phase + BlockDepthTransformer.collect(block_structure) + BlockNavigationTransformer.collect(block_structure) + block_structure._collect_requested_xblock_fields() + + self.assertTrue(block_structure.has_block(chapter_x_key)) + + # transform phase + BlockDepthTransformer().transform(usage_info=None, block_structure=block_structure) + BlockNavigationTransformer(0).transform(usage_info=None, block_structure=block_structure) + block_structure._prune_unreachable() + + self.assertTrue(block_structure.has_block(chapter_x_key)) + + course_descendants = block_structure.get_transformer_block_field( + course_usage_key, + BlockNavigationTransformer, + BlockNavigationTransformer.BLOCK_NAVIGATION, + ) + + # chapter_y and its descendants should be included + for block_key in [ + course_key.make_usage_key('chapter', 'chapter_y'), + course_key.make_usage_key('sequential', 'sequential_y1'), + course_key.make_usage_key('vertical', 'vertical_y1a'), + course_key.make_usage_key('problem', 'problem_y1a_1'), + ]: + self.assertIn(unicode(block_key), course_descendants) + + # chapter_x and its descendants should not be included + for block_key in [ + chapter_x_key, + course_key.make_usage_key('sequential', 'sequential_x1'), + course_key.make_usage_key('vertical', 'vertical_x1a'), + course_key.make_usage_key('problem', 'problem_x1a_1'), + ]: + self.assertNotIn(unicode(block_key), course_descendants)