# 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 = ["""""", """some text""", """tree""", """ 3 """] for xml_str in yes: print(f"should be True for {xml_str}") assert is_pointer_tag(etree.fromstring(xml_str)) for xml_str in no: print(f"should be False for {xml_str}") assert not is_pointer_tag(etree.fromstring(xml_str)) def test_metadata_inherit(self): """Make sure that metadata is inherited properly""" print("Starting import") course = self.get_course('toy') def check_for_key(key, node, value): "recursive check for presence of key" print(f"Checking {str(node.location)}") assert getattr(node, key) == value for c in node.get_children(): check_for_key(key, c, value) check_for_key('graceperiod', course, course.graceperiod) def test_policy_loading(self): """Make sure that when two courses share content with the same org and course names, policy applies to the right one.""" toy = self.get_course('toy') two_toys = self.get_course('two_toys') assert toy.url_name == '2012_Fall' assert two_toys.url_name == 'TT_2012_Fall' toy_ch = toy.get_children()[0] two_toys_ch = two_toys.get_children()[0] assert toy_ch.display_name == 'Overview' assert two_toys_ch.display_name == 'Two Toy Overview' # Also check that the grading policy loaded assert two_toys.grade_cutoffs['C'] == 0.5999 # Also check that keys from policy are run through the # appropriate attribute maps -- 'graded' should be True, not 'true' assert toy.graded is True def test_static_tabs_import(self): """Make sure that the static tabs are imported correctly""" modulestore = XMLModuleStore(DATA_DIR, source_dirs=['toy']) location_tab_syllabus = BlockUsageLocator(CourseLocator("edX", "toy", "2012_Fall", deprecated=True), "static_tab", "syllabus", deprecated=True) toy_tab_syllabus = modulestore.get_item(location_tab_syllabus) assert toy_tab_syllabus.display_name == 'Syllabus' assert toy_tab_syllabus.course_staff_only is False location_tab_resources = BlockUsageLocator(CourseLocator("edX", "toy", "2012_Fall", deprecated=True), "static_tab", "resources", deprecated=True) toy_tab_resources = modulestore.get_item(location_tab_resources) assert toy_tab_resources.display_name == 'Resources' assert toy_tab_resources.course_staff_only is True def test_definition_loading(self): """When two courses share the same org and course name and both have a block with the same url_name, the definitions shouldn't clash. TODO (vshnayder): once we have a CMS, this shouldn't happen--locations should uniquely name definitions. But in our imperfect XML world, it can (and likely will) happen.""" modulestore = XMLModuleStore(DATA_DIR, source_dirs=['toy', 'two_toys']) location = BlockUsageLocator(CourseLocator("edX", "toy", "2012_Fall", deprecated=True), "video", "Welcome", deprecated=True) toy_video = modulestore.get_item(location) location_two = BlockUsageLocator(CourseLocator("edX", "toy", "TT_2012_Fall", deprecated=True), "video", "Welcome", deprecated=True) two_toy_video = modulestore.get_item(location_two) assert toy_video.youtube_id_1_0 == 'p2Q6BrNhdh8' assert two_toy_video.youtube_id_1_0 == 'p2Q6BrNhdh9' def test_colon_in_url_name(self): """Ensure that colons in url_names convert to file paths properly""" print("Starting import") # Not using get_courses because we need the modulestore object too afterward modulestore = XMLModuleStore(DATA_DIR, source_dirs=['toy']) courses = modulestore.get_courses() assert len(courses) == 1 course = courses[0] print("course errors:") for (msg, err) in modulestore.get_course_errors(course.id): print(msg) print(err) chapters = course.get_children() assert len(chapters) == 5 ch2 = chapters[1] assert ch2.url_name == 'secret:magic' print("Ch2 location: ", ch2.location) also_ch2 = modulestore.get_item(ch2.location) assert ch2 == also_ch2 print("making sure html loaded") loc = course.id.make_usage_key('html', 'secret:toylab') html = modulestore.get_item(loc) assert html.display_name == 'Toy lab' def test_unicode(self): """Check that courses with unicode characters in filenames and in org/course/name import properly. Currently, this means: (a) Having files with unicode names does not prevent import; (b) if files are not loaded because of unicode filenames, there are appropriate exceptions/errors to that effect.""" print("Starting import") modulestore = XMLModuleStore(DATA_DIR, source_dirs=['test_unicode']) courses = modulestore.get_courses() assert len(courses) == 1 course = courses[0] print("course errors:") # Expect to find an error/exception about characters in "®esources" expect = "InvalidKeyError" errors = modulestore.get_course_errors(course.id) assert any(((expect in msg) or (expect in err)) for (msg, err) in errors) chapters = course.get_children() assert len(chapters) == 4 def test_url_name_mangling(self): """ Make sure that url_names are only mangled once. """ modulestore = XMLModuleStore(DATA_DIR, source_dirs=['toy']) toy_id = CourseKey.from_string('edX/toy/2012_Fall') course = modulestore.get_course(toy_id) chapters = course.get_children() ch1 = chapters[0] sections = ch1.get_children() assert len(sections) == 4 for i in (2, 3): video = sections[i] # Name should be 'video_{hash}' print(f"video {i} url_name: {video.url_name}") assert len(video.url_name) == (len('video_') + 12) def test_poll_and_conditional_import(self): modulestore = XMLModuleStore(DATA_DIR, source_dirs=['conditional_and_poll']) course = modulestore.get_courses()[0] chapters = course.get_children() ch1 = chapters[0] sections = ch1.get_children() assert len(sections) == 1 conditional_location = course.id.make_usage_key('conditional', 'condone') block = modulestore.get_item(conditional_location) assert len(block.children) == 1 poll_location = course.id.make_usage_key('poll_question', 'first_poll') block = modulestore.get_item(poll_location) assert len(block.get_children()) == 0 assert block.voted is False assert block.poll_answer == '' assert block.poll_answers == {} assert block.answers ==\ [{'text': 'Yes', 'id': 'Yes'}, {'text': 'No', 'id': 'No'}, {'text': "Don't know", 'id': 'Dont_know'}] def test_error_on_import(self): '''Check that when load_error_block is false, an exception is raised, rather than returning an ErrorBlock''' bad_xml = '''''' system = self.get_system(False) self.assertRaises(etree.XMLSyntaxError, system.process_xml, bad_xml) def test_word_cloud_import(self): modulestore = XMLModuleStore(DATA_DIR, source_dirs=['word_cloud']) course = modulestore.get_courses()[0] chapters = course.get_children() ch1 = chapters[0] sections = ch1.get_children() assert len(sections) == 1 location = course.id.make_usage_key('word_cloud', 'cloud1') block = modulestore.get_item(location) assert len(block.get_children()) == 0 assert block.num_inputs == 5 assert block.num_top_words == 250 def test_cohort_config(self): """ Check that cohort config parsing works right. Note: The cohort config on the CourseBlock is no longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings. """ modulestore = XMLModuleStore(DATA_DIR, source_dirs=['toy']) toy_id = CourseKey.from_string('edX/toy/2012_Fall') course = modulestore.get_course(toy_id) # No config -> False assert not course.is_cohorted # empty config -> False course.cohort_config = {} assert not course.is_cohorted # false config -> False course.cohort_config = {'cohorted': False} assert not course.is_cohorted # and finally... course.cohort_config = {'cohorted': True} assert course.is_cohorted