# lint-amnesty, pylint: disable=missing-module-docstring
import datetime
from tempfile import mkdtemp
from unittest.mock import Mock, patch
from zoneinfo import ZoneInfo
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 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(ZoneInfo("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 = ["""