Convert LibraryContentModule to LibraryContentBlock. (#24611)

This commit is contained in:
Usman Khalid
2020-09-15 17:41:29 +05:00
committed by GitHub
parent 5429f8510f
commit 8546fa6b6f
8 changed files with 173 additions and 79 deletions

View File

@@ -279,7 +279,7 @@ class StudioPermissionsService(object):
Deprecated. To be replaced by a more general authorization service.
Only used by LibraryContentDescriptor (and library_tools.py).
Only used by LibraryContentBlock (and library_tools.py).
"""
def __init__(self, user):
self._user = user
@@ -305,7 +305,7 @@ class StudioEditModuleRuntime(object):
def service(self, block, service_name):
"""
This block is not bound to a user but some blocks (LibraryContentModule) may need
This block is not bound to a user but some blocks (LibraryContentBlock) may need
user-specific services to check for permissions, etc.
If we return None here, CombinedSystem will load services from the descriptor runtime.
"""

View File

@@ -10,7 +10,6 @@ XMODULES = [
"customtag = xmodule.template_module:CustomTagDescriptor",
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"library_content = xmodule.library_content_module:LibraryContentDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"nonstaff_error = xmodule.error_module:NonStaffErrorDescriptor",
"poll_question = xmodule.poll_module:PollDescriptor",
@@ -33,6 +32,7 @@ XBLOCKS = [
"course_info = xmodule.html_module:CourseInfoBlock",
"html = xmodule.html_module:HtmlBlock",
"library = xmodule.library_root_xblock:LibraryRoot",
"library_content = xmodule.library_content_module:LibraryContentBlock",
"library_sourced = xmodule.library_sourced_block:LibrarySourcedBlock",
"problem = xmodule.capa_module:ProblemBlock",
"static_tab = xmodule.html_module:StaticTabBlock",

View File

@@ -24,12 +24,21 @@ from xblock.core import XBlock
from xblock.fields import Integer, List, Scope, String
from capa.responsetypes import registry
from xmodule.studio_editable import StudioEditableDescriptor, StudioEditableModule
from xmodule.mako_module import MakoTemplateBlockBase
from xmodule.studio_editable import StudioEditableBlock
from xmodule.util.xmodule_django import add_webpack_to_fragment
from xmodule.validation import StudioValidation, StudioValidationMessage
from xmodule.x_module import STUDENT_VIEW, XModule
from xmodule.xml_module import XmlMixin
from xmodule.x_module import (
HTMLSnippet,
ResourceTemplates,
shim_xmodule_js,
STUDENT_VIEW,
XModuleMixin,
XModuleDescriptorToXBlockMixin,
XModuleToXBlockMixin,
)
from .mako_module import MakoModuleDescriptor
from .xml_module import XmlDescriptor
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
@@ -59,17 +68,57 @@ def _get_capa_types():
], key=lambda item: item.get('display_name'))
class LibraryContentFields(object):
@XBlock.wants('library_tools') # Only needed in studio
@XBlock.wants('studio_user_permissions') # Only available in studio
@XBlock.wants('user')
class LibraryContentBlock(
MakoTemplateBlockBase,
XmlMixin,
XModuleDescriptorToXBlockMixin,
XModuleToXBlockMixin,
HTMLSnippet,
ResourceTemplates,
XModuleMixin,
StudioEditableBlock,
):
"""
Fields for the LibraryContentModule.
An XBlock whose children are chosen dynamically from a content library.
Can be used to create randomized assessments among other things.
Separated out for now because they need to be added to the module and the
descriptor.
Note: technically, all matching blocks from the content library are added
as children of this block, but only a subset of those children are shown to
any particular student.
"""
# pylint: disable=abstract-method
has_children = True
has_author_view = True
resources_dir = 'assets/library_content'
preview_view_js = {
'js': [],
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [],
}
mako_template = 'widgets/metadata-edit.html'
studio_js_module_name = "VerticalDescriptor"
studio_view_js = {
'js': [
resource_string(__name__, 'js/src/vertical/edit.js'),
],
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [],
}
show_in_read_only_mode = True
completion_mode = XBlockCompletionMode.AGGREGATOR
# Please note the display_name of each field below is used in
# common/test/acceptance/pages/studio/library.py:StudioLibraryContentXBlockEditModal
# to locate input elements - keep synchronized
display_name = String(
display_name=_("Display Name"),
help=_("The display name for this component."),
@@ -117,7 +166,6 @@ class LibraryContentFields(object):
default=[],
scope=Scope.user_state,
)
has_children = True
@property
def source_library_key(self):
@@ -126,19 +174,6 @@ class LibraryContentFields(object):
"""
return LibraryLocator.from_string(self.source_library_id)
#pylint: disable=abstract-method
@XBlock.wants('library_tools') # Only needed in studio
class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
"""
An XBlock whose children are chosen dynamically from a content library.
Can be used to create randomized assessments among other things.
Note: technically, all matching blocks from the content library are added
as children of this block, but only a subset of those children are shown to
any particular student.
"""
@classmethod
def make_selection(cls, selected, children, max_count, mode):
"""
@@ -341,12 +376,6 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
}))
return fragment
def validate(self):
"""
Validates the state of this Library Content Module Instance.
"""
return self.descriptor.validate()
def author_view(self, context):
"""
Renders the Studio views.
@@ -375,37 +404,33 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
fragment.initialize_js('LibraryContentAuthorView')
return fragment
def studio_view(self, _context):
"""
Return the studio view.
"""
fragment = Fragment(
self.system.render_template(self.mako_template, self.get_context())
)
add_webpack_to_fragment(fragment, 'LibraryContentBlockStudio')
shim_xmodule_js(fragment, self.studio_js_module_name)
return fragment
def get_child_descriptors(self):
"""
Return only the subset of our children relevant to the current student.
"""
return list(self._get_selected_child_blocks())
@XBlock.wants('user')
@XBlock.wants('library_tools') # Only needed in studio
@XBlock.wants('studio_user_permissions') # Only available in studio
class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor):
"""
Descriptor class for LibraryContentModule XBlock.
"""
resources_dir = 'assets/library_content'
module_class = LibraryContentModule
mako_template = 'widgets/metadata-edit.html'
js = {'js': [resource_string(__name__, 'js/src/vertical/edit.js')]}
js_module_name = "VerticalDescriptor"
show_in_read_only_mode = True
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(LibraryContentDescriptor, self).non_editable_metadata_fields
non_editable_fields = super().non_editable_metadata_fields
# The only supported mode is currently 'random'.
# Add the mode field to non_editable_metadata_fields so that it doesn't
# render in the edit form.
non_editable_fields.extend([LibraryContentFields.mode, LibraryContentFields.source_library_version])
non_editable_fields.extend([
LibraryContentBlock.mode,
LibraryContentBlock.source_library_version,
])
return non_editable_fields
@lazy
@@ -525,7 +550,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
is the override of the general XBlock method, and it will also ask
its superclass to validate.
"""
validation = super(LibraryContentDescriptor, self).validate()
validation = super().validate()
if not isinstance(validation, StudioValidation):
validation = StudioValidation.copy(validation)
library_tools = self.runtime.service(self, "library_tools")
@@ -636,7 +661,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
This overwrites the get_content_titles method included in x_module by default.
"""
titles = []
for child in self._xmodule.get_child_descriptors():
for child in self.get_child_descriptors():
titles.extend(child.get_content_titles())
return titles

View File

@@ -1,5 +1,5 @@
"""
XBlock runtime services for LibraryContentModule
XBlock runtime services for LibraryContentBlock
"""
import hashlib
@@ -27,7 +27,7 @@ def normalize_key_for_search(library_key):
class LibraryToolsService(object):
"""
Service that allows LibraryContentModule to interact with libraries in the
Service that allows LibraryContentBlock to interact with libraries in the
modulestore.
"""
def __init__(self, modulestore, user_id):
@@ -137,7 +137,7 @@ class LibraryToolsService(object):
def update_children(self, dest_block, user_perms=None, version=None):
"""
This method is to be used when the library that a LibraryContentModule
This method is to be used when the library that a LibraryContentBlock
references has been updated. It will re-fetch all matching blocks from
the libraries, and copy them as children of dest_block. The children
will be given new block_ids, but the definition ID used should be the

View File

@@ -22,6 +22,7 @@ from path import Path as path
from xmodule.capa_module import ProblemBlock
from xmodule.html_module import AboutBlock, CourseInfoBlock, HtmlBlock, StaticTabBlock
from xmodule.library_content_module import LibraryContentBlock
from xmodule.word_cloud_module import WordCloudBlock
from xmodule.x_module import XModuleDescriptor, HTMLSnippet
@@ -67,6 +68,7 @@ XBLOCK_CLASSES = [
AboutBlock,
CourseInfoBlock,
HtmlBlock,
LibraryContentBlock,
ProblemBlock,
StaticTabBlock,
VideoBlock,

View File

@@ -40,11 +40,12 @@ class CourseFieldsTestCase(unittest.TestCase):
class DummySystem(ImportSystem):
@patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS())
def __init__(self, load_error_modules):
def __init__(self, load_error_modules, course_id=None):
xmlstore = XMLModuleStore("data_dir", source_dirs=[],
load_error_modules=load_error_modules)
course_id = CourseKey.from_string('/'.join([ORG, COURSE, 'test_run']))
if course_id is None:
course_id = CourseKey.from_string('/'.join([ORG, COURSE, 'test_run']))
course_dir = "test_dir"
error_tracker = Mock()

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Basic unit tests for LibraryContentModule
Basic unit tests for LibraryContentBlock
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
"""
@@ -8,13 +8,15 @@ Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
import six
from bson.objectid import ObjectId
from fs.memoryfs import MemoryFS
from lxml import etree
from mock import Mock, patch
from search.search_engine_base import SearchEngine
from six.moves import range
from web_fragments.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
from xmodule.library_tools import LibraryToolsService
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
@@ -23,12 +25,14 @@ from xmodule.tests import get_test_system
from xmodule.validation import StudioValidationMessage
from xmodule.x_module import AUTHOR_VIEW
from .test_course_module import DummySystem as TestImportSystem
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
class LibraryContentTest(MixedSplitTestCase):
"""
Base class for tests of LibraryContentModule (library_content_module.py)
Base class for tests of LibraryContentBlock (library_content_block.py)
"""
def setUp(self):
super(LibraryContentTest, self).setUp()
@@ -70,9 +74,73 @@ class LibraryContentTest(MixedSplitTestCase):
module.xmodule_runtime = module_system
class LibraryContentModuleTestMixin(object):
class TestLibraryContentExportImport(LibraryContentTest):
"""
Basic unit tests for LibraryContentModule
Export and import tests for LibraryContentBlock
"""
maxDiff = None
def test_xml_export_import_cycle(self):
"""
Test the export-import cycle.
"""
# Children will only set after calling this.
self.lc_block.refresh_children()
lc_block = self.store.get_item(self.lc_block.location)
expected_olx = (
'<library_content display_name="{block.display_name}" max_count="{block.max_count}"'
' source_library_id="{block.source_library_id}" source_library_version="{block.source_library_version}">\n'
' <html url_name="{block.children[0].block_id}"/>\n'
' <html url_name="{block.children[1].block_id}"/>\n'
' <html url_name="{block.children[2].block_id}"/>\n'
' <html url_name="{block.children[3].block_id}"/>\n'
'</library_content>\n'
).format(
block=lc_block,
)
export_fs = MemoryFS()
# Set the virtual FS to export the olx to.
lc_block.runtime._descriptor_system.export_fs = export_fs # pylint: disable=protected-access
# Export the olx.
node = etree.Element("unknown_root")
lc_block.add_xml_to_node(node)
# Read it back
with export_fs.open('{dir}/{file_name}.xml'.format(
dir=lc_block.scope_ids.usage_id.block_type,
file_name=lc_block.scope_ids.usage_id.block_id
)) as f:
exported_olx = f.read()
# And compare.
self.assertEqual(exported_olx, expected_olx)
runtime = TestImportSystem(load_error_modules=True, course_id=lc_block.location.course_key)
runtime.resources_fs = export_fs
# Now import it.
olx_element = etree.fromstring(exported_olx)
id_generator = Mock()
imported_lc_block = LibraryContentBlock.parse_xml(olx_element, runtime, None, id_generator)
# Check the new XBlock has the same properties as the old one.
self.assertEqual(imported_lc_block.display_name, lc_block.display_name)
self.assertEqual(imported_lc_block.source_library_id, lc_block.source_library_id)
self.assertEqual(imported_lc_block.source_library_version, lc_block.source_library_version)
self.assertEqual(imported_lc_block.mode, lc_block.mode)
self.assertEqual(imported_lc_block.max_count, lc_block.max_count)
self.assertEqual(imported_lc_block.capa_type, lc_block.capa_type)
self.assertEqual(len(imported_lc_block.children), 4)
self.assertEqual(imported_lc_block.children, lc_block.children)
class LibraryContentBlockTestMixin(object):
"""
Basic unit tests for LibraryContentBlock
"""
problem_types = [
["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
@@ -241,8 +309,8 @@ class LibraryContentModuleTestMixin(object):
Test the settings that are marked as "non-editable".
"""
non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields
self.assertIn(LibraryContentDescriptor.mode, non_editable_metadata_fields)
self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields)
self.assertIn(LibraryContentBlock.mode, non_editable_metadata_fields)
self.assertNotIn(LibraryContentBlock.display_name, non_editable_metadata_fields)
def test_overlimit_blocks_chosen_randomly(self):
"""
@@ -272,8 +340,6 @@ class LibraryContentModuleTestMixin(object):
Helper method that changes the max_count of self.lc_block, refreshes
children, and asserts that the number of selected children equals the count provided.
"""
# Construct the XModule for the descriptor, if not present already.
self.lc_block._xmodule # pylint: disable=pointless-statement,protected-access
self.lc_block.max_count = count
selected = self.lc_block.get_child_descriptors()
self.assertEqual(len(selected), count)
@@ -281,7 +347,7 @@ class LibraryContentModuleTestMixin(object):
@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=None, autospec=True))
class TestLibraryContentModuleNoSearchIndex(LibraryContentModuleTestMixin, LibraryContentTest):
class TestLibraryContentBlockNoSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
"""
Tests for library container when no search index is available.
Tests fallback low-level CAPA problem introspection
@@ -293,7 +359,7 @@ search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name
@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=search_index_mock, autospec=True))
class TestLibraryContentModuleWithSearchIndex(LibraryContentModuleTestMixin, LibraryContentTest):
class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
"""
Tests for library container with mocked search engine response.
"""
@@ -312,7 +378,7 @@ class TestLibraryContentModuleWithSearchIndex(LibraryContentModuleTestMixin, Lib
def setUp(self):
""" Sets up search engine mock """
super(TestLibraryContentModuleWithSearchIndex, self).setUp()
super(TestLibraryContentBlockWithSearchIndex, self).setUp()
search_index_mock.search = Mock(side_effect=self._get_search_response)
@@ -323,7 +389,7 @@ class TestLibraryContentModuleWithSearchIndex(LibraryContentModuleTestMixin, Lib
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
class TestLibraryContentRender(LibraryContentTest):
"""
Rendering unit tests for LibraryContentModule
Rendering unit tests for LibraryContentBlock
"""
def test_preview_view(self):
@@ -348,7 +414,7 @@ class TestLibraryContentRender(LibraryContentTest):
class TestLibraryContentAnalytics(LibraryContentTest):
"""
Test analytics features of LibraryContentModule
Test analytics features of LibraryContentBlock
"""
def setUp(self):
@@ -361,7 +427,7 @@ class TestLibraryContentAnalytics(LibraryContentTest):
def _assert_event_was_published(self, event_type):
"""
Check that a LibraryContentModule analytics event was published by self.lc_block.
Check that a LibraryContentBlock analytics event was published by self.lc_block.
"""
self.assertTrue(self.publisher.called)
self.assertTrue(len(self.publisher.call_args[0]), 3) # pylint:disable=unsubscriptable-object

View File

@@ -15,7 +15,7 @@ from openedx.core.djangoapps.content.block_structure.transformer import (
FilteringTransformerMixin
)
from track import contexts
from xmodule.library_content_module import LibraryContentModule
from xmodule.library_content_module import LibraryContentBlock
from xmodule.modulestore.django import modulestore
from ..utils import get_student_module_as_dict
@@ -99,7 +99,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
# Update selected
previous_count = len(selected)
block_keys = LibraryContentModule.make_selection(selected, library_children, max_count, mode)
block_keys = LibraryContentBlock.make_selection(selected, library_children, max_count, mode)
selected = block_keys['selected']
# Save back any changes
@@ -175,7 +175,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
with tracker.get_tracker().context(full_event_name, context):
tracker.emit(full_event_name, event_data)
LibraryContentModule.publish_selected_children_events(
LibraryContentBlock.publish_selected_children_events(
block_keys,
format_block_keys,
publish_event,