Commit 05bddae3 by Don Mitchell

Merge pull request #4633 from edx/dhm/create_xthing

Make import/export work with split mongo, replace create_xmodule with create_xblock
parents 452d6965 192d7018
...@@ -40,7 +40,7 @@ class Command(BaseCommand): ...@@ -40,7 +40,7 @@ class Command(BaseCommand):
dis=do_import_static)) dis=do_import_static))
mstore = modulestore() mstore = modulestore()
_, course_items = import_from_xml( course_items = import_from_xml(
mstore, ModuleStoreEnum.UserID.mgmt_command, data_dir, course_dirs, load_error_modules=False, mstore, ModuleStoreEnum.UserID.mgmt_command, data_dir, course_dirs, load_error_modules=False,
static_content_store=contentstore(), verbose=True, static_content_store=contentstore(), verbose=True,
do_import_static=do_import_static, do_import_static=do_import_static,
......
...@@ -30,7 +30,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -30,7 +30,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation, CourseLocator from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation, CourseLocator
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
...@@ -54,16 +54,6 @@ TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) ...@@ -54,16 +54,6 @@ TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
class MongoCollectionFindWrapper(object):
def __init__(self, original):
self.original = original
self.counter = 0
def find(self, query, *args, **kwargs):
self.counter = self.counter + 1
return self.original(query, *args, **kwargs)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class ContentStoreTestCase(CourseTestCase): class ContentStoreTestCase(CourseTestCase):
""" """
...@@ -89,7 +79,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -89,7 +79,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
component_types should cause 'Video' to be present. component_types should cause 'Video' to be present.
""" """
store = self.store store = self.store
_, course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple'])
course = course_items[0] course = course_items[0]
course.advanced_modules = component_types course.advanced_modules = component_types
store.update_item(course, self.user.id) store.update_item(course, self.user.id)
...@@ -116,7 +106,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -116,7 +106,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
def test_malformed_edit_unit_request(self): def test_malformed_edit_unit_request(self):
store = self.store store = self.store
_, course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple'])
# just pick one vertical # just pick one vertical
usage_key = course_items[0].id.make_usage_key('vertical', None) usage_key = course_items[0].id.make_usage_key('vertical', None)
...@@ -126,7 +116,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -126,7 +116,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
def check_edit_unit(self, test_course_name): def check_edit_unit(self, test_course_name):
"""Verifies the editing HTML in all the verticals in the given test course""" """Verifies the editing HTML in all the verticals in the given test course"""
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', [test_course_name]) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', [test_course_name])
items = self.store.get_items(course_items[0].id, qualifiers={'category': 'vertical'}) items = self.store.get_items(course_items[0].id, qualifiers={'category': 'vertical'})
self._check_verticals(items) self._check_verticals(items)
...@@ -148,7 +138,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -148,7 +138,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
both draft and non-draft copies. both draft and non-draft copies.
''' '''
store = self.store store = self.store
_, course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple'])
course_key = course_items[0].id course_key = course_items[0].id
html_usage_key = course_key.make_usage_key('html', 'test_html') html_usage_key = course_key.make_usage_key('html', 'test_html')
...@@ -263,7 +253,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -263,7 +253,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
self.assertEqual(num_drafts, 1) self.assertEqual(num_drafts, 1)
def test_no_static_link_rewrites_on_import(self): def test_no_static_link_rewrites_on_import(self):
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course = course_items[0] course = course_items[0]
handouts_usage_key = course.id.make_usage_key('course_info', 'handouts') handouts_usage_key = course.id.make_usage_key('course_info', 'handouts')
...@@ -287,7 +277,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -287,7 +277,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
self.assertGreater(len(course.textbooks), 0) self.assertGreater(len(course.textbooks), 0)
def test_import_polls(self): def test_import_polls(self):
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course_key = course_items[0].id course_key = course_items[0].id
items = self.store.get_items(course_key, qualifiers={'category': 'poll_question'}) items = self.store.get_items(course_key, qualifiers={'category': 'poll_question'})
...@@ -307,7 +297,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -307,7 +297,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
Tests the ajax callback to render an XModule Tests the ajax callback to render an XModule
""" """
direct_store = self.store direct_store = self.store
_, course_items = import_from_xml(direct_store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(direct_store, self.user.id, 'common/test/data/', ['toy'])
usage_key = course_items[0].id.make_usage_key('vertical', 'vertical_test') usage_key = course_items[0].id.make_usage_key('vertical', 'vertical_test')
# also try a custom response which will trigger the 'is this course in whitelist' logic # also try a custom response which will trigger the 'is this course in whitelist' logic
resp = self.client.get_json( resp = self.client.get_json(
...@@ -357,7 +347,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -357,7 +347,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
while there is a base definition in /about/effort.html while there is a base definition in /about/effort.html
''' '''
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course_key = course_items[0].id course_key = course_items[0].id
effort = self.store.get_item(course_key.make_usage_key('about', 'effort')) effort = self.store.get_item(course_key.make_usage_key('about', 'effort'))
self.assertEqual(effort.data, '6 hours') self.assertEqual(effort.data, '6 hours')
...@@ -460,7 +450,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -460,7 +450,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
content_store = contentstore() content_store = contentstore()
trash_store = contentstore('trashcan') trash_store = contentstore('trashcan')
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'], static_content_store=content_store) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'], static_content_store=content_store)
# look up original (and thumbnail) in content store, should be there after import # look up original (and thumbnail) in content store, should be there after import
location = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.txt') location = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.txt')
...@@ -618,7 +608,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -618,7 +608,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
""" """
content_store = contentstore() content_store = contentstore()
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'], static_content_store=content_store) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'], static_content_store=content_store)
course_id = course_items[0].id course_id = course_items[0].id
...@@ -845,7 +835,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -845,7 +835,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
def test_course_handouts_rewrites(self): def test_course_handouts_rewrites(self):
# import a test course # import a test course
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course_id = course_items[0].id course_id = course_items[0].id
handouts_location = course_id.make_usage_key('course_info', 'handouts') handouts_location = course_id.make_usage_key('course_info', 'handouts')
...@@ -864,18 +854,17 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -864,18 +854,17 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')
wrapper = MongoCollectionFindWrapper(mongo_store.collection.find)
mongo_store.collection.find = wrapper.find
# set the branch to 'publish' in order to prevent extra lookups of draft versions
with mongo_store.branch_setting(ModuleStoreEnum.Branch.published_only):
course = mongo_store.get_course(course_id, depth=2)
# make sure we haven't done too many round trips to DB # make sure we haven't done too many round trips to DB
# note we say 3 round trips here for 1) the course, and 2 & 3) for the chapters and sequentials # note we say 4 round trips here for:
# 1) to get the run id
# 2) the course,
# 3 & 4) for the chapters and sequentials
# Because we're querying from the top of the tree, we cache information needed for inheritance, # Because we're querying from the top of the tree, we cache information needed for inheritance,
# so we don't need to make an extra query to compute it. # so we don't need to make an extra query to compute it.
self.assertEqual(wrapper.counter, 3) # set the branch to 'publish' in order to prevent extra lookups of draft versions
with mongo_store.branch_setting(ModuleStoreEnum.Branch.published_only):
with check_mongo_calls(mongo_store, 4, 0):
course = mongo_store.get_course(course_id, depth=2)
# make sure we pre-fetched a known sequential which should be at depth=2 # make sure we pre-fetched a known sequential which should be at depth=2
self.assertTrue(course_id.make_usage_key('sequential', 'vertical_sequential') in course.system.module_data) self.assertTrue(course_id.make_usage_key('sequential', 'vertical_sequential') in course.system.module_data)
...@@ -883,19 +872,16 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ...@@ -883,19 +872,16 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
# make sure we don't have a specific vertical which should be at depth=3 # make sure we don't have a specific vertical which should be at depth=3
self.assertFalse(course_id.make_usage_key('vertical', 'vertical_test') in course.system.module_data) self.assertFalse(course_id.make_usage_key('vertical', 'vertical_test') in course.system.module_data)
# Now, test with the branch set to draft. We should have one extra round trip call to check for # Now, test with the branch set to draft. No extra round trips b/c it doesn't go deep enough to get
# the existence of the draft versions # beyond direct only categories
wrapper.counter = 0 with mongo_store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
with check_mongo_calls(mongo_store, 4, 0):
mongo_store.get_course(course_id, depth=2) mongo_store.get_course(course_id, depth=2)
self.assertEqual(wrapper.counter, 4)
def test_export_course_without_content_store(self): def test_export_course_without_content_store(self):
content_store = contentstore()
# Create toy course # Create toy course
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course_id = course_items[0].id course_id = course_items[0].id
root_dir = path(mkdtemp_clean()) root_dir = path(mkdtemp_clean())
...@@ -1271,7 +1257,7 @@ class ContentStoreTest(ContentStoreTestCase): ...@@ -1271,7 +1257,7 @@ class ContentStoreTest(ContentStoreTestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['simple']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['simple'])
course_key = course_items[0].id course_key = course_items[0].id
resp = self._show_course_overview(course_key) resp = self._show_course_overview(course_key)
...@@ -1400,7 +1386,7 @@ class ContentStoreTest(ContentStoreTestCase): ...@@ -1400,7 +1386,7 @@ class ContentStoreTest(ContentStoreTestCase):
self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$') self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$')
def test_metadata_inheritance(self): def test_metadata_inheritance(self):
_, course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'])
course = course_items[0] course = course_items[0]
verticals = self.store.get_items(course.id, qualifiers={'category': 'vertical'}) verticals = self.store.get_items(course.id, qualifiers={'category': 'vertical'})
...@@ -1412,35 +1398,32 @@ class ContentStoreTest(ContentStoreTestCase): ...@@ -1412,35 +1398,32 @@ class ContentStoreTest(ContentStoreTestCase):
self.assertGreater(len(verticals), 0) self.assertGreater(len(verticals), 0)
new_component_location = course.id.make_usage_key('html', 'new_component')
# crate a new module and add it as a child to a vertical # crate a new module and add it as a child to a vertical
new_object = self.store.create_xmodule(new_component_location)
self.store.update_item(new_object, self.user.id, allow_not_found=True)
parent = verticals[0] parent = verticals[0]
parent.children.append(new_component_location) new_block = self.store.create_child(
self.store.update_item(parent, self.user.id) self.user.id, parent.location, 'html', 'new_component'
)
# flush the cache # flush the cache
new_module = self.store.get_item(new_component_location) new_block = self.store.get_item(new_block.location)
# check for grace period definition which should be defined at the course level # check for grace period definition which should be defined at the course level
self.assertEqual(parent.graceperiod, new_module.graceperiod) self.assertEqual(parent.graceperiod, new_block.graceperiod)
self.assertEqual(parent.start, new_module.start) self.assertEqual(parent.start, new_block.start)
self.assertEqual(course.start, new_module.start) self.assertEqual(course.start, new_block.start)
self.assertEqual(course.xqa_key, new_module.xqa_key) self.assertEqual(course.xqa_key, new_block.xqa_key)
# #
# now let's define an override at the leaf node level # now let's define an override at the leaf node level
# #
new_module.graceperiod = timedelta(1) new_block.graceperiod = timedelta(1)
self.store.update_item(new_module, self.user.id) self.store.update_item(new_block, self.user.id)
# flush the cache and refetch # flush the cache and refetch
new_module = self.store.get_item(new_component_location) new_block = self.store.get_item(new_block.location)
self.assertEqual(timedelta(1), new_module.graceperiod) self.assertEqual(timedelta(1), new_block.graceperiod)
def test_default_metadata_inheritance(self): def test_default_metadata_inheritance(self):
course = CourseFactory.create() course = CourseFactory.create()
...@@ -1469,7 +1452,7 @@ class ContentStoreTest(ContentStoreTestCase): ...@@ -1469,7 +1452,7 @@ class ContentStoreTest(ContentStoreTestCase):
content_store = contentstore() content_store = contentstore()
# Use conditional_and_poll, as it's got an image already # Use conditional_and_poll, as it's got an image already
__, courses = import_from_xml( courses = import_from_xml(
self.store, self.store,
self.user.id, self.user.id,
'common/test/data/', 'common/test/data/',
......
...@@ -89,7 +89,8 @@ class TemplateTests(unittest.TestCase): ...@@ -89,7 +89,8 @@ class TemplateTests(unittest.TestCase):
) )
test_chapter = self.split_store.create_xblock( test_chapter = self.split_store.create_xblock(
test_course.system, 'chapter', {'display_name': 'chapter n'}, parent_xblock=test_course test_course.system, test_course.id, 'chapter', fields={'display_name': 'chapter n'},
parent_xblock=test_course
) )
self.assertIsInstance(test_chapter, SequenceDescriptor) self.assertIsInstance(test_chapter, SequenceDescriptor)
self.assertEqual(test_chapter.display_name, 'chapter n') self.assertEqual(test_chapter.display_name, 'chapter n')
...@@ -98,7 +99,8 @@ class TemplateTests(unittest.TestCase): ...@@ -98,7 +99,8 @@ class TemplateTests(unittest.TestCase):
# test w/ a definition (e.g., a problem) # test w/ a definition (e.g., a problem)
test_def_content = '<problem>boo</problem>' test_def_content = '<problem>boo</problem>'
test_problem = self.split_store.create_xblock( test_problem = self.split_store.create_xblock(
test_course.system, 'problem', {'data': test_def_content}, parent_xblock=test_chapter test_course.system, test_course.id, 'problem', fields={'data': test_def_content},
parent_xblock=test_chapter
) )
self.assertIsInstance(test_problem, CapaDescriptor) self.assertIsInstance(test_problem, CapaDescriptor)
self.assertEqual(test_problem.data, test_def_content) self.assertEqual(test_problem.data, test_def_content)
...@@ -115,13 +117,14 @@ class TemplateTests(unittest.TestCase): ...@@ -115,13 +117,14 @@ class TemplateTests(unittest.TestCase):
display_name='fun test course', user_id='testbot' display_name='fun test course', user_id='testbot'
) )
test_chapter = self.split_store.create_xblock( test_chapter = self.split_store.create_xblock(
test_course.system, 'chapter', {'display_name': 'chapter n'}, parent_xblock=test_course test_course.system, test_course.id, 'chapter', fields={'display_name': 'chapter n'},
parent_xblock=test_course
) )
self.assertEqual(test_chapter.display_name, 'chapter n') self.assertEqual(test_chapter.display_name, 'chapter n')
test_def_content = '<problem>boo</problem>' test_def_content = '<problem>boo</problem>'
# create child # create child
new_block = self.split_store.create_xblock( new_block = self.split_store.create_xblock(
test_course.system, test_course.system, test_course.id,
'problem', 'problem',
fields={ fields={
'data': test_def_content, 'data': test_def_content,
...@@ -160,13 +163,13 @@ class TemplateTests(unittest.TestCase): ...@@ -160,13 +163,13 @@ class TemplateTests(unittest.TestCase):
guid_locator = test_course.location.course_agnostic() guid_locator = test_course.location.course_agnostic()
# verify it can be retrieved by id # verify it can be retrieved by id
self.assertIsInstance(self.split_store.get_course(id_locator), CourseDescriptor) self.assertIsInstance(self.split_store.get_course(id_locator), CourseDescriptor)
# and by guid # and by guid -- TODO reenable when split_draft supports getting specific versions
self.assertIsInstance(self.split_store.get_item(guid_locator), CourseDescriptor) # self.assertIsInstance(self.split_store.get_item(guid_locator), CourseDescriptor)
self.split_store.delete_course(id_locator, 'testbot') self.split_store.delete_course(id_locator, 'testbot')
# test can no longer retrieve by id # test can no longer retrieve by id
self.assertRaises(ItemNotFoundError, self.split_store.get_course, id_locator) self.assertRaises(ItemNotFoundError, self.split_store.get_course, id_locator)
# but can by guid # but can by guid -- same TODO as above
self.assertIsInstance(self.split_store.get_item(guid_locator), CourseDescriptor) # self.assertIsInstance(self.split_store.get_item(guid_locator), CourseDescriptor)
def test_block_generations(self): def test_block_generations(self):
""" """
......
...@@ -64,7 +64,7 @@ class ContentStoreImportTest(ModuleStoreTestCase): ...@@ -64,7 +64,7 @@ class ContentStoreImportTest(ModuleStoreTestCase):
# edx/course can be imported into a namespace with an org/course # edx/course can be imported into a namespace with an org/course
# like edx/course_name # like edx/course_name
module_store, __, course = self.load_test_import_course() module_store, __, course = self.load_test_import_course()
__, course_items = import_from_xml( course_items = import_from_xml(
module_store, module_store,
self.user.id, self.user.id,
'common/test/data', 'common/test/data',
...@@ -139,7 +139,7 @@ class ContentStoreImportTest(ModuleStoreTestCase): ...@@ -139,7 +139,7 @@ class ContentStoreImportTest(ModuleStoreTestCase):
def test_no_static_link_rewrites_on_import(self): def test_no_static_link_rewrites_on_import(self):
module_store = modulestore() module_store = modulestore()
_, courses = import_from_xml(module_store, self.user.id, 'common/test/data/', ['toy'], do_import_static=False, verbose=True) courses = import_from_xml(module_store, self.user.id, 'common/test/data/', ['toy'], do_import_static=False, verbose=True)
course_key = courses[0].id course_key = courses[0].id
handouts = module_store.get_item(course_key.make_usage_key('course_info', 'handouts')) handouts = module_store.get_item(course_key.make_usage_key('course_info', 'handouts'))
...@@ -157,10 +157,10 @@ class ContentStoreImportTest(ModuleStoreTestCase): ...@@ -157,10 +157,10 @@ class ContentStoreImportTest(ModuleStoreTestCase):
store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo)
# we try to refresh the inheritance tree for each update_item in the import # we try to refresh the inheritance tree for each update_item in the import
with check_exact_number_of_calls(store, store.refresh_cached_metadata_inheritance_tree, 46): with check_exact_number_of_calls(store, store.refresh_cached_metadata_inheritance_tree, 28):
# the post-publish step loads each item in the subtree, which calls _get_cached_metadata_inheritance_tree # _get_cached_metadata_inheritance_tree should be called only once
with check_exact_number_of_calls(store, store._get_cached_metadata_inheritance_tree, 22): with check_exact_number_of_calls(store, store._get_cached_metadata_inheritance_tree, 1):
# with bulk-edit in progress, the inheritance tree should be recomputed only at the end of the import # with bulk-edit in progress, the inheritance tree should be recomputed only at the end of the import
# NOTE: On Jenkins, with memcache enabled, the number of calls here is only 1. # NOTE: On Jenkins, with memcache enabled, the number of calls here is only 1.
......
...@@ -10,7 +10,7 @@ class DraftReorderTestCase(ModuleStoreTestCase): ...@@ -10,7 +10,7 @@ class DraftReorderTestCase(ModuleStoreTestCase):
def test_order(self): def test_order(self):
store = modulestore() store = modulestore()
_, course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['import_draft_order']) course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['import_draft_order'])
course_key = course_items[0].id course_key = course_items[0].id
sequential = store.get_item(course_key.make_usage_key('sequential', '0f4f7649b10141b0bdc9922dcf94515a')) sequential = store.get_item(course_key.make_usage_key('sequential', '0f4f7649b10141b0bdc9922dcf94515a'))
verticals = sequential.children verticals = sequential.children
......
...@@ -58,7 +58,7 @@ class XBlockImportTest(ModuleStoreTestCase): ...@@ -58,7 +58,7 @@ class XBlockImportTest(ModuleStoreTestCase):
the expected field value set. the expected field value set.
""" """
_, courses = import_from_xml( courses = import_from_xml(
self.store, self.user.id, 'common/test/data', [course_dir] self.store, self.user.id, 'common/test/data', [course_dir]
) )
......
...@@ -15,6 +15,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory ...@@ -15,6 +15,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from opaque_keys.edx.locator import CourseLocator
class LMSLinksTestCase(TestCase): class LMSLinksTestCase(TestCase):
...@@ -249,16 +250,15 @@ class XBlockVisibilityTestCase(TestCase): ...@@ -249,16 +250,15 @@ class XBlockVisibilityTestCase(TestCase):
def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False): def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False):
"""Helper to create an xblock with a start date, optionally publishing it""" """Helper to create an xblock with a start date, optionally publishing it"""
location = Location('edX', 'visibility', '2012_Fall', 'vertical', name) course_key = CourseLocator('edX', 'visibility', '2012_Fall')
vertical = modulestore().create_xmodule(location) vertical = modulestore().create_item(
vertical.start = start_date self.dummy_user, course_key, 'vertical', name,
if visible_to_staff_only: fields={'start': start_date, 'visible_to_staff_only': visible_to_staff_only}
vertical.visible_to_staff_only = visible_to_staff_only )
modulestore().update_item(vertical, self.dummy_user, allow_not_found=True)
if publish: if publish:
modulestore().publish(location, self.dummy_user) modulestore().publish(vertical.location, self.dummy_user)
return vertical return vertical
......
...@@ -247,7 +247,14 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -247,7 +247,14 @@ class CourseTestCase(ModuleStoreTestCase):
course1_items = self.store.get_items(course1_id) course1_items = self.store.get_items(course1_id)
course2_items = self.store.get_items(course2_id) course2_items = self.store.get_items(course2_id)
self.assertGreater(len(course1_items), 0) # ensure it found content instead of [] == [] self.assertGreater(len(course1_items), 0) # ensure it found content instead of [] == []
self.assertEqual(len(course1_items), len(course2_items)) if len(course1_items) != len(course2_items):
course1_block_ids = set([item.location.block_id for item in course1_items])
course2_block_ids = set([item.location.block_id for item in course2_items])
raise AssertionError(
u"Course1 extra blocks: {}; course2 extra blocks: {}".format(
course1_block_ids - course2_block_ids, course2_block_ids - course1_block_ids
)
)
for course1_item in course1_items: for course1_item in course1_items:
course2_item_location = course1_item.location.map_into_course(course2_id) course2_item_location = course1_item.location.map_into_course(course2_id)
......
...@@ -214,7 +214,7 @@ def import_handler(request, course_key_string): ...@@ -214,7 +214,7 @@ def import_handler(request, course_key_string):
logging.debug('found course.xml at {0}'.format(dirpath)) logging.debug('found course.xml at {0}'.format(dirpath))
_module_store, course_items = import_from_xml( course_items = import_from_xml(
modulestore(), modulestore(),
request.user.id, request.user.id,
settings.GITHUB_REPO_ROOT, settings.GITHUB_REPO_ROOT,
......
...@@ -49,7 +49,7 @@ class BasicAssetsTestCase(AssetsTestCase): ...@@ -49,7 +49,7 @@ class BasicAssetsTestCase(AssetsTestCase):
def test_pdf_asset(self): def test_pdf_asset(self):
module_store = modulestore() module_store = modulestore()
_, course_items = import_from_xml( course_items = import_from_xml(
module_store, module_store,
self.user.id, self.user.id,
'common/test/data/', 'common/test/data/',
...@@ -193,7 +193,7 @@ class LockAssetTestCase(AssetsTestCase): ...@@ -193,7 +193,7 @@ class LockAssetTestCase(AssetsTestCase):
# Load the toy course. # Load the toy course.
module_store = modulestore() module_store = modulestore()
_, course_items = import_from_xml( course_items = import_from_xml(
module_store, module_store,
self.user.id, self.user.id,
'common/test/data/', 'common/test/data/',
......
...@@ -91,7 +91,7 @@ class CourseDetails(object): ...@@ -91,7 +91,7 @@ class CourseDetails(object):
try: try:
about_item = store.get_item(temploc) about_item = store.get_item(temploc)
except ItemNotFoundError: except ItemNotFoundError:
about_item = store.create_xmodule(temploc, runtime=course.runtime) about_item = store.create_xblock(course.runtime, course.id, 'about', about_key)
about_item.data = data about_item.data = data
store.update_item(about_item, user.id) store.update_item(about_item, user.id)
......
...@@ -588,6 +588,27 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): ...@@ -588,6 +588,27 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
result[field.scope][field_name] = value result[field.scope][field_name] = value
return result return result
def create_course(self, org, course, run, user_id, fields=None, runtime=None, **kwargs):
"""
Creates any necessary other things for the course as a side effect and doesn't return
anything useful. The real subclass should call this before it returns the course.
"""
# clone a default 'about' overview module as well
about_location = self.make_course_key(org, course, run).make_usage_key('about', 'overview')
about_descriptor = XBlock.load_class('about')
overview_template = about_descriptor.get_template('overview.yaml')
self.create_item(
user_id,
about_location.course_key,
about_location.block_type,
block_id=about_location.block_id,
definition_data={'data': overview_template.get('data')},
metadata=overview_template.get('metadata'),
runtime=runtime,
continue_version=True,
)
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs): def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs):
""" """
This base method just copies the assets. The lower level impls must do the actual cloning of This base method just copies the assets. The lower level impls must do the actual cloning of
......
...@@ -103,6 +103,17 @@ class ModuleStoreDraftAndPublished(BranchSettingMixin): ...@@ -103,6 +103,17 @@ class ModuleStoreDraftAndPublished(BranchSettingMixin):
def convert_to_draft(self, location, user_id): def convert_to_draft(self, location, user_id):
raise NotImplementedError raise NotImplementedError
@abstractmethod
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
"""
Import the given xblock into the current branch setting: import completely overwrites any
existing block of the same id.
In ModuleStoreDraftAndPublished, importing a published block ensures that access from the draft
will get a block (either the one imported or a preexisting one). See xml_importer
"""
raise NotImplementedError
class UnsupportedRevisionError(ValueError): class UnsupportedRevisionError(ValueError):
""" """
......
...@@ -63,6 +63,12 @@ class VersionConflictError(Exception): ...@@ -63,6 +63,12 @@ class VersionConflictError(Exception):
self.requestedLocation = requestedLocation self.requestedLocation = requestedLocation
self.currentHeadVersionGuid = currentHeadVersionGuid self.currentHeadVersionGuid = currentHeadVersionGuid
def __str__(self, *args, **kwargs):
"""
Print requested and current head info
"""
return u'Requested {} but {} is current head'.format(self.requestedLocation, self.currentHeadVersionGuid)
class DuplicateCourseError(Exception): class DuplicateCourseError(Exception):
""" """
......
...@@ -170,6 +170,15 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -170,6 +170,15 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
# return the default store # return the default store
return self.default_modulestore return self.default_modulestore
# return the first store, as the default
return self.default_modulestore
@property
def default_modulestore(self):
"""
Return the default modulestore
"""
return self.modulestores[0]
def _get_modulestore_by_type(self, modulestore_type): def _get_modulestore_by_type(self, modulestore_type):
""" """
...@@ -444,6 +453,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -444,6 +453,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return modulestore.create_child(user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs) return modulestore.create_child(user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs)
@strip_key @strip_key
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
"""
See :py:meth `ModuleStoreDraftAndPublished.import_xblock`
Defer to the course's modulestore if it supports this method
"""
store = self._verify_modulestore_support(course_key, 'import_xblock')
return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime)
@strip_key
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs): def update_item(self, xblock, user_id, allow_not_found=False, **kwargs):
""" """
Update the xblock persisted to be the same as the given for all types of fields Update the xblock persisted to be the same as the given for all types of fields
...@@ -491,18 +510,21 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -491,18 +510,21 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
modulestore._drop_database() # pylint: disable=protected-access modulestore._drop_database() # pylint: disable=protected-access
@strip_key @strip_key
def create_xmodule(self, location, definition_data=None, metadata=None, runtime=None, fields={}, **kwargs): def create_xblock(self, runtime, course_key, block_type, block_id=None, fields=None, **kwargs):
""" """
Create the new xmodule but don't save it. Returns the new module. Create the new xmodule but don't save it. Returns the new module.
:param location: a Location--must have a category Args:
:param definition_data: can be empty. The initial definition_data for the kvs runtime: :py:class `xblock.runtime` from another xblock in the same course. Providing this
:param metadata: can be empty, the initial metadata for the kvs significantly speeds up processing (inheritance and subsequent persistence)
:param runtime: if you already have an xblock from the course, the xblock.runtime value course_key: :py:class `opaque_keys.CourseKey`
:param fields: a dictionary of field names and values for the new xmodule block_type: :py:class `string`: the string identifying the xblock type
""" block_id: the string uniquely identifying the block within the given course
store = self._verify_modulestore_support(location.course_key, 'create_xmodule') fields: :py:class `dict` field_name, value pairs for initializing the xblock fields. Values
return store.create_xmodule(location, definition_data, metadata, runtime, fields, **kwargs) should be the pythonic types not the json serialized ones.
"""
store = self._verify_modulestore_support(course_key, 'create_xblock')
return store.create_xblock(runtime, course_key, block_type, block_id, fields or {}, **kwargs)
@strip_key @strip_key
def get_courses_for_wiki(self, wiki_slug, **kwargs): def get_courses_for_wiki(self, wiki_slug, **kwargs):
......
...@@ -29,7 +29,6 @@ from importlib import import_module ...@@ -29,7 +29,6 @@ from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str from xmodule.errortracker import null_error_tracker, exc_info_to_str
from xmodule.mako_module import MakoDescriptorSystem from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.html_module import AboutDescriptor
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
from xblock.exceptions import InvalidScopeError from xblock.exceptions import InvalidScopeError
from xblock.fields import Scope, ScopeIds, Reference, ReferenceList, ReferenceValueDict from xblock.fields import Scope, ScopeIds, Reference, ReferenceList, ReferenceValueDict
...@@ -476,6 +475,16 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -476,6 +475,16 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return course_key.replace(run=self._course_run_cache[cache_key]) return course_key.replace(run=self._course_run_cache[cache_key])
def for_branch_setting(self, location):
"""
Returns the Location that is for the current branch setting.
"""
if location.category in DIRECT_ONLY_CATEGORIES:
return location.replace(revision=MongoRevisionKey.published)
if self.get_branch_setting() == ModuleStoreEnum.Branch.draft_preferred:
return location.replace(revision=MongoRevisionKey.draft)
return location.replace(revision=MongoRevisionKey.published)
def _compute_metadata_inheritance_tree(self, course_id): def _compute_metadata_inheritance_tree(self, course_id):
''' '''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
...@@ -938,50 +947,38 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -938,50 +947,38 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
if courses.count() > 0: if courses.count() > 0:
raise DuplicateCourseError(course_id, courses[0]['_id']) raise DuplicateCourseError(course_id, courses[0]['_id'])
location = course_id.make_usage_key('course', course_id.run) xblock = self.create_item(user_id, course_id, 'course', course_id.run, fields=fields, **kwargs)
course = self.create_xmodule(
location,
fields=fields,
**kwargs
)
self.update_item(course, user_id, allow_not_found=True)
# clone a default 'about' overview module as well # create any other necessary things as a side effect
about_location = location.replace( super(MongoModuleStore, self).create_course(
category='about', org, course, run, user_id, runtime=xblock.runtime, **kwargs
name='overview'
)
overview_template = AboutDescriptor.get_template('overview.yaml')
self.create_item(
user_id,
about_location.course_key,
about_location.block_type,
block_id=about_location.block_id,
definition_data=overview_template.get('data'),
runtime=course.system
) )
return course return xblock
def create_xmodule(self, location, definition_data=None, metadata=None, runtime=None, fields={}, **kwargs): def create_xblock(
self, runtime, course_key, block_type, block_id=None, fields=None,
metadata=None, definition_data=None, **kwargs
):
""" """
Create the new xmodule but don't save it. Returns the new module. Create the new xblock but don't save it. Returns the new module.
:param location: a Location--must have a category
:param definition_data: can be empty. The initial definition_data for the kvs
:param metadata: can be empty, the initial metadata for the kvs
:param runtime: if you already have an xblock from the course, the xblock.runtime value :param runtime: if you already have an xblock from the course, the xblock.runtime value
:param fields: a dictionary of field names and values for the new xmodule :param fields: a dictionary of field names and values for the new xmodule
""" """
location = location.replace(run=self.fill_in_run(location.course_key).run)
# differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these.
if metadata is None: if metadata is None:
metadata = {} metadata = {}
if definition_data is None: if definition_data is None:
definition_data = {} definition_data = {}
# @Cale, should this use LocalId like we do in split?
if block_id is None:
if block_type == 'course':
block_id = course_key.run
else:
block_id = u'{}_{}'.format(block_type, uuid4().hex[:5])
if runtime is None: if runtime is None:
services = {} services = {}
if self.i18n_service: if self.i18n_service:
...@@ -990,7 +987,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -990,7 +987,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
runtime = CachingDescriptorSystem( runtime = CachingDescriptorSystem(
modulestore=self, modulestore=self,
module_data={}, module_data={},
course_key=location.course_key, course_key=course_key,
default_class=self.default_class, default_class=self.default_class,
resources_fs=None, resources_fs=None,
error_tracker=self.error_tracker, error_tracker=self.error_tracker,
...@@ -1000,14 +997,15 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -1000,14 +997,15 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
select=self.xblock_select, select=self.xblock_select,
services=services, services=services,
) )
xblock_class = runtime.load_block_type(location.category) xblock_class = runtime.load_block_type(block_type)
dbmodel = self._create_new_field_data(location.category, location, definition_data, metadata) location = course_key.make_usage_key(block_type, block_id)
dbmodel = self._create_new_field_data(block_type, location, definition_data, metadata)
xmodule = runtime.construct_xblock_from_class( xmodule = runtime.construct_xblock_from_class(
xblock_class, xblock_class,
# We're loading a descriptor, so student_id is meaningless # We're loading a descriptor, so student_id is meaningless
# We also don't have separate notions of definition and usage ids yet, # We also don't have separate notions of definition and usage ids yet,
# so we use the location for both. # so we use the location for both.
ScopeIds(None, location.category, location, location), ScopeIds(None, block_type, location, location),
dbmodel, dbmodel,
) )
if fields is not None: if fields is not None:
...@@ -1032,10 +1030,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -1032,10 +1030,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
a new identifier will be generated a new identifier will be generated
""" """
if block_id is None: if block_id is None:
block_id = uuid4().hex if block_type == 'course':
block_id = course_key.run
else:
block_id = u'{}_{}'.format(block_type, uuid4().hex[:5])
location = course_key.make_usage_key(block_type, block_id) runtime = kwargs.pop('runtime', None)
xblock = self.create_xmodule(location, **kwargs) xblock = self.create_xblock(runtime, course_key, block_type, block_id, **kwargs)
xblock = self.update_item(xblock, user_id, allow_not_found=True) xblock = self.update_item(xblock, user_id, allow_not_found=True)
return xblock return xblock
...@@ -1063,6 +1064,15 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -1063,6 +1064,15 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return xblock return xblock
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
"""
Simple implementation of overwriting any existing xblock
"""
if block_type == 'course':
block_id = course_key.run
xblock = self.create_xblock(runtime, course_key, block_type, block_id, fields)
return self.update_item(xblock, user_id, allow_not_found=True)
def _get_course_for_item(self, location, depth=0): def _get_course_for_item(self, location, depth=0):
''' '''
for a given Xmodule, return the course that it belongs to for a given Xmodule, return the course that it belongs to
......
...@@ -12,9 +12,7 @@ import logging ...@@ -12,9 +12,7 @@ import logging
from opaque_keys.edx.locations import Location from opaque_keys.edx.locations import Location
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import PublishState, ModuleStoreEnum from xmodule.modulestore import PublishState, ModuleStoreEnum
from xmodule.modulestore.exceptions import ( from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError, DuplicateCourseError
ItemNotFoundError, DuplicateItemError, InvalidBranchSetting, DuplicateCourseError
)
from xmodule.modulestore.mongo.base import ( from xmodule.modulestore.mongo.base import (
MongoModuleStore, MongoRevisionKey, as_draft, as_published, MongoModuleStore, MongoRevisionKey, as_draft, as_published,
SORT_REVISION_FAVOR_DRAFT SORT_REVISION_FAVOR_DRAFT
...@@ -292,7 +290,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -292,7 +290,7 @@ class DraftModuleStore(MongoModuleStore):
else ModuleStoreEnum.RevisionOption.draft_preferred else ModuleStoreEnum.RevisionOption.draft_preferred
return super(DraftModuleStore, self).get_parent_location(location, revision, **kwargs) return super(DraftModuleStore, self).get_parent_location(location, revision, **kwargs)
def create_xmodule(self, location, definition_data=None, metadata=None, runtime=None, fields={}, **kwargs): def create_xblock(self, runtime, course_key, block_type, block_id=None, fields=None, **kwargs):
""" """
Create the new xmodule but don't save it. Returns the new module with a draft locator if Create the new xmodule but don't save it. Returns the new module with a draft locator if
the category allows drafts. If the category does not allow drafts, just creates a published module. the category allows drafts. If the category does not allow drafts, just creates a published module.
...@@ -303,13 +301,11 @@ class DraftModuleStore(MongoModuleStore): ...@@ -303,13 +301,11 @@ class DraftModuleStore(MongoModuleStore):
:param runtime: if you already have an xmodule from the course, the xmodule.runtime value :param runtime: if you already have an xmodule from the course, the xmodule.runtime value
:param fields: a dictionary of field names and values for the new xmodule :param fields: a dictionary of field names and values for the new xmodule
""" """
self._verify_branch_setting(ModuleStoreEnum.Branch.draft_preferred) new_block = super(DraftModuleStore, self).create_xblock(
runtime, course_key, block_type, block_id, fields, **kwargs
if location.category not in DIRECT_ONLY_CATEGORIES:
location = as_draft(location)
return wrap_draft(
super(DraftModuleStore, self).create_xmodule(location, definition_data, metadata, runtime, fields)
) )
new_block.location = self.for_branch_setting(new_block.location)
return wrap_draft(new_block)
def get_items(self, course_key, revision=None, **kwargs): def get_items(self, course_key, revision=None, **kwargs):
""" """
...@@ -395,7 +391,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -395,7 +391,7 @@ class DraftModuleStore(MongoModuleStore):
DuplicateItemError: if the source or any of its descendants already has a draft copy. Only DuplicateItemError: if the source or any of its descendants already has a draft copy. Only
useful for unpublish b/c we don't want unpublish to overwrite any existing drafts. useful for unpublish b/c we don't want unpublish to overwrite any existing drafts.
""" """
# verify input conditions # verify input conditions: can only convert to draft branch; so, verify that's the setting
self._verify_branch_setting(ModuleStoreEnum.Branch.draft_preferred) self._verify_branch_setting(ModuleStoreEnum.Branch.draft_preferred)
_verify_revision_is_published(location) _verify_revision_is_published(location)
...@@ -440,13 +436,12 @@ class DraftModuleStore(MongoModuleStore): ...@@ -440,13 +436,12 @@ class DraftModuleStore(MongoModuleStore):
In addition to the superclass's behavior, this method converts the unit to draft if it's not In addition to the superclass's behavior, this method converts the unit to draft if it's not
direct-only and not already draft. direct-only and not already draft.
""" """
self._verify_branch_setting(ModuleStoreEnum.Branch.draft_preferred) draft_loc = self.for_branch_setting(xblock.location)
# if the xblock is direct-only, update the PUBLISHED version # if the revision is published, defer to base
if xblock.location.category in DIRECT_ONLY_CATEGORIES: if draft_loc.revision == MongoRevisionKey.published:
return super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found) return super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found)
draft_loc = as_draft(xblock.location)
if not super(DraftModuleStore, self).has_item(draft_loc): if not super(DraftModuleStore, self).has_item(draft_loc):
try: try:
# ignore any descendants which are already draft # ignore any descendants which are already draft
......
...@@ -62,6 +62,7 @@ class SplitMigrator(object): ...@@ -62,6 +62,7 @@ class SplitMigrator(object):
new_org, new_course, new_run, user_id, new_org, new_course, new_run, user_id,
fields=new_fields, fields=new_fields,
master_branch=ModuleStoreEnum.BranchName.published, master_branch=ModuleStoreEnum.BranchName.published,
skip_auto_publish=True,
**kwargs **kwargs
) )
...@@ -101,6 +102,7 @@ class SplitMigrator(object): ...@@ -101,6 +102,7 @@ class SplitMigrator(object):
module, course_version_locator, new_course.location.block_id module, course_version_locator, new_course.location.block_id
), ),
continue_version=True, continue_version=True,
skip_auto_publish=True,
**kwargs **kwargs
) )
# after done w/ published items, add version for DRAFT pointing to the published structure # after done w/ published items, add version for DRAFT pointing to the published structure
......
...@@ -78,6 +78,7 @@ from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection ...@@ -78,6 +78,7 @@ from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.split_mongo import encode_key_for_mongo, decode_key_from_mongo from xmodule.modulestore.split_mongo import encode_key_for_mongo, decode_key_from_mongo
from _collections import defaultdict from _collections import defaultdict
from types import NoneType
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -119,7 +120,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -119,7 +120,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
def __init__(self, contentstore, doc_store_config, fs_root, render_template, def __init__(self, contentstore, doc_store_config, fs_root, render_template,
default_class=None, default_class=None,
error_tracker=null_error_tracker, error_tracker=null_error_tracker,
i18n_service=None, i18n_service=None, services=None,
**kwargs): **kwargs):
""" """
:param doc_store_config: must have a host, db, and collection entries. Other common entries: port, tz_aware. :param doc_store_config: must have a host, db, and collection entries. Other common entries: port, tz_aware.
...@@ -143,7 +144,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -143,7 +144,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
self.fs_root = path(fs_root) self.fs_root = path(fs_root)
self.error_tracker = error_tracker self.error_tracker = error_tracker
self.render_template = render_template self.render_template = render_template
self.i18n_service = i18n_service self.services = services or {}
if i18n_service is not None:
self.services["i18n"] = i18n_service
def close_connections(self): def close_connections(self):
""" """
...@@ -218,26 +221,10 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -218,26 +221,10 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
given depth. Load the definitions into each block if lazy is False; given depth. Load the definitions into each block if lazy is False;
otherwise, use the lazy definition placeholder. otherwise, use the lazy definition placeholder.
''' '''
system = self._get_cache(course_entry['structure']['_id']) runtime = self._get_cache(course_entry['structure']['_id'])
if system is None: if runtime is None:
services = {} runtime = self.create_runtime(course_entry, lazy)
if self.i18n_service: self._add_cache(course_entry['structure']['_id'], runtime)
services["i18n"] = self.i18n_service
system = CachingDescriptorSystem(
modulestore=self,
course_entry=course_entry,
module_data={},
lazy=lazy,
default_class=self.default_class,
error_tracker=self.error_tracker,
render_template=self.render_template,
resources_fs=None,
mixins=self.xblock_mixins,
select=self.xblock_select,
services=services,
)
self._add_cache(course_entry['structure']['_id'], system)
course_key = CourseLocator( course_key = CourseLocator(
version_guid=course_entry['structure']['_id'], version_guid=course_entry['structure']['_id'],
org=course_entry.get('org'), org=course_entry.get('org'),
...@@ -245,8 +232,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -245,8 +232,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
run=course_entry.get('run'), run=course_entry.get('run'),
branch=course_entry.get('branch'), branch=course_entry.get('branch'),
) )
self.cache_items(system, block_ids, course_key, depth, lazy) self.cache_items(runtime, block_ids, course_key, depth, lazy)
return [system.load_item(block_id, course_entry, **kwargs) for block_id in block_ids] return [runtime.load_item(block_id, course_entry, **kwargs) for block_id in block_ids]
def _get_cache(self, course_version_guid): def _get_cache(self, course_version_guid):
""" """
...@@ -547,7 +534,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -547,7 +534,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
return [ return [
BlockUsageLocator( BlockUsageLocator(
course_key=course_key, block_type=blocks[block_id]['category'], block_id=block_id course_key=course_key, block_type=blocks[block_id]['category'], block_id=block_id
).version_agnostic() )
for block_id in items for block_id in items
] ]
...@@ -831,7 +818,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -831,7 +818,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# split handles all the fields in one dict not separated by scope # split handles all the fields in one dict not separated by scope
fields = fields or {} fields = fields or {}
fields.update(kwargs.pop('metadata', {}) or {}) fields.update(kwargs.pop('metadata', {}) or {})
fields.update(kwargs.pop('definition_data', {}) or {}) definition_data = kwargs.pop('definition_data', {})
if definition_data:
if not isinstance(definition_data, dict):
definition_data = {'data': definition_data} # backward compatibility to mongo's hack
fields.update(definition_data)
# find course_index entry if applicable and structures entry # find course_index entry if applicable and structures entry
index_entry = self._get_index_if_valid(course_key, force, continue_version) index_entry = self._get_index_if_valid(course_key, force, continue_version)
...@@ -853,6 +844,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -853,6 +844,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
new_id = new_structure['_id'] new_id = new_structure['_id']
edit_info = {
'edited_on': datetime.datetime.now(UTC),
'edited_by': user_id,
'previous_version': None,
'update_version': new_id,
}
# generate usage id # generate usage id
if block_id is not None: if block_id is not None:
if encode_key_for_mongo(block_id) in new_structure['blocks']: if encode_key_for_mongo(block_id) in new_structure['blocks']:
...@@ -869,12 +866,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -869,12 +866,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"category": block_type, "category": block_type,
"definition": definition_locator.definition_id, "definition": definition_locator.definition_id,
"fields": self._serialize_fields(block_type, block_fields), "fields": self._serialize_fields(block_type, block_fields),
'edit_info': { 'edit_info': edit_info,
'edited_on': datetime.datetime.now(UTC),
'edited_by': user_id,
'previous_version': None,
'update_version': new_id,
}
}) })
if continue_version: if continue_version:
...@@ -965,14 +957,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -965,14 +957,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if source_index is None: if source_index is None:
raise ItemNotFoundError("Cannot find a course at {0}. Aborting".format(source_course_id)) raise ItemNotFoundError("Cannot find a course at {0}. Aborting".format(source_course_id))
return self.create_course( return self.create_course(
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=fields, dest_course_id.org, dest_course_id.course, dest_course_id.run,
versions_dict=source_index['versions'], search_targets=source_index['search_targets'], **kwargs user_id,
fields=fields,
versions_dict=source_index['versions'],
search_targets=source_index['search_targets'],
skip_auto_publish=True,
**kwargs
) )
DEFAULT_ROOT_BLOCK_ID = 'course'
def create_course( def create_course(
self, org, course, run, user_id, master_branch=None, fields=None, self, org, course, run, user_id, master_branch=None, fields=None,
versions_dict=None, search_targets=None, root_category='course', versions_dict=None, search_targets=None, root_category='course',
root_block_id='course', **kwargs root_block_id=None, **kwargs
): ):
""" """
Create a new entry in the active courses index which points to an existing or new structure. Returns Create a new entry in the active courses index which points to an existing or new structure. Returns
...@@ -1016,6 +1014,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1016,6 +1014,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
provide any fields overrides, see above). if not provided, will create a mostly empty course provide any fields overrides, see above). if not provided, will create a mostly empty course
structure with just a category course root xblock. structure with just a category course root xblock.
""" """
# either need to assert this or have a default
assert master_branch is not None
# check course and run's uniqueness # check course and run's uniqueness
locator = CourseLocator(org=org, course=course, run=run, branch=master_branch) locator = CourseLocator(org=org, course=course, run=run, branch=master_branch)
index = self.db_connection.get_course_index(locator) index = self.db_connection.get_course_index(locator)
...@@ -1048,7 +1048,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1048,7 +1048,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
self.db_connection.insert_definition(definition_entry) self.db_connection.insert_definition(definition_entry)
draft_structure = self._new_structure( draft_structure = self._new_structure(
user_id, root_block_id, root_category, block_fields, definition_id user_id,
root_block_id or SplitMongoModuleStore.DEFAULT_ROOT_BLOCK_ID,
root_category,
block_fields,
definition_id
) )
new_id = draft_structure['_id'] new_id = draft_structure['_id']
...@@ -1101,6 +1105,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1101,6 +1105,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if fields is not None: if fields is not None:
self._update_search_targets(index_entry, fields) self._update_search_targets(index_entry, fields)
self.db_connection.insert_course_index(index_entry) self.db_connection.insert_course_index(index_entry)
# expensive hack to persist default field values set in __init__ method (e.g., wiki_slug) # expensive hack to persist default field values set in __init__ method (e.g., wiki_slug)
course = self.get_course(locator, **kwargs) course = self.get_course(locator, **kwargs)
return self.update_item(course, user_id, **kwargs) return self.update_item(course, user_id, **kwargs)
...@@ -1120,25 +1125,60 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1120,25 +1125,60 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
The implementation tries to detect which, if any changes, actually need to be saved and thus won't version The implementation tries to detect which, if any changes, actually need to be saved and thus won't version
the definition, structure, nor course if they didn't change. the definition, structure, nor course if they didn't change.
""" """
original_structure = self._lookup_course(descriptor.location)['structure']
index_entry = self._get_index_if_valid(descriptor.location, force)
partitioned_fields = self.partition_xblock_fields_by_scope(descriptor) partitioned_fields = self.partition_xblock_fields_by_scope(descriptor)
return self._update_item_from_fields(
user_id, descriptor.location.course_key, descriptor.location.block_type, descriptor.location.block_id,
partitioned_fields, descriptor.definition_locator, allow_not_found, force, **kwargs
) or descriptor
def _update_item_from_fields(
self, user_id, course_key, block_type, block_id, partitioned_fields,
definition_locator, allow_not_found, force, **kwargs
):
"""
Broke out guts of update_item for short-circuited internal use only
"""
if allow_not_found and isinstance(block_id, (LocalId, NoneType)):
fields = {}
for subfields in partitioned_fields.itervalues():
fields.update(subfields)
return self.create_item(
user_id, course_key, block_type, fields=fields, force=force
)
original_structure = self._lookup_course(course_key)['structure']
index_entry = self._get_index_if_valid(course_key, force)
original_entry = self._get_block_from_structure(original_structure, block_id)
if original_entry is None:
if allow_not_found:
fields = {}
for subfields in partitioned_fields.itervalues():
fields.update(subfields)
return self.create_item(
user_id, course_key, block_type, block_id=block_id, fields=fields, force=force,
)
else:
raise ItemNotFoundError(course_key.make_usage_key(block_type, block_id))
is_updated = False
definition_fields = partitioned_fields[Scope.content] definition_fields = partitioned_fields[Scope.content]
descriptor.definition_locator, is_updated = self.update_definition_from_data( if definition_locator is None:
descriptor.definition_locator, definition_fields, user_id definition_locator = DefinitionLocator(original_entry['category'], original_entry['definition'])
if definition_fields:
definition_locator, is_updated = self.update_definition_from_data(
definition_locator, definition_fields, user_id
) )
original_entry = self._get_block_from_structure(original_structure, descriptor.location.block_id)
# check metadata # check metadata
settings = partitioned_fields[Scope.settings] settings = partitioned_fields[Scope.settings]
settings = self._serialize_fields(descriptor.category, settings) settings = self._serialize_fields(block_type, settings)
if not is_updated: if not is_updated:
is_updated = self._compare_settings(settings, original_entry['fields']) is_updated = self._compare_settings(settings, original_entry['fields'])
# check children # check children
if descriptor.has_children: if partitioned_fields.get(Scope.children, {}): # purposely not 'is not None'
serialized_children = [child.block_id for child in descriptor.children] serialized_children = [child.block_id for child in partitioned_fields[Scope.children]['children']]
is_updated = is_updated or original_entry['fields'].get('children', []) != serialized_children is_updated = is_updated or original_entry['fields'].get('children', []) != serialized_children
if is_updated: if is_updated:
settings['children'] = serialized_children settings['children'] = serialized_children
...@@ -1146,9 +1186,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1146,9 +1186,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# if updated, rev the structure # if updated, rev the structure
if is_updated: if is_updated:
new_structure = self._version_structure(original_structure, user_id) new_structure = self._version_structure(original_structure, user_id)
block_data = self._get_block_from_structure(new_structure, descriptor.location.block_id) block_data = self._get_block_from_structure(new_structure, block_id)
block_data["definition"] = descriptor.definition_locator.definition_id block_data["definition"] = definition_locator.definition_id
block_data["fields"] = settings block_data["fields"] = settings
new_id = new_structure['_id'] new_id = new_structure['_id']
...@@ -1163,25 +1203,28 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1163,25 +1203,28 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if index_entry is not None: if index_entry is not None:
self._update_search_targets(index_entry, definition_fields) self._update_search_targets(index_entry, definition_fields)
self._update_search_targets(index_entry, settings) self._update_search_targets(index_entry, settings)
self._update_head(index_entry, descriptor.location.branch, new_id) self._update_head(index_entry, course_key.branch, new_id)
course_key = CourseLocator( course_key = CourseLocator(
org=index_entry['org'], org=index_entry['org'],
course=index_entry['course'], course=index_entry['course'],
run=index_entry['run'], run=index_entry['run'],
branch=descriptor.location.branch, branch=course_key.branch,
version_guid=new_id version_guid=new_id
) )
else: else:
course_key = CourseLocator(version_guid=new_id) course_key = CourseLocator(version_guid=new_id)
# fetch and return the new item--fetching is unnecessary but a good qc step # fetch and return the new item--fetching is unnecessary but a good qc step
new_locator = descriptor.location.map_into_course(course_key) new_locator = course_key.make_usage_key(block_type, block_id)
return self.get_item(new_locator, **kwargs) return self.get_item(new_locator, **kwargs)
else: else:
# nothing changed, just return the one sent in return None
return descriptor
def create_xblock(self, runtime, category, fields=None, block_id=None, definition_id=None, parent_xblock=None, **kwargs): # pylint: disable=unused-argument
def create_xblock(
self, runtime, course_key, block_type, block_id=None, fields=None,
definition_id=None, parent_xblock=None, **kwargs
):
""" """
This method instantiates the correct subclass of XModuleDescriptor based This method instantiates the correct subclass of XModuleDescriptor based
on the contents of json_data. It does not persist it and can create one which on the contents of json_data. It does not persist it and can create one which
...@@ -1190,14 +1233,16 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1190,14 +1233,16 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
parent_xblock is used to compute inherited metadata as well as to append the new xblock. parent_xblock is used to compute inherited metadata as well as to append the new xblock.
json_data: json_data:
- 'category': the xmodule category - 'block_type': the xmodule block_type
- 'fields': a dict of locally set fields (not inherited) in json format not pythonic typed format! - 'fields': a dict of locally set fields (not inherited) in json format not pythonic typed format!
- 'definition': the object id of the existing definition - 'definition': the object id of the existing definition
""" """
xblock_class = runtime.load_block_type(category) assert runtime is not None
xblock_class = runtime.load_block_type(block_type)
json_data = { json_data = {
'category': category, 'category': block_type,
'fields': fields or {}, 'fields': {},
} }
if definition_id is not None: if definition_id is not None:
json_data['definition'] = definition_id json_data['definition'] = definition_id
...@@ -1209,6 +1254,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1209,6 +1254,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
json_data['_inherited_settings'][field_name] = fields[field_name] json_data['_inherited_settings'][field_name] = fields[field_name]
new_block = runtime.xblock_from_json(xblock_class, block_id, json_data, **kwargs) new_block = runtime.xblock_from_json(xblock_class, block_id, json_data, **kwargs)
for field_name, value in fields.iteritems():
setattr(new_block, field_name, value)
if parent_xblock is not None: if parent_xblock is not None:
parent_xblock.children.append(new_block.scope_ids.usage_id) parent_xblock.children.append(new_block.scope_ids.usage_id)
# decache pending children field settings # decache pending children field settings
...@@ -1372,8 +1420,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1372,8 +1420,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
root_block_id = source_structure['root'] root_block_id = source_structure['root']
if not any(root_block_id == subtree.block_id for subtree in subtree_list): if not any(root_block_id == subtree.block_id for subtree in subtree_list):
raise ItemNotFoundError(u'Must publish course root {}'.format(root_block_id)) raise ItemNotFoundError(u'Must publish course root {}'.format(root_block_id))
root_source = source_structure['blocks'][root_block_id]
# create branch # create branch
destination_structure = self._new_structure(user_id, root_block_id) destination_structure = self._new_structure(
user_id, root_block_id, root_category=root_source['category'],
# leave off the fields b/c the children must be filtered
definition_id=root_source['definition'],
)
else: else:
destination_structure = self._lookup_course(destination_course)['structure'] destination_structure = self._lookup_course(destination_course)['structure']
destination_structure = self._version_structure(destination_structure, user_id) destination_structure = self._version_structure(destination_structure, user_id)
...@@ -1774,6 +1827,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1774,6 +1827,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
new_id = ObjectId() new_id = ObjectId()
if root_category is not None: if root_category is not None:
encoded_root = encode_key_for_mongo(root_block_id) encoded_root = encode_key_for_mongo(root_block_id)
if block_fields is None:
block_fields = {}
blocks = { blocks = {
encoded_root: self._new_block( encoded_root: self._new_block(
user_id, root_category, block_fields, definition_id, new_id user_id, root_category, block_fields, definition_id, new_id
...@@ -1969,6 +2024,23 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1969,6 +2024,23 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
""" """
return {ModuleStoreEnum.Type.split: self.db_connection.heartbeat()} return {ModuleStoreEnum.Type.split: self.db_connection.heartbeat()}
def create_runtime(self, course_entry, lazy):
"""
Create the proper runtime for this course
"""
return CachingDescriptorSystem(
modulestore=self,
course_entry=course_entry,
module_data={},
lazy=lazy,
default_class=self.default_class,
error_tracker=self.error_tracker,
render_template=self.render_template,
resources_fs=None,
mixins=self.xblock_mixins,
select=self.xblock_select,
services=self.services,
)
class SparseList(list): class SparseList(list):
""" """
......
...@@ -2,11 +2,12 @@ ...@@ -2,11 +2,12 @@
Module for the dual-branch fall-back Draft->Published Versioning ModuleStore Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
""" """
from ..exceptions import ItemNotFoundError
from split import SplitMongoModuleStore, EXCLUDE_ALL from split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.modulestore import ModuleStoreEnum, PublishState from xmodule.modulestore import ModuleStoreEnum, PublishState
from xmodule.modulestore.exceptions import InsufficientSpecificationError from xmodule.modulestore.exceptions import InsufficientSpecificationError
from xmodule.modulestore.draft_and_published import ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError from xmodule.modulestore.draft_and_published import (
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
)
class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleStore): class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleStore):
...@@ -14,24 +15,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -14,24 +15,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
A subclass of Split that supports a dual-branch fall-back versioning framework A subclass of Split that supports a dual-branch fall-back versioning framework
with a Draft branch that falls back to a Published branch. with a Draft branch that falls back to a Published branch.
""" """
def _lookup_course(self, course_locator): def create_course(self, org, course, run, user_id, skip_auto_publish=False, **kwargs):
"""
overrides the implementation of _lookup_course in SplitMongoModuleStore in order to
use the configured branch_setting in the course_locator
"""
if course_locator.org and course_locator.course and course_locator.run:
if course_locator.branch is None:
# default it based on branch_setting
branch_setting = self.get_branch_setting()
if branch_setting == ModuleStoreEnum.Branch.draft_preferred:
course_locator = course_locator.for_branch(ModuleStoreEnum.BranchName.draft)
elif branch_setting == ModuleStoreEnum.Branch.published_only:
course_locator = course_locator.for_branch(ModuleStoreEnum.BranchName.published)
else:
raise InsufficientSpecificationError(course_locator)
return super(DraftVersioningModuleStore, self)._lookup_course(course_locator)
def create_course(self, org, course, run, user_id, **kwargs):
""" """
Creates and returns the course. Creates and returns the course.
...@@ -48,9 +32,26 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -48,9 +32,26 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
item = super(DraftVersioningModuleStore, self).create_course( item = super(DraftVersioningModuleStore, self).create_course(
org, course, run, user_id, master_branch=master_branch, **kwargs org, course, run, user_id, master_branch=master_branch, **kwargs
) )
if master_branch == ModuleStoreEnum.BranchName.draft and not skip_auto_publish:
# any other value is hopefully only cloning or doing something which doesn't want this value add
self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs)
# create any other necessary things as a side effect: ensure they populate the draft branch
# and rely on auto publish to populate the published branch: split's create course doesn't
# call super b/c it needs the auto publish above to have happened before any of the create_items
# in this. The explicit use of SplitMongoModuleStore is intentional
with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, item.id):
# pylint: disable=bad-super-call
super(SplitMongoModuleStore, self).create_course(
org, course, run, user_id, runtime=item.runtime, **kwargs
)
return item return item
def get_course(self, course_id, depth=0, **kwargs):
course_id = self._map_revision_to_branch(course_id)
return super(DraftVersioningModuleStore, self).get_course(course_id, depth=depth, **kwargs)
def get_courses(self, **kwargs): def get_courses(self, **kwargs):
""" """
Returns all the courses on the Draft or Published branch depending on the branch setting. Returns all the courses on the Draft or Published branch depending on the branch setting.
...@@ -88,13 +89,18 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -88,13 +89,18 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
def create_item( def create_item(
self, user_id, course_key, block_type, block_id=None, self, user_id, course_key, block_type, block_id=None,
definition_locator=None, fields=None, definition_locator=None, fields=None,
force=False, continue_version=False, **kwargs force=False, continue_version=False, skip_auto_publish=False, **kwargs
): ):
"""
See :py:meth `ModuleStoreDraftAndPublished.create_item`
"""
course_key = self._map_revision_to_branch(course_key)
item = super(DraftVersioningModuleStore, self).create_item( item = super(DraftVersioningModuleStore, self).create_item(
user_id, course_key, block_type, block_id=block_id, user_id, course_key, block_type, block_id=block_id,
definition_locator=definition_locator, fields=fields, definition_locator=definition_locator, fields=fields,
force=force, continue_version=continue_version, **kwargs force=force, continue_version=continue_version, **kwargs
) )
if not skip_auto_publish:
self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs) self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs)
return item return item
...@@ -102,6 +108,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -102,6 +108,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
self, user_id, parent_usage_key, block_type, block_id=None, self, user_id, parent_usage_key, block_type, block_id=None,
fields=None, continue_version=False, **kwargs fields=None, continue_version=False, **kwargs
): ):
parent_usage_key = self._map_revision_to_branch(parent_usage_key)
item = super(DraftVersioningModuleStore, self).create_child( item = super(DraftVersioningModuleStore, self).create_child(
user_id, parent_usage_key, block_type, block_id=block_id, user_id, parent_usage_key, block_type, block_id=block_id,
fields=fields, continue_version=continue_version, **kwargs fields=fields, continue_version=continue_version, **kwargs
...@@ -148,12 +155,18 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -148,12 +155,18 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
""" """
Maps RevisionOptions to BranchNames, inserting them into the key Maps RevisionOptions to BranchNames, inserting them into the key
""" """
if revision == ModuleStoreEnum.RevisionOption.published_only: if revision == ModuleStoreEnum.RevisionOption.published_only:
return key.for_branch(ModuleStoreEnum.BranchName.published) return key.for_branch(ModuleStoreEnum.BranchName.published)
elif revision == ModuleStoreEnum.RevisionOption.draft_only: elif revision == ModuleStoreEnum.RevisionOption.draft_only:
return key.for_branch(ModuleStoreEnum.BranchName.draft) return key.for_branch(ModuleStoreEnum.BranchName.draft)
elif revision is None: elif revision is None:
if key.branch is not None:
return key return key
elif self.get_branch_setting(key) == ModuleStoreEnum.Branch.draft_preferred:
return key.for_branch(ModuleStoreEnum.BranchName.draft)
else:
return key.for_branch(ModuleStoreEnum.BranchName.published)
else: else:
raise UnsupportedRevisionError() raise UnsupportedRevisionError()
...@@ -196,6 +209,10 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -196,6 +209,10 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
location = self._map_revision_to_branch(location, revision=revision) location = self._map_revision_to_branch(location, revision=revision)
return SplitMongoModuleStore.get_parent_location(self, location, **kwargs) return SplitMongoModuleStore.get_parent_location(self, location, **kwargs)
def get_orphans(self, course_key, **kwargs):
course_key = self._map_revision_to_branch(course_key)
return super(DraftVersioningModuleStore, self).get_orphans(course_key, **kwargs)
def has_changes(self, xblock): def has_changes(self, xblock):
""" """
Checks if the given block has unpublished changes Checks if the given block has unpublished changes
...@@ -252,6 +269,29 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -252,6 +269,29 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
""" """
raise NotImplementedError() raise NotImplementedError()
def get_course_history_info(self, course_locator):
"""
See :py:meth `xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_course_history_info`
"""
course_locator = self._map_revision_to_branch(course_locator)
return super(DraftVersioningModuleStore, self).get_course_history_info(course_locator)
def get_course_successors(self, course_locator, version_history_depth=1):
"""
See :py:meth `xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_course_successors`
"""
course_locator = self._map_revision_to_branch(course_locator)
return super(DraftVersioningModuleStore, self).get_course_successors(
course_locator, version_history_depth=version_history_depth
)
def get_block_generations(self, block_locator):
"""
See :py:meth `xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_block_generations`
"""
block_locator = self._map_revision_to_branch(block_locator)
return super(DraftVersioningModuleStore, self).get_block_generations(block_locator)
def compute_publish_state(self, xblock): def compute_publish_state(self, xblock):
""" """
Returns whether this xblock is draft, public, or private. Returns whether this xblock is draft, public, or private.
...@@ -292,3 +332,37 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -292,3 +332,37 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
Return the version of the given database representation of a block. Return the version of the given database representation of a block.
""" """
return block['edit_info'].get('source_version', block['edit_info']['update_version']) return block['edit_info'].get('source_version', block['edit_info']['update_version'])
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
"""
Split-based modulestores need to import published blocks to both branches
"""
# hardcode course root block id
if block_type == 'course':
block_id = self.DEFAULT_ROOT_BLOCK_ID
new_usage_key = course_key.make_usage_key(block_type, block_id)
if self.get_branch_setting() == ModuleStoreEnum.Branch.published_only:
# if importing a direct only, override existing draft
if block_type in DIRECT_ONLY_CATEGORIES:
draft_course = course_key.for_branch(ModuleStoreEnum.BranchName.draft)
with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, draft_course):
draft = self.import_xblock(user_id, draft_course, block_type, block_id, fields, runtime)
self._auto_publish_no_children(draft.location, block_type, user_id)
return self.get_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.published))
# if new to published
elif not self.has_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.published)):
# check whether it's new to draft
if not self.has_item(new_usage_key.for_branch(ModuleStoreEnum.BranchName.draft)):
# add to draft too
draft_course = course_key.for_branch(ModuleStoreEnum.BranchName.draft)
with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, draft_course):
draft = self.import_xblock(user_id, draft_course, block_type, block_id, fields, runtime)
return self.publish(draft.location, user_id, blacklist=EXCLUDE_ALL)
# do the import
partitioned_fields = self.partition_fields_by_scope(block_type, fields)
course_key = self._map_revision_to_branch(course_key) # cast to branch_setting
return self._update_item_from_fields(
user_id, course_key, block_type, block_id, partitioned_fields, None, allow_not_found=True, force=True
)
...@@ -349,12 +349,6 @@ class ModuleStoreTestCase(TestCase): ...@@ -349,12 +349,6 @@ class ModuleStoreTestCase(TestCase):
fields={"data": "TBD"} fields={"data": "TBD"}
) )
self.store.create_item( self.store.create_item(
self.user.id, self.toy_loc, "about", block_id="overview",
fields={
"data": "<section class=\"about\">\n <h2>About This Course</h2>\n <p>Include your long course description here. The long course description should contain 150-400 words.</p>\n\n <p>This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.</p>\n</section>\n\n<section class=\"prerequisites\">\n <h2>Prerequisites</h2>\n <p>Add information about course prerequisites here.</p>\n</section>\n\n<section class=\"course-staff\">\n <h2>Course Staff</h2>\n <article class=\"teacher\">\n <div class=\"teacher-image\">\n <img src=\"/static/images/pl-faculty.png\" align=\"left\" style=\"margin:0 20 px 0\" alt=\"Course Staff Image #1\">\n </div>\n\n <h3>Staff Member #1</h3>\n <p>Biography of instructor/staff member #1</p>\n </article>\n\n <article class=\"teacher\">\n <div class=\"teacher-image\">\n <img src=\"/static/images/pl-faculty.png\" align=\"left\" style=\"margin:0 20 px 0\" alt=\"Course Staff Image #2\">\n </div>\n\n <h3>Staff Member #2</h3>\n <p>Biography of instructor/staff member #2</p>\n </article>\n</section>\n\n<section class=\"faq\">\n <section class=\"responses\">\n <h2>Frequently Asked Questions</h2>\n <article class=\"response\">\n <h3>Do I need to buy a textbook?</h3>\n <p>No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if you’d like.</p>\n </article>\n\n <article class=\"response\">\n <h3>Question #2</h3>\n <p>Your answer would be displayed here.</p>\n </article>\n </section>\n</section>\n"
}
)
self.store.create_item(
self.user.id, self.toy_loc, "course_info", "handouts", self.user.id, self.toy_loc, "course_info", "handouts",
fields={"data": "<a href='/static/handouts/sample_handout.txt'>Sample</a>"} fields={"data": "<a href='/static/handouts/sample_handout.txt'>Sample</a>"}
) )
......
...@@ -3,7 +3,7 @@ from factory.containers import CyclicDefinitionError ...@@ -3,7 +3,7 @@ from factory.containers import CyclicDefinitionError
from uuid import uuid4 from uuid import uuid4
from xmodule.modulestore import prefer_xmodules, ModuleStoreEnum from xmodule.modulestore import prefer_xmodules, ModuleStoreEnum
from opaque_keys.edx.locations import Location from opaque_keys.edx.locations import Location, SlashSeparatedCourseKey
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from xblock.core import XBlock from xblock.core import XBlock
from xmodule.tabs import StaticTab from xmodule.tabs import StaticTab
...@@ -55,20 +55,14 @@ class CourseFactory(XModuleFactory): ...@@ -55,20 +55,14 @@ class CourseFactory(XModuleFactory):
run = kwargs.get('run', name) run = kwargs.get('run', name)
user_id = kwargs.pop('user_id', ModuleStoreEnum.UserID.test) user_id = kwargs.pop('user_id', ModuleStoreEnum.UserID.test)
location = Location(org, number, run, 'course', name)
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
# Write the data to the mongo datastore # Write the data to the mongo datastore
new_course = store.create_xmodule(location, metadata=kwargs.get('metadata', None)) kwargs.update(kwargs.get('metadata', {}))
course_key = SlashSeparatedCourseKey(org, number, run)
# The rest of kwargs become attributes on the course: # TODO - We really should call create_course here. However, since create_course verifies there are no
for k, v in kwargs.iteritems(): # duplicates, this breaks several tests that do not clean up properly in between tests.
setattr(new_course, k, v) new_course = store.create_xblock(None, course_key, 'course', block_id=run, fields=kwargs)
store.update_item(new_course, user_id, allow_not_found=True)
# Save the attributes we just set
new_course.save()
# Update the data in the mongo datastore
store.update_item(new_course, user_id)
return new_course return new_course
......
...@@ -16,20 +16,18 @@ import ddt ...@@ -16,20 +16,18 @@ import ddt
import itertools import itertools
import random import random
from contextlib import contextmanager, nested from contextlib import contextmanager, nested
from unittest import SkipTest
from shutil import rmtree from shutil import rmtree
from tempfile import mkdtemp from tempfile import mkdtemp
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.tests import CourseComparisonTest from xmodule.tests import CourseComparisonTest
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.mongo.base import ModuleStoreEnum from xmodule.modulestore.mongo.base import ModuleStoreEnum
from xmodule.modulestore.mongo.draft import DraftModuleStore from xmodule.modulestore.mongo.draft import DraftModuleStore
from xmodule.modulestore.mixed import MixedModuleStore from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.contentstore.mongo import MongoContentStore from xmodule.contentstore.mongo import MongoContentStore
from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore
COMMON_DOCSTORE_CONFIG = { COMMON_DOCSTORE_CONFIG = {
'host': 'localhost' 'host': 'localhost'
...@@ -101,9 +99,7 @@ class MongoModulestoreBuilder(object): ...@@ -101,9 +99,7 @@ class MongoModulestoreBuilder(object):
yield modulestore yield modulestore
finally: finally:
# Delete the created database # Delete the created database
db = modulestore.database modulestore._drop_database()
db.connection.drop_database(db)
db.connection.close()
# Delete the created directory on the filesystem # Delete the created directory on the filesystem
rmtree(fs_root) rmtree(fs_root)
...@@ -127,7 +123,6 @@ class VersioningModulestoreBuilder(object): ...@@ -127,7 +123,6 @@ class VersioningModulestoreBuilder(object):
all of its assets. all of its assets.
""" """
# pylint: disable=unreachable # pylint: disable=unreachable
raise SkipTest("DraftVersioningModuleStore doesn't yet support the same interface as the rest of the modulestores")
doc_store_config = dict( doc_store_config = dict(
db='modulestore{}'.format(random.randint(0, 10000)), db='modulestore{}'.format(random.randint(0, 10000)),
collection='split_module', collection='split_module',
...@@ -136,7 +131,7 @@ class VersioningModulestoreBuilder(object): ...@@ -136,7 +131,7 @@ class VersioningModulestoreBuilder(object):
# Set up a temp directory for storing filesystem content created during import # Set up a temp directory for storing filesystem content created during import
fs_root = mkdtemp() fs_root = mkdtemp()
modulestore = SplitMongoModuleStore( modulestore = DraftVersioningModuleStore(
contentstore, contentstore,
doc_store_config, doc_store_config,
fs_root, fs_root,
...@@ -147,9 +142,7 @@ class VersioningModulestoreBuilder(object): ...@@ -147,9 +142,7 @@ class VersioningModulestoreBuilder(object):
yield modulestore yield modulestore
finally: finally:
# Delete the created database # Delete the created database
db = modulestore.db modulestore._drop_database()
db.connection.drop_database(db)
db.connection.close()
# Delete the created directory on the filesystem # Delete the created directory on the filesystem
rmtree(fs_root) rmtree(fs_root)
...@@ -220,9 +213,7 @@ class MongoContentstoreBuilder(object): ...@@ -220,9 +213,7 @@ class MongoContentstoreBuilder(object):
yield contentstore yield contentstore
finally: finally:
# Delete the created database # Delete the created database
db = contentstore.fs_files.database contentstore._drop_database()
db.connection.drop_database(db)
db.connection.close()
def __repr__(self): def __repr__(self):
return 'MongoContentstoreBuilder()' return 'MongoContentstoreBuilder()'
...@@ -235,7 +226,10 @@ MODULESTORE_SETUPS = ( ...@@ -235,7 +226,10 @@ MODULESTORE_SETUPS = (
MixedModulestoreBuilder([('split', VersioningModulestoreBuilder())]), MixedModulestoreBuilder([('split', VersioningModulestoreBuilder())]),
) )
CONTENTSTORE_SETUPS = (MongoContentstoreBuilder(),) CONTENTSTORE_SETUPS = (MongoContentstoreBuilder(),)
COURSE_DATA_NAMES = ('toy', 'manual-testing-complete') COURSE_DATA_NAMES = (
'toy',
'manual-testing-complete',
)
@ddt.ddt @ddt.ddt
...@@ -259,8 +253,6 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest): ...@@ -259,8 +253,6 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest):
)) ))
@ddt.unpack @ddt.unpack
def test_round_trip(self, source_builder, dest_builder, source_content_builder, dest_content_builder, course_data_name): def test_round_trip(self, source_builder, dest_builder, source_content_builder, dest_content_builder, course_data_name):
source_course_key = SlashSeparatedCourseKey('source', 'course', 'key')
dest_course_key = SlashSeparatedCourseKey('dest', 'course', 'key')
# Construct the contentstore for storing the first import # Construct the contentstore for storing the first import
with source_content_builder.build() as source_content: with source_content_builder.build() as source_content:
...@@ -270,6 +262,9 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest): ...@@ -270,6 +262,9 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest):
with dest_content_builder.build() as dest_content: with dest_content_builder.build() as dest_content:
# Construct the modulestore for storing the second import (using the second contentstore) # Construct the modulestore for storing the second import (using the second contentstore)
with dest_builder.build(dest_content) as dest_store: with dest_builder.build(dest_content) as dest_store:
source_course_key = source_store.make_course_key('source', 'course', 'key')
dest_course_key = dest_store.make_course_key('dest', 'course', 'key')
import_from_xml( import_from_xml(
source_store, source_store,
'test_user', 'test_user',
...@@ -297,7 +292,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest): ...@@ -297,7 +292,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest):
create_new_course_if_not_present=True, create_new_course_if_not_present=True,
) )
self.exclude_field(source_course_key.make_usage_key('course', 'key'), 'wiki_slug') self.exclude_field(None, 'wiki_slug')
self.exclude_field(None, 'xml_attributes') self.exclude_field(None, 'xml_attributes')
self.ignore_asset_key('_id') self.ignore_asset_key('_id')
self.ignore_asset_key('uploadDate') self.ignore_asset_key('uploadDate')
......
...@@ -348,7 +348,7 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -348,7 +348,7 @@ class TestMixedModuleStore(unittest.TestCase):
# split: 3 to get the course structure & the course definition (show_calculator is scope content) # split: 3 to get the course structure & the course definition (show_calculator is scope content)
# before the change. 1 during change to refetch the definition. 3 afterward (b/c it calls get_item to return the "new" object). # before the change. 1 during change to refetch the definition. 3 afterward (b/c it calls get_item to return the "new" object).
# 2 sends to update index & structure (calculator is a setting field) # 2 sends to update index & structure (calculator is a setting field)
@ddt.data(('draft', 7, 5), ('split', 7, 2)) @ddt.data(('draft', 7, 5), ('split', 6, 2))
@ddt.unpack @ddt.unpack
def test_update_item(self, default_ms, max_find, max_send): def test_update_item(self, default_ms, max_find, max_send):
""" """
...@@ -853,7 +853,6 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -853,7 +853,6 @@ class TestMixedModuleStore(unittest.TestCase):
# detached items (not considered as orphans) # detached items (not considered as orphans)
detached_locations = [ detached_locations = [
course_id.make_usage_key('static_tab', 'StaticTab'), course_id.make_usage_key('static_tab', 'StaticTab'),
course_id.make_usage_key('about', 'overview'),
course_id.make_usage_key('course_info', 'updates'), course_id.make_usage_key('course_info', 'updates'),
] ]
......
...@@ -9,7 +9,6 @@ from path import path ...@@ -9,7 +9,6 @@ from path import path
import re import re
import random import random
from xblock.fields import Scope
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ( from xmodule.modulestore.exceptions import (
...@@ -268,7 +267,7 @@ class SplitModuleTest(unittest.TestCase): ...@@ -268,7 +267,7 @@ class SplitModuleTest(unittest.TestCase):
"category": "problem", "category": "problem",
"fields": { "fields": {
"display_name": "Problem 3.1", "display_name": "Problem 3.1",
"graceperiod": "4 hours 0 minutes 0 seconds" "graceperiod": _time_delta_field.from_json("4 hours 0 minutes 0 seconds"),
}, },
}, },
{ {
...@@ -486,7 +485,7 @@ class SplitModuleTest(unittest.TestCase): ...@@ -486,7 +485,7 @@ class SplitModuleTest(unittest.TestCase):
parent = split_store.get_item(block_usage) parent = split_store.get_item(block_usage)
block_id = LocalId(spec['id']) block_id = LocalId(spec['id'])
child = split_store.create_xblock( child = split_store.create_xblock(
course.runtime, spec['category'], spec['fields'], block_id, parent_xblock=parent course.runtime, course.id, spec['category'], block_id, spec['fields'], parent_xblock=parent
) )
new_ele_dict[spec['id']] = child new_ele_dict[spec['id']] = child
course = split_store.persist_xblock_dag(course, revision['user_id']) course = split_store.persist_xblock_dag(course, revision['user_id'])
......
"""
Each store has slightly different semantics wrt draft v published. XML doesn't officially recognize draft
but does hold it in a subdir. Old mongo has a virtual but not physical draft for every unit in published state.
Split mongo has a physical for every unit in every state.
Given that, here's a table of semantics and behaviors where - means no record and letters indicate values.
For xml, (-, x) means the item is published and can be edited. For split, it means the item's
been deleted from draft and will be deleted from published the next time it gets published. old mongo
can't represent that virtual state (2nd row in table)
In the table body, the tuples represent virtual modulestore result. The row headers represent the pre-import
modulestore state.
Modulestore virtual | XML physical (draft, published)
(draft, published) | (-, -) | (x, -) | (x, x) | (x, y) | (-, x)
----------------------+--------------------------------------------
(-, -) | (-, -) | (x, -) | (x, x) | (x, y) | (-, x)
(-, a) | (-, a) | (x, a) | (x, x) | (x, y) | (-, x) : deleted from draft before import
(a, -) | (a, -) | (x, -) | (x, x) | (x, y) | (a, x)
(a, a) | (a, a) | (x, a) | (x, x) | (x, y) | (a, x)
(a, b) | (a, b) | (x, b) | (x, x) | (x, y) | (a, x)
"""
import logging import logging
import os import os
import mimetypes import mimetypes
...@@ -117,29 +139,36 @@ def import_from_xml( ...@@ -117,29 +139,36 @@ def import_from_xml(
target_course_id=None, verbose=False, target_course_id=None, verbose=False,
do_import_static=True, create_new_course_if_not_present=False): do_import_static=True, create_new_course_if_not_present=False):
""" """
Import the specified xml data_dir into the "store" modulestore, Import xml-based courses from data_dir into modulestore.
using org and course as the location org and course.
course_dirs: If specified, the list of course_dirs to load. Otherwise, load Returns:
list of new course objects
Args:
store: a modulestore implementing ModuleStoreWriteBase in which to store the imported courses.
data_dir: the root directory from which to find the xml courses.
course_dirs: If specified, the list of data_dir subdirectories to load. Otherwise, load
all course dirs all course dirs
target_course_id is the CourseKey that all modules should be remapped to target_course_id: is the CourseKey that all modules should be remapped to
after import off disk. We do this remapping as a post-processing step after import off disk. NOTE: this only makes sense if importing only
because there's logic in the importing which expects a 'url_name' as an one course. If there are more than one course loaded from data_dir/course_dirs & you
identifier to where things are on disk supply this id, this method will raise an AssertException.
e.g. ../policies/<url_name>/policy.json as well as metadata keys in
the policy.json. so we need to keep the original url_name during import static_content_store: the static asset store
:param do_import_static: do_import_static: if True, then import the course's static files into static_content_store
if False, then static files are not imported into the static content This can be employed for courses which have substantial
store. This can be employed for courses which have substantial unchanging static content, which is too inefficient to import every
unchanging static content, which is to inefficient to import every
time the course is loaded. Static content for some courses may also be time the course is loaded. Static content for some courses may also be
served directly by nginx, instead of going through django. served directly by nginx, instead of going through django.
: create_new_course_if_not_present: create_new_course_if_not_present: If True, then a new course is created if it doesn't already exist.
If True, then a new course is created if it doesn't already exist. Otherwise, it throws an InvalidLocationError if the course does not exist.
The check for existing courses is case-insensitive.
default_class, load_error_modules: are arguments for constructing the XMLModuleStore (see its doc)
""" """
xml_module_store = XMLModuleStore( xml_module_store = XMLModuleStore(
...@@ -156,45 +185,115 @@ def import_from_xml( ...@@ -156,45 +185,115 @@ def import_from_xml(
if target_course_id: if target_course_id:
assert(len(xml_module_store.modules) == 1) assert(len(xml_module_store.modules) == 1)
# NOTE: the XmlModuleStore does not implement get_items() new_courses = []
# which would be a preferable means to enumerate the entire collection
# of course modules. It will be left as a TBD to implement that
# method on XmlModuleStore.
course_items = []
for course_key in xml_module_store.modules.keys(): for course_key in xml_module_store.modules.keys():
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key):
if target_course_id is not None: if target_course_id is not None:
dest_course_id = target_course_id dest_course_id = target_course_id
else: else:
dest_course_id = course_key dest_course_id = store.make_course_key(course_key.org, course_key.course, course_key.run)
runtime = None
# Creates a new course if it doesn't already exist # Creates a new course if it doesn't already exist
if create_new_course_if_not_present and not store.has_course(dest_course_id, ignore_case=True): if create_new_course_if_not_present and not store.has_course(dest_course_id, ignore_case=True):
try: try:
store.create_course(dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id) new_course = store.create_course(dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id)
runtime = new_course.runtime
except DuplicateCourseError: except DuplicateCourseError:
# course w/ same org and course exists # course w/ same org and course exists
# The Mongo modulestore checks *with* the run in has_course, but not in create_course.
log.debug( log.debug(
"Skipping import of course with id, {0}," "Skipping import of course with id, %s,"
"since it collides with an existing one".format(dest_course_id) "since it collides with an existing one", dest_course_id
) )
continue continue
with store.bulk_write_operations(dest_course_id): with store.bulk_write_operations(dest_course_id):
course_data_path = None source_course = xml_module_store.get_course(course_key)
# STEP 1: find and import course module
course, course_data_path = _import_course_module(
store, runtime, user_id,
data_dir, course_key, dest_course_id, source_course,
do_import_static, verbose
)
new_courses.append(course)
# STEP 2: import static content
_import_static_content_wrapper(
static_content_store, do_import_static, course_data_path, dest_course_id, verbose
)
# STEP 3: import PUBLISHED items
# now loop through all the modules depth first and then orphans
with store.branch_setting(ModuleStoreEnum.Branch.published_only, dest_course_id):
all_locs = set(xml_module_store.modules[course_key].keys())
all_locs.remove(source_course.location)
def depth_first(subtree):
"""
Import top down just so import code can make assumptions about parents always being available
"""
if subtree.has_children:
for child in subtree.get_children():
try:
all_locs.remove(child.location)
except KeyError:
# tolerate same child occurring under 2 parents such as in
# ContentStoreTest.test_image_import
pass
if verbose:
log.debug('importing module location {loc}'.format(loc=child.location))
_import_module_and_update_references(
child,
store,
user_id,
course_key,
dest_course_id,
do_import_static=do_import_static,
runtime=course.runtime
)
depth_first(child)
depth_first(source_course)
for leftover in all_locs:
if verbose:
log.debug('importing module location {loc}'.format(loc=leftover))
_import_module_and_update_references(
xml_module_store.get_item(leftover), store,
user_id,
course_key,
dest_course_id,
do_import_static=do_import_static,
runtime=course.runtime
)
# STEP 4: import any DRAFT items
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, dest_course_id):
_import_course_draft(
xml_module_store,
store,
user_id,
course_data_path,
course_key,
dest_course_id,
course.runtime
)
return new_courses
def _import_course_module(
store, runtime, user_id, data_dir, course_key, dest_course_id, source_course, do_import_static,
verbose,
):
if verbose: if verbose:
log.debug("Scanning {0} for course module...".format(course_key)) log.debug("Scanning {0} for course module...".format(course_key))
# Quick scan to get course module as we need some info from there. # Quick scan to get course module as we need some info from there.
# Also we need to make sure that the course module is committed # Also we need to make sure that the course module is committed
# first into the store # first into the store
for module in xml_module_store.modules[course_key].itervalues(): course_data_path = path(data_dir) / source_course.data_dir
if module.scope_ids.block_type == 'course':
course_data_path = path(data_dir) / module.data_dir
log.debug(u'======> IMPORTING course {course_key}'.format( log.debug(u'======> IMPORTING course {course_key}'.format(
course_key=course_key, course_key=course_key,
...@@ -202,19 +301,22 @@ def import_from_xml( ...@@ -202,19 +301,22 @@ def import_from_xml(
if not do_import_static: if not do_import_static:
# for old-style xblock where this was actually linked to kvs # for old-style xblock where this was actually linked to kvs
module.static_asset_path = module.data_dir source_course.static_asset_path = source_course.data_dir
module.save() source_course.save()
log.debug('course static_asset_path={path}'.format( log.debug('course static_asset_path={path}'.format(
path=module.static_asset_path path=source_course.static_asset_path
)) ))
log.debug('course data_dir={0}'.format(module.data_dir)) log.debug('course data_dir={0}'.format(source_course.data_dir))
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, dest_course_id):
course = _import_module_and_update_references( course = _import_module_and_update_references(
module, store, user_id, source_course, store, user_id,
course_key, course_key,
dest_course_id, dest_course_id,
do_import_static=do_import_static do_import_static=do_import_static,
runtime=runtime,
) )
for entry in course.pdf_textbooks: for entry in course.pdf_textbooks:
...@@ -251,12 +353,10 @@ def import_from_xml( ...@@ -251,12 +353,10 @@ def import_from_xml(
CourseTabList.initialize_default(course) CourseTabList.initialize_default(course)
store.update_item(course, user_id) store.update_item(course, user_id)
return course, course_data_path
course_items.append(course)
break
# TODO: shouldn't this raise an exception if course wasn't found?
def _import_static_content_wrapper(static_content_store, do_import_static, course_data_path, dest_course_id, verbose):
# then import all the static content # then import all the static content
if static_content_store is not None and do_import_static: if static_content_store is not None and do_import_static:
# first pass to find everything in /static/ # first pass to find everything in /static/
...@@ -288,44 +388,6 @@ def import_from_xml( ...@@ -288,44 +388,6 @@ def import_from_xml(
dest_course_id, subpath=simport, verbose=verbose dest_course_id, subpath=simport, verbose=verbose
) )
# now loop through all the modules
for module in xml_module_store.modules[course_key].itervalues():
if module.scope_ids.block_type == 'course':
# we've already saved the course module up at the top
# of the loop so just skip over it in the inner loop
continue
if verbose:
log.debug('importing module location {loc}'.format(
loc=module.location
))
_import_module_and_update_references(
module, store,
user_id,
course_key,
dest_course_id,
do_import_static=do_import_static,
runtime=course.runtime
)
# finally, publish the course
store.publish(course.location, user_id)
# now import any DRAFT items
_import_course_draft(
xml_module_store,
store,
user_id,
course_data_path,
course_key,
dest_course_id,
course.runtime
)
return xml_module_store, course_items
def _import_module_and_update_references( def _import_module_and_update_references(
module, store, user_id, module, store, user_id,
source_course_id, dest_course_id, source_course_id, dest_course_id,
...@@ -343,11 +405,6 @@ def _import_module_and_update_references( ...@@ -343,11 +405,6 @@ def _import_module_and_update_references(
) )
# Move the module to a new course # Move the module to a new course
new_usage_key = module.scope_ids.usage_id.map_into_course(dest_course_id)
if new_usage_key.category == 'course':
new_usage_key = new_usage_key.replace(name=dest_course_id.run)
new_module = store.create_xmodule(new_usage_key, runtime=runtime)
def _convert_reference_fields_to_new_namespace(reference): def _convert_reference_fields_to_new_namespace(reference):
""" """
Convert a reference to the new namespace, but only Convert a reference to the new namespace, but only
...@@ -361,25 +418,23 @@ def _import_module_and_update_references( ...@@ -361,25 +418,23 @@ def _import_module_and_update_references(
else: else:
return reference return reference
fields = {}
for field_name, field in module.fields.iteritems(): for field_name, field in module.fields.iteritems():
if field.is_set_on(module): if field.is_set_on(module):
if isinstance(field, Reference): if isinstance(field, Reference):
new_ref = _convert_reference_fields_to_new_namespace(getattr(module, field_name)) fields[field_name] = _convert_reference_fields_to_new_namespace(field.read_from(module))
setattr(new_module, field_name, new_ref)
elif isinstance(field, ReferenceList): elif isinstance(field, ReferenceList):
references = getattr(module, field_name) references = field.read_from(module)
new_references = [_convert_reference_fields_to_new_namespace(reference) for reference in references] fields[field_name] = [_convert_reference_fields_to_new_namespace(reference) for reference in references]
setattr(new_module, field_name, new_references)
elif isinstance(field, ReferenceValueDict): elif isinstance(field, ReferenceValueDict):
reference_dict = getattr(module, field_name) reference_dict = field.read_from(module)
new_reference_dict = { fields[field_name] = {
key: _convert_reference_fields_to_new_namespace(reference) key: _convert_reference_fields_to_new_namespace(reference)
for key, reference for key, reference
in reference_dict.items() in reference_dict.items()
} }
setattr(new_module, field_name, new_reference_dict)
elif field_name == 'xml_attributes': elif field_name == 'xml_attributes':
value = getattr(module, field_name) value = field.read_from(module)
# remove any export/import only xml_attributes # remove any export/import only xml_attributes
# which are used to wire together draft imports # which are used to wire together draft imports
if 'parent_sequential_url' in value: if 'parent_sequential_url' in value:
...@@ -387,11 +442,11 @@ def _import_module_and_update_references( ...@@ -387,11 +442,11 @@ def _import_module_and_update_references(
if 'index_in_children_list' in value: if 'index_in_children_list' in value:
del value['index_in_children_list'] del value['index_in_children_list']
setattr(new_module, field_name, value) fields[field_name] = value
else: else:
setattr(new_module, field_name, getattr(module, field_name)) fields[field_name] = field.read_from(module)
store.update_item(new_module, user_id, allow_not_found=True)
return new_module return store.import_xblock(user_id, dest_course_id, module.location.category, module.location.block_id, fields, runtime)
def _import_course_draft( def _import_course_draft(
...@@ -494,8 +549,8 @@ def _import_course_draft( ...@@ -494,8 +549,8 @@ def _import_course_draft(
# attributes (they are normally in the parent object, # attributes (they are normally in the parent object,
# aka sequential), so we have to replace the location.name # aka sequential), so we have to replace the location.name
# with the XML filename that is part of the pack # with the XML filename that is part of the pack
fn, fileExtension = os.path.splitext(filename) filename, __ = os.path.splitext(filename)
descriptor.location = descriptor.location.replace(name=fn) descriptor.location = descriptor.location.replace(name=filename)
index = int(descriptor.xml_attributes['index_in_children_list']) index = int(descriptor.xml_attributes['index_in_children_list'])
if index in drafts: if index in drafts:
...@@ -526,7 +581,6 @@ def _import_course_draft( ...@@ -526,7 +581,6 @@ def _import_course_draft(
# Note though that verticals nested below the unit level will not have # Note though that verticals nested below the unit level will not have
# a parent_sequential_url and do not need special handling. # a parent_sequential_url and do not need special handling.
if module.location.category == 'vertical' and 'parent_sequential_url' in module.xml_attributes: if module.location.category == 'vertical' and 'parent_sequential_url' in module.xml_attributes:
non_draft_location = module.location.replace(revision=MongoRevisionKey.published)
sequential_url = module.xml_attributes['parent_sequential_url'] sequential_url = module.xml_attributes['parent_sequential_url']
index = int(module.xml_attributes['index_in_children_list']) index = int(module.xml_attributes['index_in_children_list'])
...@@ -536,7 +590,8 @@ def _import_course_draft( ...@@ -536,7 +590,8 @@ def _import_course_draft(
seq_location = seq_location.map_into_course(target_course_id) seq_location = seq_location.map_into_course(target_course_id)
sequential = store.get_item(seq_location, depth=0) sequential = store.get_item(seq_location, depth=0)
if non_draft_location not in sequential.children: non_draft_location = module.location.map_into_course(target_course_id)
if not any(child.block_id == module.location.block_id for child in sequential.children):
sequential.children.insert(index, non_draft_location) sequential.children.insert(index, non_draft_location)
store.update_item(sequential, user_id) store.update_item(sequential, user_id)
......
...@@ -16,13 +16,16 @@ from mock import Mock ...@@ -16,13 +16,16 @@ from mock import Mock
from path import path from path import path
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds from xblock.fields import ScopeIds, Scope
from xmodule.x_module import ModuleSystem, XModuleDescriptor, XModuleMixin from xmodule.x_module import ModuleSystem, XModuleDescriptor, XModuleMixin
from xmodule.modulestore.inheritance import InheritanceMixin, own_metadata from xmodule.modulestore.inheritance import InheritanceMixin, own_metadata
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.mako_module import MakoDescriptorSystem from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import PublishState, ModuleStoreEnum
from xmodule.modulestore.mongo.draft import DraftModuleStore
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES
MODULE_DIR = path(__file__).dirname() MODULE_DIR = path(__file__).dirname()
...@@ -193,32 +196,45 @@ class CourseComparisonTest(unittest.TestCase): ...@@ -193,32 +196,45 @@ class CourseComparisonTest(unittest.TestCase):
Any field value mentioned in ``self.field_exclusions`` by the key (usage_id, field_name) Any field value mentioned in ``self.field_exclusions`` by the key (usage_id, field_name)
will be ignored for the purpose of equality checking. will be ignored for the purpose of equality checking.
""" """
expected_items = expected_store.get_items(expected_course_key) # compare published
actual_items = actual_store.get_items(actual_course_key) expected_items = expected_store.get_items(expected_course_key, revision=ModuleStoreEnum.RevisionOption.published_only)
actual_items = actual_store.get_items(actual_course_key, revision=ModuleStoreEnum.RevisionOption.published_only)
self.assertGreater(len(expected_items), 0) self.assertGreater(len(expected_items), 0)
self._assertCoursesEqual(expected_items, actual_items, actual_course_key)
# compare draft
if expected_store.get_modulestore_type(None) == ModuleStoreEnum.Type.split:
revision = ModuleStoreEnum.RevisionOption.draft_only
else:
revision = None
expected_items = expected_store.get_items(expected_course_key, revision=revision)
if actual_store.get_modulestore_type(None) == ModuleStoreEnum.Type.split:
revision = ModuleStoreEnum.RevisionOption.draft_only
else:
revision = None
actual_items = actual_store.get_items(actual_course_key, revision=revision)
self._assertCoursesEqual(expected_items, actual_items, actual_course_key, expect_drafts=True)
def _assertCoursesEqual(self, expected_items, actual_items, actual_course_key, expect_drafts=False):
self.assertEqual(len(expected_items), len(actual_items)) self.assertEqual(len(expected_items), len(actual_items))
actual_item_map = {item.location: item for item in actual_items} actual_item_map = {
item.location.block_id: item
for item in actual_items
}
for expected_item in expected_items: for expected_item in expected_items:
actual_item_location = expected_item.location.map_into_course(actual_course_key) actual_item_location = actual_course_key.make_usage_key(expected_item.category, expected_item.location.block_id)
# split and old mongo use different names for the course root but we don't know which
# modulestore actual's come from here; so, assume old mongo and if that fails, assume split
if expected_item.location.category == 'course': if expected_item.location.category == 'course':
actual_item_location = actual_item_location.replace(name=actual_item_location.run) actual_item_location = actual_item_location.replace(name=actual_item_location.run)
actual_item = actual_item_map.get(actual_item_location) actual_item = actual_item_map.get(actual_item_location.block_id)
# must be split
# compare published state if actual_item is None and expected_item.location.category == 'course':
exp_pub_state = expected_store.compute_publish_state(expected_item) actual_item_location = actual_item_location.replace(name='course')
act_pub_state = actual_store.compute_publish_state(actual_item) actual_item = actual_item_map.get(actual_item_location.block_id)
self.assertEqual( self.assertIsNotNone(actual_item, u'cannot find {} in {}'.format(actual_item_location, actual_item_map))
exp_pub_state,
act_pub_state,
'Published states for usages {} and {} differ: {!r} != {!r}'.format(
expected_item.location,
actual_item.location,
exp_pub_state,
act_pub_state
)
)
# compare fields # compare fields
self.assertEqual(expected_item.fields, actual_item.fields) self.assertEqual(expected_item.fields, actual_item.fields)
...@@ -251,12 +267,20 @@ class CourseComparisonTest(unittest.TestCase): ...@@ -251,12 +267,20 @@ class CourseComparisonTest(unittest.TestCase):
# compare children # compare children
self.assertEqual(expected_item.has_children, actual_item.has_children) self.assertEqual(expected_item.has_children, actual_item.has_children)
if expected_item.has_children: if expected_item.has_children:
expected_children = [] actual_course_key = actual_item.location.course_key.version_agnostic()
for course1_item_child in expected_item.children: expected_children = [
expected_children.append( course1_item_child.location.map_into_course(actual_course_key)
course1_item_child.map_into_course(actual_course_key) for course1_item_child in expected_item.get_children()
) # get_children was returning drafts for published parents :-(
self.assertEqual(expected_children, actual_item.children) if expect_drafts or not getattr(course1_item_child, 'is_draft', False)
]
actual_children = [
item_child.location.version_agnostic()
for item_child in actual_item.get_children()
# get_children was returning drafts for published parents :-(
if expect_drafts or not getattr(item_child, 'is_draft', False)
]
self.assertEqual(expected_children, actual_children)
def assertAssetEqual(self, expected_course_key, expected_asset, actual_course_key, actual_asset): def assertAssetEqual(self, expected_course_key, expected_asset, actual_course_key, actual_asset):
""" """
...@@ -296,7 +320,6 @@ class CourseComparisonTest(unittest.TestCase): ...@@ -296,7 +320,6 @@ class CourseComparisonTest(unittest.TestCase):
``actual_course_key`` in ``actual_store`` are identical, allowing for differences related ``actual_course_key`` in ``actual_store`` are identical, allowing for differences related
to their being from different course keys. to their being from different course keys.
""" """
expected_content, expected_count = expected_store.get_all_content_for_course(expected_course_key) expected_content, expected_count = expected_store.get_all_content_for_course(expected_course_key)
actual_content, actual_count = actual_store.get_all_content_for_course(actual_course_key) actual_content, actual_count = actual_store.get_all_content_for_course(actual_course_key)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment