Commit 87558c2c by Calen Pennington

Merge pull request #5212 from cpennington/split/import-export-performance

Improve performance of split-mongo import/export performance
parents cd1a4d1d 1a17b31a
...@@ -56,7 +56,7 @@ class ContentStoreImportTest(ModuleStoreTestCase): ...@@ -56,7 +56,7 @@ class ContentStoreImportTest(ModuleStoreTestCase):
target_course_id=target_course_id, target_course_id=target_course_id,
create_new_course_if_not_present=create_new_course_if_not_present, create_new_course_if_not_present=create_new_course_if_not_present,
) )
course_id = SlashSeparatedCourseKey('edX', 'test_import_course', '2012_Fall') course_id = module_store.make_course_key('edX', 'test_import_course', '2012_Fall')
course = module_store.get_course(course_id) course = module_store.get_course(course_id)
self.assertIsNotNone(course) self.assertIsNotNone(course)
......
...@@ -5,6 +5,9 @@ source = common/lib/xmodule ...@@ -5,6 +5,9 @@ source = common/lib/xmodule
[report] [report]
ignore_errors = True ignore_errors = True
exclude_lines =
pragma: no cover
raise NotImplementedError
[html] [html]
title = XModule Python Test Coverage Report title = XModule Python Test Coverage Report
......
...@@ -167,7 +167,7 @@ class MongoContentStore(ContentStore): ...@@ -167,7 +167,7 @@ class MongoContentStore(ContentStore):
policy.setdefault(asset['asset_key'].name, {})[attr] = value policy.setdefault(asset['asset_key'].name, {})[attr] = value
with open(assets_policy_file, 'w') as f: with open(assets_policy_file, 'w') as f:
json.dump(policy, f) json.dump(policy, f, sort_keys=True, indent=4)
def get_all_content_thumbnails_for_course(self, course_key): def get_all_content_thumbnails_for_course(self, course_key):
return self._get_all_content_for_course(course_key, get_thumbnails=True)[0] return self._get_all_content_for_course(course_key, get_thumbnails=True)[0]
......
...@@ -2,7 +2,7 @@ import time ...@@ -2,7 +2,7 @@ import time
import logging import logging
import re import re
from xblock.fields import Field from xblock.fields import JSONField
import datetime import datetime
import dateutil.parser import dateutil.parser
...@@ -11,7 +11,7 @@ from pytz import UTC ...@@ -11,7 +11,7 @@ from pytz import UTC
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class Date(Field): class Date(JSONField):
''' '''
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes. Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes.
''' '''
...@@ -85,7 +85,7 @@ class Date(Field): ...@@ -85,7 +85,7 @@ class Date(Field):
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$') TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
class Timedelta(Field): class Timedelta(JSONField):
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
MUTABLE = False MUTABLE = False
...@@ -101,6 +101,10 @@ class Timedelta(Field): ...@@ -101,6 +101,10 @@ class Timedelta(Field):
""" """
if time_str is None: if time_str is None:
return None return None
if isinstance(time_str, datetime.timedelta):
return time_str
parts = TIMEDELTA_REGEX.match(time_str) parts = TIMEDELTA_REGEX.match(time_str)
if not parts: if not parts:
return return
...@@ -112,6 +116,9 @@ class Timedelta(Field): ...@@ -112,6 +116,9 @@ class Timedelta(Field):
return datetime.timedelta(**time_params) return datetime.timedelta(**time_params)
def to_json(self, value): def to_json(self, value):
if value is None:
return None
values = [] values = []
for attr in ('days', 'hours', 'minutes', 'seconds'): for attr in ('days', 'hours', 'minutes', 'seconds'):
cur_value = getattr(value, attr, 0) cur_value = getattr(value, attr, 0)
...@@ -129,7 +136,7 @@ class Timedelta(Field): ...@@ -129,7 +136,7 @@ class Timedelta(Field):
return self.from_json(value) return self.from_json(value)
class RelativeTime(Field): class RelativeTime(JSONField):
""" """
Field for start_time and end_time video module properties. Field for start_time and end_time video module properties.
...@@ -182,6 +189,9 @@ class RelativeTime(Field): ...@@ -182,6 +189,9 @@ class RelativeTime(Field):
if not value: if not value:
return datetime.timedelta(seconds=0) return datetime.timedelta(seconds=0)
if isinstance(value, datetime.timedelta):
return value
# We've seen serialized versions of float in this field # We've seen serialized versions of float in this field
if isinstance(value, float): if isinstance(value, float):
return datetime.timedelta(seconds=value) return datetime.timedelta(seconds=value)
......
...@@ -634,7 +634,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead): ...@@ -634,7 +634,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
''' '''
Set up the error-tracking logic. Set up the error-tracking logic.
''' '''
super(ModuleStoreReadBase, self).__init__() super(ModuleStoreReadBase, self).__init__(**kwargs)
self._course_errors = defaultdict(make_error_tracker) # location -> ErrorLog self._course_errors = defaultdict(make_error_tracker) # location -> ErrorLog
# TODO move the inheritance_cache_subsystem to classes which use it # TODO move the inheritance_cache_subsystem to classes which use it
self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem
......
...@@ -18,6 +18,8 @@ import threading ...@@ -18,6 +18,8 @@ import threading
from xmodule.util.django import get_current_request_hostname from xmodule.util.django import get_current_request_hostname
import xmodule.modulestore # pylint: disable=unused-import import xmodule.modulestore # pylint: disable=unused-import
from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.draft_and_published import BranchSettingMixin
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
import xblock.reference.plugins import xblock.reference.plugins
...@@ -66,6 +68,12 @@ def create_modulestore_instance(engine, content_store, doc_store_config, options ...@@ -66,6 +68,12 @@ def create_modulestore_instance(engine, content_store, doc_store_config, options
except InvalidCacheBackendError: except InvalidCacheBackendError:
metadata_inheritance_cache = get_cache('default') metadata_inheritance_cache = get_cache('default')
if issubclass(class_, MixedModuleStore):
_options['create_modulestore_instance'] = create_modulestore_instance
if issubclass(class_, BranchSettingMixin):
_options['branch_setting_func'] = _get_modulestore_branch_setting
return class_( return class_(
contentstore=content_store, contentstore=content_store,
metadata_inheritance_cache_subsystem=metadata_inheritance_cache, metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
...@@ -75,8 +83,6 @@ def create_modulestore_instance(engine, content_store, doc_store_config, options ...@@ -75,8 +83,6 @@ def create_modulestore_instance(engine, content_store, doc_store_config, options
doc_store_config=doc_store_config, doc_store_config=doc_store_config,
i18n_service=i18n_service or ModuleI18nService(), i18n_service=i18n_service or ModuleI18nService(),
fs_service=fs_service or xblock.reference.plugins.FSService(), fs_service=fs_service or xblock.reference.plugins.FSService(),
branch_setting_func=_get_modulestore_branch_setting,
create_modulestore_instance=create_modulestore_instance,
**_options **_options
) )
......
...@@ -25,11 +25,11 @@ class BranchSettingMixin(object): ...@@ -25,11 +25,11 @@ class BranchSettingMixin(object):
:param branch_setting_func: a function that returns the default branch setting for this object. :param branch_setting_func: a function that returns the default branch setting for this object.
If not specified, ModuleStoreEnum.Branch.published_only is used as the default setting. If not specified, ModuleStoreEnum.Branch.published_only is used as the default setting.
""" """
super(BranchSettingMixin, self).__init__(*args, **kwargs)
self.default_branch_setting_func = kwargs.pop( self.default_branch_setting_func = kwargs.pop(
'branch_setting_func', 'branch_setting_func',
lambda: ModuleStoreEnum.Branch.published_only lambda: ModuleStoreEnum.Branch.published_only
) )
super(BranchSettingMixin, self).__init__(*args, **kwargs)
# cache the branch setting on a local thread to support a multi-threaded environment # cache the branch setting on a local thread to support a multi-threaded environment
self.thread_cache = threading.local() self.thread_cache = threading.local()
...@@ -69,9 +69,6 @@ class ModuleStoreDraftAndPublished(BranchSettingMixin): ...@@ -69,9 +69,6 @@ class ModuleStoreDraftAndPublished(BranchSettingMixin):
""" """
__metaclass__ = ABCMeta __metaclass__ = ABCMeta
def __init__(self, *args, **kwargs):
super(ModuleStoreDraftAndPublished, self).__init__(*args, **kwargs)
@abstractmethod @abstractmethod
def delete_item(self, location, user_id, revision=None, **kwargs): def delete_item(self, location, user_id, revision=None, **kwargs):
raise NotImplementedError raise NotImplementedError
...@@ -116,7 +113,7 @@ class ModuleStoreDraftAndPublished(BranchSettingMixin): ...@@ -116,7 +113,7 @@ class ModuleStoreDraftAndPublished(BranchSettingMixin):
raise NotImplementedError raise NotImplementedError
class UnsupportedRevisionError(ValueError): class UnsupportedRevisionError(ValueError):
""" """
This error is raised if a method is called with an unsupported revision parameter. This error is raised if a method is called with an unsupported revision parameter.
""" """
......
...@@ -74,7 +74,9 @@ class DuplicateCourseError(Exception): ...@@ -74,7 +74,9 @@ class DuplicateCourseError(Exception):
""" """
existing_entry will have the who, when, and other properties of the existing entry existing_entry will have the who, when, and other properties of the existing entry
""" """
super(DuplicateCourseError, self).__init__() super(DuplicateCourseError, self).__init__(
u'Cannot create course {}, which duplicates {}'.format(course_id, existing_entry)
)
self.course_id = course_id self.course_id = course_id
self.existing_entry = existing_entry self.existing_entry = existing_entry
...@@ -84,9 +86,6 @@ class InvalidBranchSetting(Exception): ...@@ -84,9 +86,6 @@ class InvalidBranchSetting(Exception):
Raised when the process' branch setting did not match the required setting for the attempted operation on a store. Raised when the process' branch setting did not match the required setting for the attempted operation on a store.
""" """
def __init__(self, expected_setting, actual_setting): def __init__(self, expected_setting, actual_setting):
super(InvalidBranchSetting, self).__init__() super(InvalidBranchSetting, self).__init__(u"Invalid branch: expected {} but got {}".format(expected_setting, actual_setting))
self.expected_setting = expected_setting self.expected_setting = expected_setting
self.actual_setting = actual_setting self.actual_setting = actual_setting
def __unicode__(self, *args, **kwargs):
return u"Invalid branch: expected {} but got {}".format(self.expected_setting, self.actual_setting)
...@@ -214,11 +214,19 @@ class InheritingFieldData(KvsFieldData): ...@@ -214,11 +214,19 @@ class InheritingFieldData(KvsFieldData):
""" """
The default for an inheritable name is found on a parent. The default for an inheritable name is found on a parent.
""" """
if name in self.inheritable_names and block.parent is not None: if name in self.inheritable_names:
parent = block.get_parent() # Walk up the content tree to find the first ancestor
if parent: # that this field is set on. Use the field from the current
return getattr(parent, name) # block so that if it has a different default than the root
super(InheritingFieldData, self).default(block, name) # node of the tree, the block's default will be used.
field = block.fields[name]
ancestor = block.get_parent()
while ancestor is not None:
if field.is_set_on(ancestor):
return field.read_json(ancestor)
else:
ancestor = ancestor.get_parent()
return super(InheritingFieldData, self).default(block, name)
def inheriting_field_data(kvs): def inheriting_field_data(kvs):
......
...@@ -20,3 +20,5 @@ class BlockKey(namedtuple('BlockKey', 'type id')): ...@@ -20,3 +20,5 @@ class BlockKey(namedtuple('BlockKey', 'type id')):
def from_usage_key(cls, usage_key): def from_usage_key(cls, usage_key):
return cls(usage_key.block_type, usage_key.block_id) return cls(usage_key.block_type, usage_key.block_id)
CourseEnvelope = namedtuple('CourseEnvelope', 'course_key structure')
import sys import sys
import logging import logging
from contracts import contract, new_contract from contracts import contract, new_contract
from lazy import lazy
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, DefinitionLocator from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, DefinitionLocator
...@@ -12,12 +13,14 @@ from .split_mongo_kvs import SplitMongoKVS ...@@ -12,12 +13,14 @@ from .split_mongo_kvs import SplitMongoKVS
from fs.osfs import OSFS from fs.osfs import OSFS
from .definition_lazy_loader import DefinitionLazyLoader from .definition_lazy_loader import DefinitionLazyLoader
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
from xmodule.modulestore.split_mongo import BlockKey from xmodule.modulestore.inheritance import inheriting_field_data, InheritanceMixin
from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
new_contract('BlockUsageLocator', BlockUsageLocator) new_contract('BlockUsageLocator', BlockUsageLocator)
new_contract('BlockKey', BlockKey) new_contract('BlockKey', BlockKey)
new_contract('CourseEnvelope', CourseEnvelope)
class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
...@@ -27,6 +30,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -27,6 +30,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
Computes the settings (nee 'metadata') inheritance upon creation. Computes the settings (nee 'metadata') inheritance upon creation.
""" """
@contract(course_entry=CourseEnvelope)
def __init__(self, modulestore, course_entry, default_class, module_data, lazy, **kwargs): def __init__(self, modulestore, course_entry, default_class, module_data, lazy, **kwargs):
""" """
Computes the settings inheritance and sets up the cache. Computes the settings inheritance and sets up the cache.
...@@ -34,8 +38,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -34,8 +38,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
modulestore: the module store that can be used to retrieve additional modulestore: the module store that can be used to retrieve additional
modules modules
course_entry: the originally fetched enveloped course_structure w/ branch and course id info course_entry: the originally fetched enveloped course_structure w/ branch and course id info.
plus a dictionary of cached inherited_settings indexed by (block_type, block_id) tuple.
Callers to _load_item provide an override but that function ignores the provided structure and Callers to _load_item provide an override but that function ignores the provided structure and
only looks at the branch and course id only looks at the branch and course id
...@@ -43,10 +46,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -43,10 +46,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
underlying modulestore underlying modulestore
""" """
# needed by capa_problem (as runtime.filestore via this.resources_fs) # needed by capa_problem (as runtime.filestore via this.resources_fs)
if 'course' in course_entry: if course_entry.course_key.course:
root = modulestore.fs_root / course_entry['org'] / course_entry['course'] / course_entry['run'] root = modulestore.fs_root / course_entry.course_key.org / course_entry.course_key.course / course_entry.course_key.run
else: else:
root = modulestore.fs_root / course_entry['structure']['_id'] root = modulestore.fs_root / course_entry.structure['_id']
root.makedirs_p() # create directory if it doesn't exist root.makedirs_p() # create directory if it doesn't exist
super(CachingDescriptorSystem, self).__init__( super(CachingDescriptorSystem, self).__init__(
...@@ -59,16 +62,19 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -59,16 +62,19 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
self.course_entry = course_entry self.course_entry = course_entry
self.lazy = lazy self.lazy = lazy
self.module_data = module_data self.module_data = module_data
# Compute inheritance
modulestore.inherit_settings(
course_entry['structure'].get('blocks', {}),
course_entry['structure'].get('root'),
course_entry.setdefault('inherited_settings', {}),
)
self.default_class = default_class self.default_class = default_class
self.local_modules = {} self.local_modules = {}
@contract(usage_key="BlockUsageLocator | BlockKey") @lazy
@contract(returns="dict(BlockKey: BlockKey)")
def _parent_map(self):
parent_map = {}
for block_key, block in self.course_entry.structure['blocks'].iteritems():
for child in block['fields'].get('children', []):
parent_map[child] = block_key
return parent_map
@contract(usage_key="BlockUsageLocator | BlockKey", course_entry_override="CourseEnvelope | None")
def _load_item(self, usage_key, course_entry_override=None, **kwargs): def _load_item(self, usage_key, course_entry_override=None, **kwargs):
# usage_key is either a UsageKey or just the block_key. if a usage_key, # usage_key is either a UsageKey or just the block_key. if a usage_key,
if isinstance(usage_key, BlockUsageLocator): if isinstance(usage_key, BlockUsageLocator):
...@@ -88,20 +94,17 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -88,20 +94,17 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
block_key = usage_key block_key = usage_key
course_info = course_entry_override or self.course_entry course_info = course_entry_override or self.course_entry
course_key = CourseLocator( course_key = course_info.course_key
version_guid=course_info['structure']['_id'],
org=course_info.get('org'), if course_entry_override:
course=course_info.get('course'), structure_id = course_entry_override.structure.get('_id')
run=course_info.get('run'), else:
branch=course_info.get('branch'), structure_id = self.course_entry.structure.get('_id')
)
json_data = self.get_module_data(block_key, course_key) json_data = self.get_module_data(block_key, course_key)
class_ = self.load_block_type(json_data.get('block_type')) class_ = self.load_block_type(json_data.get('block_type'))
# pass None for inherited_settings to signal that it should get the settings from cache return self.xblock_from_json(class_, course_key, block_key, json_data, course_entry_override, **kwargs)
new_item = self.xblock_from_json(class_, course_key, block_key, json_data, None, course_entry_override, **kwargs)
return new_item
@contract(block_key=BlockKey, course_key=CourseLocator) @contract(block_key=BlockKey, course_key=CourseLocator)
def get_module_data(self, block_key, course_key): def get_module_data(self, block_key, course_key):
...@@ -134,36 +137,29 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -134,36 +137,29 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
# is the intended one when not given a course_entry_override; thus, the caching of the last branch/course id. # is the intended one when not given a course_entry_override; thus, the caching of the last branch/course id.
@contract(block_key="BlockKey | None") @contract(block_key="BlockKey | None")
def xblock_from_json( def xblock_from_json(
self, class_, course_key, block_key, json_data, inherited_settings, course_entry_override=None, **kwargs self, class_, course_key, block_key, json_data, course_entry_override=None, **kwargs
): ):
if course_entry_override is None: if course_entry_override is None:
course_entry_override = self.course_entry course_entry_override = self.course_entry
else: else:
# most recent retrieval is most likely the right one for next caller (see comment above fn) # most recent retrieval is most likely the right one for next caller (see comment above fn)
self.course_entry['branch'] = course_entry_override['branch'] self.course_entry = CourseEnvelope(course_entry_override.course_key, self.course_entry.structure)
self.course_entry['org'] = course_entry_override['org']
self.course_entry['course'] = course_entry_override['course']
self.course_entry['run'] = course_entry_override['run']
definition_id = json_data.get('definition') definition_id = json_data.get('definition')
# If no usage id is provided, generate an in-memory id # If no usage id is provided, generate an in-memory id
if block_key is None: if block_key is None:
block_key = BlockKey(json_data['block_type'], LocalId()) block_key = BlockKey(json_data['block_type'], LocalId())
else:
if inherited_settings is None:
# see if there's a value in course_entry
if block_key in self.course_entry['inherited_settings']:
inherited_settings = self.course_entry['inherited_settings'][block_key]
elif block_key not in self.course_entry['inherited_settings']:
self.course_entry['inherited_settings'][block_key] = inherited_settings
if definition_id is not None and not json_data.get('definition_loaded', False): if definition_id is not None and not json_data.get('definition_loaded', False):
definition_loader = DefinitionLazyLoader( definition_loader = DefinitionLazyLoader(
self.modulestore, block_key.type, definition_id, self.modulestore,
course_key,
block_key.type,
definition_id,
lambda fields: self.modulestore.convert_references_to_keys( lambda fields: self.modulestore.convert_references_to_keys(
course_key, self.load_block_type(block_key.type), course_key, self.load_block_type(block_key.type),
fields, self.course_entry['structure']['blocks'], fields, self.course_entry.structure['blocks'],
) )
) )
else: else:
...@@ -180,15 +176,24 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -180,15 +176,24 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
) )
converted_fields = self.modulestore.convert_references_to_keys( converted_fields = self.modulestore.convert_references_to_keys(
block_locator.course_key, class_, json_data.get('fields', {}), self.course_entry['structure']['blocks'], block_locator.course_key, class_, json_data.get('fields', {}), self.course_entry.structure['blocks'],
) )
if block_key in self._parent_map:
parent_key = self._parent_map[block_key]
parent = course_key.make_usage_key(parent_key.type, parent_key.id)
else:
parent = None
kvs = SplitMongoKVS( kvs = SplitMongoKVS(
definition_loader, definition_loader,
converted_fields, converted_fields,
inherited_settings, parent=parent,
**kwargs field_decorator=kwargs.get('field_decorator')
) )
field_data = KvsFieldData(kvs)
if InheritanceMixin in self.modulestore.xblock_mixins:
field_data = inheriting_field_data(kvs)
else:
field_data = KvsFieldData(kvs)
try: try:
module = self.construct_xblock_from_class( module = self.construct_xblock_from_class(
...@@ -201,8 +206,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -201,8 +206,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
return ErrorDescriptor.from_json( return ErrorDescriptor.from_json(
json_data, json_data,
self, self,
BlockUsageLocator( course_entry_override.course_key.make_usage_key(
CourseLocator(version_guid=course_entry_override['structure']['_id']),
block_type='error', block_type='error',
block_id=block_key.id block_id=block_key.id
), ),
......
...@@ -8,13 +8,14 @@ class DefinitionLazyLoader(object): ...@@ -8,13 +8,14 @@ class DefinitionLazyLoader(object):
object doesn't force access during init but waits until client wants the object doesn't force access during init but waits until client wants the
definition. Only works if the modulestore is a split mongo store. definition. Only works if the modulestore is a split mongo store.
""" """
def __init__(self, modulestore, block_type, definition_id, field_converter): def __init__(self, modulestore, course_key, block_type, definition_id, field_converter):
""" """
Simple placeholder for yet-to-be-fetched data Simple placeholder for yet-to-be-fetched data
:param modulestore: the pymongo db connection with the definitions :param modulestore: the pymongo db connection with the definitions
:param definition_locator: the id of the record in the above to fetch :param definition_locator: the id of the record in the above to fetch
""" """
self.modulestore = modulestore self.modulestore = modulestore
self.course_key = course_key
self.definition_locator = DefinitionLocator(block_type, definition_id) self.definition_locator = DefinitionLocator(block_type, definition_id)
self.field_converter = field_converter self.field_converter = field_converter
...@@ -23,4 +24,4 @@ class DefinitionLazyLoader(object): ...@@ -23,4 +24,4 @@ class DefinitionLazyLoader(object):
Fetch the definition. Note, the caller should replace this lazy Fetch the definition. Note, the caller should replace this lazy
loader pointer with the result so as not to fetch more than once loader pointer with the result so as not to fetch more than once
""" """
return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id) return self.modulestore.get_definition(self.course_key, self.definition_locator.definition_id)
...@@ -3,7 +3,14 @@ Segregation of pymongo functions from the data modeling mechanisms for split mod ...@@ -3,7 +3,14 @@ Segregation of pymongo functions from the data modeling mechanisms for split mod
""" """
import re import re
import pymongo import pymongo
import time
# Import this just to export it
from pymongo.errors import DuplicateKeyError # pylint: disable=unused-import
from contracts import check from contracts import check
from functools import wraps
from pymongo.errors import AutoReconnect
from xmodule.exceptions import HeartbeatFailure from xmodule.exceptions import HeartbeatFailure
from xmodule.modulestore.split_mongo import BlockKey from xmodule.modulestore.split_mongo import BlockKey
from datetime import tzinfo from datetime import tzinfo
...@@ -62,6 +69,32 @@ def structure_to_mongo(structure): ...@@ -62,6 +69,32 @@ def structure_to_mongo(structure):
return new_structure return new_structure
def autoretry_read(wait=0.1, retries=5):
"""
Automatically retry a read-only method in the case of a pymongo
AutoReconnect exception.
See http://emptysqua.re/blog/save-the-monkey-reliably-writing-to-mongodb/
for a discussion of this technique.
"""
def decorate(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for attempt in xrange(retries):
try:
return fn(*args, **kwargs)
break
except AutoReconnect:
# Reraise if we failed on our last attempt
if attempt == retries - 1:
raise
if wait:
time.sleep(wait)
return wrapper
return decorate
class MongoConnection(object): class MongoConnection(object):
""" """
Segregation of pymongo functions from the data modeling mechanisms for split modulestore. Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
...@@ -106,12 +139,14 @@ class MongoConnection(object): ...@@ -106,12 +139,14 @@ class MongoConnection(object):
else: else:
raise HeartbeatFailure("Can't connect to {}".format(self.database.name)) raise HeartbeatFailure("Can't connect to {}".format(self.database.name))
@autoretry_read()
def get_structure(self, key): def get_structure(self, key):
""" """
Get the structure from the persistence mechanism whose id is the given key Get the structure from the persistence mechanism whose id is the given key
""" """
return structure_from_mongo(self.structures.find_one({'_id': key})) return structure_from_mongo(self.structures.find_one({'_id': key}))
@autoretry_read()
def find_structures_by_id(self, ids): def find_structures_by_id(self, ids):
""" """
Return all structures that specified in ``ids``. Return all structures that specified in ``ids``.
...@@ -121,6 +156,7 @@ class MongoConnection(object): ...@@ -121,6 +156,7 @@ class MongoConnection(object):
""" """
return [structure_from_mongo(structure) for structure in self.structures.find({'_id': {'$in': ids}})] return [structure_from_mongo(structure) for structure in self.structures.find({'_id': {'$in': ids}})]
@autoretry_read()
def find_structures_derived_from(self, ids): def find_structures_derived_from(self, ids):
""" """
Return all structures that were immediately derived from a structure listed in ``ids``. Return all structures that were immediately derived from a structure listed in ``ids``.
...@@ -130,6 +166,7 @@ class MongoConnection(object): ...@@ -130,6 +166,7 @@ class MongoConnection(object):
""" """
return [structure_from_mongo(structure) for structure in self.structures.find({'previous_version': {'$in': ids}})] return [structure_from_mongo(structure) for structure in self.structures.find({'previous_version': {'$in': ids}})]
@autoretry_read()
def find_ancestor_structures(self, original_version, block_key): def find_ancestor_structures(self, original_version, block_key):
""" """
Find all structures that originated from ``original_version`` that contain ``block_key``. Find all structures that originated from ``original_version`` that contain ``block_key``.
...@@ -149,12 +186,13 @@ class MongoConnection(object): ...@@ -149,12 +186,13 @@ class MongoConnection(object):
} }
})] })]
def upsert_structure(self, structure): def insert_structure(self, structure):
""" """
Update the db record for structure, creating that record if it doesn't already exist Insert a new structure into the database.
""" """
self.structures.update({'_id': structure['_id']}, structure_to_mongo(structure), upsert=True) self.structures.insert(structure_to_mongo(structure))
@autoretry_read()
def get_course_index(self, key, ignore_case=False): def get_course_index(self, key, ignore_case=False):
""" """
Get the course_index from the persistence mechanism whose id is the given key Get the course_index from the persistence mechanism whose id is the given key
...@@ -171,6 +209,7 @@ class MongoConnection(object): ...@@ -171,6 +209,7 @@ class MongoConnection(object):
} }
return self.course_index.find_one(query) return self.course_index.find_one(query)
@autoretry_read()
def find_matching_course_indexes(self, branch=None, search_targets=None): def find_matching_course_indexes(self, branch=None, search_targets=None):
""" """
Find the course_index matching particular conditions. Find the course_index matching particular conditions.
...@@ -229,18 +268,19 @@ class MongoConnection(object): ...@@ -229,18 +268,19 @@ class MongoConnection(object):
'run': course_index['run'], 'run': course_index['run'],
}) })
@autoretry_read()
def get_definition(self, key): def get_definition(self, key):
""" """
Get the definition from the persistence mechanism whose id is the given key Get the definition from the persistence mechanism whose id is the given key
""" """
return self.definitions.find_one({'_id': key}) return self.definitions.find_one({'_id': key})
def find_matching_definitions(self, query): @autoretry_read()
def get_definitions(self, definitions):
""" """
Find the definitions matching the query. Right now the query must be a legal mongo query Retrieve all definitions listed in `definitions`.
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
""" """
return self.definitions.find(query) return self.definitions.find({'$in': {'_id': definitions}})
def insert_definition(self, definition): def insert_definition(self, definition):
""" """
......
...@@ -242,7 +242,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -242,7 +242,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
:return: True if the draft and published versions differ :return: True if the draft and published versions differ
""" """
def get_course(branch_name): def get_course(branch_name):
return self._lookup_course(xblock.location.course_key.for_branch(branch_name))['structure'] return self._lookup_course(xblock.location.course_key.for_branch(branch_name)).structure
def get_block(course_structure, block_key): def get_block(course_structure, block_key):
return self._get_block_from_structure(course_structure, block_key) return self._get_block_from_structure(course_structure, block_key)
...@@ -318,7 +318,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -318,7 +318,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
# get head version of Published branch # get head version of Published branch
published_course_structure = self._lookup_course( published_course_structure = self._lookup_course(
location.course_key.for_branch(ModuleStoreEnum.BranchName.published) location.course_key.for_branch(ModuleStoreEnum.BranchName.published)
)['structure'] ).structure
published_block = self._get_block_from_structure( published_block = self._get_block_from_structure(
published_course_structure, published_course_structure,
BlockKey.from_usage_key(location) BlockKey.from_usage_key(location)
...@@ -327,7 +327,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -327,7 +327,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
raise InvalidVersionError(location) raise InvalidVersionError(location)
# create a new versioned draft structure # create a new versioned draft structure
draft_course_structure = self._lookup_course(draft_course_key)['structure'] draft_course_structure = self._lookup_course(draft_course_key).structure
new_structure = self.version_structure(draft_course_key, draft_course_structure, user_id) new_structure = self.version_structure(draft_course_key, draft_course_structure, user_id)
# remove the block and its descendants from the new structure # remove the block and its descendants from the new structure
...@@ -394,7 +394,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS ...@@ -394,7 +394,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
pass pass
def _get_head(self, xblock, branch): def _get_head(self, xblock, branch):
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch))['structure'] course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location)) return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location))
def _get_version(self, block): def _get_version(self, block):
......
import copy import copy
from contracts import contract, new_contract
from xblock.fields import Scope from xblock.fields import Scope
from collections import namedtuple from collections import namedtuple
from xblock.exceptions import InvalidScopeError from xblock.exceptions import InvalidScopeError
from .definition_lazy_loader import DefinitionLazyLoader from .definition_lazy_loader import DefinitionLazyLoader
from xmodule.modulestore.inheritance import InheritanceKeyValueStore from xmodule.modulestore.inheritance import InheritanceKeyValueStore
from opaque_keys.edx.locator import BlockUsageLocator
# id is a BlockUsageLocator, def_id is the definition's guid # id is a BlockUsageLocator, def_id is the definition's guid
SplitMongoKVSid = namedtuple('SplitMongoKVSid', 'id, def_id') SplitMongoKVSid = namedtuple('SplitMongoKVSid', 'id, def_id')
new_contract('BlockUsageLocator', BlockUsageLocator)
class SplitMongoKVS(InheritanceKeyValueStore): class SplitMongoKVS(InheritanceKeyValueStore):
...@@ -15,22 +18,25 @@ class SplitMongoKVS(InheritanceKeyValueStore): ...@@ -15,22 +18,25 @@ class SplitMongoKVS(InheritanceKeyValueStore):
known to the MongoModuleStore (data, children, and metadata) known to the MongoModuleStore (data, children, and metadata)
""" """
def __init__(self, definition, initial_values, inherited_settings, **kwargs): @contract(parent="BlockUsageLocator | None")
def __init__(self, definition, initial_values, parent, field_decorator=None):
""" """
:param definition: either a lazyloader or definition id for the definition :param definition: either a lazyloader or definition id for the definition
:param initial_values: a dictionary of the locally set values :param initial_values: a dictionary of the locally set values
:param inherited_settings: the json value of each inheritable field from above this.
Note, local fields may override and disagree w/ this b/c this says what the value
should be if the field is undefined.
""" """
# deepcopy so that manipulations of fields does not pollute the source # deepcopy so that manipulations of fields does not pollute the source
super(SplitMongoKVS, self).__init__(copy.deepcopy(initial_values), inherited_settings) super(SplitMongoKVS, self).__init__(copy.deepcopy(initial_values))
self._definition = definition # either a DefinitionLazyLoader or the db id of the definition. self._definition = definition # either a DefinitionLazyLoader or the db id of the definition.
# if the db id, then the definition is presumed to be loaded into _fields # if the db id, then the definition is presumed to be loaded into _fields
# a decorator function for field values (to be called when a field is accessed) # a decorator function for field values (to be called when a field is accessed)
self.field_decorator = kwargs.get('field_decorator', lambda x: x) if field_decorator is None:
self.field_decorator = lambda x: x
else:
self.field_decorator = field_decorator
self.parent = parent
def get(self, key): def get(self, key):
...@@ -38,8 +44,7 @@ class SplitMongoKVS(InheritanceKeyValueStore): ...@@ -38,8 +44,7 @@ class SplitMongoKVS(InheritanceKeyValueStore):
if key.field_name not in self._fields: if key.field_name not in self._fields:
# parent undefined in editing runtime (I think) # parent undefined in editing runtime (I think)
if key.scope == Scope.parent: if key.scope == Scope.parent:
# see STUD-624. Right now copies MongoKeyValueStore.get's behavior of returning None return self.parent
return None
if key.scope == Scope.children: if key.scope == Scope.children:
# didn't find children in _fields; so, see if there's a default # didn't find children in _fields; so, see if there's a default
raise KeyError() raise KeyError()
......
...@@ -28,6 +28,8 @@ from xmodule.modulestore.xml_importer import import_from_xml ...@@ -28,6 +28,8 @@ 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 from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import XModuleMixin
COMMON_DOCSTORE_CONFIG = { COMMON_DOCSTORE_CONFIG = {
...@@ -36,6 +38,9 @@ COMMON_DOCSTORE_CONFIG = { ...@@ -36,6 +38,9 @@ COMMON_DOCSTORE_CONFIG = {
} }
XBLOCK_MIXINS = (InheritanceMixin, XModuleMixin)
class MemoryCache(object): class MemoryCache(object):
""" """
This fits the metadata_inheritance_cache_subsystem interface used by This fits the metadata_inheritance_cache_subsystem interface used by
...@@ -95,6 +100,7 @@ class MongoModulestoreBuilder(object): ...@@ -95,6 +100,7 @@ class MongoModulestoreBuilder(object):
render_template=repr, render_template=repr,
branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred, branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred,
metadata_inheritance_cache_subsystem=MemoryCache(), metadata_inheritance_cache_subsystem=MemoryCache(),
xblock_mixins=XBLOCK_MIXINS,
) )
modulestore.ensure_indexes() modulestore.ensure_indexes()
...@@ -139,6 +145,7 @@ class VersioningModulestoreBuilder(object): ...@@ -139,6 +145,7 @@ class VersioningModulestoreBuilder(object):
doc_store_config, doc_store_config,
fs_root, fs_root,
render_template=repr, render_template=repr,
xblock_mixins=XBLOCK_MIXINS,
) )
modulestore.ensure_indexes() modulestore.ensure_indexes()
...@@ -189,7 +196,13 @@ class MixedModulestoreBuilder(object): ...@@ -189,7 +196,13 @@ class MixedModulestoreBuilder(object):
# Generate a fake list of stores to give the already generated stores appropriate names # Generate a fake list of stores to give the already generated stores appropriate names
stores = [{'NAME': name, 'ENGINE': 'This space deliberately left blank'} for name in names] stores = [{'NAME': name, 'ENGINE': 'This space deliberately left blank'} for name in names]
modulestore = MixedModuleStore(contentstore, self.mappings, stores, create_modulestore_instance=create_modulestore_instance) modulestore = MixedModuleStore(
contentstore,
self.mappings,
stores,
create_modulestore_instance=create_modulestore_instance,
xblock_mixins=XBLOCK_MIXINS,
)
yield modulestore yield modulestore
...@@ -269,8 +282,8 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest): ...@@ -269,8 +282,8 @@ 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') source_course_key = source_store.make_course_key('a', 'course', 'course')
dest_course_key = dest_store.make_course_key('dest', 'course', 'key') dest_course_key = dest_store.make_course_key('a', 'course', 'course')
import_from_xml( import_from_xml(
source_store, source_store,
...@@ -287,20 +300,30 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest): ...@@ -287,20 +300,30 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest):
source_content, source_content,
source_course_key, source_course_key,
self.export_dir, self.export_dir,
'exported_course', 'exported_source_course',
) )
import_from_xml( import_from_xml(
dest_store, dest_store,
'test_user', 'test_user',
self.export_dir, self.export_dir,
course_dirs=['exported_source_course'],
static_content_store=dest_content, static_content_store=dest_content,
target_course_id=dest_course_key, target_course_id=dest_course_key,
create_new_course_if_not_present=True, create_new_course_if_not_present=True,
) )
export_to_xml(
dest_store,
dest_content,
dest_course_key,
self.export_dir,
'exported_dest_course',
)
self.exclude_field(None, 'wiki_slug') self.exclude_field(None, 'wiki_slug')
self.exclude_field(None, 'xml_attributes') self.exclude_field(None, 'xml_attributes')
self.exclude_field(None, 'parent')
self.ignore_asset_key('_id') self.ignore_asset_key('_id')
self.ignore_asset_key('uploadDate') self.ignore_asset_key('uploadDate')
self.ignore_asset_key('content_son') self.ignore_asset_key('content_son')
......
...@@ -18,6 +18,7 @@ from uuid import uuid4 ...@@ -18,6 +18,7 @@ from uuid import uuid4
# TODO remove this import and the configuration -- xmodule should not depend on django! # TODO remove this import and the configuration -- xmodule should not depend on django!
from django.conf import settings from django.conf import settings
from xmodule.modulestore.edit_info import EditInfoMixin from xmodule.modulestore.edit_info import EditInfoMixin
from xmodule.modulestore.inheritance import InheritanceMixin
if not settings.configured: if not settings.configured:
settings.configure() settings.configure()
...@@ -26,17 +27,17 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -26,17 +27,17 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.draft_and_published import UnsupportedRevisionError from xmodule.modulestore.draft_and_published import UnsupportedRevisionError, ModuleStoreDraftAndPublished
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError, NoPathToItem from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError, NoPathToItem
from xmodule.modulestore.mixed import MixedModuleStore from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.search import path_to_location from xmodule.modulestore.search import path_to_location
from xmodule.modulestore.tests.factories import check_mongo_calls from xmodule.modulestore.tests.factories import check_mongo_calls
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.tests import DATA_DIR from xmodule.tests import DATA_DIR, CourseComparisonTest
@ddt.ddt @ddt.ddt
class TestMixedModuleStore(unittest.TestCase): class TestMixedModuleStore(CourseComparisonTest):
""" """
Quasi-superclass which tests Location based apps against both split and mongo dbs (Locator and Quasi-superclass which tests Location based apps against both split and mongo dbs (Locator and
Location-based dbs) Location-based dbs)
...@@ -58,7 +59,7 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -58,7 +59,7 @@ class TestMixedModuleStore(unittest.TestCase):
'default_class': DEFAULT_CLASS, 'default_class': DEFAULT_CLASS,
'fs_root': DATA_DIR, 'fs_root': DATA_DIR,
'render_template': RENDER_TEMPLATE, 'render_template': RENDER_TEMPLATE,
'xblock_mixins': (EditInfoMixin,) 'xblock_mixins': (EditInfoMixin, InheritanceMixin),
} }
DOC_STORE_CONFIG = { DOC_STORE_CONFIG = {
'host': HOST, 'host': HOST,
...@@ -244,7 +245,8 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -244,7 +245,8 @@ class TestMixedModuleStore(unittest.TestCase):
for course_id, course_key in self.course_locations.iteritems() # pylint: disable=maybe-no-member for course_id, course_key in self.course_locations.iteritems() # pylint: disable=maybe-no-member
} }
self.fake_location = self.course_locations[self.MONGO_COURSEID].course_key.make_usage_key('vertical', 'fake') mongo_course_key = self.course_locations[self.MONGO_COURSEID].course_key
self.fake_location = self.store.make_course_key(mongo_course_key.org, mongo_course_key.course, mongo_course_key.run).make_usage_key('vertical', 'fake')
self.xml_chapter_location = self.course_locations[self.XML_COURSEID1].replace( self.xml_chapter_location = self.course_locations[self.XML_COURSEID1].replace(
category='chapter', name='Overview' category='chapter', name='Overview'
...@@ -1046,7 +1048,7 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -1046,7 +1048,7 @@ class TestMixedModuleStore(unittest.TestCase):
self.store.revert_to_published(self.vertical_x1a, self.user_id) self.store.revert_to_published(self.vertical_x1a, self.user_id)
reverted_parent = self.store.get_item(self.vertical_x1a) reverted_parent = self.store.get_item(self.vertical_x1a)
self.assertEqual(vertical_children_num, len(published_parent.children)) self.assertEqual(vertical_children_num, len(published_parent.children))
self.assertEqual(reverted_parent, published_parent) self.assertBlocksEqualByFields(reverted_parent, published_parent)
self.assertFalse(self._has_changes(self.vertical_x1a)) self.assertFalse(self._has_changes(self.vertical_x1a))
@ddt.data('draft', 'split') @ddt.data('draft', 'split')
...@@ -1081,7 +1083,8 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -1081,7 +1083,8 @@ class TestMixedModuleStore(unittest.TestCase):
orig_vertical = self.store.get_item(self.vertical_x1a) orig_vertical = self.store.get_item(self.vertical_x1a)
self.store.revert_to_published(self.vertical_x1a, self.user_id) self.store.revert_to_published(self.vertical_x1a, self.user_id)
reverted_vertical = self.store.get_item(self.vertical_x1a) reverted_vertical = self.store.get_item(self.vertical_x1a)
self.assertEqual(orig_vertical, reverted_vertical)
self.assertBlocksEqualByFields(orig_vertical, reverted_vertical)
@ddt.data('draft', 'split') @ddt.data('draft', 'split')
def test_revert_to_published_no_published(self, default_ms): def test_revert_to_published_no_published(self, default_ms):
...@@ -1787,9 +1790,11 @@ def create_modulestore_instance(engine, contentstore, doc_store_config, options, ...@@ -1787,9 +1790,11 @@ def create_modulestore_instance(engine, contentstore, doc_store_config, options,
""" """
class_ = load_function(engine) class_ = load_function(engine)
if issubclass(class_, ModuleStoreDraftAndPublished):
options['branch_setting_func'] = lambda: ModuleStoreEnum.Branch.draft_preferred
return class_( return class_(
doc_store_config=doc_store_config, doc_store_config=doc_store_config,
contentstore=contentstore, contentstore=contentstore,
branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred,
**options **options
) )
...@@ -10,6 +10,7 @@ from contracts import contract ...@@ -10,6 +10,7 @@ from contracts import contract
from importlib import import_module from importlib import import_module
from path import path from path import path
from xblock.fields import Reference, ReferenceList, ReferenceValueDict
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 (
...@@ -1592,7 +1593,7 @@ class TestInheritance(SplitModuleTest): ...@@ -1592,7 +1593,7 @@ class TestInheritance(SplitModuleTest):
# unset on parent, retrieve child, verify unset # unset on parent, retrieve child, verify unset
chapter = modulestore().get_item(chapter.location.version_agnostic()) chapter = modulestore().get_item(chapter.location.version_agnostic())
chapter.fields['visible_to_staff_only'].delete_from(chapter) del chapter.visible_to_staff_only
modulestore().update_item(chapter, self.user_id) modulestore().update_item(chapter, self.user_id)
problem = modulestore().get_item(problem.location.version_agnostic()) problem = modulestore().get_item(problem.location.version_agnostic())
...@@ -1756,12 +1757,26 @@ class TestPublish(SplitModuleTest): ...@@ -1756,12 +1757,26 @@ class TestPublish(SplitModuleTest):
for field in source.fields.values(): for field in source.fields.values():
if field.name == 'children': if field.name == 'children':
self._compare_children(field.read_from(source), field.read_from(pub_copy), unexpected_blocks) self._compare_children(field.read_from(source), field.read_from(pub_copy), unexpected_blocks)
elif isinstance(field, (Reference, ReferenceList, ReferenceValueDict)):
self.assertReferenceEqual(field.read_from(source), field.read_from(pub_copy))
else: else:
self.assertEqual(field.read_from(source), field.read_from(pub_copy)) self.assertEqual(field.read_from(source), field.read_from(pub_copy))
for unexp in unexpected_blocks: for unexp in unexpected_blocks:
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
modulestore().get_item(dest_course_loc.make_usage_key(unexp.type, unexp.id)) modulestore().get_item(dest_course_loc.make_usage_key(unexp.type, unexp.id))
def assertReferenceEqual(self, expected, actual):
if isinstance(expected, BlockUsageLocator):
expected = BlockKey.from_usage_key(expected)
actual = BlockKey.from_usage_key(actual)
elif isinstance(expected, list):
expected = [BlockKey.from_usage_key(key) for key in expected]
actual = [BlockKey.from_usage_key(key) for key in actual]
elif isinstance(expected, dict):
expected = {key: BlockKey.from_usage_key(val) for (key, val) in expected}
actual = {key: BlockKey.from_usage_key(val) for (key, val) in actual}
self.assertEqual(expected, actual)
@contract( @contract(
source_children="list(BlockUsageLocator)", source_children="list(BlockUsageLocator)",
dest_children="list(BlockUsageLocator)", dest_children="list(BlockUsageLocator)",
......
...@@ -370,7 +370,7 @@ class XMLModuleStore(ModuleStoreReadBase): ...@@ -370,7 +370,7 @@ class XMLModuleStore(ModuleStoreReadBase):
""" """
def __init__( def __init__(
self, data_dir, default_class=None, course_dirs=None, course_ids=None, self, data_dir, default_class=None, course_dirs=None, course_ids=None,
load_error_modules=True, i18n_service=None, pyfs_service=None, **kwargs load_error_modules=True, i18n_service=None, fs_service=None, **kwargs
): ):
""" """
Initialize an XMLModuleStore from data_dir Initialize an XMLModuleStore from data_dir
...@@ -409,7 +409,7 @@ class XMLModuleStore(ModuleStoreReadBase): ...@@ -409,7 +409,7 @@ class XMLModuleStore(ModuleStoreReadBase):
self.field_data = inheriting_field_data(kvs=DictKeyValueStore()) self.field_data = inheriting_field_data(kvs=DictKeyValueStore())
self.i18n_service = i18n_service self.i18n_service = i18n_service
self.pyfs_service = pyfs_service self.fs_service = fs_service
# If we are specifically asked for missing courses, that should # If we are specifically asked for missing courses, that should
# be an error. If we are asked for "all" courses, find the ones # be an error. If we are asked for "all" courses, find the ones
...@@ -555,8 +555,8 @@ class XMLModuleStore(ModuleStoreReadBase): ...@@ -555,8 +555,8 @@ class XMLModuleStore(ModuleStoreReadBase):
if self.i18n_service: if self.i18n_service:
services['i18n'] = self.i18n_service services['i18n'] = self.i18n_service
if self.pyfs_service: if self.fs_service:
services['fs'] = self.pyfs_service services['fs'] = self.fs_service
system = ImportSystem( system = ImportSystem(
xmlstore=self, xmlstore=self,
......
...@@ -42,6 +42,7 @@ from xmodule.modulestore.django import ASSET_IGNORE_REGEX ...@@ -42,6 +42,7 @@ from xmodule.modulestore.django import ASSET_IGNORE_REGEX
from xmodule.modulestore.exceptions import DuplicateCourseError from xmodule.modulestore.exceptions import DuplicateCourseError
from xmodule.modulestore.mongo.base import MongoRevisionKey from xmodule.modulestore.mongo.base import MongoRevisionKey
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -588,6 +589,7 @@ def _import_course_draft( ...@@ -588,6 +589,7 @@ def _import_course_draft(
# IMPORTANT: Be sure to update the sequential in the NEW namespace # IMPORTANT: Be sure to update the sequential in the NEW namespace
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)
non_draft_location = module.location.map_into_course(target_course_id) non_draft_location = module.location.map_into_course(target_course_id)
......
...@@ -42,6 +42,9 @@ class Group(namedtuple("Group", "id name")): ...@@ -42,6 +42,9 @@ class Group(namedtuple("Group", "id name")):
Raises TypeError if the value doesn't have the right keys. Raises TypeError if the value doesn't have the right keys.
""" """
if isinstance(value, Group):
return value
for key in ('id', 'name', 'version'): for key in ('id', 'name', 'version'):
if key not in value: if key not in value:
raise TypeError("Group dict {0} missing value key '{1}'".format( raise TypeError("Group dict {0} missing value key '{1}'".format(
...@@ -96,6 +99,9 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")): ...@@ -96,6 +99,9 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
Raises TypeError if the value doesn't have the right keys. Raises TypeError if the value doesn't have the right keys.
""" """
if isinstance(value, UserPartition):
return value
for key in ('id', 'name', 'description', 'version', 'groups'): for key in ('id', 'name', 'description', 'version', 'groups'):
if key not in value: if key not in value:
raise TypeError("UserPartition dict {0} missing value key '{1}'" raise TypeError("UserPartition dict {0} missing value key '{1}'"
......
...@@ -13,7 +13,9 @@ import pprint ...@@ -13,7 +13,9 @@ import pprint
import unittest import unittest
from contextlib import contextmanager from contextlib import contextmanager
from lazy import lazy
from mock import Mock from mock import Mock
from operator import attrgetter
from path import path from path import path
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
...@@ -193,7 +195,7 @@ class BulkAssertionManager(object): ...@@ -193,7 +195,7 @@ class BulkAssertionManager(object):
self._equal_actual.append((description, actual)) self._equal_actual.append((description, actual))
def run_assertions(self): def run_assertions(self):
self._test_case.assertEqual(self._equal_expected, self._equal_actual) super(BulkAssertionTest, self._test_case).assertEqual(self._equal_expected, self._equal_actual)
class BulkAssertionTest(unittest.TestCase): class BulkAssertionTest(unittest.TestCase):
...@@ -224,8 +226,29 @@ class BulkAssertionTest(unittest.TestCase): ...@@ -224,8 +226,29 @@ class BulkAssertionTest(unittest.TestCase):
self._manager.assertEqual(expected, actual, message) self._manager.assertEqual(expected, actual, message)
else: else:
super(BulkAssertionTest, self).assertEqual(expected, actual, message) super(BulkAssertionTest, self).assertEqual(expected, actual, message)
assertEquals = assertEqual
class LazyFormat(object):
"""
An stringy object that delays formatting until it's put into a string context.
"""
__slots__ = ('template', 'args', 'kwargs', '_message')
def __init__(self, template, *args, **kwargs):
self.template = template
self.args = args
self.kwargs = kwargs
self._message = None
def __unicode__(self):
if self._message is None:
self._message = self.template.format(*self.args, **self.kwargs)
return self._message
def __repr__(self):
return unicode(self)
class CourseComparisonTest(BulkAssertionTest): class CourseComparisonTest(BulkAssertionTest):
""" """
Mixin that has methods for comparing courses for equality. Mixin that has methods for comparing courses for equality.
...@@ -255,6 +278,65 @@ class CourseComparisonTest(BulkAssertionTest): ...@@ -255,6 +278,65 @@ class CourseComparisonTest(BulkAssertionTest):
""" """
self.ignored_asset_keys.add(key_name) self.ignored_asset_keys.add(key_name)
def assertReferenceRelativelyEqual(self, reference_field, expected_block, actual_block):
"""
Assert that the supplied reference field is identical on the expected_block and actual_block,
assoming that the references are only relative (that is, comparing only on block_type and block_id,
not course_key).
"""
def extract_key(usage_key):
if usage_key is None:
return None
else:
return (usage_key.block_type, usage_key.block_id)
expected = reference_field.read_from(expected_block)
actual = reference_field.read_from(actual_block)
if isinstance(reference_field, Reference):
expected = extract_key(expected)
actual = extract_key(actual)
elif isinstance(reference_field, ReferenceList):
expected = [extract_key(key) for key in expected]
actual = [extract_key(key) for key in actual]
elif isinstance(reference_field, ReferenceValueDict):
expected = {key: extract_key(val) for (key, val) in expected.iteritems()}
actual = {key: extract_key(val) for (key, val) in actual.iteritems()}
self.assertEqual(
expected,
actual,
LazyFormat(
"Field {} doesn't match between usages {} and {}: {!r} != {!r}",
reference_field.name,
expected_block.scope_ids.usage_id,
actual_block.scope_ids.usage_id,
expected,
actual
)
)
def assertBlocksEqualByFields(self, expected_block, actual_block):
self.assertEqual(expected_block.fields, actual_block.fields)
for field in expected_block.fields.values():
self.assertFieldEqual(field, expected_block, actual_block)
def assertFieldEqual(self, field, expected_block, actual_block):
if isinstance(field, (Reference, ReferenceList, ReferenceValueDict)):
self.assertReferenceRelativelyEqual(field, expected_block, actual_block)
else:
expected = field.read_from(expected_block)
actual = field.read_from(actual_block)
self.assertEqual(
expected,
actual,
LazyFormat(
"Field {} doesn't match between usages {} and {}: {!r} != {!r}",
field.name,
expected_block.scope_ids.usage_id,
actual_block.scope_ids.usage_id,
expected,
actual
)
)
def assertCoursesEqual(self, expected_store, expected_course_key, actual_store, actual_course_key): def assertCoursesEqual(self, expected_store, expected_course_key, actual_store, actual_course_key):
""" """
Assert that the courses identified by ``expected_course_key`` in ``expected_store`` and Assert that the courses identified by ``expected_course_key`` in ``expected_store`` and
...@@ -312,11 +394,7 @@ class CourseComparisonTest(BulkAssertionTest): ...@@ -312,11 +394,7 @@ class CourseComparisonTest(BulkAssertionTest):
actual_item = actual_item_map.get(map_key(actual_item_location)) actual_item = actual_item_map.get(map_key(actual_item_location))
# Formatting the message slows down tests of large courses significantly, so only do it if it would be used # Formatting the message slows down tests of large courses significantly, so only do it if it would be used
if actual_item is None: self.assertIsNotNone(actual_item, LazyFormat(u'cannot find {} in {}', map_key(actual_item_location), actual_item_map))
msg = u'cannot find {} in {}'.format(map_key(actual_item_location), actual_item_map)
else:
msg = None
self.assertIsNotNone(actual_item, msg)
# compare fields # compare fields
self.assertEqual(expected_item.fields, actual_item.fields) self.assertEqual(expected_item.fields, actual_item.fields)
...@@ -332,20 +410,7 @@ class CourseComparisonTest(BulkAssertionTest): ...@@ -332,20 +410,7 @@ class CourseComparisonTest(BulkAssertionTest):
if field_name == 'children': if field_name == 'children':
continue continue
exp_value = map_references(field.read_from(expected_item), field, actual_course_key) self.assertFieldEqual(field, expected_item, actual_item)
actual_value = field.read_from(actual_item)
# Formatting the message slows down tests of large courses significantly, so only do it if it would be used
if exp_value != actual_value:
msg = "Field {!r} doesn't match between usages {} and {}: {!r} != {!r}".format(
field_name,
expected_item.scope_ids.usage_id,
actual_item.scope_ids.usage_id,
exp_value,
actual_value,
)
else:
msg = None
self.assertEqual(exp_value, actual_value, msg)
# compare children # compare children
self.assertEqual(expected_item.has_children, actual_item.has_children) self.assertEqual(expected_item.has_children, actual_item.has_children)
......
import ddt
from xmodule.tests import BulkAssertionTest
@ddt.ddt
class TestBulkAssertionTestCase(BulkAssertionTest):
@ddt.data(
('assertTrue', True),
('assertFalse', False),
('assertIs', 1, 1),
('assertIsNot', 1, 2),
('assertIsNone', None),
('assertIsNotNone', 1),
('assertIn', 1, (1, 2, 3)),
('assertNotIn', 5, (1, 2, 3)),
('assertIsInstance', 1, int),
('assertNotIsInstance', '1', int),
('assertRaises', KeyError, {}.__getitem__, '1'),
)
@ddt.unpack
def test_passing_asserts_passthrough(self, assertion, *args):
getattr(self, assertion)(*args)
@ddt.data(
('assertTrue', False),
('assertFalse', True),
('assertIs', 1, 2),
('assertIsNot', 1, 1),
('assertIsNone', 1),
('assertIsNotNone', None),
('assertIn', 5, (1, 2, 3)),
('assertNotIn', 1, (1, 2, 3)),
('assertIsInstance', '1', int),
('assertNotIsInstance', 1, int),
('assertRaises', ValueError, lambda: None),
)
@ddt.unpack
def test_failing_asserts_passthrough(self, assertion, *args):
# Use super(BulkAssertionTest) to make sure we get un-adulturated assertions
with super(BulkAssertionTest, self).assertRaises(AssertionError):
getattr(self, assertion)(*args)
def test_no_bulk_assert_equals(self):
# Use super(BulkAssertionTest) to make sure we get un-adulturated assertions
with super(BulkAssertionTest, self).assertRaises(AssertionError):
self.assertEquals(1, 2)
@ddt.data(
'assertEqual', 'assertEquals'
)
def test_bulk_assert_equals(self, asserterFn):
asserter = getattr(self, asserterFn)
contextmanager = self.bulk_assertions()
contextmanager.__enter__()
super(BulkAssertionTest, self).assertIsNotNone(self._manager)
asserter(1, 2)
asserter(3, 4)
# Use super(BulkAssertionTest) to make sure we get un-adulturated assertions
with super(BulkAssertionTest, self).assertRaises(AssertionError):
contextmanager.__exit__(None, None, None)
@ddt.data(
'assertEqual', 'assertEquals'
)
def test_bulk_assert_closed(self, asserterFn):
asserter = getattr(self, asserterFn)
with self.bulk_assertions():
asserter(1, 1)
asserter(2, 2)
# Use super(BulkAssertionTest) to make sure we get un-adulturated assertions
with super(BulkAssertionTest, self).assertRaises(AssertionError):
asserter(1, 2)
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
Base class for pages specific to a course in Studio. Base class for pages specific to a course in Studio.
""" """
import os
from opaque_keys.edx.locator import CourseLocator
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from . import BASE_URL from . import BASE_URL
...@@ -34,5 +36,12 @@ class CoursePage(PageObject): ...@@ -34,5 +36,12 @@ class CoursePage(PageObject):
""" """
Construct a URL to the page within the course. Construct a URL to the page within the course.
""" """
course_key = "{course_org}/{course_num}/{course_run}".format(**self.course_info) # TODO - is there a better way to make this agnostic to the underlying default module store?
return "/".join([BASE_URL, self.url_path, course_key]) default_store = os.environ.get('DEFAULT_STORE', 'draft')
course_key = CourseLocator(
self.course_info['course_org'],
self.course_info['course_num'],
self.course_info['course_run'],
deprecated=(default_store == 'draft')
)
return "/".join([BASE_URL, self.url_path, unicode(course_key)])
...@@ -115,35 +115,36 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -115,35 +115,36 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
field_data_cache must include data from the course module and 2 levels of its descendents field_data_cache must include data from the course module and 2 levels of its descendents
''' '''
course_module = get_module_for_descriptor(user, request, course, field_data_cache, course.id) with modulestore().bulk_operations(course.id):
if course_module is None: course_module = get_module_for_descriptor(user, request, course, field_data_cache, course.id)
return None if course_module is None:
return None
chapters = list() chapters = list()
for chapter in course_module.get_display_items(): for chapter in course_module.get_display_items():
if chapter.hide_from_toc: if chapter.hide_from_toc:
continue continue
sections = list() sections = list()
for section in chapter.get_display_items(): for section in chapter.get_display_items():
active = (chapter.url_name == active_chapter and active = (chapter.url_name == active_chapter and
section.url_name == active_section) section.url_name == active_section)
if not section.hide_from_toc: if not section.hide_from_toc:
sections.append({'display_name': section.display_name_with_default, sections.append({'display_name': section.display_name_with_default,
'url_name': section.url_name, 'url_name': section.url_name,
'format': section.format if section.format is not None else '', 'format': section.format if section.format is not None else '',
'due': get_extended_due_date(section), 'due': get_extended_due_date(section),
'active': active, 'active': active,
'graded': section.graded, 'graded': section.graded,
}) })
chapters.append({'display_name': chapter.display_name_with_default, chapters.append({'display_name': chapter.display_name_with_default,
'url_name': chapter.url_name, 'url_name': chapter.url_name,
'sections': sections, 'sections': sections,
'active': chapter.url_name == active_chapter}) 'active': chapter.url_name == active_chapter})
return chapters return chapters
def get_module(user, request, usage_key, field_data_cache, def get_module(user, request, usage_key, field_data_cache,
......
...@@ -326,19 +326,29 @@ class TestTOC(ModuleStoreTestCase): ...@@ -326,19 +326,29 @@ class TestTOC(ModuleStoreTestCase):
self.request = factory.get(chapter_url) self.request = factory.get(chapter_url)
self.request.user = UserFactory() self.request.user = UserFactory()
self.modulestore = self.store._get_modulestore_for_courseid(self.course_key) self.modulestore = self.store._get_modulestore_for_courseid(self.course_key)
with check_mongo_calls(num_finds, num_sends): with self.modulestore.bulk_operations(self.course_key):
self.toy_course = self.store.get_course(self.toy_loc, depth=2) with check_mongo_calls(num_finds, num_sends):
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents( self.toy_course = self.store.get_course(self.toy_loc, depth=2)
self.toy_loc, self.request.user, self.toy_course, depth=2 self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
) self.toy_loc, self.request.user, self.toy_course, depth=2
)
# TODO: LMS-11220: Document why split find count is 9 # Mongo makes 3 queries to load the course to depth 2:
# TODO: LMS-11220: Document why mongo find count is 4 # - 1 for the course
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 9, 0)) # - 1 for its children
# - 1 for its grandchildren
# Split makes 6 queries to load the course to depth 2:
# - load the structure
# - load 5 definitions
# Split makes 2 queries to render the toc:
# - it loads the active version at the start of the bulk operation
# - it loads the course definition for inheritance, because it's outside
# the bulk-operation marker that loaded the course descriptor
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 6, 0, 2))
@ddt.unpack @ddt.unpack
def test_toc_toy_from_chapter(self, default_ms, num_finds, num_sends): def test_toc_toy_from_chapter(self, default_ms, setup_finds, setup_sends, toc_finds):
with self.store.default_store(default_ms): with self.store.default_store(default_ms):
self.setup_modulestore(default_ms, num_finds, num_sends) self.setup_modulestore(default_ms, setup_finds, setup_sends)
expected = ([{'active': True, 'sections': expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
'format': u'Lecture Sequence', 'due': None, 'active': False}, 'format': u'Lecture Sequence', 'due': None, 'active': False},
...@@ -354,20 +364,29 @@ class TestTOC(ModuleStoreTestCase): ...@@ -354,20 +364,29 @@ class TestTOC(ModuleStoreTestCase):
'format': '', 'due': None, 'active': False}], 'format': '', 'due': None, 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) 'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
with check_mongo_calls(0, 0): with check_mongo_calls(toc_finds, 0):
actual = render.toc_for_course( actual = render.toc_for_course(
self.request.user, self.request, self.toy_course, self.chapter, None, self.field_data_cache self.request.user, self.request, self.toy_course, self.chapter, None, self.field_data_cache
) )
for toc_section in expected: for toc_section in expected:
self.assertIn(toc_section, actual) self.assertIn(toc_section, actual)
# TODO: LMS-11220: Document why split find count is 9 # Mongo makes 3 queries to load the course to depth 2:
# TODO: LMS-11220: Document why mongo find count is 4 # - 1 for the course
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 9, 0)) # - 1 for its children
# - 1 for its grandchildren
# Split makes 6 queries to load the course to depth 2:
# - load the structure
# - load 5 definitions
# Split makes 2 queries to render the toc:
# - it loads the active version at the start of the bulk operation
# - it loads the course definition for inheritance, because it's outside
# the bulk-operation marker that loaded the course descriptor
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 6, 0, 2))
@ddt.unpack @ddt.unpack
def test_toc_toy_from_section(self, default_ms, num_finds, num_sends): def test_toc_toy_from_section(self, default_ms, setup_finds, setup_sends, toc_finds):
with self.store.default_store(default_ms): with self.store.default_store(default_ms):
self.setup_modulestore(default_ms, num_finds, num_sends) self.setup_modulestore(default_ms, setup_finds, setup_sends)
section = 'Welcome' section = 'Welcome'
expected = ([{'active': True, 'sections': expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
...@@ -384,7 +403,8 @@ class TestTOC(ModuleStoreTestCase): ...@@ -384,7 +403,8 @@ class TestTOC(ModuleStoreTestCase):
'format': '', 'due': None, 'active': False}], 'format': '', 'due': None, 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) 'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
actual = render.toc_for_course(self.request.user, self.request, self.toy_course, self.chapter, section, self.field_data_cache) with check_mongo_calls(toc_finds, 0):
actual = render.toc_for_course(self.request.user, self.request, self.toy_course, self.chapter, section, self.field_data_cache)
for toc_section in expected: for toc_section in expected:
self.assertIn(toc_section, actual) self.assertIn(toc_section, actual)
......
...@@ -131,7 +131,7 @@ rednose==0.3 ...@@ -131,7 +131,7 @@ rednose==0.3
selenium==2.42.1 selenium==2.42.1
splinter==0.5.4 splinter==0.5.4
testtools==0.9.34 testtools==0.9.34
PyContracts==1.6.4 PyContracts==1.6.5
# Used for Segment.io analytics # Used for Segment.io analytics
analytics-python==0.4.4 analytics-python==0.4.4
......
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
-e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash -e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash
-e git+https://github.com/edx/acid-block.git@459aff7b63db8f2c5decd1755706c1a64fb4ebb1#egg=acid-xblock -e git+https://github.com/edx/acid-block.git@459aff7b63db8f2c5decd1755706c1a64fb4ebb1#egg=acid-xblock
-e git+https://github.com/edx/edx-ora2.git@release-2014-09-18T16.00#egg=edx-ora2 -e git+https://github.com/edx/edx-ora2.git@release-2014-09-18T16.00#egg=edx-ora2
-e git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys -e git+https://github.com/edx/opaque-keys.git@295d93170b2f6e57e3a2b9ba0a52087a4e8712c5#egg=opaque-keys
-e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease -e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease
-e git+https://github.com/edx/i18n-tools.git@56f048af9b6868613c14aeae760548834c495011#egg=i18n-tools -e git+https://github.com/edx/i18n-tools.git@56f048af9b6868613c14aeae760548834c495011#egg=i18n-tools
-e git+https://github.com/edx/edx-oauth2-provider.git@0.2.2#egg=oauth2-provider -e git+https://github.com/edx/edx-oauth2-provider.git@0.2.2#egg=oauth2-provider
......
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