Finishing async course structure work

- Added tests
- Updated model field specification
- Fixed issue of multiple event emission
- Updated admin page
- Added management command to manually generate course structures
This commit is contained in:
Clinton Blackburn
2015-02-09 11:44:20 -05:00
parent 88681ba91a
commit 92ca64fa2a
21 changed files with 483 additions and 253 deletions

View File

@@ -170,7 +170,7 @@ class BulkOperationsMixin(object):
self._active_bulk_ops = ActiveBulkThread(self._bulk_ops_record_type)
@contextmanager
def bulk_operations(self, course_id):
def bulk_operations(self, course_id, emit_signals=True):
"""
A context manager for notifying the store of bulk operations. This affects only the current thread.
@@ -181,7 +181,7 @@ class BulkOperationsMixin(object):
self._begin_bulk_operation(course_id)
yield
finally:
self._end_bulk_operation(course_id)
self._end_bulk_operation(course_id, emit_signals)
# the relevant type of bulk_ops_record for the mixin (overriding classes should override
# this variable)
@@ -197,12 +197,14 @@ class BulkOperationsMixin(object):
# Retrieve the bulk record based on matching org/course/run (possibly ignoring case)
if ignore_case:
for key, record in self._active_bulk_ops.records.iteritems():
if (
# Shortcut: check basic equivalence for cases where org/course/run might be None.
if key == course_key or (
key.org.lower() == course_key.org.lower() and
key.course.lower() == course_key.course.lower() and
key.run.lower() == course_key.run.lower()
):
return record
return self._active_bulk_ops.records[course_key.for_branch(None)]
@property
@@ -242,7 +244,7 @@ class BulkOperationsMixin(object):
if bulk_ops_record.is_root:
self._start_outermost_bulk_operation(bulk_ops_record, course_key)
def _end_outermost_bulk_operation(self, bulk_ops_record, course_key):
def _end_outermost_bulk_operation(self, bulk_ops_record, course_key, emit_signals=True):
"""
The outermost nested bulk_operation call: do the actual end of the bulk operation.
@@ -250,7 +252,7 @@ class BulkOperationsMixin(object):
"""
pass
def _end_bulk_operation(self, course_key):
def _end_bulk_operation(self, course_key, emit_signals=True):
"""
End the active bulk operation on course_key.
"""
@@ -266,7 +268,7 @@ class BulkOperationsMixin(object):
if bulk_ops_record.active:
return
self._end_outermost_bulk_operation(bulk_ops_record, course_key)
self._end_outermost_bulk_operation(bulk_ops_record, course_key, emit_signals)
self._clear_bulk_ops_record(course_key)
@@ -900,7 +902,7 @@ class ModuleStoreRead(ModuleStoreAssetBase):
pass
@contextmanager
def bulk_operations(self, course_id):
def bulk_operations(self, course_id, emit_signals=True): # pylint: disable=unused-argument
"""
A context manager for notifying the store of bulk operations. This affects only the current thread.
"""
@@ -1242,10 +1244,11 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
This base method just copies the assets. The lower level impls must do the actual cloning of
content.
"""
# copy the assets
if self.contentstore:
self.contentstore.copy_all_course_assets(source_course_id, dest_course_id)
return dest_course_id
with self.bulk_operations(dest_course_id):
# copy the assets
if self.contentstore:
self.contentstore.copy_all_course_assets(source_course_id, dest_course_id)
return dest_course_id
def delete_course(self, course_key, user_id, **kwargs):
"""

View File

@@ -7,25 +7,30 @@ Passes settings.MODULESTORE as kwargs to MongoModuleStore
from __future__ import absolute_import
from importlib import import_module
import logging
import re
from django.conf import settings
# This configuration must be executed BEFORE any additional Django imports. Otherwise, the imports may fail due to
# Django not being configured properly. This mostly applies to tests.
if not settings.configured:
settings.configure()
from django.core.cache import get_cache, InvalidCacheBackendError
import django.dispatch
import django.utils
import re
from xmodule.util.django import get_current_request_hostname
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.modulestore.draft_and_published import BranchSettingMixin
from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.util.django import get_current_request_hostname
import xblock.reference.plugins
# We may not always have the request_cache module available
try:
# We may not always have the request_cache module available
from request_cache.middleware import RequestCache
HAS_REQUEST_CACHE = True
except ImportError:
HAS_REQUEST_CACHE = False
@@ -34,12 +39,15 @@ except ImportError:
try:
from xblock_django.user_service import DjangoXBlockUserService
from crum import get_current_user
HAS_USER_SERVICE = True
except ImportError:
HAS_USER_SERVICE = False
log = logging.getLogger(__name__)
ASSET_IGNORE_REGEX = getattr(settings, "ASSET_IGNORE_REGEX", r"(^\._.*$)|(^\.DS_Store$)|(^.*~$)")
class SignalHandler(object):
"""
This class is to allow the modulestores to emit signals that can be caught
@@ -55,7 +63,7 @@ class SignalHandler(object):
@receiver(SignalHandler.course_published)
def listen_for_course_publish(sender, course_key, **kwargs):
do_my_expensive_update(course_key)
do_my_expensive_update.delay(course_key)
@task()
def do_my_expensive_update(course_key):
@@ -67,7 +75,7 @@ class SignalHandler(object):
2. The sender is going to be the class of the modulestore sending it.
3. Always have **kwargs in your signal handler, as new things may be added.
4. The thing that listens for the signal lives in process, but should do
almost no work. It's main job is to kick off the celery task that will
almost no work. Its main job is to kick off the celery task that will
do the actual work.
"""
@@ -81,8 +89,14 @@ class SignalHandler(object):
self.modulestore_class = modulestore_class
def send(self, signal_name, **kwargs):
"""
Send the signal to the receivers.
"""
signal = self._mapping[signal_name]
signal.send_robust(sender=self.modulestore_class, **kwargs)
responses = signal.send_robust(sender=self.modulestore_class, **kwargs)
for receiver, response in responses:
log.info('Sent %s signal to %s with kwargs %s. Response was: %s', signal_name, receiver, kwargs, response)
def load_function(path):
@@ -196,6 +210,7 @@ class ModuleI18nService(object):
i18n service.
"""
def __getattr__(self, name):
return getattr(django.utils.translation, name)
@@ -213,6 +228,7 @@ class ModuleI18nService(object):
# right there. If you are reading this comment after April 1, 2014,
# then Cale was a liar.
from util.date_utils import strftime_localized
return strftime_localized(*args, **kwargs)
@@ -224,6 +240,7 @@ def _get_modulestore_branch_setting():
The value of the branch setting is cached in a thread-local variable so it is not repeatedly recomputed
"""
def get_branch_setting():
"""
Finds and returns the branch setting based on the Django request and the configuration settings

