Enable (and test) xml export of pure XBlocks
[LMS-179] [LMS-209] [LMS-1345]
This commit is contained in:
@@ -149,7 +149,7 @@ class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
|
||||
|
||||
for child_loc in group:
|
||||
child = self.system.load_item(child_loc)
|
||||
group_elem.append(etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
self.runtime.add_block_as_child_node(child, group_elem)
|
||||
|
||||
return xml_object
|
||||
|
||||
|
||||
@@ -222,9 +222,9 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
|
||||
show_tag_list = []
|
||||
for child in xml_object:
|
||||
if child.tag == 'show':
|
||||
location = ConditionalDescriptor.parse_sources(child, system)
|
||||
children.extend(location)
|
||||
show_tag_list.extend(location.url()) # pylint: disable=no-member
|
||||
locations = ConditionalDescriptor.parse_sources(child, system)
|
||||
children.extend(locations)
|
||||
show_tag_list.extend(location.url() for location in locations) # pylint: disable=no-member
|
||||
else:
|
||||
try:
|
||||
descriptor = system.process_xml(etree.tostring(child))
|
||||
@@ -244,6 +244,5 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
|
||||
tag_name='show', sources=location)
|
||||
xml_object.append(etree.fromstring(show_str))
|
||||
else:
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
self.runtime.add_block_as_child_node(child, xml_object)
|
||||
return xml_object
|
||||
|
||||
@@ -400,6 +400,5 @@ class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor):
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('crowdsource_hinter')
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
self.runtime.add_block_as_child_node(child, xml_object)
|
||||
return xml_object
|
||||
|
||||
@@ -3,6 +3,7 @@ Methods for exporting course data to XML
|
||||
"""
|
||||
|
||||
import logging
|
||||
import lxml.etree
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from fs.osfs import OSFS
|
||||
@@ -57,11 +58,13 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
course = modulestore.get_course(course_id)
|
||||
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
export_fs = course.runtime.export_fs = fs.makeopendir(course_dir)
|
||||
|
||||
root = lxml.etree.Element('unknown')
|
||||
course.add_xml_to_node(root)
|
||||
|
||||
xml = course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
lxml.etree.ElementTree(root).write(course_xml)
|
||||
|
||||
# export the static assets
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
@@ -112,7 +115,9 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
sequential = modulestore.get_item(Location(parent_locs[0]))
|
||||
index = sequential.children.index(draft_vertical.location.url())
|
||||
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
|
||||
draft_vertical.export_to_xml(draft_course_dir)
|
||||
draft_vertical.runtime.export_fs = draft_course_dir
|
||||
node = lxml.etree.Element('unknown')
|
||||
draft_vertical.add_xml_to_node(node)
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_id, course_location, category_type, dirname, file_suffix=''):
|
||||
|
||||
@@ -100,8 +100,7 @@ class RandomizeDescriptor(RandomizeFields, SequenceDescriptor):
|
||||
|
||||
xml_object = etree.Element('randomize')
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
self.runtime.add_block_as_child_node(child, xml_object)
|
||||
return xml_object
|
||||
|
||||
def has_dynamic_children(self):
|
||||
|
||||
@@ -150,6 +150,5 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('sequential')
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
self.runtime.add_block_as_child_node(child, xml_object)
|
||||
return xml_object
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
Tests of XML export
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from tempfile import mkdtemp
|
||||
import unittest
|
||||
import shutil
|
||||
from textwrap import dedent
|
||||
import ddt
|
||||
import lxml.etree
|
||||
import mock
|
||||
|
||||
import os
|
||||
import pytz
|
||||
import shutil
|
||||
import tarfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from fs.osfs import OSFS
|
||||
from path import path
|
||||
import uuid
|
||||
import tarfile
|
||||
import os
|
||||
from tempfile import mkdtemp
|
||||
from textwrap import dedent
|
||||
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import String, Scope, Integer
|
||||
from xblock.test.tools import blocks_are_equivalent
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
@@ -23,6 +29,7 @@ from xmodule.modulestore.xml_exporter import (
|
||||
)
|
||||
from xmodule.tests import DATA_DIR
|
||||
from xmodule.tests.helpers import directories_equal
|
||||
from xmodule.x_module import XModuleMixin
|
||||
|
||||
|
||||
def strip_filenames(descriptor):
|
||||
@@ -43,6 +50,16 @@ def strip_filenames(descriptor):
|
||||
descriptor.save()
|
||||
|
||||
|
||||
class PureXBlock(XBlock):
|
||||
|
||||
"""Class for testing pure XBlocks."""
|
||||
|
||||
has_children = True
|
||||
field1 = String(default="something", scope=Scope.user_state)
|
||||
field2 = Integer(scope=Scope.user_state)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RoundTripTestCase(unittest.TestCase):
|
||||
"""
|
||||
Check that our test courses roundtrip properly.
|
||||
@@ -51,8 +68,25 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
Thus we make sure that export and import work properly.
|
||||
"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
self.temp_dir = mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.temp_dir)
|
||||
|
||||
@mock.patch('xmodule.course_module.requests.get')
|
||||
def check_export_roundtrip(self, data_dir, course_dir, mock_get):
|
||||
@ddt.data(
|
||||
"toy",
|
||||
"simple",
|
||||
"conditional_and_poll",
|
||||
"self_assessment",
|
||||
"graphic_slider_tool",
|
||||
"test_exam_registration",
|
||||
"word_cloud",
|
||||
"pure_xblock",
|
||||
)
|
||||
@XBlock.register_temp_plugin(PureXBlock, 'pure')
|
||||
def test_export_roundtrip(self, course_dir, mock_get):
|
||||
|
||||
# Patch network calls to retrieve the textbook TOC
|
||||
mock_get.return_value.text = dedent("""
|
||||
@@ -64,11 +98,11 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
root_dir = path(self.temp_dir)
|
||||
print("Copying test course to temp dir {0}".format(root_dir))
|
||||
|
||||
data_dir = path(data_dir)
|
||||
data_dir = path(DATA_DIR)
|
||||
shutil.copytree(data_dir / course_dir, root_dir / course_dir)
|
||||
|
||||
print("Starting import")
|
||||
initial_import = XMLModuleStore(root_dir, course_dirs=[course_dir])
|
||||
initial_import = XMLModuleStore(root_dir, course_dirs=[course_dir], xblock_mixins=(XModuleMixin,))
|
||||
|
||||
courses = initial_import.get_courses()
|
||||
self.assertEquals(len(courses), 1)
|
||||
@@ -78,14 +112,15 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
# will still be there.
|
||||
print("Starting export")
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
initial_course.runtime.export_fs = fs.makeopendir(course_dir)
|
||||
root = lxml.etree.Element('root')
|
||||
|
||||
xml = initial_course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
initial_course.add_xml_to_node(root)
|
||||
with initial_course.runtime.export_fs.open('course.xml', 'w') as course_xml:
|
||||
lxml.etree.ElementTree(root).write(course_xml)
|
||||
|
||||
print("Starting second import")
|
||||
second_import = XMLModuleStore(root_dir, course_dirs=[course_dir])
|
||||
second_import = XMLModuleStore(root_dir, course_dirs=[course_dir], xblock_mixins=(XModuleMixin,))
|
||||
|
||||
courses2 = second_import.get_courses()
|
||||
self.assertEquals(len(courses2), 1)
|
||||
@@ -98,48 +133,24 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
strip_filenames(initial_course)
|
||||
strip_filenames(exported_course)
|
||||
|
||||
self.assertEquals(initial_course, exported_course)
|
||||
self.assertTrue(blocks_are_equivalent(initial_course, exported_course))
|
||||
self.assertEquals(initial_course.id, exported_course.id)
|
||||
course_id = initial_course.id
|
||||
|
||||
print("Checking key equality")
|
||||
self.assertEquals(sorted(initial_import.modules[course_id].keys()),
|
||||
sorted(second_import.modules[course_id].keys()))
|
||||
self.assertItemsEqual(
|
||||
initial_import.modules[course_id].keys(),
|
||||
second_import.modules[course_id].keys()
|
||||
)
|
||||
|
||||
print("Checking module equality")
|
||||
for location in initial_import.modules[course_id].keys():
|
||||
print("Checking", location)
|
||||
self.assertEquals(initial_import.modules[course_id][location],
|
||||
second_import.modules[course_id][location])
|
||||
self.assertTrue(blocks_are_equivalent(
|
||||
initial_import.modules[course_id][location],
|
||||
second_import.modules[course_id][location]
|
||||
))
|
||||
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
self.temp_dir = mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.temp_dir)
|
||||
|
||||
def test_toy_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "toy")
|
||||
|
||||
def test_simple_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "simple")
|
||||
|
||||
def test_conditional_and_poll_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "conditional_and_poll")
|
||||
|
||||
def test_selfassessment_roundtrip(self):
|
||||
#Test selfassessment xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR, "self_assessment")
|
||||
|
||||
def test_graphicslidertool_roundtrip(self):
|
||||
#Test graphicslidertool xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR, "graphic_slider_tool")
|
||||
|
||||
def test_exam_registration_roundtrip(self):
|
||||
# Test exam_registration xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR, "test_exam_registration")
|
||||
|
||||
def test_word_cloud_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "word_cloud")
|
||||
|
||||
|
||||
class TestEdxJsonEncoder(unittest.TestCase):
|
||||
|
||||
@@ -158,9 +158,9 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
system = self.get_system()
|
||||
descriptor = system.process_xml(bad_xml)
|
||||
|
||||
resource_fs = None
|
||||
tag_xml = descriptor.export_to_xml(resource_fs)
|
||||
re_import_descriptor = system.process_xml(tag_xml)
|
||||
node = etree.Element('unknown')
|
||||
descriptor.add_xml_to_node(node)
|
||||
re_import_descriptor = system.process_xml(etree.tostring(node))
|
||||
|
||||
self.assertEqual(re_import_descriptor.__class__.__name__, 'ErrorDescriptorWithMixins')
|
||||
|
||||
@@ -182,12 +182,11 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
descriptor = system.process_xml(xml_str_in)
|
||||
|
||||
# export it
|
||||
resource_fs = None
|
||||
xml_str_out = descriptor.export_to_xml(resource_fs)
|
||||
node = etree.Element('unknown')
|
||||
descriptor.add_xml_to_node(node)
|
||||
|
||||
# Now make sure the exported xml is a sequential
|
||||
xml_out = etree.fromstring(xml_str_out)
|
||||
self.assertEqual(xml_out.tag, 'sequential')
|
||||
self.assertEqual(node.tag, 'sequential')
|
||||
|
||||
def test_metadata_import_export(self):
|
||||
"""Two checks:
|
||||
@@ -221,19 +220,19 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
)
|
||||
|
||||
# Now export and check things
|
||||
resource_fs = MemoryFS()
|
||||
exported_xml = descriptor.export_to_xml(resource_fs)
|
||||
descriptor.runtime.export_fs = MemoryFS()
|
||||
node = etree.Element('unknown')
|
||||
descriptor.add_xml_to_node(node)
|
||||
|
||||
# Check that the exported xml is just a pointer
|
||||
print("Exported xml:", exported_xml)
|
||||
pointer = etree.fromstring(exported_xml)
|
||||
self.assertTrue(is_pointer_tag(pointer))
|
||||
print("Exported xml:", etree.tostring(node))
|
||||
self.assertTrue(is_pointer_tag(node))
|
||||
# but it's a special case course pointer
|
||||
self.assertEqual(pointer.attrib['course'], COURSE)
|
||||
self.assertEqual(pointer.attrib['org'], ORG)
|
||||
self.assertEqual(node.attrib['course'], COURSE)
|
||||
self.assertEqual(node.attrib['org'], ORG)
|
||||
|
||||
# Does the course still have unicorns?
|
||||
with resource_fs.open('course/{url_name}.xml'.format(url_name=url_name)) as f:
|
||||
with descriptor.runtime.export_fs.open('course/{url_name}.xml'.format(url_name=url_name)) as f:
|
||||
course_xml = etree.fromstring(f.read())
|
||||
|
||||
self.assertEqual(course_xml.attrib['unicorn'], 'purple')
|
||||
@@ -247,7 +246,7 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
|
||||
# Does the chapter tag now have a due attribute?
|
||||
# hardcoded path to child
|
||||
with resource_fs.open('chapter/ch.xml') as f:
|
||||
with descriptor.runtime.export_fs.open('chapter/ch.xml') as f:
|
||||
chapter_xml = etree.fromstring(f.read())
|
||||
self.assertEqual(chapter_xml.tag, 'chapter')
|
||||
self.assertFalse('due' in chapter_xml.attrib)
|
||||
|
||||
@@ -222,6 +222,7 @@ class XModuleMixin(XBlockMixin):
|
||||
for child_loc in self.children:
|
||||
try:
|
||||
child = self.runtime.get_block(child_loc)
|
||||
child.runtime.export_fs = self.runtime.export_fs
|
||||
except ItemNotFoundError:
|
||||
log.exception(u'Unable to load item {loc}, skipping'.format(loc=child_loc))
|
||||
continue
|
||||
@@ -685,6 +686,21 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
"""
|
||||
raise NotImplementedError('Modules must implement from_xml to be parsable from xml')
|
||||
|
||||
def add_xml_to_node(self, node):
|
||||
"""
|
||||
Export this :class:`XModuleDescriptor` as XML, by setting attributes on the provided
|
||||
`node`.
|
||||
"""
|
||||
xml_string = self.export_to_xml(self.runtime.export_fs)
|
||||
exported_node = etree.fromstring(xml_string)
|
||||
node.tag = exported_node.tag
|
||||
node.text = exported_node.text
|
||||
node.tail = exported_node.tail
|
||||
for key, value in exported_node.items():
|
||||
node.set(key, value)
|
||||
|
||||
node.extend(list(exported_node))
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
"""
|
||||
Returns an xml string representing this module, and all modules
|
||||
@@ -926,6 +942,9 @@ class DescriptorSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable
|
||||
"""
|
||||
super(DescriptorSystem, self).__init__(**kwargs)
|
||||
|
||||
# This is used by XModules to write out separate files during xml export
|
||||
self.export_fs = None
|
||||
|
||||
self.load_item = load_item
|
||||
self.resources_fs = resources_fs
|
||||
self.error_tracker = error_tracker
|
||||
@@ -996,6 +1015,11 @@ class DescriptorSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable
|
||||
def publish(self, block, event):
|
||||
raise NotImplementedError("edX Platform doesn't currently implement XBlock publish")
|
||||
|
||||
def add_block_as_child_node(self, block, node):
|
||||
child = etree.SubElement(node, "unknown")
|
||||
child.set('url_name', block.url_name)
|
||||
block.add_xml_to_node(child)
|
||||
|
||||
|
||||
class XMLParsingSystem(DescriptorSystem):
|
||||
def __init__(self, process_xml, **kwargs):
|
||||
|
||||
8
common/test/data/pure_xblock/course.xml
Normal file
8
common/test/data/pure_xblock/course.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<course org="edX" course="pure_xblock" url_name="2012_Fall">
|
||||
<pure field1="a.field1" url_name="pure0">
|
||||
<vertical url_name="vert0">
|
||||
<pure field2="10" url_name="pure1"/>
|
||||
<pure field2="20" url_name="pure2"/>
|
||||
</vertical>
|
||||
</pure>
|
||||
</course>
|
||||
@@ -1,3 +1,4 @@
|
||||
import lxml.etree
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
@@ -30,9 +31,11 @@ def export(course, export_dir):
|
||||
' May clobber/confuse things'.format(dir=export_dir))
|
||||
|
||||
try:
|
||||
xml = course.export_to_xml(fs)
|
||||
course.runtime.export_fs = fs
|
||||
root = lxml.etree.Element('root')
|
||||
course.add_xml_to_node(root)
|
||||
with fs.open('course.xml', mode='w') as f:
|
||||
f.write(xml)
|
||||
root.write(f)
|
||||
|
||||
return True
|
||||
except:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
|
||||
|
||||
# Our libraries:
|
||||
-e git+https://github.com/edx/XBlock.git@de92d3bf798699a6bbd06b54012ef15934c41ac0#egg=XBlock
|
||||
-e git+https://github.com/edx/XBlock.git@3830ee50015b460fad63ff3b71f77bf1a2684195#egg=XBlock
|
||||
-e git+https://github.com/edx/codejail.git@e3d98f9455#egg=codejail
|
||||
-e git+https://github.com/edx/diff-cover.git@v0.2.9#egg=diff_cover
|
||||
-e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool
|
||||
|
||||
Reference in New Issue
Block a user