# lint-amnesty, pylint: disable=missing-module-docstring
import datetime
from tempfile import mkdtemp
from unittest.mock import Mock, patch
import ddt
from django.test import TestCase
from fs.osfs import OSFS
from lxml import etree
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Date, Integer, Scope, String
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xmodule.modulestore.inheritance import InheritanceMixin, compute_inherited_metadata
from xmodule.modulestore.xml import XMLImportingModuleStoreRuntime, LibraryXMLModuleStore, XMLModuleStore
from xmodule.tests import DATA_DIR
from xmodule.x_module import XModuleMixin
from xmodule.xml_block import is_pointer_tag
ORG = 'test_org'
COURSE = 'test_course'
RUN = 'test_run'
class DummyModuleStoreRuntime(XMLImportingModuleStoreRuntime): # pylint: disable=abstract-method, missing-class-docstring
"""
Minimal modulestore runtime for tests
"""
@patch('xmodule.modulestore.xml.OSFS', lambda dir: OSFS(mkdtemp()))
def __init__(self, load_error_blocks, library=False):
if library:
xmlstore = LibraryXMLModuleStore("data_dir", source_dirs=[], load_error_blocks=load_error_blocks)
else:
xmlstore = XMLModuleStore("data_dir", source_dirs=[], load_error_blocks=load_error_blocks)
course_id = CourseKey.from_string('/'.join([ORG, COURSE, RUN]))
course_dir = "test_dir"
error_tracker = Mock()
super().__init__(
xmlstore=xmlstore,
course_id=course_id,
course_dir=course_dir,
error_tracker=error_tracker,
load_error_blocks=load_error_blocks,
mixins=(InheritanceMixin, XModuleMixin),
services={'field-data': KvsFieldData(DictKeyValueStore())},
)
class BaseCourseTestCase(TestCase):
'''Make sure block imports work properly, including for malformed inputs'''
@staticmethod
def get_system(load_error_blocks=True, library=False):
'''Get a dummy system'''
return DummyModuleStoreRuntime(load_error_blocks, library=library)
def get_course(self, name):
"""Get a test course by directory name. If there's more than one, error."""
print(f"Importing {name}")
modulestore = XMLModuleStore(
DATA_DIR,
source_dirs=[name],
xblock_mixins=(InheritanceMixin,),
)
courses = modulestore.get_courses()
assert len(courses) == 1
return courses[0]
class GenericXBlock(XBlock):
"""XBlock for testing pure xblock xml import"""
has_children = True
field1 = String(default="something", scope=Scope.user_state)
field2 = Integer(scope=Scope.user_state)
@ddt.ddt
class PureXBlockImportTest(BaseCourseTestCase):
"""
Tests of import pure XBlocks (not XModules) from xml
"""
def assert_xblocks_are_good(self, block):
"""Assert a number of conditions that must be true for `block` to be good."""
scope_ids = block.scope_ids
assert scope_ids.usage_id is not None
assert scope_ids.def_id is not None
for child_id in block.children:
child = block.runtime.get_block(child_id)
self.assert_xblocks_are_good(child)
@XBlock.register_temp_plugin(GenericXBlock)
@ddt.data(
"",
"",
"",
)
@patch('xmodule.x_module.XModuleMixin.location')
def test_parsing_pure_xblock(self, xml, mock_location):
system = self.get_system(load_error_blocks=False)
block = system.process_xml(xml)
assert isinstance(block, GenericXBlock)
self.assert_xblocks_are_good(block)
assert not mock_location.called
class ImportTestCase(BaseCourseTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
date = Date()
def test_fallback(self):
'''Check that malformed xml loads as an ErrorBlock.'''
# Use an exotic character to also flush out Unicode issues.
bad_xml = ''''''
system = self.get_system()
block = system.process_xml(bad_xml)
assert block.__class__.__name__ == 'ErrorBlockWithMixins'
def test_unique_url_names(self):
'''Check that each error gets its very own url_name'''
bad_xml = ''''''
bad_xml2 = ''''''
system = self.get_system()
block1 = system.process_xml(bad_xml)
block2 = system.process_xml(bad_xml2)
assert block1.location != block2.location
# Check that each vertical gets its very own url_name
bad_xml = ''''''
bad_xml2 = ''''''
block1 = system.process_xml(bad_xml)
block2 = system.process_xml(bad_xml2)
assert block1.location != block2.location
def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly'''
self.maxDiff = None
bad_xml = ''''''
system = self.get_system()
block = system.process_xml(bad_xml)
node = etree.Element('unknown')
block.add_xml_to_node(node)
re_import_block = system.process_xml(etree.tostring(node))
assert re_import_block.__class__.__name__ == 'ErrorBlockWithMixins'
assert block.contents == re_import_block.contents
assert block.error_msg == re_import_block.error_msg
def test_fixed_xml_tag(self):
"""Make sure a tag that's been fixed exports as the original tag type"""
# create a error tag with valid xml contents
root = etree.Element('error')
good_xml = ''''''
root.text = good_xml
xml_str_in = etree.tostring(root)
# load it
system = self.get_system()
block = system.process_xml(xml_str_in)
# export it
node = etree.Element('unknown')
block.add_xml_to_node(node)
# Now make sure the exported xml is a sequential
assert node.tag == 'sequential'
def course_block_inheritance_check(self, block, from_date_string, unicorn_color, course_run=RUN):
"""
Checks to make sure that metadata inheritance on a course block is respected.
"""
# pylint: disable=protected-access
print((block, block._field_data))
assert block.due == ImportTestCase.date.from_json(from_date_string)
# Check that the child inherits due correctly
child = block.get_children()[0]
assert child.due == ImportTestCase.date.from_json(from_date_string)
# need to convert v to canonical json b4 comparing
assert ImportTestCase.date.to_json(ImportTestCase.date.from_json(from_date_string)) ==\
child.xblock_kvs.inherited_settings['due']
# Now export and check things
file_system = OSFS(mkdtemp())
block.runtime.export_fs = file_system.makedir('course', recreate=True)
node = etree.Element('unknown')
block.add_xml_to_node(node)
# Check that the exported xml is just a pointer
print(("Exported xml:", etree.tostring(node)))
assert is_pointer_tag(node)
# but it's a special case course pointer
assert node.attrib['course'] == COURSE
assert node.attrib['org'] == ORG
# Does the course still have unicorns?
with block.runtime.export_fs.open(f'course/{course_run}.xml') as f:
course_xml = etree.fromstring(f.read())
assert course_xml.attrib['unicorn'] == unicorn_color
# the course and org tags should be _only_ in the pointer
assert 'course' not in course_xml.attrib
assert 'org' not in course_xml.attrib
# did we successfully strip the url_name from the definition contents?
assert 'url_name' not in course_xml.attrib
# Does the chapter tag now have a due attribute?
# hardcoded path to child
with block.runtime.export_fs.open('chapter/ch.xml') as f:
chapter_xml = etree.fromstring(f.read())
assert chapter_xml.tag == 'chapter'
assert 'due' not in chapter_xml.attrib
def test_metadata_import_export(self):
"""Two checks:
- unknown metadata is preserved across import-export
- inherited metadata doesn't leak to children.
"""
system = self.get_system()
from_date_string = 'March 20 17:00'
url_name = 'test1'
unicorn_color = 'purple'
start_xml = '''
Two houses, ...
'''.format(
due=from_date_string, org=ORG, course=COURSE, url_name=url_name, unicorn_color=unicorn_color
)
block = system.process_xml(start_xml)
compute_inherited_metadata(block)
self.course_block_inheritance_check(block, from_date_string, unicorn_color)
def test_library_metadata_import_export(self):
"""Two checks:
- unknown metadata is preserved across import-export
- inherited metadata doesn't leak to children.
"""
system = self.get_system(library=True)
from_date_string = 'March 26 17:00'
url_name = 'test2'
unicorn_color = 'rainbow'
start_xml = '''
Two houses, ...
'''.format(
due=from_date_string, org=ORG, course=COURSE, url_name=url_name, unicorn_color=unicorn_color
)
block = system.process_xml(start_xml)
compute_inherited_metadata(block)
# Check the course block, since it has inheritance
block = block.get_children()[0]
self.course_block_inheritance_check(block, from_date_string, unicorn_color)
def test_metadata_no_inheritance(self):
"""
Checks that default value of None (for due) does not get marked as inherited when a
course is the root block.
"""
system = self.get_system()
url_name = 'test1'
start_xml = '''
Two houses, ...
'''.format(org=ORG, course=COURSE, url_name=url_name)
block = system.process_xml(start_xml)
compute_inherited_metadata(block)
self.course_block_no_inheritance_check(block)
def test_library_metadata_no_inheritance(self):
"""
Checks that the default value of None (for due) does not get marked as inherited when a
library is the root block.
"""
system = self.get_system()
url_name = 'test1'
start_xml = '''
Two houses, ...
'''.format(org=ORG, course=COURSE, url_name=url_name)
block = system.process_xml(start_xml)
compute_inherited_metadata(block)
# Run the checks on the course node instead.
block = block.get_children()[0]
self.course_block_no_inheritance_check(block)
def course_block_no_inheritance_check(self, block):
"""
Verifies that a default value of None (for due) does not get marked as inherited.
"""
assert block.due is None
# Check that the child does not inherit a value for due
child = block.get_children()[0]
assert child.due is None
# Check that the child hasn't started yet
assert datetime.datetime.now(UTC) <= child.start
def override_metadata_check(self, block, child, course_due, child_due):
"""
Verifies that due date can be overriden at child level.
"""
assert block.due == ImportTestCase.date.from_json(course_due)
assert child.due == ImportTestCase.date.from_json(child_due)
# Test inherited metadata. Due does not appear here (because explicitly set on child).
assert ImportTestCase.date.to_json(ImportTestCase.date.from_json(course_due)) == child.xblock_kvs.inherited_settings['due'] # pylint: disable=line-too-long
def test_metadata_override_default(self):
"""
Checks that due date can be overriden at child level when a course is the root.
"""
system = self.get_system()
course_due = 'March 20 17:00'
child_due = 'April 10 00:00'
url_name = 'test1'
start_xml = '''
Two houses, ...
'''.format(due=course_due, org=ORG, course=COURSE, url_name=url_name)
block = system.process_xml(start_xml)
child = block.get_children()[0]
# pylint: disable=protected-access
child._field_data.set(child, 'due', child_due)
compute_inherited_metadata(block)
self.override_metadata_check(block, child, course_due, child_due)
def test_library_metadata_override_default(self):
"""
Checks that due date can be overriden at child level when a library is the root.
"""
system = self.get_system()
course_due = 'March 20 17:00'
child_due = 'April 10 00:00'
url_name = 'test1'
start_xml = '''
Two houses, ...
'''.format(due=course_due, org=ORG, course=COURSE, url_name=url_name)
block = system.process_xml(start_xml)
# Chapter is two levels down here.
child = block.get_children()[0].get_children()[0]
# pylint: disable=protected-access
child._field_data.set(child, 'due', child_due)
compute_inherited_metadata(block)
block = block.get_children()[0]
self.override_metadata_check(block, child, course_due, child_due)
def test_is_pointer_tag(self):
"""
Check that is_pointer_tag works properly.
"""
yes = ["""
""",
"""""",
""" """,
"""""",
""""""]
no = ["""