From a78b94d83a6f6453c5bf6e1dc71dda826e8ca4fd Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Wed, 28 Oct 2015 19:09:39 -0400 Subject: [PATCH] Transformer: ContentLibraryTransformer --- .../xmodule/xmodule/library_content_module.py | 170 ++++++++++++----- .../transformers/library_content.py | 177 ++++++++++++++++++ .../tests/test_library_content.py | 165 ++++++++++++++++ 3 files changed, 464 insertions(+), 48 deletions(-) create mode 100644 lms/djangoapps/course_blocks/transformers/library_content.py create mode 100644 lms/djangoapps/course_blocks/transformers/tests/test_library_content.py diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 033f34b069..97c84da980 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -134,8 +134,65 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): any particular student. """ + @classmethod + def make_selection(cls, selected, children, max_count, mode): + """ + Dynamically selects block_ids indicating which of the possible children are displayed to the current user. + + Arguments: + selected - list of (block_type, block_id) tuples assigned to this student + children - children of this block + max_count - number of components to display to each student + mode - how content is drawn from the library + + Returns: + A dict containing the following keys: + + 'selected' (set) of (block_type, block_id) tuples assigned to this student + 'invalid' (set) of dropped (block_type, block_id) tuples that are no longer valid + 'overlimit' (set) of dropped (block_type, block_id) tuples that were previously selected + 'added' (set) of newly added (block_type, block_id) tuples + """ + selected = set(tuple(k) for k in selected) # set of (block_type, block_id) tuples assigned to this student + + # Determine which of our children we will show: + valid_block_keys = set([(c.block_type, c.block_id) for c in children]) + # Remove any selected blocks that are no longer valid: + invalid_block_keys = (selected - valid_block_keys) + if invalid_block_keys: + selected -= invalid_block_keys + + # If max_count has been decreased, we may have to drop some previously selected blocks: + overlimit_block_keys = set() + while len(selected) > max_count: + overlimit_block_keys.add(selected.pop()) + + # Do we have enough blocks now? + num_to_add = max_count - len(selected) + + added_block_keys = None + if num_to_add > 0: + # We need to select [more] blocks to display to this user: + pool = valid_block_keys - selected + if mode == "random": + num_to_add = min(len(pool), num_to_add) + added_block_keys = set(random.sample(pool, num_to_add)) + # We now have the correct n random children to show for this user. + else: + raise NotImplementedError("Unsupported mode.") + selected |= added_block_keys + + return { + 'selected': selected, + 'invalid': invalid_block_keys, + 'overlimit': overlimit_block_keys, + 'added': added_block_keys, + } + def _publish_event(self, event_name, result, **kwargs): - """ Helper method to publish an event for analytics purposes """ + """ + Helper method to publish an event for analytics purposes + """ event_data = { "location": unicode(self.location), "result": result, @@ -146,6 +203,61 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): self.runtime.publish(self, "edx.librarycontentblock.content.{}".format(event_name), event_data) self._last_event_result_count = len(result) # pylint: disable=attribute-defined-outside-init + @classmethod + def publish_selected_children_events(cls, block_keys, format_block_keys, publish_event): + """ + Helper method for publishing events when children blocks are + selected/updated for a user. This helper is also used by + the ContentLibraryTransformer. + + Arguments: + + block_keys - + A dict describing which events to publish (add or + remove), see `make_selection` above for format details. + + format_block_keys - + A function to convert block keys to the format expected + by publish_event. Must have the signature: + + [(block_type, block_id)] -> T + + Where T is a collection of block keys as accepted by + `publish_event`. + + publish_event - + Function that handles the actual publishing. Must have + the signature: + + <'removed'|'assigned'> -> result:T -> removed:T -> reason:basestring -> None + + Where T is a collection of block_keys as returned by + `format_block_keys`. + """ + if block_keys['invalid']: + # reason "invalid" means deleted from library or a different library is now being used. + publish_event( + "removed", + result=format_block_keys(block_keys['selected']), + removed=format_block_keys(block_keys['invalid']), + reason="invalid" + ) + + if block_keys['overlimit']: + publish_event( + "removed", + result=format_block_keys(block_keys['selected']), + removed=format_block_keys(block_keys['overlimit']), + reason="overlimit" + ) + + if block_keys['added']: + publish_event( + "assigned", + result=format_block_keys(block_keys['selected']), + added=format_block_keys(block_keys['added']) + ) + def selected_children(self): """ Returns a set() of block_ids indicating which of the possible children @@ -161,61 +273,23 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): # Already done: return self._selected_set # pylint: disable=access-member-before-definition - selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples assigned to this student + block_keys = self.make_selection(self.selected, self.children, self.max_count, "random") # pylint: disable=no-member + # Publish events for analytics purposes: lib_tools = self.runtime.service(self, 'library_tools') format_block_keys = lambda keys: lib_tools.create_block_analytics_summary(self.location.course_key, keys) + self.publish_selected_children_events( + block_keys, + format_block_keys, + self._publish_event, + ) - # Determine which of our children we will show: - valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member - # Remove any selected blocks that are no longer valid: - invalid_block_keys = (selected - valid_block_keys) - if invalid_block_keys: - selected -= invalid_block_keys - # Publish an event for analytics purposes: - # reason "invalid" means deleted from library or a different library is now being used. - self._publish_event( - "removed", - result=format_block_keys(selected), - removed=format_block_keys(invalid_block_keys), - reason="invalid" - ) - # If max_count has been decreased, we may have to drop some previously selected blocks: - overlimit_block_keys = set() - while len(selected) > self.max_count: - overlimit_block_keys.add(selected.pop()) - if overlimit_block_keys: - # Publish an event for analytics purposes: - self._publish_event( - "removed", - result=format_block_keys(selected), - removed=format_block_keys(overlimit_block_keys), - reason="overlimit" - ) - # Do we have enough blocks now? - num_to_add = self.max_count - len(selected) - if num_to_add > 0: - added_block_keys = None - # We need to select [more] blocks to display to this user: - pool = valid_block_keys - selected - if self.mode == "random": - num_to_add = min(len(pool), num_to_add) - added_block_keys = set(random.sample(pool, num_to_add)) - # We now have the correct n random children to show for this user. - else: - raise NotImplementedError("Unsupported mode.") - selected |= added_block_keys - if added_block_keys: - # Publish an event for analytics purposes: - self._publish_event( - "assigned", - result=format_block_keys(selected), - added=format_block_keys(added_block_keys) - ) # Save our selections to the user state, to ensure consistency: + selected = block_keys['selected'] self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. # Cache the results self._selected_set = selected # pylint: disable=attribute-defined-outside-init + return selected def _get_selected_child_blocks(self): diff --git a/lms/djangoapps/course_blocks/transformers/library_content.py b/lms/djangoapps/course_blocks/transformers/library_content.py new file mode 100644 index 0000000000..48a7ce74c1 --- /dev/null +++ b/lms/djangoapps/course_blocks/transformers/library_content.py @@ -0,0 +1,177 @@ +""" +Content Library Transformer. +""" +import json +from courseware.models import StudentModule +from openedx.core.lib.block_cache.transformer import BlockStructureTransformer +from xmodule.library_content_module import LibraryContentModule +from xmodule.modulestore.django import modulestore +from eventtracking import tracker + + +class ContentLibraryTransformer(BlockStructureTransformer): + """ + A transformer that manipulates the block structure by removing all + blocks within a library_content module to which a user should not + have access. + + Staff users are *not* exempted from library content pathways. + """ + VERSION = 1 + + @classmethod + def name(cls): + """ + Unique identifier for the transformer's class; + same identifier used in setup.py. + """ + return "library_content" + + @classmethod + def collect(cls, block_structure): + """ + Collects any information that's necessary to execute this + transformer's transform method. + """ + block_structure.request_xblock_fields('mode') + block_structure.request_xblock_fields('max_count') + block_structure.request_xblock_fields('category') + store = modulestore() + + # needed for analytics purposes + def summarize_block(usage_key): + """ Basic information about the given block """ + orig_key, orig_version = store.get_block_original_usage(usage_key) + return { + "usage_key": unicode(usage_key), + "original_usage_key": unicode(orig_key) if orig_key else None, + "original_usage_version": unicode(orig_version) if orig_version else None, + } + + # For each block check if block is library_content. + # If library_content add children array to content_library_children field + for block_key in block_structure.topological_traversal( + filter_func=lambda block_key: block_key.block_type == 'library_content', + yield_descendants_of_unyielded=True, + ): + xblock = block_structure.get_xblock(block_key) + for child_key in xblock.children: + summary = summarize_block(child_key) + block_structure.set_transformer_block_field(child_key, cls, 'block_analytics_summary', summary) + + def transform(self, usage_info, block_structure): + """ + Mutates block_structure based on the given usage_info. + """ + + all_library_children = set() + all_selected_children = set() + for block_key in block_structure.topological_traversal( + filter_func=lambda block_key: block_key.block_type == 'library_content', + yield_descendants_of_unyielded=True, + ): + library_children = block_structure.get_children(block_key) + if library_children: + all_library_children.update(library_children) + selected = [] + mode = block_structure.get_xblock_field(block_key, 'mode') + max_count = block_structure.get_xblock_field(block_key, 'max_count') + + # Retrieve "selected" json from LMS MySQL database. + module = self._get_student_module(usage_info.user, usage_info.course_key, block_key) + if module: + state_dict = json.loads(module.state) + # Add all selected entries for this user for this + # library module to the selected list. + for state in state_dict['selected']: + usage_key = usage_info.course_key.make_usage_key(state[0], state[1]) + if usage_key in library_children: + selected.append((state[0], state[1])) + + # update selected + previous_count = len(selected) + block_keys = LibraryContentModule.make_selection(selected, library_children, max_count, mode) + selected = block_keys['selected'] + + # publish events for analytics + self._publish_events(block_structure, block_key, previous_count, max_count, block_keys) + all_selected_children.update(usage_info.course_key.make_usage_key(s[0], s[1]) for s in selected) + + def check_child_removal(block_key): + """ + Return True if selected block should be removed. + + Block is removed if it is part of library_content, but has + not been selected for current user. + """ + if block_key not in all_library_children: + return False + if block_key in all_selected_children: + return False + return True + + # Check and remove all non-selected children from course + # structure. + block_structure.remove_block_if( + check_child_removal + ) + + @classmethod + def _get_student_module(cls, user, course_key, block_key): + """ + Get the student module for the given user for the given block. + + Arguments: + user (User) + course_key (CourseLocator) + block_key (BlockUsageLocator) + + Returns: + StudentModule if exists, or None. + """ + try: + return StudentModule.objects.get( + student=user, + course_id=course_key, + module_state_key=block_key, + state__contains='"selected": [[' + ) + except StudentModule.DoesNotExist: + return None + + @classmethod + def _publish_events(cls, block_structure, location, previous_count, max_count, block_keys): + """ + Helper method to publish events for analytics purposes + """ + + def format_block_keys(keys): + """ + Helper function to format block keys + """ + json_result = [] + for key in keys: + info = block_structure.get_transformer_block_field( + key, ContentLibraryTransformer, 'block_analytics_summary' + ) + json_result.append(info) + return json_result + + def publish_event(event_name, result, **kwargs): + """ + Helper function to publish an event for analytics purposes + """ + event_data = { + "location": unicode(location), + "previous_count": previous_count, + "result": result, + "max_count": max_count + } + event_data.update(kwargs) + tracker.emit("edx.librarycontentblock.content.{}".format(event_name), event_data) + + LibraryContentModule.publish_selected_children_events( + block_keys, + format_block_keys, + publish_event, + ) diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py b/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py new file mode 100644 index 0000000000..b712ef33d4 --- /dev/null +++ b/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py @@ -0,0 +1,165 @@ +""" +Tests for ContentLibraryTransformer. +""" +import mock +from student.tests.factories import CourseEnrollmentFactory + +from course_blocks.transformers.library_content import ContentLibraryTransformer +from course_blocks.api import get_course_blocks, clear_course_from_cache +from lms.djangoapps.course_blocks.transformers.tests.test_helpers import CourseStructureTestCase + + +class MockedModule(object): + """ + Object with mocked selected modules for user. + """ + def __init__(self, state): + """ + Set state attribute on initialize. + """ + self.state = state + + +class ContentLibraryTransformerTestCase(CourseStructureTestCase): + """ + ContentLibraryTransformer Test + """ + + def setUp(self): + """ + Setup course structure and create user for content library transformer test. + """ + super(ContentLibraryTransformerTestCase, self).setUp() + + # Build course. + self.course_hierarchy = self.get_course_hierarchy() + self.blocks = self.build_course(self.course_hierarchy) + self.course = self.blocks['course'] + clear_course_from_cache(self.course.id) + + # Enroll user in course. + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True) + + self.selected_module = MockedModule('{"selected": [["vertical", "vertical_vertical2"]]}') + self.transformer = ContentLibraryTransformer() + + def get_course_hierarchy(self): + """ + Get a course hierarchy to test with. + """ + return [{ + 'org': 'ContentLibraryTransformer', + 'course': 'CL101F', + 'run': 'test_run', + '#type': 'course', + '#ref': 'course', + '#children': [ + { + '#type': 'chapter', + '#ref': 'chapter1', + '#children': [ + { + '#type': 'sequential', + '#ref': 'lesson1', + '#children': [ + { + '#type': 'vertical', + '#ref': 'vertical1', + '#children': [ + { + 'metadata': {'category': 'library_content'}, + '#type': 'library_content', + '#ref': 'library_content1', + '#children': [ + { + 'metadata': {'display_name': "CL Vertical 2"}, + '#type': 'vertical', + '#ref': 'vertical2', + '#children': [ + { + 'metadata': {'display_name': "HTML1"}, + '#type': 'html', + '#ref': 'html1', + } + ] + }, + { + 'metadata': {'display_name': "CL Vertical 3"}, + '#type': 'vertical', + '#ref': 'vertical3', + '#children': [ + { + 'metadata': {'display_name': "HTML2"}, + '#type': 'html', + '#ref': 'html2', + } + ] + } + ] + } + ], + } + ], + } + ], + } + ] + }] + + def test_content_library(self): + """ + Test when course has content library section. + First test user can't see any content library section, + and after that mock response from MySQL db. + Check user can see mocked sections in content library. + """ + raw_block_structure = get_course_blocks( + self.user, + self.course.location, + transformers={} + ) + self.assertEqual(len(list(raw_block_structure.get_block_keys())), len(self.blocks)) + + clear_course_from_cache(self.course.id) + trans_block_structure = get_course_blocks( + self.user, + self.course.location, + transformers={self.transformer} + ) + + # Should dynamically assign a block to student + trans_keys = set(trans_block_structure.get_block_keys()) + block_key_set = self.get_block_key_set( + self.blocks, 'course', 'chapter1', 'lesson1', 'vertical1', 'library_content1' + ) + for key in block_key_set: + self.assertIn(key, trans_keys) + + vertical2_selected = self.get_block_key_set(self.blocks, 'vertical2').pop() in trans_keys + vertical3_selected = self.get_block_key_set(self.blocks, 'vertical3').pop() in trans_keys + self.assertTrue(vertical2_selected or vertical3_selected) + + # Check course structure again, with mocked selected modules for a user. + with mock.patch( + 'course_blocks.transformers.library_content.ContentLibraryTransformer._get_student_module', + return_value=self.selected_module + ): + clear_course_from_cache(self.course.id) + trans_block_structure = get_course_blocks( + self.user, + self.course.location, + transformers={self.transformer} + ) + self.assertEqual( + set(trans_block_structure.get_block_keys()), + self.get_block_key_set( + self.blocks, + 'course', + 'chapter1', + 'lesson1', + 'vertical1', + 'library_content1', + 'vertical2', + 'html1' + ) + )