Merge pull request #23265 from open-craft/guruprasad/BB-112-fix-randomized-content-block-order
[BB-112] Fix the unpredictable order randomization issue with randomized content blocks
This commit is contained in:
@@ -40,6 +40,7 @@ def get_course_block_access_transformers(user):
|
||||
"""
|
||||
course_block_access_transformers = [
|
||||
library_content.ContentLibraryTransformer(),
|
||||
library_content.ContentLibraryOrderTransformer(),
|
||||
start_date.StartDateTransformer(),
|
||||
ContentTypeGateTransformer(),
|
||||
user_partitions.UserPartitionTransformer(),
|
||||
|
||||
@@ -4,6 +4,8 @@ Content Library Transformer.
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
|
||||
import six
|
||||
from eventtracking import tracker
|
||||
@@ -19,6 +21,8 @@ from xmodule.modulestore.django import modulestore
|
||||
|
||||
from ..utils import get_student_module_as_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransformer):
|
||||
"""
|
||||
@@ -40,17 +44,8 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
|
||||
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 set_block_analytics_summary(cls, block_structure, store):
|
||||
"""Set the block analytics summary information in the children fields."""
|
||||
def summarize_block(usage_key):
|
||||
""" Basic information about the given block """
|
||||
orig_key, orig_version = store.get_block_original_usage(usage_key)
|
||||
@@ -71,6 +66,20 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
|
||||
summary = summarize_block(child_key)
|
||||
block_structure.set_transformer_block_field(child_key, cls, 'block_analytics_summary', summary)
|
||||
|
||||
@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
|
||||
cls.set_block_analytics_summary(block_structure, store)
|
||||
|
||||
def transform_block_filters(self, usage_info, block_structure):
|
||||
all_library_children = set()
|
||||
all_selected_children = set()
|
||||
@@ -102,6 +111,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
|
||||
# Save back any changes
|
||||
if any(block_keys[changed] for changed in ('invalid', 'overlimit', 'added')):
|
||||
state_dict['selected'] = list(selected)
|
||||
random.shuffle(state_dict['selected'])
|
||||
StudentModule.save_state(
|
||||
student=usage_info.user,
|
||||
course_id=usage_info.course_key,
|
||||
@@ -112,7 +122,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
|
||||
)
|
||||
|
||||
# publish events for analytics
|
||||
self._publish_events(
|
||||
self.publish_events(
|
||||
block_structure,
|
||||
block_key,
|
||||
previous_count,
|
||||
@@ -137,7 +147,8 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
|
||||
|
||||
return [block_structure.create_removal_filter(check_child_removal)]
|
||||
|
||||
def _publish_events(self, block_structure, location, previous_count, max_count, block_keys, user_id):
|
||||
@staticmethod
|
||||
def publish_events(block_structure, location, previous_count, max_count, block_keys, user_id):
|
||||
"""
|
||||
Helper method to publish events for analytics purposes
|
||||
"""
|
||||
@@ -177,3 +188,83 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
|
||||
format_block_keys,
|
||||
publish_event,
|
||||
)
|
||||
|
||||
|
||||
class ContentLibraryOrderTransformer(BlockStructureTransformer):
|
||||
"""
|
||||
A transformer that manipulates the block structure by modifying the order of the
|
||||
selected blocks within a library_content module to match the order of the selections
|
||||
made by the ContentLibraryTransformer or the corresponding XBlock. So this transformer
|
||||
requires the selections for the randomized content block to be already
|
||||
made either by the ContentLibraryTransformer or the XBlock.
|
||||
|
||||
Staff users are *not* exempted from library content pathways/
|
||||
"""
|
||||
WRITE_VERSION = 1
|
||||
READ_VERSION = 1
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
"""
|
||||
Unique identifier for the transformer's class;
|
||||
same identifier used in setup.py
|
||||
"""
|
||||
return "library_content_randomize"
|
||||
|
||||
@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')
|
||||
|
||||
ContentLibraryTransformer.set_block_analytics_summary(block_structure, modulestore())
|
||||
|
||||
def transform(self, usage_info, block_structure):
|
||||
"""
|
||||
Transforms the order of the children of the randomized content block
|
||||
to match the order of the selections made and stored in the XBlock 'selected' field.
|
||||
"""
|
||||
for block_key in block_structure:
|
||||
if block_key.block_type != 'library_content':
|
||||
continue
|
||||
|
||||
library_children = block_structure.get_children(block_key)
|
||||
|
||||
if library_children:
|
||||
state_dict = get_student_module_as_dict(usage_info.user, usage_info.course_key, block_key)
|
||||
current_children_blocks = set(block.block_id for block in library_children)
|
||||
current_selected_blocks = set(item[1] for item in state_dict['selected'])
|
||||
|
||||
# Ensure that the current children blocks are the same as the stored selections
|
||||
# before ordering the blocks.
|
||||
if current_children_blocks != current_selected_blocks:
|
||||
logger.info(
|
||||
u'Mismatch between the children of %s in the stored state and the actual children for user %s',
|
||||
str(block_key),
|
||||
usage_info.user.username
|
||||
)
|
||||
previous_count = len(current_selected_blocks.intersection(current_children_blocks))
|
||||
mode = block_structure.get_xblock_field(block_key, 'mode')
|
||||
max_count = block_structure.get_xblock_field(block_key, 'max_count')
|
||||
block_keys = LibraryContentModule.make_selection(
|
||||
state_dict['selected'], library_children, max_count, mode
|
||||
)
|
||||
state_dict['selected'] = block_keys['selected']
|
||||
random.shuffle(state_dict['selected'])
|
||||
StudentModule.save_state(
|
||||
student=usage_info.user,
|
||||
course_id=usage_info.course_key,
|
||||
module_state_key=block_key,
|
||||
defaults={
|
||||
'state': json.dumps(state_dict)
|
||||
},
|
||||
)
|
||||
ContentLibraryTransformer.publish_events(
|
||||
block_structure, block_key, previous_count, max_count, block_keys, usage_info.user.id
|
||||
)
|
||||
|
||||
ordering_data = {block[1]: position for position, block in enumerate(state_dict['selected'])}
|
||||
library_children.sort(key=lambda block, data=ordering_data: data[block.block_id])
|
||||
|
||||
@@ -4,13 +4,14 @@ Tests for ContentLibraryTransformer.
|
||||
|
||||
|
||||
from six.moves import range
|
||||
import mock
|
||||
|
||||
from openedx.core.djangoapps.content.block_structure.api import clear_course_from_cache
|
||||
from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers
|
||||
from student.tests.factories import CourseEnrollmentFactory
|
||||
|
||||
from ...api import get_course_blocks
|
||||
from ..library_content import ContentLibraryTransformer
|
||||
from ..library_content import ContentLibraryTransformer, ContentLibraryOrderTransformer
|
||||
from .helpers import CourseStructureTestCase
|
||||
|
||||
|
||||
@@ -167,3 +168,130 @@ class ContentLibraryTransformerTestCase(CourseStructureTestCase):
|
||||
),
|
||||
u"Expected 'selected' equality failed in iteration {}.".format(i)
|
||||
)
|
||||
|
||||
|
||||
class ContentLibraryOrderTransformerTestCase(CourseStructureTestCase):
|
||||
"""
|
||||
ContentLibraryOrderTransformer Test
|
||||
"""
|
||||
TRANSFORMER_CLASS_TO_TEST = ContentLibraryOrderTransformer
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup course structure and create user for content library order transformer test.
|
||||
"""
|
||||
super(ContentLibraryOrderTransformerTestCase, self).setUp()
|
||||
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)
|
||||
|
||||
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',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'metadata': {'display_name': "CL Vertical 4"},
|
||||
'#type': 'vertical',
|
||||
'#ref': 'vertical4',
|
||||
'#children': [
|
||||
{
|
||||
'metadata': {'display_name': "HTML3"},
|
||||
'#type': 'html',
|
||||
'#ref': 'html3',
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}]
|
||||
|
||||
@mock.patch('lms.djangoapps.course_blocks.transformers.library_content.get_student_module_as_dict')
|
||||
def test_content_library_randomize(self, mocked):
|
||||
"""
|
||||
Test whether the order of the children blocks matches the order of the selected blocks when
|
||||
course has content library section
|
||||
"""
|
||||
mocked.return_value = {
|
||||
'selected': [
|
||||
['vertical', 'vertical_vertical3'],
|
||||
['vertical', 'vertical_vertical2'],
|
||||
['vertical', 'vertical_vertical4'],
|
||||
]
|
||||
}
|
||||
for i in range(5):
|
||||
trans_block_structure = get_course_blocks(
|
||||
self.user,
|
||||
self.course.location,
|
||||
self.transformers,
|
||||
)
|
||||
children = []
|
||||
for block_key in trans_block_structure.topological_traversal():
|
||||
if block_key.block_type == 'library_content':
|
||||
children = trans_block_structure.get_children(block_key)
|
||||
break
|
||||
|
||||
expected_children = ['vertical_vertical3', 'vertical_vertical2', 'vertical_vertical4']
|
||||
self.assertEqual(
|
||||
expected_children,
|
||||
[child.block_id for child in children],
|
||||
u"Expected 'selected' equality failed in iteration {}.".format(i)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user