View File

@@ -637,6 +637,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
* copy the assets
* migrate the courseware
"""
source_modulestore = self._get_modulestore_for_courselike(source_course_id)
# for a temporary period of time, we may want to hardcode dest_modulestore as split if there's a split
# to have only course re-runs go to split. This code, however, uses the config'd priority
@@ -646,9 +647,9 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
if dest_modulestore.get_modulestore_type() == ModuleStoreEnum.Type.split:
split_migrator = SplitMigrator(dest_modulestore, source_modulestore)
split_migrator.migrate_mongo_course(
source_course_id, user_id, dest_course_id.org, dest_course_id.course, dest_course_id.run, fields, **kwargs
)
split_migrator.migrate_mongo_course(source_course_id, user_id, dest_course_id.org,
dest_course_id.course, dest_course_id.run, fields, **kwargs)
# the super handles assets and any other necessities
super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs)
else:
@@ -918,13 +919,13 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
yield
@contextmanager
def bulk_operations(self, course_id):
def bulk_operations(self, course_id, emit_signals=True):
"""
A context manager for notifying the store of bulk operations.
If course_id is None, the default store is used.
"""
store = self._get_modulestore_for_courselike(course_id)
with store.bulk_operations(course_id):
with store.bulk_operations(course_id, emit_signals):
yield
def ensure_indexes(self):

View File

@@ -448,13 +448,17 @@ class MongoBulkOpsMixin(BulkOperationsMixin):
# ensure it starts clean
bulk_ops_record.dirty = False
def _end_outermost_bulk_operation(self, bulk_ops_record, course_id):
def _end_outermost_bulk_operation(self, bulk_ops_record, course_id, emit_signals=True):
"""
Restart updating the meta-data inheritance cache for the given course.
Refresh the meta-data inheritance cache now since it was temporarily disabled.
"""
if bulk_ops_record.dirty:
self.refresh_cached_metadata_inheritance_tree(course_id)
if emit_signals and self.signal_handler:
self.signal_handler.send("course_published", course_key=course_id)
bulk_ops_record.dirty = False # brand spanking clean now
def _is_in_bulk_operation(self, course_id, ignore_case=False):
@@ -1119,14 +1123,15 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
if courses.count() > 0:
raise DuplicateCourseError(course_id, courses[0]['_id'])
xblock = self.create_item(user_id, course_id, 'course', course_id.run, fields=fields, **kwargs)
with self.bulk_operations(course_id):
xblock = self.create_item(user_id, course_id, 'course', course_id.run, fields=fields, **kwargs)
# create any other necessary things as a side effect
super(MongoModuleStore, self).create_course(
org, course, run, user_id, runtime=xblock.runtime, **kwargs
)
# create any other necessary things as a side effect
super(MongoModuleStore, self).create_course(
org, course, run, user_id, runtime=xblock.runtime, **kwargs
)
return xblock
return xblock
def create_xblock(
self, runtime, course_key, block_type, block_id=None, fields=None,
@@ -1307,6 +1312,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
is_publish_root: when publishing, this indicates whether xblock is the root of the publish and should
therefore propagate subtree edit info up the tree
"""
course_key = xblock.location.course_key
try:
definition_data = self._serialize_scope(xblock, Scope.content)
now = datetime.now(UTC)
@@ -1358,8 +1365,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
except ItemNotFoundError:
if not allow_not_found:
raise
elif not self.has_course(xblock.location.course_key):
raise ItemNotFoundError(xblock.location.course_key)
elif not self.has_course(course_key):
raise ItemNotFoundError(course_key)
return xblock

View File

@@ -167,44 +167,46 @@ class DraftModuleStore(MongoModuleStore):
if not self.has_course(source_course_id):
raise ItemNotFoundError("Cannot find a course at {0}. Aborting".format(source_course_id))
# verify that the dest_location really is an empty course
# b/c we don't want the payload, I'm copying the guts of get_items here
query = self._course_key_to_son(dest_course_id)
query['_id.category'] = {'$nin': ['course', 'about']}
if self.collection.find(query).limit(1).count() > 0:
raise DuplicateCourseError(
dest_course_id,
"Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(
dest_course_id
with self.bulk_operations(dest_course_id):
# verify that the dest_location really is an empty course
# b/c we don't want the payload, I'm copying the guts of get_items here
query = self._course_key_to_son(dest_course_id)
query['_id.category'] = {'$nin': ['course', 'about']}
if self.collection.find(query).limit(1).count() > 0:
raise DuplicateCourseError(
dest_course_id,
"Course at destination {0} is not an empty course. "
"You can only clone into an empty course. Aborting...".format(
dest_course_id
)
)
)
# clone the assets
super(DraftModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields)
# clone the assets
super(DraftModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields)
# get the whole old course
new_course = self.get_course(dest_course_id)
if new_course is None:
# create_course creates the about overview
new_course = self.create_course(
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=fields
)
else:
# update fields on existing course
for key, value in fields.iteritems():
setattr(new_course, key, value)
self.update_item(new_course, user_id)
# get the whole old course
new_course = self.get_course(dest_course_id)
if new_course is None:
# create_course creates the about overview
new_course = self.create_course(
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=fields
)
else:
# update fields on existing course
for key, value in fields.iteritems():
setattr(new_course, key, value)
self.update_item(new_course, user_id)
# Get all modules under this namespace which is (tag, org, course) tuple
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only)
self._clone_modules(modules, dest_course_id, user_id)
course_location = dest_course_id.make_usage_key('course', dest_course_id.run)
self.publish(course_location, user_id)
# Get all modules under this namespace which is (tag, org, course) tuple
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.published_only)
self._clone_modules(modules, dest_course_id, user_id)
course_location = dest_course_id.make_usage_key('course', dest_course_id.run)
self.publish(course_location, user_id)
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.draft_only)
self._clone_modules(modules, dest_course_id, user_id)
modules = self.get_items(source_course_id, revision=ModuleStoreEnum.RevisionOption.draft_only)
self._clone_modules(modules, dest_course_id, user_id)
return True
return True
def _clone_modules(self, modules, dest_course_id, user_id):
"""Clones each module into the given course"""
@@ -447,7 +449,12 @@ class DraftModuleStore(MongoModuleStore):
# if the revision is published, defer to base
if draft_loc.revision == MongoRevisionKey.published:
return super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found)
item = super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found)
course_key = xblock.location.course_key
bulk_record = self._get_bulk_ops_record(course_key)
if self.signal_handler and not bulk_record.active:
self.signal_handler.send("course_published", course_key=course_key)
return item
if not super(DraftModuleStore, self).has_item(draft_loc):
try:
@@ -715,15 +722,17 @@ class DraftModuleStore(MongoModuleStore):
_verify_revision_is_published(location)
_internal_depth_first(location, True)
course_key = location.course_key
bulk_record = self._get_bulk_ops_record(course_key)
if len(to_be_deleted) > 0:
bulk_record = self._get_bulk_ops_record(location.course_key)
bulk_record.dirty = True
self.collection.remove({'_id': {'$in': to_be_deleted}})
if self.signal_handler and not bulk_record.active:
self.signal_handler.send("course_published", course_key=course_key)
# Now it's been published, add the object to the courseware search index so that it appears in search results
CoursewareSearchIndexer.add_to_search_index(self, location)
if self.signal_handler:
self.signal_handler.send("course_published", course_key=location.course_key)
return self.get_item(as_published(location))
@@ -737,6 +746,11 @@ class DraftModuleStore(MongoModuleStore):
self._verify_branch_setting(ModuleStoreEnum.Branch.draft_preferred)
self._convert_to_draft(location, user_id, delete_published=True)
course_key = location.course_key
bulk_record = self._get_bulk_ops_record(course_key)
if self.signal_handler and not bulk_record.active:
self.signal_handler.send("course_published", course_key=course_key)
def revert_to_published(self, location, user_id=None):
"""
Reverts an item to its last published version (recursively traversing all of its descendants).

View File

@@ -229,12 +229,17 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
# Ensure that any edits to the index don't pollute the initial_index
bulk_write_record.index = copy.deepcopy(bulk_write_record.initial_index)
def _end_outermost_bulk_operation(self, bulk_write_record, course_key):
def _end_outermost_bulk_operation(self, bulk_write_record, course_key, emit_signals=True):
"""
End the active bulk write operation on course_key.
"""
dirty = False
# If the content is dirty, then update the database
for _id in bulk_write_record.structures.viewkeys() - bulk_write_record.structures_in_db:
dirty = True
try:
self.db_connection.insert_structure(bulk_write_record.structures[_id])
except DuplicateKeyError:
@@ -244,6 +249,8 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
log.debug("Attempted to insert duplicate structure %s", _id)
for _id in bulk_write_record.definitions.viewkeys() - bulk_write_record.definitions_in_db:
dirty = True
try:
self.db_connection.insert_definition(bulk_write_record.definitions[_id])
except DuplicateKeyError:
@@ -253,11 +260,18 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
log.debug("Attempted to insert duplicate definition %s", _id)
if bulk_write_record.index is not None and bulk_write_record.index != bulk_write_record.initial_index:
dirty = True
if bulk_write_record.initial_index is None:
self.db_connection.insert_course_index(bulk_write_record.index)
else:
self.db_connection.update_course_index(bulk_write_record.index, from_index=bulk_write_record.initial_index)
if dirty and emit_signals:
signal_handler = getattr(self, 'signal_handler', None)
if signal_handler:
signal_handler.send("course_published", course_key=course_key)
def get_course_index(self, course_key, ignore_case=False):
"""
Return the index for course_key.
@@ -675,7 +689,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
depth: how deep below these to prefetch
lazy: whether to fetch definitions or use placeholders
'''
with self.bulk_operations(course_key):
with self.bulk_operations(course_key, emit_signals=False):
new_module_data = {}
for block_id in base_block_ids:
new_module_data = self.descendants(
@@ -1563,18 +1577,20 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
source_index = self.get_course_index_info(source_course_id)
if source_index is None:
raise ItemNotFoundError("Cannot find a course at {0}. Aborting".format(source_course_id))
new_course = self.create_course(
dest_course_id.org, dest_course_id.course, dest_course_id.run,
user_id,
fields=fields,
versions_dict=source_index['versions'],
search_targets=source_index['search_targets'],
skip_auto_publish=True,
**kwargs
)
# don't copy assets until we create the course in case something's awry
super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs)
return new_course
with self.bulk_operations(dest_course_id):
new_course = self.create_course(
dest_course_id.org, dest_course_id.course, dest_course_id.run,
user_id,
fields=fields,
versions_dict=source_index['versions'],
search_targets=source_index['search_targets'],
skip_auto_publish=True,
**kwargs
)
# don't copy assets until we create the course in case something's awry
super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id, fields, **kwargs)
return new_course
DEFAULT_ROOT_BLOCK_ID = 'course'

View File

@@ -118,7 +118,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs):
old_descriptor_locn = descriptor.location
descriptor.location = self._map_revision_to_branch(old_descriptor_locn)
with self.bulk_operations(descriptor.location.course_key):
emit_signals = descriptor.location.branch == ModuleStoreEnum.BranchName.published \
or descriptor.location.block_type in DIRECT_ONLY_CATEGORIES
with self.bulk_operations(descriptor.location.course_key, emit_signals=emit_signals):
item = super(DraftVersioningModuleStore, self).update_item(
descriptor,
user_id,
@@ -139,7 +142,9 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
See :py:meth `ModuleStoreDraftAndPublished.create_item`
"""
course_key = self._map_revision_to_branch(course_key)
with self.bulk_operations(course_key):
emit_signals = course_key.branch == ModuleStoreEnum.BranchName.published \
or block_type in DIRECT_ONLY_CATEGORIES
with self.bulk_operations(course_key, emit_signals=emit_signals):
item = super(DraftVersioningModuleStore, self).create_item(
user_id, course_key, block_type, block_id=block_id,
definition_locator=definition_locator, fields=fields,
@@ -354,11 +359,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
# Now it's been published, add the object to the courseware search index so that it appears in search results
CoursewareSearchIndexer.add_to_search_index(self, location)
published_location = location.for_branch(ModuleStoreEnum.BranchName.published)
if self.signal_handler:
self.signal_handler.send("course_published", course_key=published_location.course_key)
return self.get_item(published_location, **kwargs)
return self.get_item(location.for_branch(ModuleStoreEnum.BranchName.published), **kwargs)
def unpublish(self, location, user_id, **kwargs):
"""

View File

@@ -4,6 +4,7 @@ Unit tests for the Mixed Modulestore, with DDT for the various stores (Split, Dr
"""
from collections import namedtuple
import datetime
import logging
import ddt
import itertools
import mimetypes
@@ -13,6 +14,7 @@ from uuid import uuid4
# before importing the module
# TODO remove this import and the configuration -- xmodule should not depend on django!
from django.conf import settings
from mock_django import mock_signal_receiver
from nose.plugins.attrib import attr
import pymongo
from pytz import UTC
@@ -23,7 +25,7 @@ from xmodule.modulestore.tests.test_cross_modulestore_import_export import Mongo
from xmodule.contentstore.content import StaticContent
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.xml_importer import import_from_xml
from nose import SkipTest
from xmodule.modulestore.django import SignalHandler
if not settings.configured:
settings.configure()
@@ -32,7 +34,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryLocator
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.draft_and_published import UnsupportedRevisionError
from xmodule.modulestore.draft_and_published import UnsupportedRevisionError, DIRECT_ONLY_CATEGORIES
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError, NoPathToItem
from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.search import path_to_location
@@ -42,6 +44,8 @@ from xmodule.modulestore.tests.utils import create_modulestore_instance, Locatio
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.tests import DATA_DIR, CourseComparisonTest
log = logging.getLogger(__name__)
@ddt.ddt
@attr('mongo')
@@ -1960,134 +1964,74 @@ class TestMixedModuleStore(CourseComparisonTest):
self.assertCoursesEqual(source_store, source_course_key, dest_store, dest_course_id)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_import_delete_import(self, default):
"""
Test that deleting an element after import and then re-importing restores that element in draft
as well as published branches (PLAT_297)
"""
# set the default modulestore
def test_course_publish_signal_firing(self, default):
with MongoContentstoreBuilder().build() as contentstore:
self.store = MixedModuleStore(
contentstore=contentstore,
create_modulestore_instance=create_modulestore_instance,
mappings={},
signal_handler=SignalHandler(MixedModuleStore),
**self.OPTIONS
)
self.addCleanup(self.store.close_all_connections)
with self.store.default_store(default):
dest_course_key = self.store.make_course_key('a', 'course', 'course')
courses = import_from_xml(
self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False,
static_content_store=contentstore,
target_course_id=dest_course_key,
create_course_if_not_present=True,
)
course_id = courses[0].id
# no need to verify course content here as test_cross_modulestore_import_export does that
# delete the vertical
vertical_loc = course_id.make_usage_key('vertical', 'vertical_test')
self.assertTrue(self.store.has_item(vertical_loc))
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_id):
self.store.delete_item(vertical_loc, self.user_id)
# verify it's in the published still
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_id):
self.assertTrue(self.store.has_item(vertical_loc))
self.assertIsNotNone(self.store.thread_cache.default_store.signal_handler)
# now re-import
import_from_xml(
self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False,
static_content_store=contentstore,
target_course_id=dest_course_key,
)
# verify it's in both published and draft
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_id):
self.assertTrue(self.store.has_item(vertical_loc))
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_id):
self.assertTrue(self.store.has_item(vertical_loc))
with mock_signal_receiver(SignalHandler.course_published) as receiver:
self.assertEqual(receiver.call_count, 0)
@ddt.data(ModuleStoreEnum.Type.split) # Need to fix and add ModuleStoreEnum.Type.mongo,
def test_delete_dag(self, default):
"""
Test that deleting an element with more than one parent fully removes it from the course.
"""
# set the default modulestore
with MongoContentstoreBuilder().build() as contentstore:
self.store = MixedModuleStore(
contentstore=contentstore,
create_modulestore_instance=create_modulestore_instance,
mappings={},
**self.OPTIONS
)
self.addCleanup(self.store.close_all_connections)
with self.store.default_store(default):
dest_course_key = self.store.make_course_key('a', 'course', 'course')
courses = import_from_xml(
self.store, self.user_id, DATA_DIR, ['xml_dag'], load_error_modules=False,
static_content_store=contentstore,
target_course_id=dest_course_key,
create_course_if_not_present=True,
)
course_id = courses[0].id
# ensure both parents point to the dag item
dag_item = course_id.make_usage_key('html', 'toyhtml')
one_parent = course_id.make_usage_key('vertical', 'vertical_test')
other_parent = course_id.make_usage_key('vertical', 'zeta')
with self.store.bulk_operations(course_id):
# actually should test get_parent but it's not alphabetized yet
self.assertEqual(self.store.get_parent_location(dag_item), one_parent)
for parent_loc in [one_parent, other_parent]:
parent = self.store.get_item(parent_loc)
self.assertIn(dag_item, parent.children)
# just testing draft branch assuming it doesn't matter which branch
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_id):
self.store.delete_item(dag_item, self.user_id)
for parent_loc in [one_parent, other_parent]:
parent = self.store.get_item(parent_loc)
self.assertNotIn(dag_item, parent.children)
# Course creation and publication should fire the signal
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
self.assertEqual(receiver.call_count, 1)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_import_edit_import(self, default):
"""
Test that editing an element after import and then re-importing resets the draft and pub'd
to the imported pub'd value (PLAT-299)
"""
if default == ModuleStoreEnum.Type.mongo:
raise SkipTest
# set the default modulestore
with MongoContentstoreBuilder().build() as contentstore:
self.store = MixedModuleStore(
contentstore=contentstore,
create_modulestore_instance=create_modulestore_instance,
mappings={},
**self.OPTIONS
)
self.addCleanup(self.store.close_all_connections)
with self.store.default_store(default):
dest_course_key = self.store.make_course_key('a', 'course', 'course')
courses = import_from_xml(
self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False,
static_content_store=contentstore,
target_course_id=dest_course_key,
create_course_if_not_present=True,
)
course_id = courses[0].id
# no need to verify course content here as test_cross_modulestore_import_export does that
# delete the vertical
vertical_loc = course_id.make_usage_key('vertical', 'vertical_test')
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_id):
vertical = self.store.get_item(vertical_loc)
vertical.display_name = "4"
self.store.update_item(vertical, self.user_id)
course_key = course.id
# now re-import
import_from_xml(
self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False,
static_content_store=contentstore,
target_course_id=dest_course_key,
)
# verify it's the same in both published and draft (toy has no drafts)
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_id):
draft_vertical = self.store.get_item(vertical_loc)
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_id):
published_vertical = self.store.get_item(vertical_loc)
self.assertEqual(draft_vertical.display_name, published_vertical.display_name)
# Test non-draftable block types. The block should be published with every change.
categories = DIRECT_ONLY_CATEGORIES
for block_type in categories:
log.debug('Testing with block type %s', block_type)
receiver.reset_mock()
block = self.store.create_item(self.user_id, course_key, block_type)
self.assertEqual(receiver.call_count, 1)
block.display_name = block_type
self.store.update_item(block, self.user_id)
self.assertEqual(receiver.call_count, 2)
self.store.publish(block.location, self.user_id)
self.assertEqual(receiver.call_count, 3)
# Test a draftable block type, which needs to be explicitly published.
receiver.reset_mock()
block = self.store.create_child(self.user_id, course.location, 'problem')
self.assertEqual(receiver.call_count, 1)
self.store.update_item(block, self.user_id)
self.assertEqual(receiver.call_count, 1)
self.store.publish(block.location, self.user_id)
self.assertEqual(receiver.call_count, 2)
self.store.unpublish(block.location, self.user_id)
self.assertEqual(receiver.call_count, 3)
self.store.delete_item(block.location, self.user_id)
self.assertEqual(receiver.call_count, 4)
# Test course re-runs
receiver.reset_mock()
dest_course_id = self.store.make_course_key("org.other", "course.other", "run.other")
self.store.clone_course(course_key, dest_course_id, self.user_id)
self.assertEqual(receiver.call_count, 1)
# Test course imports
# Note: The signal is fired once when the course is created and
# a second time after the actual data import.
receiver.reset_mock()
import_from_xml(
self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False,
static_content_store=contentstore,
create_course_if_not_present=True,
)
self.assertEqual(receiver.call_count, 2)

View File

@@ -22,7 +22,7 @@ class TestPublish(SplitWMongoCourseBoostrapper):
# There are 12 created items and 7 parent updates
# create course: finds: 1 to verify uniqueness, 1 to find parents
# sends: 1 to create course, 1 to create overview
with check_mongo_calls(5, 2):
with check_mongo_calls(4, 2):
super(TestPublish, self)._create_course(split=False) # 2 inserts (course and overview)
# with bulk will delay all inheritance computations which won't be added into the mongo_calls

View File

@@ -48,6 +48,7 @@ def create_modulestore_instance(
return class_(
doc_store_config=doc_store_config,
contentstore=contentstore,
signal_handler=signal_handler,
**options
)

View File

@@ -294,8 +294,8 @@ class XMLModuleStore(ModuleStoreReadBase):
"""
def __init__(
self, data_dir, default_class=None, course_dirs=None, course_ids=None,
load_error_modules=True, i18n_service=None, fs_service=None, user_service=None,
signal_handler=None, **kwargs
load_error_modules=True, i18n_service=None, fs_service=None, user_service=None,
signal_handler=None, **kwargs # pylint: disable=unused-argument
):
"""
Initialize an XMLModuleStore from data_dir