Transformer: ContentLibraryTransformer
This commit is contained in:
committed by
J. Cliff Dyer
parent
d1674ca85f
commit
a78b94d83a
@@ -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):
|
||||
|
||||
177
lms/djangoapps/course_blocks/transformers/library_content.py
Normal file
177
lms/djangoapps/course_blocks/transformers/library_content.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user