* Consolidates and renames the runtime used as a base for all the others:
* Before: `xmodule.x_module:DescriptorSystem` and
`xmodule.mako_block:MakoDescriptorSystem`.
* After: `xmodule.x_module:ModuleStoreRuntime`.
* Co-locates and renames the runtimes for importing course OLX:
* Before: `xmodule.x_module:XMLParsingSystem` and
`xmodule.modulestore.xml:ImportSystem`.
* After: `xmodule.modulestore.xml:XMLParsingModuleStoreRuntime` and
`xmodule.modulestore.xml:XMLImportingModuleStoreRuntime`.
* Note: I would have liked to consolidate these, but it would have
involved nontrivial test refactoring.
* Renames the stub Old Mongo runtime:
* Before: `xmodule.modulestore.mongo.base:CachingDescriptorSystem`.
* After: `xmodule.modulestore.mongo.base:OldModuleStoreRuntime`.
* Renames the Split Mongo runtime, the which is what runs courses in LMS and CMS:
* Before: `xmodule.modulestore.split_mongo.caching_descriptor_system:CachingDescriptorSystem`.
* After: `xmodule.modulestore.split_mongo.runtime:SplitModuleStoreRuntime`.
* Renames some of the dummy runtimes used only in unit tests.
408 lines
17 KiB
Python
408 lines
17 KiB
Python
# lint-amnesty, pylint: disable=missing-module-docstring
|
|
|
|
import json
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from django.conf import settings
|
|
from fs.memoryfs import MemoryFS
|
|
from lxml import etree
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
|
from web_fragments.fragment import Fragment
|
|
from xblock.field_data import DictFieldData
|
|
from xblock.fields import ScopeIds
|
|
|
|
from xmodule.conditional_block import ConditionalBlock
|
|
from xmodule.error_block import ErrorBlock
|
|
from xmodule.modulestore.xml import CourseLocationManager, XMLImportingModuleStoreRuntime, XMLModuleStore
|
|
from xmodule.tests import DATA_DIR, get_test_system, prepare_block_runtime
|
|
from xmodule.tests.xml import XModuleXmlImportTest
|
|
from xmodule.tests.xml import factories as xml
|
|
from xmodule.validation import StudioValidationMessage
|
|
from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
|
|
|
|
ORG = 'test_org'
|
|
COURSE = 'conditional' # name of directory with course data
|
|
|
|
|
|
class DummyModuleStoreRuntime(XMLImportingModuleStoreRuntime): # pylint: disable=abstract-method
|
|
"""
|
|
Minimal modulestore runtime for tests
|
|
"""
|
|
|
|
@patch('xmodule.modulestore.xml.OSFS', lambda directory: MemoryFS())
|
|
def __init__(self, load_error_blocks):
|
|
|
|
xmlstore = XMLModuleStore("data_dir", source_dirs=[], load_error_blocks=load_error_blocks)
|
|
|
|
super().__init__(
|
|
xmlstore=xmlstore,
|
|
course_id=CourseKey.from_string('/'.join([ORG, COURSE, 'test_run'])),
|
|
course_dir='test_dir',
|
|
error_tracker=Mock(),
|
|
load_error_blocks=load_error_blocks,
|
|
)
|
|
|
|
@property
|
|
def render_template(self): # lint-amnesty, pylint: disable=method-hidden
|
|
raise Exception("Shouldn't be called")
|
|
|
|
|
|
class ConditionalBlockFactory(xml.XmlImportFactory):
|
|
"""
|
|
Factory for generating ConditionalBlock for testing purposes
|
|
"""
|
|
tag = 'conditional'
|
|
|
|
|
|
class ConditionalFactory:
|
|
"""
|
|
A helper class to create a conditional block and associated source and child blocks
|
|
to allow for testing.
|
|
"""
|
|
@staticmethod
|
|
def create(system, source_is_error_block=False, source_visible_to_staff_only=False):
|
|
"""
|
|
return a dict of blocks: the conditional with a single source and a single child.
|
|
Keys are 'cond_block', 'source_block', and 'child_block'.
|
|
|
|
if the source_is_error_block flag is set, create a real ErrorBlock for the source.
|
|
"""
|
|
|
|
# construct source block and module:
|
|
source_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run", deprecated=True),
|
|
"problem", "SampleProblem", deprecated=True)
|
|
if source_is_error_block:
|
|
# Make an error block
|
|
source_block = ErrorBlock.from_xml(
|
|
'some random xml data',
|
|
system,
|
|
id_generator=CourseLocationManager(source_location.course_key),
|
|
error_msg='random error message'
|
|
)
|
|
else:
|
|
source_block = Mock(name='source_block')
|
|
source_block.location = source_location
|
|
|
|
source_block.visible_to_staff_only = source_visible_to_staff_only
|
|
source_block.runtime = system
|
|
source_block.render = lambda view, context=None: system.render(source_block, view, context)
|
|
|
|
# construct other blocks:
|
|
child_block = Mock(name='child_block')
|
|
child_block.visible_to_staff_only = False
|
|
child_block._xmodule.student_view.return_value = Fragment(content='<p>This is a secret</p>') # lint-amnesty, pylint: disable=protected-access
|
|
child_block.student_view = child_block._xmodule.student_view # lint-amnesty, pylint: disable=protected-access
|
|
child_block.runtime = system
|
|
child_block.render = lambda view, context=None: system.render(child_block, view, context)
|
|
child_block.location = source_location.replace(category='html', name='child')
|
|
|
|
def visible_to_nonstaff_users(desc):
|
|
"""
|
|
Returns if the object is visible to nonstaff users.
|
|
"""
|
|
return not desc.visible_to_staff_only
|
|
|
|
def load_item(usage_id, for_parent=None): # pylint: disable=unused-argument
|
|
"""Test-only implementation of load_item that simply returns static xblocks."""
|
|
return {
|
|
child_block.location: child_block,
|
|
source_location: source_block
|
|
}.get(usage_id)
|
|
|
|
system.load_item = load_item
|
|
|
|
# construct conditional block:
|
|
cond_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run", deprecated=True),
|
|
"conditional", "SampleConditional", deprecated=True)
|
|
field_data = DictFieldData({
|
|
'data': '<conditional/>',
|
|
'conditional_attr': 'attempted',
|
|
'conditional_value': 'true',
|
|
'xml_attributes': {'attempted': 'true'},
|
|
'children': [child_block.location],
|
|
})
|
|
|
|
cond_block = ConditionalBlock(
|
|
system,
|
|
field_data,
|
|
ScopeIds(None, None, cond_location, cond_location)
|
|
)
|
|
system.get_block_for_descriptor = lambda desc: desc if visible_to_nonstaff_users(desc) else None
|
|
cond_block.get_required_blocks = [
|
|
system.get_block_for_descriptor(source_block),
|
|
]
|
|
|
|
# return dict:
|
|
return {'cond_block': cond_block,
|
|
'source_block': source_block,
|
|
'child_block': child_block}
|
|
|
|
|
|
class ConditionalBlockBasicTest(unittest.TestCase):
|
|
"""
|
|
Make sure that conditional block works, using mocks for
|
|
other blocks.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.test_system = get_test_system()
|
|
|
|
def test_icon_class(self):
|
|
'''verify that get_icon_class works independent of condition satisfaction'''
|
|
blocks = ConditionalFactory.create(self.test_system)
|
|
for attempted in ["false", "true"]:
|
|
for icon_class in ['other', 'problem', 'video']:
|
|
blocks['source_block'].is_attempted = attempted
|
|
blocks['child_block'].get_icon_class = lambda: icon_class # lint-amnesty, pylint: disable=cell-var-from-loop
|
|
assert blocks['cond_block'].get_icon_class() == icon_class
|
|
|
|
def test_get_html(self):
|
|
blocks = ConditionalFactory.create(self.test_system)
|
|
# because get_test_system returns the repr of the context dict passed to render_template,
|
|
# we reverse it here
|
|
html = blocks['cond_block'].render(STUDENT_VIEW).content
|
|
mako_service = blocks['cond_block'].runtime.service(blocks['cond_block'], 'mako')
|
|
expected = mako_service.render_lms_template('conditional_ajax.html', {
|
|
'ajax_url': blocks['cond_block'].ajax_url,
|
|
'element_id': 'i4x-edX-conditional_test-conditional-SampleConditional',
|
|
'depends': 'i4x-edX-conditional_test-problem-SampleProblem',
|
|
})
|
|
assert expected == html
|
|
|
|
def test_handle_ajax(self):
|
|
blocks = ConditionalFactory.create(self.test_system)
|
|
blocks['cond_block'].save()
|
|
blocks['source_block'].is_attempted = "false"
|
|
ajax = json.loads(blocks['cond_block'].handle_ajax('', ''))
|
|
fragments = ajax['fragments']
|
|
assert not any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
# now change state of the capa problem to make it completed
|
|
blocks['source_block'].is_attempted = "true"
|
|
ajax = json.loads(blocks['cond_block'].handle_ajax('', ''))
|
|
blocks['cond_block'].save()
|
|
fragments = ajax['fragments']
|
|
assert any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
def test_error_as_source(self):
|
|
'''
|
|
Check that handle_ajax works properly if the source is really an ErrorBlock,
|
|
and that the condition is not satisfied.
|
|
'''
|
|
blocks = ConditionalFactory.create(self.test_system, source_is_error_block=True)
|
|
blocks['cond_block'].save()
|
|
ajax = json.loads(blocks['cond_block'].handle_ajax('', ''))
|
|
fragments = ajax['fragments']
|
|
assert not any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
@patch('xmodule.conditional_block.log')
|
|
def test_conditional_with_staff_only_source_block(self, mock_log):
|
|
blocks = ConditionalFactory.create(
|
|
self.test_system,
|
|
source_visible_to_staff_only=True,
|
|
)
|
|
cond_block = blocks['cond_block']
|
|
cond_block.save()
|
|
cond_block.is_attempted = "false"
|
|
cond_block.handle_ajax('', '')
|
|
assert not mock_log.warn.called
|
|
assert None in cond_block.get_required_blocks
|
|
|
|
|
|
class ConditionalBlockXmlTest(unittest.TestCase):
|
|
"""
|
|
Make sure ConditionalBlock works, by loading data in from an XML-defined course.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
def add_block_as_child_node(block, node):
|
|
child = etree.SubElement(node, "unknown")
|
|
block.add_xml_to_node(child)
|
|
self.test_system = get_test_system(add_get_block_overrides=True)
|
|
self.test_system.add_block_as_child_node = add_block_as_child_node
|
|
self.modulestore = XMLModuleStore(DATA_DIR, source_dirs=['conditional_and_poll'])
|
|
courses = self.modulestore.get_courses()
|
|
assert len(courses) == 1
|
|
self.course = courses[0]
|
|
|
|
def get_block_for_location(self, location):
|
|
block = self.modulestore.get_item(location, depth=None)
|
|
return self.test_system.get_block_for_descriptor(block)
|
|
|
|
@patch('xmodule.x_module.block_global_local_resource_url')
|
|
@patch.dict(settings.FEATURES, {'ENABLE_EDXNOTES': False})
|
|
def test_conditional_block(self, _):
|
|
"""Make sure that conditional block works"""
|
|
# edx - HarvardX
|
|
# cond_test - ER22x
|
|
location = BlockUsageLocator(CourseLocator("HarvardX", "ER22x", "2013_Spring", deprecated=True),
|
|
"conditional", "condone", deprecated=True)
|
|
|
|
block = self.get_block_for_location(location)
|
|
html = block.render(STUDENT_VIEW).content
|
|
mako_service = block.runtime.service(block, 'mako')
|
|
html_expect = mako_service.render_lms_template(
|
|
'conditional_ajax.html',
|
|
{
|
|
# Test ajax url is just usage-id / handler_name
|
|
'ajax_url': f'{str(location)}/xmodule_handler',
|
|
'element_id': 'i4x-HarvardX-ER22x-conditional-condone',
|
|
'depends': 'i4x-HarvardX-ER22x-problem-choiceprob'
|
|
}
|
|
)
|
|
assert html == html_expect
|
|
|
|
ajax = json.loads(block.handle_ajax('', ''))
|
|
fragments = ajax['fragments']
|
|
assert not any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
# Now change state of the capa problem to make it completed
|
|
inner_block = self.get_block_for_location(location.replace(category="problem", name='choiceprob'))
|
|
inner_block.attempts = 1
|
|
# Save our modifications to the underlying KeyValueStore so they can be persisted
|
|
inner_block.save()
|
|
|
|
ajax = json.loads(block.handle_ajax('', ''))
|
|
fragments = ajax['fragments']
|
|
assert any(('This is a secret' in item['content']) for item in fragments)
|
|
|
|
def test_conditional_block_with_empty_sources_list(self):
|
|
"""
|
|
If a ConditionalBlock is initialized with an empty sources_list, we assert that the sources_list is set
|
|
via generating UsageKeys from the values in xml_attributes['sources']
|
|
"""
|
|
dummy_system = Mock()
|
|
dummy_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run"),
|
|
"conditional", "SampleConditional")
|
|
dummy_scope_ids = ScopeIds(None, None, dummy_location, dummy_location)
|
|
dummy_field_data = DictFieldData({
|
|
'data': '<conditional/>',
|
|
'xml_attributes': {'sources': 'i4x://HarvardX/ER22x/poll_question/T15_poll'},
|
|
'children': None,
|
|
})
|
|
conditional = ConditionalBlock(
|
|
dummy_system,
|
|
dummy_field_data,
|
|
dummy_scope_ids,
|
|
)
|
|
|
|
new_run = conditional.location.course_key.run # lint-amnesty, pylint: disable=unused-variable
|
|
assert conditional.sources_list[0] == BlockUsageLocator.from_string(conditional.xml_attributes['sources'])\
|
|
.replace(run=dummy_location.course_key.run)
|
|
|
|
def test_conditional_block_parse_sources(self):
|
|
dummy_system = Mock()
|
|
dummy_location = BlockUsageLocator(CourseLocator("edX", "conditional_test", "test_run"),
|
|
"conditional", "SampleConditional")
|
|
dummy_scope_ids = ScopeIds(None, None, dummy_location, dummy_location)
|
|
dummy_field_data = DictFieldData({
|
|
'data': '<conditional/>',
|
|
'xml_attributes': {'sources': 'i4x://HarvardX/ER22x/poll_question/T15_poll;i4x://HarvardX/ER22x/poll_question/T16_poll'}, # lint-amnesty, pylint: disable=line-too-long
|
|
'children': None,
|
|
})
|
|
conditional = ConditionalBlock(
|
|
dummy_system,
|
|
dummy_field_data,
|
|
dummy_scope_ids,
|
|
)
|
|
assert conditional.parse_sources(conditional.xml_attributes) == ['i4x://HarvardX/ER22x/poll_question/T15_poll',
|
|
'i4x://HarvardX/ER22x/poll_question/T16_poll']
|
|
|
|
def test_conditional_block_parse_attr_values(self):
|
|
root = '<conditional attempted="false"></conditional>'
|
|
xml_object = etree.XML(root)
|
|
definition = ConditionalBlock.definition_from_xml(xml_object, Mock())[0]
|
|
expected_definition = {
|
|
'show_tag_list': [],
|
|
'conditional_attr': 'attempted',
|
|
'conditional_value': 'false',
|
|
'conditional_message': ''
|
|
}
|
|
|
|
assert definition == expected_definition
|
|
|
|
def test_presence_attributes_in_xml_attributes(self):
|
|
blocks = ConditionalFactory.create(self.test_system)
|
|
blocks['cond_block'].save()
|
|
blocks['cond_block'].definition_to_xml(Mock())
|
|
expected_xml_attributes = {
|
|
'attempted': 'true',
|
|
'message': 'You must complete {link} before you can access this unit.',
|
|
'sources': ''
|
|
}
|
|
self.assertDictEqual(blocks['cond_block'].xml_attributes, expected_xml_attributes)
|
|
|
|
|
|
class ConditionalBlockStudioTest(XModuleXmlImportTest):
|
|
"""
|
|
Unit tests for how conditional test interacts with Studio.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
course = xml.CourseFactory.build()
|
|
sequence = xml.SequenceFactory.build(parent=course)
|
|
conditional = ConditionalBlockFactory(
|
|
parent=sequence,
|
|
attribs={
|
|
'group_id_to_child': '{"0": "i4x://edX/xml_test_course/html/conditional_0"}'
|
|
}
|
|
)
|
|
xml.HtmlFactory(parent=conditional, url_name='conditional_0', text='This is a secret HTML')
|
|
|
|
self.course = self.process_xml(course)
|
|
self.sequence = self.course.get_children()[0]
|
|
self.conditional = self.sequence.get_children()[0]
|
|
|
|
user = Mock(username='ma', email='ma@edx.org', is_staff=False, is_active=True)
|
|
self.conditional.runtime = prepare_block_runtime(self.course.runtime)
|
|
self.conditional.bind_for_student(
|
|
user.id
|
|
)
|
|
|
|
def test_render_author_view(self,):
|
|
"""
|
|
Test the rendering of the Studio author view.
|
|
"""
|
|
|
|
def create_studio_context(root_xblock, is_unit_page):
|
|
"""
|
|
Context for rendering the studio "author_view".
|
|
"""
|
|
return {
|
|
'reorderable_items': set(),
|
|
'root_xblock': root_xblock,
|
|
'is_unit_page': is_unit_page
|
|
}
|
|
|
|
context = create_studio_context(self.conditional, False)
|
|
html = self.course.runtime.render(self.conditional, AUTHOR_VIEW, context).content
|
|
assert 'This is a secret HTML' in html
|
|
|
|
context = create_studio_context(self.sequence, True)
|
|
html = self.course.runtime.render(self.conditional, AUTHOR_VIEW, context).content
|
|
assert 'This is a secret HTML' not in html
|
|
|
|
def test_non_editable_settings(self):
|
|
"""
|
|
Test the settings that are marked as "non-editable".
|
|
"""
|
|
non_editable_metadata_fields = self.conditional.non_editable_metadata_fields
|
|
assert ConditionalBlock.due in non_editable_metadata_fields
|
|
|
|
def test_validation_messages(self):
|
|
"""
|
|
Test the validation message for a correctly configured conditional.
|
|
"""
|
|
self.conditional.sources_list = None
|
|
validation = self.conditional.validate()
|
|
assert validation.summary.text == 'This component has no source components configured yet.'
|
|
assert validation.summary.type == StudioValidationMessage.NOT_CONFIGURED
|
|
assert validation.summary.action_class == 'edit-button'
|
|
assert validation.summary.action_label == 'Configure list of sources'
|