Commit a78b94d8 by Nimisha Asthagiri Committed by J. Cliff Dyer

Transformer: ContentLibraryTransformer

parent d1674ca8
......@@ -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)
# 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)
self.publish_selected_children_events(
block_keys,
format_block_keys,
self._publish_event,
)
# 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):
......
"""
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,
)
"""
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'
)
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment