Merge pull request #6492 from open-craft/content_libraries/13-analytics-enhancements
Content libraries analytics enhancements (SOL-121)
This commit is contained in:
@@ -34,9 +34,8 @@ def i_create_a_course(step):
|
||||
create_a_course()
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@step('I click the course link in Studio Home$')
|
||||
def i_click_the_course_link_in_studio_home(step):
|
||||
def i_click_the_course_link_in_studio_home(step): # pylint: disable=invalid-name
|
||||
course_css = 'a.course-link'
|
||||
world.css_click(course_css)
|
||||
|
||||
|
||||
@@ -28,28 +28,27 @@ GLOBAL_WAIT_FOR_TIMEOUT = 60
|
||||
|
||||
REQUIREJS_WAIT = {
|
||||
# Settings - Schedule & Details
|
||||
re.compile('^Schedule & Details Settings \|'): [
|
||||
re.compile(r'^Schedule & Details Settings \|'): [
|
||||
"jquery", "js/base", "js/models/course",
|
||||
"js/models/settings/course_details", "js/views/settings/main"],
|
||||
|
||||
# Settings - Advanced Settings
|
||||
re.compile('^Advanced Settings \|'): [
|
||||
re.compile(r'^Advanced Settings \|'): [
|
||||
"jquery", "js/base", "js/models/course", "js/models/settings/advanced",
|
||||
"js/views/settings/advanced", "codemirror"],
|
||||
|
||||
# Unit page
|
||||
re.compile('^Unit \|'): [
|
||||
re.compile(r'^Unit \|'): [
|
||||
"jquery", "js/base", "js/models/xblock_info", "js/views/pages/container",
|
||||
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
|
||||
# Content - Outline
|
||||
# Note that calling your org, course number, or display name, 'course' will mess this up
|
||||
re.compile('^Course Outline \|'): [
|
||||
re.compile(r'^Course Outline \|'): [
|
||||
"js/base", "js/models/course", "js/models/location", "js/models/section"],
|
||||
|
||||
# Dashboard
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
re.compile('^Studio Home \|'): [
|
||||
re.compile(r'^Studio Home \|'): [
|
||||
"js/sock", "gettext", "js/base",
|
||||
"jquery.ui", "coffee/src/main", "underscore"],
|
||||
|
||||
@@ -60,7 +59,7 @@ REQUIREJS_WAIT = {
|
||||
],
|
||||
|
||||
# Pages
|
||||
re.compile('^Pages \|'): [
|
||||
re.compile(r'^Pages \|'): [
|
||||
'js/models/explicit_url', 'coffee/src/views/tabs',
|
||||
'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1'
|
||||
],
|
||||
|
||||
@@ -220,25 +220,56 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
|
||||
if hasattr(self, "_selected_set"):
|
||||
# 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
|
||||
previous_count = len(selected)
|
||||
|
||||
lib_tools = self.runtime.service(self, 'library_tools')
|
||||
format_block_keys = lambda keys: lib_tools.create_block_analytics_summary(self.location.course_key, keys)
|
||||
|
||||
def publish_event(event_name, **kwargs):
|
||||
""" Publish an event for analytics purposes """
|
||||
event_data = {
|
||||
"location": unicode(self.location),
|
||||
"result": format_block_keys(selected),
|
||||
"previous_count": previous_count,
|
||||
"max_count": self.max_count,
|
||||
}
|
||||
event_data.update(kwargs)
|
||||
self.runtime.publish(self, "edx.librarycontentblock.content.{}".format(event_name), event_data)
|
||||
|
||||
# Determine which of our children we will show:
|
||||
selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples
|
||||
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:
|
||||
selected -= (selected - valid_block_keys)
|
||||
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.
|
||||
publish_event("removed", 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:
|
||||
selected.pop()
|
||||
overlimit_block_keys.add(selected.pop())
|
||||
if overlimit_block_keys:
|
||||
# Publish an event for analytics purposes:
|
||||
publish_event("removed", 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":
|
||||
pool = valid_block_keys - selected
|
||||
num_to_add = min(len(pool), num_to_add)
|
||||
selected |= set(random.sample(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:
|
||||
publish_event("assigned", added=format_block_keys(added_block_keys))
|
||||
# Save our selections to the user state, to ensure consistency:
|
||||
self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page.
|
||||
# Cache the results
|
||||
|
||||
@@ -44,6 +44,44 @@ class LibraryToolsService(object):
|
||||
return library.location.library_key.version_guid
|
||||
return None
|
||||
|
||||
def create_block_analytics_summary(self, course_key, block_keys):
|
||||
"""
|
||||
Given a CourseKey and a list of (block_type, block_id) pairs,
|
||||
prepare the JSON-ready metadata needed for analytics logging.
|
||||
|
||||
This is [
|
||||
{"usage_key": x, "original_usage_key": y, "original_usage_version": z, "descendants": [...]}
|
||||
]
|
||||
where the main list contains all top-level blocks, and descendants contains a *flat* list of all
|
||||
descendants of the top level blocks, if any.
|
||||
"""
|
||||
def summarize_block(usage_key):
|
||||
""" Basic information about the given block """
|
||||
orig_key, orig_version = self.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,
|
||||
}
|
||||
|
||||
result_json = []
|
||||
for block_key in block_keys:
|
||||
key = course_key.make_usage_key(*block_key)
|
||||
info = summarize_block(key)
|
||||
info['descendants'] = []
|
||||
try:
|
||||
block = self.store.get_item(key, depth=None) # Load the item and all descendants
|
||||
children = list(getattr(block, "children", []))
|
||||
while children:
|
||||
child_key = children.pop()
|
||||
child = self.store.get_item(child_key)
|
||||
info['descendants'].append(summarize_block(child_key))
|
||||
children.extend(getattr(child, "children", []))
|
||||
except ItemNotFoundError:
|
||||
pass # The block has been deleted
|
||||
result_json.append(info)
|
||||
return result_json
|
||||
|
||||
def _filter_child(self, usage_key, capa_type):
|
||||
"""
|
||||
Filters children by CAPA problem type, if configured
|
||||
|
||||
@@ -509,6 +509,18 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
|
||||
store = self._get_modulestore_for_courseid(location.course_key)
|
||||
return store.get_parent_location(location, **kwargs)
|
||||
|
||||
def get_block_original_usage(self, usage_key):
|
||||
"""
|
||||
If a block was inherited into another structure using copy_from_template,
|
||||
this will return the original block usage locator from which the
|
||||
copy was inherited.
|
||||
"""
|
||||
try:
|
||||
store = self._verify_modulestore_support(usage_key.course_key, 'get_block_original_usage')
|
||||
return store.get_block_original_usage(usage_key)
|
||||
except NotImplementedError:
|
||||
return None, None
|
||||
|
||||
def get_modulestore_type(self, course_id):
|
||||
"""
|
||||
Returns a type which identifies which modulestore is servicing the given course_id.
|
||||
|
||||
@@ -454,12 +454,17 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
|
||||
if block_info['edit_info'].get('update_version') == update_version:
|
||||
return
|
||||
|
||||
original_usage = block_info['edit_info'].get('original_usage')
|
||||
original_usage_version = block_info['edit_info'].get('original_usage_version')
|
||||
block_info['edit_info'] = {
|
||||
'edited_on': datetime.datetime.now(UTC),
|
||||
'edited_by': user_id,
|
||||
'previous_version': block_info['edit_info']['update_version'],
|
||||
'update_version': update_version,
|
||||
}
|
||||
if original_usage:
|
||||
block_info['edit_info']['original_usage'] = original_usage
|
||||
block_info['edit_info']['original_usage_version'] = original_usage_version
|
||||
|
||||
def find_matching_course_indexes(self, branch=None, search_targets=None):
|
||||
"""
|
||||
@@ -1254,6 +1259,21 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
# TODO implement
|
||||
pass
|
||||
|
||||
def get_block_original_usage(self, usage_key):
|
||||
"""
|
||||
If a block was inherited into another structure using copy_from_template,
|
||||
this will return the original block usage locator and version from
|
||||
which the copy was inherited.
|
||||
|
||||
Returns usage_key, version if the data is available, otherwise returns (None, None)
|
||||
"""
|
||||
blocks = self._lookup_course(usage_key.course_key).structure['blocks']
|
||||
block = blocks.get(BlockKey.from_usage_key(usage_key))
|
||||
if block and 'original_usage' in block['edit_info']:
|
||||
usage_key = BlockUsageLocator.from_string(block['edit_info']['original_usage'])
|
||||
return usage_key, block['edit_info'].get('original_usage_version')
|
||||
return None, None
|
||||
|
||||
def create_definition_from_data(self, course_key, new_def_data, category, user_id):
|
||||
"""
|
||||
Pull the definition fields out of descriptor and save to the db as a new definition
|
||||
@@ -2214,6 +2234,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
# Setting it to the source_block_info structure version here breaks split_draft's has_changes() method.
|
||||
new_block_info['edit_info']['edited_by'] = user_id
|
||||
new_block_info['edit_info']['edited_on'] = datetime.datetime.now(UTC)
|
||||
new_block_info['edit_info']['original_usage'] = unicode(usage_key.replace(branch=None, version_guid=None))
|
||||
new_block_info['edit_info']['original_usage_version'] = source_block_info['edit_info'].get('update_version')
|
||||
dest_structure['blocks'][new_block_key] = new_block_info
|
||||
|
||||
children = source_block_info['fields'].get('children')
|
||||
|
||||
@@ -268,6 +268,15 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
|
||||
location = self._map_revision_to_branch(location, revision=revision)
|
||||
return super(DraftVersioningModuleStore, self).get_parent_location(location, **kwargs)
|
||||
|
||||
def get_block_original_usage(self, usage_key):
|
||||
"""
|
||||
If a block was inherited into another structure using copy_from_template,
|
||||
this will return the original block usage locator from which the
|
||||
copy was inherited.
|
||||
"""
|
||||
usage_key = self._map_revision_to_branch(usage_key)
|
||||
return super(DraftVersioningModuleStore, self).get_block_original_usage(usage_key)
|
||||
|
||||
def get_orphans(self, course_key, **kwargs):
|
||||
course_key = self._map_revision_to_branch(course_key)
|
||||
return super(DraftVersioningModuleStore, self).get_orphans(course_key, **kwargs)
|
||||
|
||||
@@ -119,6 +119,7 @@ class MixedSplitTestCase(TestCase):
|
||||
extra.update(kwargs)
|
||||
return ItemFactory.create(
|
||||
category=category,
|
||||
parent=parent_block,
|
||||
parent_location=parent_block.location,
|
||||
modulestore=self.store,
|
||||
**extra
|
||||
|
||||
@@ -5,22 +5,22 @@ Basic unit tests for LibraryContentModule
|
||||
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
|
||||
"""
|
||||
from bson.objectid import ObjectId
|
||||
from mock import patch
|
||||
from mock import Mock, patch
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from unittest import TestCase
|
||||
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.runtime import Runtime as VanillaRuntime
|
||||
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
from xmodule.library_content_module import (
|
||||
LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor
|
||||
)
|
||||
from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory
|
||||
from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.validation import StudioValidationMessage
|
||||
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
|
||||
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
|
||||
|
||||
@@ -32,46 +32,21 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
def setUp(self):
|
||||
super(LibraryContentTest, self).setUp()
|
||||
|
||||
self.tools = LibraryToolsService(self.store)
|
||||
self.library = LibraryFactory.create(modulestore=self.store)
|
||||
self.lib_blocks = [
|
||||
ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user_id,
|
||||
publish_item=False,
|
||||
metadata={"data": "Hello world from block {}".format(i), },
|
||||
modulestore=self.store,
|
||||
)
|
||||
self.make_block("html", self.library, data="Hello world from block {}".format(i))
|
||||
for i in range(1, 5)
|
||||
]
|
||||
self.course = CourseFactory.create(modulestore=self.store)
|
||||
self.chapter = ItemFactory.create(
|
||||
category="chapter",
|
||||
parent_location=self.course.location,
|
||||
user_id=self.user_id,
|
||||
modulestore=self.store,
|
||||
)
|
||||
self.sequential = ItemFactory.create(
|
||||
category="sequential",
|
||||
parent_location=self.chapter.location,
|
||||
user_id=self.user_id,
|
||||
modulestore=self.store,
|
||||
)
|
||||
self.vertical = ItemFactory.create(
|
||||
category="vertical",
|
||||
parent_location=self.sequential.location,
|
||||
user_id=self.user_id,
|
||||
modulestore=self.store,
|
||||
)
|
||||
self.lc_block = ItemFactory.create(
|
||||
category="library_content",
|
||||
parent_location=self.vertical.location,
|
||||
user_id=self.user_id,
|
||||
modulestore=self.store,
|
||||
metadata={
|
||||
'max_count': 1,
|
||||
'source_libraries': [LibraryVersionReference(self.library.location.library_key)]
|
||||
}
|
||||
self.chapter = self.make_block("chapter", self.course)
|
||||
self.sequential = self.make_block("sequential", self.chapter)
|
||||
self.vertical = self.make_block("vertical", self.sequential)
|
||||
self.lc_block = self.make_block(
|
||||
"library_content",
|
||||
self.vertical,
|
||||
max_count=1,
|
||||
source_libraries=[LibraryVersionReference(self.library.location.library_key)]
|
||||
)
|
||||
|
||||
def _bind_course_module(self, module):
|
||||
@@ -80,6 +55,7 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
"""
|
||||
module_system = get_test_system(course_id=self.course.location.course_key)
|
||||
module_system.descriptor_runtime = module.runtime
|
||||
module_system._services['library_tools'] = self.tools # pylint: disable=protected-access
|
||||
|
||||
def get_module(descriptor):
|
||||
"""Mocks module_system get_module function"""
|
||||
@@ -92,6 +68,11 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
module_system.get_module = get_module
|
||||
module.xmodule_runtime = module_system
|
||||
|
||||
|
||||
class TestLibraryContentModule(LibraryContentTest):
|
||||
"""
|
||||
Basic unit tests for LibraryContentModule
|
||||
"""
|
||||
def _get_capa_problem_type_xml(self, *args):
|
||||
""" Helper function to create empty CAPA problem definition """
|
||||
problem = "<problem>"
|
||||
@@ -111,20 +92,8 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
["coderesponse", "optionresponse"]
|
||||
]
|
||||
for problem_type in problem_types:
|
||||
ItemFactory.create(
|
||||
category="problem",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user_id,
|
||||
publish_item=False,
|
||||
data=self._get_capa_problem_type_xml(*problem_type),
|
||||
modulestore=self.store,
|
||||
)
|
||||
self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type))
|
||||
|
||||
|
||||
class TestLibraryContentModule(LibraryContentTest):
|
||||
"""
|
||||
Basic unit tests for LibraryContentModule
|
||||
"""
|
||||
def test_lib_content_block(self):
|
||||
"""
|
||||
Test that blocks from a library are copied and added as children
|
||||
@@ -338,3 +307,178 @@ class TestLibraryList(TestCase):
|
||||
lib_list = LibraryList()
|
||||
with self.assertRaises(ValueError):
|
||||
lib_list.from_json(["Not-a-library-key,whatever"])
|
||||
|
||||
|
||||
class TestLibraryContentAnalytics(LibraryContentTest):
|
||||
"""
|
||||
Test analytics features of LibraryContentModule
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestLibraryContentAnalytics, self).setUp()
|
||||
self.publisher = Mock()
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_module(self.lc_block)
|
||||
self.lc_block.xmodule_runtime.publish = self.publisher
|
||||
|
||||
def _assert_event_was_published(self, event_type):
|
||||
"""
|
||||
Check that a LibraryContentModule analytics event was published by self.lc_block.
|
||||
"""
|
||||
self.assertTrue(self.publisher.called)
|
||||
self.assertTrue(len(self.publisher.call_args[0]), 3)
|
||||
_, event_name, event_data = self.publisher.call_args[0]
|
||||
self.assertEqual(event_name, "edx.librarycontentblock.content.{}".format(event_type))
|
||||
self.assertEqual(event_data["location"], unicode(self.lc_block.location))
|
||||
return event_data
|
||||
|
||||
def test_assigned_event(self):
|
||||
"""
|
||||
Test the "assigned" event emitted when a student is assigned specific blocks.
|
||||
"""
|
||||
# In the beginning was the lc_block and it assigned one child to the student:
|
||||
child = self.lc_block.get_child_descriptors()[0]
|
||||
child_lib_location, child_lib_version = self.store.get_block_original_usage(child.location)
|
||||
self.assertIsInstance(child_lib_version, ObjectId)
|
||||
event_data = self._assert_event_was_published("assigned")
|
||||
block_info = {
|
||||
"usage_key": unicode(child.location),
|
||||
"original_usage_key": unicode(child_lib_location),
|
||||
"original_usage_version": unicode(child_lib_version),
|
||||
"descendants": [],
|
||||
}
|
||||
self.assertEqual(event_data, {
|
||||
"location": unicode(self.lc_block.location),
|
||||
"added": [block_info],
|
||||
"result": [block_info],
|
||||
"previous_count": 0,
|
||||
"max_count": 1,
|
||||
})
|
||||
self.publisher.reset_mock()
|
||||
|
||||
# Now increase max_count so that one more child will be added:
|
||||
self.lc_block.max_count = 2
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
children = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 2)
|
||||
child, new_child = children if children[0].location == child.location else reversed(children)
|
||||
event_data = self._assert_event_was_published("assigned")
|
||||
self.assertEqual(event_data["added"][0]["usage_key"], unicode(new_child.location))
|
||||
self.assertEqual(len(event_data["result"]), 2)
|
||||
self.assertEqual(event_data["previous_count"], 1)
|
||||
self.assertEqual(event_data["max_count"], 2)
|
||||
|
||||
def test_assigned_descendants(self):
|
||||
"""
|
||||
Test the "assigned" event emitted includes descendant block information.
|
||||
"""
|
||||
# Replace the blocks in the library with a block that has descendants:
|
||||
with self.store.bulk_operations(self.library.location.library_key):
|
||||
self.library.children = []
|
||||
main_vertical = self.make_block("vertical", self.library)
|
||||
inner_vertical = self.make_block("vertical", main_vertical)
|
||||
html_block = self.make_block("html", inner_vertical)
|
||||
problem_block = self.make_block("problem", inner_vertical)
|
||||
self.lc_block.refresh_children()
|
||||
|
||||
# Reload lc_block and set it up for a student:
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_module(self.lc_block)
|
||||
self.lc_block.xmodule_runtime.publish = self.publisher
|
||||
|
||||
# Get the keys of each of our blocks, as they appear in the course:
|
||||
course_usage_main_vertical = self.lc_block.children[0]
|
||||
course_usage_inner_vertical = self.store.get_item(course_usage_main_vertical).children[0]
|
||||
inner_vertical_in_course = self.store.get_item(course_usage_inner_vertical)
|
||||
course_usage_html = inner_vertical_in_course.children[0]
|
||||
course_usage_problem = inner_vertical_in_course.children[1]
|
||||
|
||||
# Trigger a publish event:
|
||||
self.lc_block.get_child_descriptors()
|
||||
event_data = self._assert_event_was_published("assigned")
|
||||
|
||||
for block_list in (event_data["added"], event_data["result"]):
|
||||
self.assertEqual(len(block_list), 1) # main_vertical is the only root block added, and is the only result.
|
||||
self.assertEqual(block_list[0]["usage_key"], unicode(course_usage_main_vertical))
|
||||
|
||||
# Check that "descendants" is a flat, unordered list of all of main_vertical's descendants:
|
||||
descendants_expected = (
|
||||
(inner_vertical.location, course_usage_inner_vertical),
|
||||
(html_block.location, course_usage_html),
|
||||
(problem_block.location, course_usage_problem),
|
||||
)
|
||||
descendant_data_expected = {}
|
||||
for lib_key, course_usage_key in descendants_expected:
|
||||
descendant_data_expected[unicode(course_usage_key)] = {
|
||||
"usage_key": unicode(course_usage_key),
|
||||
"original_usage_key": unicode(lib_key),
|
||||
"original_usage_version": unicode(self.store.get_block_original_usage(course_usage_key)[1]),
|
||||
}
|
||||
self.assertEqual(len(block_list[0]["descendants"]), len(descendant_data_expected))
|
||||
for descendant in block_list[0]["descendants"]:
|
||||
self.assertEqual(descendant, descendant_data_expected.get(descendant["usage_key"]))
|
||||
|
||||
def test_removed_overlimit(self):
|
||||
"""
|
||||
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
|
||||
We go from one blocks assigned to none because max_count has been decreased.
|
||||
"""
|
||||
# Decrease max_count to 1, causing the block to be overlimit:
|
||||
self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect
|
||||
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
|
||||
self.lc_block.max_count = 0
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
|
||||
# Check that the event says that one block was removed, leaving no blocks left:
|
||||
children = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 0)
|
||||
event_data = self._assert_event_was_published("removed")
|
||||
self.assertEqual(len(event_data["removed"]), 1)
|
||||
self.assertEqual(event_data["result"], [])
|
||||
self.assertEqual(event_data["reason"], "overlimit")
|
||||
|
||||
def test_removed_invalid(self):
|
||||
"""
|
||||
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
|
||||
We go from two blocks assigned, to one because the others have been deleted from the library.
|
||||
"""
|
||||
# Start by assigning two blocks to the student:
|
||||
self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect
|
||||
self.lc_block.max_count = 2
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
initial_blocks_assigned = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(initial_blocks_assigned), 2)
|
||||
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
|
||||
# Now make sure that one of the assigned blocks will have to be un-assigned.
|
||||
# To cause an "invalid" event, we delete all blocks from the content library
|
||||
# except for one of the two already assigned to the student:
|
||||
keep_block_key = initial_blocks_assigned[0].location
|
||||
keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key)
|
||||
deleted_block_key = initial_blocks_assigned[1].location
|
||||
self.library.children = [keep_block_lib_usage_key]
|
||||
self.store.update_item(self.library, self.user_id)
|
||||
self.lc_block.refresh_children()
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
|
||||
# Check that the event says that one block was removed, leaving one block left:
|
||||
children = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 1)
|
||||
event_data = self._assert_event_was_published("removed")
|
||||
self.assertEqual(event_data["removed"], [{
|
||||
"usage_key": unicode(deleted_block_key),
|
||||
"original_usage_key": None, # Note: original_usage_key info is sadly unavailable because the block has been
|
||||
# deleted so that info can no longer be retrieved
|
||||
"original_usage_version": None,
|
||||
"descendants": [],
|
||||
}])
|
||||
self.assertEqual(event_data["result"], [{
|
||||
"usage_key": unicode(keep_block_key),
|
||||
"original_usage_key": unicode(keep_block_lib_usage_key),
|
||||
"original_usage_version": unicode(keep_block_lib_version),
|
||||
"descendants": [],
|
||||
}])
|
||||
self.assertEqual(event_data["reason"], "invalid")
|
||||
|
||||
@@ -755,6 +755,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
|
||||
|
||||
try:
|
||||
descriptor = modulestore().get_item(usage_key)
|
||||
descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
|
||||
except ItemNotFoundError:
|
||||
log.warn(
|
||||
"Invalid location for course id {course_id}: {usage_key}".format(
|
||||
@@ -768,8 +769,13 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
|
||||
tracking_context = {
|
||||
'module': {
|
||||
'display_name': descriptor.display_name_with_default,
|
||||
'usage_key': unicode(descriptor.location),
|
||||
}
|
||||
}
|
||||
# For blocks that are inherited from a content library, we add some additional metadata:
|
||||
if descriptor_orig_usage_key is not None:
|
||||
tracking_context['module']['original_usage_key'] = unicode(descriptor_orig_usage_key)
|
||||
tracking_context['module']['original_usage_version'] = unicode(descriptor_orig_version)
|
||||
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course_id,
|
||||
|
||||
@@ -5,6 +5,7 @@ Test for lms courseware app, module render unit
|
||||
from functools import partial
|
||||
import json
|
||||
|
||||
from bson import ObjectId
|
||||
import ddt
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -13,6 +14,7 @@ from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from mock import MagicMock, patch, Mock
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from xblock.field_data import FieldData
|
||||
from xblock.runtime import Runtime
|
||||
@@ -971,12 +973,13 @@ class TestModuleTrackingContext(ModuleStoreTestCase):
|
||||
|
||||
def test_context_contains_display_name(self, mock_tracker):
|
||||
problem_display_name = u'Option Response Problem'
|
||||
actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker, problem_display_name)
|
||||
self.assertEquals(problem_display_name, actual_display_name)
|
||||
module_info = self.handle_callback_and_get_module_info(mock_tracker, problem_display_name)
|
||||
self.assertEquals(problem_display_name, module_info['display_name'])
|
||||
|
||||
def handle_callback_and_get_display_name_from_event(self, mock_tracker, problem_display_name=None):
|
||||
def handle_callback_and_get_module_info(self, mock_tracker, problem_display_name=None):
|
||||
"""
|
||||
Creates a fake module, invokes the callback and extracts the display name from the emitted problem_check event.
|
||||
Creates a fake module, invokes the callback and extracts the 'module'
|
||||
metadata from the emitted problem_check event.
|
||||
"""
|
||||
descriptor_kwargs = {
|
||||
'category': 'problem',
|
||||
@@ -1000,12 +1003,28 @@ class TestModuleTrackingContext(ModuleStoreTestCase):
|
||||
event = mock_call[1][0]
|
||||
|
||||
self.assertEquals(event['event_type'], 'problem_check')
|
||||
return event['context']['module']['display_name']
|
||||
return event['context']['module']
|
||||
|
||||
def test_missing_display_name(self, mock_tracker):
|
||||
actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker)
|
||||
actual_display_name = self.handle_callback_and_get_module_info(mock_tracker)['display_name']
|
||||
self.assertTrue(actual_display_name.startswith('problem'))
|
||||
|
||||
def test_library_source_information(self, mock_tracker):
|
||||
"""
|
||||
Check that XBlocks that are inherited from a library include the
|
||||
information about their library block source in events.
|
||||
We patch the modulestore to avoid having to create a library.
|
||||
"""
|
||||
original_usage_key = UsageKey.from_string(u'block-v1:A+B+C+type@problem+block@abcd1234')
|
||||
original_usage_version = ObjectId()
|
||||
mock_get_original_usage = lambda _, key: (original_usage_key, original_usage_version)
|
||||
with patch('xmodule.modulestore.mixed.MixedModuleStore.get_block_original_usage', mock_get_original_usage):
|
||||
module_info = self.handle_callback_and_get_module_info(mock_tracker)
|
||||
self.assertIn('original_usage_key', module_info)
|
||||
self.assertEqual(module_info['original_usage_key'], unicode(original_usage_key))
|
||||
self.assertIn('original_usage_version', module_info)
|
||||
self.assertEqual(module_info['original_usage_version'], unicode(original_usage_version))
|
||||
|
||||
|
||||
class TestXmoduleRuntimeEvent(TestSubmittingProblems):
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.conf import settings
|
||||
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
|
||||
from openedx.core.djangoapps.user_api.api import course_tag as user_course_tag_api
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
|
||||
@@ -199,6 +200,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract
|
||||
course_id=kwargs.get('course_id'),
|
||||
track_function=kwargs.get('track_function', None),
|
||||
)
|
||||
services['library_tools'] = LibraryToolsService(modulestore())
|
||||
services['fs'] = xblock.reference.plugins.FSService()
|
||||
self.request_token = kwargs.pop('request_token', None)
|
||||
super(LmsModuleSystem, self).__init__(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user