3831 lines
169 KiB
Python
3831 lines
169 KiB
Python
"""
|
|
Unit tests for the Mixed Modulestore, with DDT for the various stores (Split, Draft, XML)
|
|
"""
|
|
|
|
|
|
import datetime
|
|
import itertools
|
|
import logging
|
|
import mimetypes
|
|
from collections import namedtuple
|
|
from contextlib import contextmanager
|
|
from shutil import rmtree
|
|
from tempfile import mkdtemp
|
|
from uuid import uuid4
|
|
from unittest.mock import Mock, call, patch
|
|
|
|
import ddt
|
|
from openedx_events.content_authoring.data import CourseData, XBlockData
|
|
from openedx_events.content_authoring.signals import (
|
|
COURSE_CREATED,
|
|
XBLOCK_CREATED,
|
|
XBLOCK_DELETED,
|
|
XBLOCK_PUBLISHED,
|
|
XBLOCK_UPDATED
|
|
)
|
|
from openedx_events.tests.utils import OpenEdxEventsTestMixin
|
|
import pymongo
|
|
import pytest
|
|
# Mixed modulestore depends on django, so we'll manually configure some django settings
|
|
# before importing the module
|
|
# TODO remove this import and the configuration -- xmodule should not depend on django!
|
|
from django.conf import settings
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryLocator # pylint: disable=unused-import
|
|
from pytz import UTC
|
|
from web_fragments.fragment import Fragment
|
|
from xblock.core import XBlockAside
|
|
from xblock.fields import Scope, ScopeIds, String
|
|
from xblock.runtime import DictKeyValueStore, KvsFieldData
|
|
from xblock.test.tools import TestRuntime
|
|
|
|
from openedx.core.lib.tests import attr
|
|
from xmodule.contentstore.content import StaticContent
|
|
from xmodule.exceptions import InvalidVersionError
|
|
from xmodule.modulestore import ModuleStoreEnum
|
|
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
|
|
from xmodule.modulestore.edit_info import EditInfoMixin
|
|
from xmodule.modulestore.exceptions import (
|
|
DuplicateCourseError,
|
|
ItemNotFoundError,
|
|
NoPathToItem,
|
|
)
|
|
from xmodule.modulestore.inheritance import InheritanceMixin
|
|
from xmodule.modulestore.mixed import MixedModuleStore
|
|
from xmodule.modulestore.search import navigation_index, path_to_location
|
|
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
|
|
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
|
|
from xmodule.modulestore.tests.factories import check_exact_number_of_calls, check_mongo_calls
|
|
from xmodule.modulestore.tests.mongo_connection import MONGO_HOST, MONGO_PORT_NUM
|
|
from xmodule.modulestore.tests.test_asides import AsideTestType
|
|
from xmodule.modulestore.tests.utils import MongoContentstoreBuilder, create_modulestore_instance
|
|
from xmodule.modulestore.xml_exporter import export_course_to_xml
|
|
from xmodule.modulestore.xml_importer import LocationMixin, import_course_from_xml
|
|
from xmodule.tests import DATA_DIR, CourseComparisonTest
|
|
from xmodule.x_module import XModuleMixin
|
|
|
|
if not settings.configured:
|
|
settings.configure()
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class CommonMixedModuleStoreSetup(CourseComparisonTest, OpenEdxEventsTestMixin):
|
|
"""
|
|
Quasi-superclass which tests Location based apps against both split and mongo dbs (Locator and
|
|
Location-based dbs)
|
|
"""
|
|
HOST = MONGO_HOST
|
|
PORT = MONGO_PORT_NUM
|
|
DB = 'test_mongo_%s' % uuid4().hex[:5]
|
|
COLLECTION = 'modulestore'
|
|
ASSET_COLLECTION = 'assetstore'
|
|
FS_ROOT = DATA_DIR
|
|
DEFAULT_CLASS = 'xmodule.hidden_block.HiddenBlock'
|
|
RENDER_TEMPLATE = lambda t_n, d, ctx=None, nsp='main': ''
|
|
|
|
MONGO_COURSEID = 'MITx/999/2013_Spring'
|
|
|
|
modulestore_options = {
|
|
'default_class': DEFAULT_CLASS,
|
|
'fs_root': DATA_DIR,
|
|
'render_template': RENDER_TEMPLATE,
|
|
'xblock_mixins': (EditInfoMixin, InheritanceMixin, LocationMixin, XModuleMixin),
|
|
}
|
|
DOC_STORE_CONFIG = {
|
|
'host': HOST,
|
|
'port': PORT,
|
|
'db': DB,
|
|
'collection': COLLECTION,
|
|
'asset_collection': ASSET_COLLECTION,
|
|
}
|
|
OPTIONS = {
|
|
'stores': [
|
|
{
|
|
'NAME': ModuleStoreEnum.Type.split,
|
|
'ENGINE': 'xmodule.modulestore.split_mongo.split_draft.DraftVersioningModuleStore',
|
|
'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
|
|
'OPTIONS': modulestore_options
|
|
},
|
|
{
|
|
'NAME': ModuleStoreEnum.Type.mongo,
|
|
'ENGINE': 'xmodule.modulestore.mongo.draft.DraftModuleStore',
|
|
'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
|
|
'OPTIONS': modulestore_options
|
|
},
|
|
],
|
|
'xblock_mixins': modulestore_options['xblock_mixins'],
|
|
}
|
|
ENABLED_OPENEDX_EVENTS = [
|
|
"org.openedx.content_authoring.course.created.v1",
|
|
"org.openedx.content_authoring.xblock.created.v1",
|
|
"org.openedx.content_authoring.xblock.updated.v1",
|
|
"org.openedx.content_authoring.xblock.deleted.v1",
|
|
"org.openedx.content_authoring.xblock.published.v1",
|
|
]
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
"""
|
|
Set up class method for the Test class.
|
|
This method starts manually events isolation. Explanation here:
|
|
openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44
|
|
"""
|
|
super().setUpClass()
|
|
cls.start_events_isolation()
|
|
|
|
def setUp(self):
|
|
"""
|
|
Set up the database for testing
|
|
"""
|
|
super().setUp()
|
|
|
|
self.exclude_field(None, 'wiki_slug')
|
|
self.exclude_field(None, 'xml_attributes')
|
|
self.exclude_field(None, 'parent')
|
|
self.ignore_asset_key('_id')
|
|
self.ignore_asset_key('uploadDate')
|
|
self.ignore_asset_key('content_son')
|
|
self.ignore_asset_key('thumbnail_location')
|
|
|
|
self.options = getattr(self, 'options', self.OPTIONS)
|
|
self.connection = pymongo.MongoClient(
|
|
host=self.HOST,
|
|
port=self.PORT,
|
|
tz_aware=True,
|
|
)
|
|
self.connection.drop_database(self.DB)
|
|
self.addCleanup(self._drop_database)
|
|
self.addCleanup(self._close_connection)
|
|
|
|
# define attrs which get set in initdb to quell pylint
|
|
self.writable_chapter_location = self.store = self.fake_location = None
|
|
self.course_locations = {}
|
|
|
|
self.user_id = ModuleStoreEnum.UserID.test
|
|
|
|
def _check_connection(self):
|
|
"""
|
|
Check mongodb connection is open or not.
|
|
"""
|
|
try:
|
|
self.connection.admin.command('ping')
|
|
return True
|
|
except pymongo.errors.InvalidOperation:
|
|
return False
|
|
|
|
def _ensure_connection(self):
|
|
"""
|
|
Make sure that mongodb connection is open.
|
|
"""
|
|
if not self._check_connection():
|
|
self.connection = pymongo.MongoClient(
|
|
host=self.HOST,
|
|
port=self.PORT,
|
|
tz_aware=True,
|
|
)
|
|
|
|
def _drop_database(self):
|
|
"""
|
|
Drop mongodb database.
|
|
"""
|
|
self._ensure_connection()
|
|
self.connection.drop_database(self.DB)
|
|
|
|
def _close_connection(self):
|
|
"""
|
|
Close mongodb connection.
|
|
"""
|
|
try:
|
|
self.connection.close()
|
|
except pymongo.errors.InvalidOperation:
|
|
pass
|
|
|
|
def _create_course(self, course_key, asides=None):
|
|
"""
|
|
Create a course w/ one item in the persistence store using the given course & item location.
|
|
"""
|
|
# create course
|
|
with self.store.bulk_operations(course_key):
|
|
self.course = self.store.create_course(course_key.org, course_key.course, course_key.run, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
if isinstance(self.course.id, CourseLocator):
|
|
self.course_locations[self.MONGO_COURSEID] = self.course.location
|
|
else:
|
|
assert self.course.id == course_key
|
|
|
|
# create chapter
|
|
chapter = self.store.create_child(self.user_id, self.course.location, 'chapter',
|
|
block_id='Overview', asides=asides)
|
|
self.writable_chapter_location = chapter.location
|
|
|
|
def _create_block_hierarchy(self):
|
|
"""
|
|
Creates a hierarchy of blocks for testing
|
|
Each block's (version_agnostic) location is assigned as a field of the class and can be easily accessed
|
|
"""
|
|
BlockInfo = namedtuple('BlockInfo', 'field_name, category, display_name, sub_tree')
|
|
|
|
trees = [
|
|
BlockInfo(
|
|
'chapter_x', 'chapter', 'Chapter_x', [
|
|
BlockInfo(
|
|
'sequential_x1', 'sequential', 'Sequential_x1', [
|
|
BlockInfo(
|
|
'vertical_x1a', 'vertical', 'Vertical_x1a', [
|
|
BlockInfo('problem_x1a_1', 'problem', 'Problem_x1a_1', []),
|
|
BlockInfo('problem_x1a_2', 'problem', 'Problem_x1a_2', []),
|
|
BlockInfo('problem_x1a_3', 'problem', 'Problem_x1a_3', []),
|
|
BlockInfo('html_x1a_1', 'html', 'HTML_x1a_1', []),
|
|
]
|
|
),
|
|
BlockInfo(
|
|
'vertical_x1b', 'vertical', 'Vertical_x1b', []
|
|
)
|
|
]
|
|
),
|
|
BlockInfo(
|
|
'sequential_x2', 'sequential', 'Sequential_x2', []
|
|
)
|
|
]
|
|
),
|
|
BlockInfo(
|
|
'chapter_y', 'chapter', 'Chapter_y', [
|
|
BlockInfo(
|
|
'sequential_y1', 'sequential', 'Sequential_y1', [
|
|
BlockInfo(
|
|
'vertical_y1a', 'vertical', 'Vertical_y1a', [
|
|
BlockInfo('problem_y1a_1', 'problem', 'Problem_y1a_1', []),
|
|
BlockInfo('problem_y1a_2', 'problem', 'Problem_y1a_2', []),
|
|
BlockInfo('problem_y1a_3', 'problem', 'Problem_y1a_3', []),
|
|
]
|
|
)
|
|
]
|
|
)
|
|
]
|
|
)
|
|
]
|
|
|
|
def create_sub_tree(parent, block_info):
|
|
"""
|
|
recursive function that creates the given block and its descendants
|
|
"""
|
|
block = self.store.create_child(
|
|
self.user_id, parent.location,
|
|
block_info.category, block_id=block_info.display_name,
|
|
fields={'display_name': block_info.display_name},
|
|
)
|
|
for tree in block_info.sub_tree:
|
|
create_sub_tree(block, tree)
|
|
setattr(self, block_info.field_name, block.location)
|
|
|
|
with self.store.bulk_operations(self.course.id):
|
|
for tree in trees:
|
|
create_sub_tree(self.course, tree)
|
|
|
|
def _course_key_from_string(self, string):
|
|
"""
|
|
Get the course key for the given course string
|
|
"""
|
|
return self.course_locations[string].course_key
|
|
|
|
def _has_changes(self, location):
|
|
"""
|
|
Helper function that loads the item before calling has_changes
|
|
"""
|
|
return self.store.has_changes(self.store.get_item(location))
|
|
|
|
def _initialize_mixed(self, mappings=None, contentstore=None):
|
|
"""
|
|
initializes the mixed modulestore.
|
|
"""
|
|
mappings = mappings or {}
|
|
self.store = MixedModuleStore(
|
|
contentstore, create_modulestore_instance=create_modulestore_instance,
|
|
mappings=mappings,
|
|
**self.options
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
def initdb(self, default):
|
|
"""
|
|
Initialize the database and create one test course in it
|
|
"""
|
|
# set the default modulestore
|
|
store_configs = self.options['stores']
|
|
for index in range(len(store_configs)): # lint-amnesty, pylint: disable=consider-using-enumerate
|
|
if store_configs[index]['NAME'] == default:
|
|
if index > 0:
|
|
store_configs[index], store_configs[0] = store_configs[0], store_configs[index]
|
|
break
|
|
self._initialize_mixed()
|
|
|
|
test_course_key = CourseLocator.from_string(self.MONGO_COURSEID)
|
|
test_course_key = test_course_key.make_usage_key('course', test_course_key.run).course_key
|
|
self.fake_location = self.store.make_course_key(
|
|
test_course_key.org,
|
|
test_course_key.course,
|
|
test_course_key.run
|
|
).make_usage_key('vertical', 'fake')
|
|
self._create_course(test_course_key)
|
|
|
|
assert default == self.store.get_modulestore_type(self.course.id)
|
|
|
|
|
|
class AsideFoo(XBlockAside):
|
|
"""
|
|
Test xblock aside class
|
|
"""
|
|
FRAG_CONTENT = "<p>Aside Foo rendered</p>"
|
|
|
|
field11 = String(default="aside1_default_value1", scope=Scope.content)
|
|
field12 = String(default="aside1_default_value2", scope=Scope.settings)
|
|
|
|
@XBlockAside.aside_for('student_view')
|
|
def student_view_aside(self, block, context): # pylint: disable=unused-argument
|
|
"""Add to the student view"""
|
|
return Fragment(self.FRAG_CONTENT)
|
|
|
|
|
|
class AsideBar(XBlockAside):
|
|
"""
|
|
Test xblock aside class
|
|
"""
|
|
FRAG_CONTENT = "<p>Aside Bar rendered</p>"
|
|
|
|
field21 = String(default="aside2_default_value1", scope=Scope.content)
|
|
field22 = String(default="aside2_default_value2", scope=Scope.settings)
|
|
|
|
@XBlockAside.aside_for('student_view')
|
|
def student_view_aside(self, block, context): # pylint: disable=unused-argument
|
|
"""Add to the student view"""
|
|
return Fragment(self.FRAG_CONTENT)
|
|
|
|
|
|
@ddt.ddt
|
|
@attr('mongo')
|
|
class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
|
"""
|
|
Tests of the MixedModulestore interface methods.
|
|
"""
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_get_modulestore_type(self, default_ms):
|
|
"""
|
|
Make sure we get back the store type we expect for given mappings
|
|
"""
|
|
self.initdb(default_ms)
|
|
assert self.store.get_modulestore_type(self._course_key_from_string(self.MONGO_COURSEID)) == default_ms
|
|
# try an unknown mapping, it should be the 'default' store
|
|
assert self.store.get_modulestore_type(CourseKey.from_string('foo/bar/2012_Fall')) == default_ms
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_get_modulestore_cache(self, default_ms):
|
|
"""
|
|
Make sure we cache discovered course mappings
|
|
"""
|
|
self.initdb(default_ms)
|
|
# unset mappings
|
|
self.store.mappings = {}
|
|
course_key = self.course_locations[self.MONGO_COURSEID].course_key
|
|
with check_exact_number_of_calls(self.store.default_modulestore, 'has_course', 1):
|
|
assert self.store.default_modulestore == self.store._get_modulestore_for_courselike(course_key) # pylint: disable=protected-access, line-too-long
|
|
assert course_key in self.store.mappings
|
|
assert self.store.default_modulestore == self.store._get_modulestore_for_courselike(course_key) # pylint: disable=protected-access, line-too-long
|
|
|
|
@ddt.data(*itertools.product(
|
|
(ModuleStoreEnum.Type.split,),
|
|
(True, False)
|
|
))
|
|
@ddt.unpack
|
|
def test_duplicate_course_error(self, default_ms, reset_mixed_mappings):
|
|
"""
|
|
Make sure we get back the store type we expect for given mappings
|
|
"""
|
|
self._initialize_mixed(mappings={})
|
|
with self.store.default_store(default_ms):
|
|
self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
if reset_mixed_mappings:
|
|
self.store.mappings = {}
|
|
with pytest.raises(DuplicateCourseError):
|
|
self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_duplicate_course_error_with_different_case_ids(self, default_store):
|
|
"""
|
|
Verify that course can not be created with same course_id with different case.
|
|
"""
|
|
self._initialize_mixed(mappings={})
|
|
with self.store.default_store(default_store):
|
|
self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
|
|
with pytest.raises(DuplicateCourseError):
|
|
self.store.create_course('ORG_X', 'COURSE_Y', 'RUN_Z', self.user_id)
|
|
|
|
# split: has one lookup for the course and then one for the course items
|
|
# but the active_versions check is done in MySQL
|
|
@ddt.data((ModuleStoreEnum.Type.split, [1, 1], 0))
|
|
@ddt.unpack
|
|
def test_has_item(self, default_ms, max_find, max_send):
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
with check_mongo_calls(max_find.pop(0), max_send):
|
|
assert self.store.has_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
|
|
# try negative cases
|
|
with check_mongo_calls(max_find.pop(0), max_send):
|
|
assert not self.store.has_item(self.fake_location)
|
|
|
|
# verify that an error is raised when the revision is not valid
|
|
with pytest.raises(UnsupportedRevisionError):
|
|
self.store.has_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred)
|
|
|
|
# split:
|
|
# problem: active_versions, structure
|
|
# non-existent problem: ditto
|
|
@ddt.data((ModuleStoreEnum.Type.split, [1, 0], [1, 1], 0))
|
|
@ddt.unpack
|
|
def test_get_item(self, default_ms, num_mysql, max_find, max_send):
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
with check_mongo_calls(max_find.pop(0), max_send), self.assertNumQueries(num_mysql.pop(0)):
|
|
assert self.store.get_item(self.problem_x1a_1) is not None # lint-amnesty, pylint: disable=no-member
|
|
|
|
# try negative cases
|
|
with check_mongo_calls(max_find.pop(0), max_send), self.assertNumQueries(num_mysql.pop(0)):
|
|
with pytest.raises(ItemNotFoundError):
|
|
self.store.get_item(self.fake_location)
|
|
|
|
# verify that an error is raised when the revision is not valid
|
|
with pytest.raises(UnsupportedRevisionError):
|
|
self.store.get_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred)
|
|
|
|
# Split:
|
|
# mysql: fetch course's active version from SplitModulestoreCourseIndex, spurious refetch x2
|
|
# find: get structure
|
|
@ddt.data((ModuleStoreEnum.Type.split, 2, 1, 0))
|
|
@ddt.unpack
|
|
def test_get_items(self, default_ms, num_mysql, max_find, max_send):
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
course_locn = self.course_locations[self.MONGO_COURSEID]
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
blocks = self.store.get_items(course_locn.course_key, qualifiers={'category': 'problem'})
|
|
assert len(blocks) == 6
|
|
|
|
# verify that an error is raised when the revision is not valid
|
|
with pytest.raises(UnsupportedRevisionError):
|
|
self.store.get_items(
|
|
self.course_locations[self.MONGO_COURSEID].course_key,
|
|
revision=ModuleStoreEnum.RevisionOption.draft_preferred
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_version_on_block(self, default_ms):
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
course = self.store.get_course(self.course.id)
|
|
course_version = course.course_version
|
|
|
|
if default_ms == ModuleStoreEnum.Type.split:
|
|
assert course_version is not None
|
|
else:
|
|
assert course_version is None
|
|
|
|
blocks = self.store.get_items(self.course.id, qualifiers={'category': 'problem'})
|
|
blocks.append(self.store.get_item(self.problem_x1a_1)) # lint-amnesty, pylint: disable=no-member
|
|
assert len(blocks) == 7
|
|
for block in blocks:
|
|
assert block.course_version == course_version
|
|
# ensure that when the block is retrieved from the runtime cache,
|
|
# the course version is still present
|
|
cached_block = course.runtime.get_block(block.location)
|
|
assert cached_block.course_version == block.course_version
|
|
|
|
@ddt.data((ModuleStoreEnum.Type.split, 2, False))
|
|
@ddt.unpack
|
|
def test_get_items_include_orphans(self, default_ms, expected_items_in_tree, orphan_in_items):
|
|
"""
|
|
Test `include_orphans` option helps in returning only those items which are present in course tree.
|
|
It tests that orphans are not fetched when calling `get_item` with `include_orphans`.
|
|
|
|
Params:
|
|
expected_items_in_tree:
|
|
Number of items that will be returned after `get_items` would be called with `include_orphans`.
|
|
In split, it would not get orphan items.
|
|
In mongo, it would still get orphan items because `include_orphans` would not have any impact on mongo
|
|
modulestore which will return same number of items as called without `include_orphans` kwarg.
|
|
|
|
orphan_in_items:
|
|
When `get_items` is called with `include_orphans` kwarg, then check if an orphan is returned or not.
|
|
False when called in split modulestore because in split get_items is expected to not retrieve orphans
|
|
now because of `include_orphans`.
|
|
True when called in mongo modulstore because `include_orphans` does not have any effect on mongo.
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
course_key = test_course.id
|
|
|
|
items = self.store.get_items(course_key)
|
|
# Check items found are either course or about type
|
|
assert {'course', 'about'}.issubset({item.location.block_type for item in items}) # pylint: disable=line-too-long
|
|
# Assert that about is a detached category found in get_items
|
|
assert [item.location.block_type for item in items if item.location.block_type == 'about'][0]\
|
|
in DETACHED_XBLOCK_TYPES
|
|
assert len(items) == 2
|
|
|
|
# Check that orphans are not found
|
|
orphans = self.store.get_orphans(course_key)
|
|
assert len(orphans) == 0
|
|
|
|
# Add an orphan to test course
|
|
orphan = course_key.make_usage_key('chapter', 'OrphanChapter')
|
|
self.store.create_item(self.user_id, orphan.course_key, orphan.block_type, block_id=orphan.block_id)
|
|
|
|
# Check that now an orphan is found
|
|
orphans = self.store.get_orphans(course_key)
|
|
assert orphan in orphans
|
|
assert len(orphans) == 1
|
|
|
|
# Check now `get_items` retrieves an extra item added above which is an orphan.
|
|
items = self.store.get_items(course_key)
|
|
assert orphan in [item.location for item in items]
|
|
assert len(items) == 3
|
|
|
|
# Check now `get_items` with `include_orphans` kwarg does not retrieves an orphan block.
|
|
items_in_tree = self.store.get_items(course_key, include_orphans=False)
|
|
|
|
# Check that course and about blocks are found in get_items
|
|
assert {'course', 'about'}.issubset({item.location.block_type for item in items_in_tree})
|
|
# Check orphan is found or not - this is based on mongo/split modulestore. It should be found in mongo.
|
|
assert (orphan in [item.location for item in items_in_tree]) == orphan_in_items
|
|
assert len(items_in_tree) == expected_items_in_tree
|
|
|
|
# split:
|
|
# mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record,
|
|
# check CONTENT_TAGGING_AUTO CourseWaffleFlag
|
|
# find: definitions (calculator field), structures
|
|
# sends: 2 sends to update index & structure (note, it would also be definition if a content field changed)
|
|
@ddt.data((ModuleStoreEnum.Type.split, 4, 2, 2))
|
|
@ddt.unpack
|
|
def test_update_item(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Update should succeed for r/w dbs
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
problem = self.store.get_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
# if following raised, then the test is really a noop, change it
|
|
assert problem.max_attempts != 2, 'Default changed making test meaningless'
|
|
problem.max_attempts = 2
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
problem = self.store.update_item(problem, self.user_id)
|
|
|
|
assert problem.max_attempts == 2, "Update didn't persist"
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_has_changes_direct_only(self, default_ms):
|
|
"""
|
|
Tests that has_changes() returns false when a new xblock in a direct only category is checked
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
# Create dummy direct only xblocks
|
|
chapter = self.store.create_item(
|
|
self.user_id,
|
|
test_course.id,
|
|
'chapter',
|
|
block_id='vertical_container'
|
|
)
|
|
|
|
# Check that neither xblock has changes
|
|
assert not self.store.has_changes(test_course)
|
|
assert not self.store.has_changes(chapter)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_has_changes(self, default_ms):
|
|
"""
|
|
Tests that has_changes() only returns true when changes are present
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
# Create a dummy component to test against
|
|
xblock = self.store.create_item(
|
|
self.user_id,
|
|
test_course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
|
|
# Not yet published, so changes are present
|
|
assert self.store.has_changes(xblock)
|
|
|
|
# Publish and verify that there are no unpublished changes
|
|
newXBlock = self.store.publish(xblock.location, self.user_id)
|
|
assert not self.store.has_changes(newXBlock)
|
|
|
|
# Change the component, then check that there now are changes
|
|
component = self.store.get_item(xblock.location)
|
|
component.display_name = 'Changed Display Name'
|
|
|
|
component = self.store.update_item(component, self.user_id)
|
|
assert self.store.has_changes(component)
|
|
|
|
# Publish and verify again
|
|
component = self.store.publish(component.location, self.user_id)
|
|
assert not self.store.has_changes(component)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_unit_stuck_in_draft_mode(self, default_ms):
|
|
"""
|
|
After revert_to_published() the has_changes() should return false if draft has no changes
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
# Create a dummy component to test against
|
|
xblock = self.store.create_item(
|
|
self.user_id,
|
|
test_course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
|
|
# Not yet published, so changes are present
|
|
assert self.store.has_changes(xblock)
|
|
|
|
# Publish and verify that there are no unpublished changes
|
|
component = self.store.publish(xblock.location, self.user_id)
|
|
assert not self.store.has_changes(component)
|
|
|
|
self.store.revert_to_published(component.location, self.user_id)
|
|
component = self.store.get_item(component.location)
|
|
assert not self.store.has_changes(component)
|
|
|
|
# Publish and verify again
|
|
component = self.store.publish(component.location, self.user_id)
|
|
assert not self.store.has_changes(component)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_unit_stuck_in_published_mode(self, default_ms):
|
|
"""
|
|
After revert_to_published() the has_changes() should return true if draft has changes
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
# Create a dummy component to test against
|
|
xblock = self.store.create_item(
|
|
self.user_id,
|
|
test_course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
|
|
# Not yet published, so changes are present
|
|
assert self.store.has_changes(xblock)
|
|
|
|
# Publish and verify that there are no unpublished changes
|
|
component = self.store.publish(xblock.location, self.user_id)
|
|
assert not self.store.has_changes(component)
|
|
|
|
# Discard changes and verify that there are no changes
|
|
self.store.revert_to_published(component.location, self.user_id)
|
|
component = self.store.get_item(component.location)
|
|
assert not self.store.has_changes(component)
|
|
|
|
# Change the component, then check that there now are changes
|
|
component = self.store.get_item(component.location)
|
|
component.display_name = 'Changed Display Name'
|
|
self.store.update_item(component, self.user_id)
|
|
|
|
# Verify that changes are present
|
|
assert self.store.has_changes(component)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_unit_stuck_in_published_mode_after_delete(self, default_ms):
|
|
"""
|
|
Test that a unit does not get stuck in published mode
|
|
after discarding a component changes and deleting a component
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
# Create a dummy vertical & html component to test against
|
|
vertical = self.store.create_item(
|
|
self.user_id,
|
|
test_course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
component = self.store.create_child(
|
|
self.user_id,
|
|
vertical.location,
|
|
'html',
|
|
block_id='html_component'
|
|
)
|
|
|
|
# publish vertical changes
|
|
self.store.publish(vertical.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
|
|
# Change a component, then check that there now are changes
|
|
component = self.store.get_item(component.location)
|
|
component.display_name = 'Changed Display Name'
|
|
self.store.update_item(component, self.user_id)
|
|
assert self._has_changes(vertical.location)
|
|
|
|
# Discard changes and verify that there are no changes
|
|
self.store.revert_to_published(vertical.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
|
|
# Delete the component and verify that the unit has changes
|
|
self.store.delete_item(component.location, self.user_id)
|
|
vertical = self.store.get_item(vertical.location)
|
|
assert self._has_changes(vertical.location)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_publish_automatically_after_delete_unit(self, default_ms):
|
|
"""
|
|
Check that sequential publishes automatically after deleting a unit
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
|
|
|
|
# create sequential and vertical to test against
|
|
sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential')
|
|
vertical = self.store.create_child(self.user_id, sequential.location, 'vertical', 'test_vertical')
|
|
|
|
# publish sequential changes
|
|
self.store.publish(sequential.location, self.user_id)
|
|
assert not self._has_changes(sequential.location)
|
|
|
|
# delete vertical and check sequential has no changes
|
|
self.store.delete_item(vertical.location, self.user_id)
|
|
assert not self._has_changes(sequential.location)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_create_event(self, default_ms):
|
|
"""
|
|
Check that COURSE_CREATED event is sent when a course is created.
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
event_receiver = Mock()
|
|
COURSE_CREATED.connect(event_receiver)
|
|
|
|
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
|
|
|
|
event_receiver.assert_called()
|
|
|
|
self.assertDictContainsSubset(
|
|
{
|
|
"signal": COURSE_CREATED,
|
|
"sender": None,
|
|
"course": CourseData(
|
|
course_key=test_course.id,
|
|
),
|
|
},
|
|
event_receiver.call_args.kwargs
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_xblock_create_event(self, default_ms):
|
|
"""
|
|
Check that XBLOCK_CREATED event is sent when xblock is created.
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
event_receiver = Mock()
|
|
XBLOCK_CREATED.connect(event_receiver)
|
|
|
|
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
|
|
|
|
# create sequential to test against
|
|
sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential')
|
|
|
|
event_receiver.assert_called()
|
|
|
|
assert event_receiver.call_args.kwargs['signal'] == XBLOCK_CREATED
|
|
assert event_receiver.call_args.kwargs['xblock_info'].usage_key == sequential.location
|
|
assert event_receiver.call_args.kwargs['xblock_info'].block_type == sequential.location.block_type
|
|
assert event_receiver.call_args.kwargs['xblock_info'].version.for_branch(None) == sequential.location
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_xblock_update_event(self, default_ms):
|
|
"""
|
|
Check that XBLOCK_UPDATED event is sent when xblock is updated.
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
event_receiver = Mock()
|
|
XBLOCK_UPDATED.connect(event_receiver)
|
|
|
|
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
|
|
|
|
# create sequential to test against
|
|
sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential')
|
|
|
|
# Change the xblock
|
|
sequential.display_name = 'Updated Display Name'
|
|
self.store.update_item(sequential, user_id=self.user_id)
|
|
|
|
event_receiver.assert_called()
|
|
|
|
assert event_receiver.call_args.kwargs['signal'] == XBLOCK_UPDATED
|
|
assert event_receiver.call_args.kwargs['xblock_info'].usage_key == sequential.location
|
|
assert event_receiver.call_args.kwargs['xblock_info'].block_type == sequential.location.block_type
|
|
assert event_receiver.call_args.kwargs['xblock_info'].version.for_branch(None) == sequential.location
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_xblock_publish_event(self, default_ms):
|
|
"""
|
|
Check that XBLOCK_PUBLISHED event is sent when xblock is published.
|
|
"""
|
|
self.initdb(default_ms)
|
|
event_receiver = Mock()
|
|
XBLOCK_PUBLISHED.connect(event_receiver)
|
|
|
|
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
|
|
|
|
# create sequential and vertical to test against
|
|
sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential')
|
|
self.store.create_child(self.user_id, sequential.location, 'vertical', 'test_vertical')
|
|
|
|
# publish sequential changes
|
|
self.store.publish(sequential.location, self.user_id)
|
|
|
|
event_receiver.assert_called()
|
|
self.assertDictContainsSubset(
|
|
{
|
|
"signal": XBLOCK_PUBLISHED,
|
|
"sender": None,
|
|
"xblock_info": XBlockData(
|
|
usage_key=sequential.location,
|
|
block_type=sequential.location.block_type,
|
|
),
|
|
},
|
|
event_receiver.call_args.kwargs
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_xblock_delete_event(self, default_ms):
|
|
"""
|
|
Check that XBLOCK_DELETED event is sent when xblock is deleted.
|
|
"""
|
|
self.initdb(default_ms)
|
|
event_receiver = Mock()
|
|
XBLOCK_DELETED.connect(event_receiver)
|
|
|
|
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
|
|
|
|
# create sequential and vertical to test against
|
|
sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential')
|
|
vertical = self.store.create_child(self.user_id, sequential.location, 'vertical', 'test_vertical')
|
|
|
|
# publish sequential changes
|
|
self.store.publish(sequential.location, self.user_id)
|
|
|
|
# delete vertical
|
|
self.store.delete_item(vertical.location, self.user_id)
|
|
|
|
event_receiver.assert_called()
|
|
self.assertDictContainsSubset(
|
|
{
|
|
"signal": XBLOCK_DELETED,
|
|
"sender": None,
|
|
"xblock_info": XBlockData(
|
|
usage_key=vertical.location,
|
|
block_type=vertical.location.block_type,
|
|
),
|
|
},
|
|
event_receiver.call_args.kwargs
|
|
)
|
|
|
|
def setup_has_changes(self, default_ms):
|
|
"""
|
|
Common set up for has_changes tests below.
|
|
Returns a dictionary of useful location maps for testing.
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
locations = {
|
|
'grandparent': self.chapter_x, # lint-amnesty, pylint: disable=no-member
|
|
'parent_sibling': self.sequential_x2, # lint-amnesty, pylint: disable=no-member
|
|
'parent': self.sequential_x1, # lint-amnesty, pylint: disable=no-member
|
|
'child_sibling': self.vertical_x1b, # lint-amnesty, pylint: disable=no-member
|
|
'child': self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
}
|
|
|
|
# Publish the vertical units
|
|
self.store.publish(locations['parent_sibling'], self.user_id)
|
|
self.store.publish(locations['parent'], self.user_id)
|
|
|
|
return locations
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_has_changes_ancestors(self, default_ms):
|
|
"""
|
|
Tests that has_changes() returns true on ancestors when a child is changed
|
|
"""
|
|
locations = self.setup_has_changes(default_ms)
|
|
|
|
# Verify that there are no unpublished changes
|
|
for key in locations:
|
|
assert not self._has_changes(locations[key])
|
|
|
|
# Change the child
|
|
child = self.store.get_item(locations['child'])
|
|
child.display_name = 'Changed Display Name'
|
|
self.store.update_item(child, self.user_id)
|
|
|
|
# All ancestors should have changes, but not siblings
|
|
assert self._has_changes(locations['grandparent'])
|
|
assert self._has_changes(locations['parent'])
|
|
assert self._has_changes(locations['child'])
|
|
assert not self._has_changes(locations['parent_sibling'])
|
|
assert not self._has_changes(locations['child_sibling'])
|
|
|
|
# Publish the unit with changes
|
|
self.store.publish(locations['parent'], self.user_id)
|
|
|
|
# Verify that there are no unpublished changes
|
|
for key in locations:
|
|
assert not self._has_changes(locations[key])
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_has_changes_publish_ancestors(self, default_ms):
|
|
"""
|
|
Tests that has_changes() returns false after a child is published only if all children are unchanged
|
|
"""
|
|
locations = self.setup_has_changes(default_ms)
|
|
|
|
# Verify that there are no unpublished changes
|
|
for key in locations:
|
|
assert not self._has_changes(locations[key])
|
|
|
|
# Change both children
|
|
child = self.store.get_item(locations['child'])
|
|
child_sibling = self.store.get_item(locations['child_sibling'])
|
|
child.display_name = 'Changed Display Name'
|
|
child_sibling.display_name = 'Changed Display Name'
|
|
self.store.update_item(child, user_id=self.user_id)
|
|
self.store.update_item(child_sibling, user_id=self.user_id)
|
|
|
|
# Verify that ancestors have changes
|
|
assert self._has_changes(locations['grandparent'])
|
|
assert self._has_changes(locations['parent'])
|
|
|
|
# Publish one child
|
|
self.store.publish(locations['child_sibling'], self.user_id)
|
|
|
|
# Verify that ancestors still have changes
|
|
assert self._has_changes(locations['grandparent'])
|
|
assert self._has_changes(locations['parent'])
|
|
|
|
# Publish the other child
|
|
self.store.publish(locations['child'], self.user_id)
|
|
|
|
# Verify that ancestors now have no changes
|
|
assert not self._has_changes(locations['grandparent'])
|
|
assert not self._has_changes(locations['parent'])
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_has_changes_add_remove_child(self, default_ms):
|
|
"""
|
|
Tests that has_changes() returns true for the parent when a child with changes is added
|
|
and false when that child is removed.
|
|
"""
|
|
locations = self.setup_has_changes(default_ms)
|
|
|
|
# Test that the ancestors don't have changes
|
|
assert not self._has_changes(locations['grandparent'])
|
|
assert not self._has_changes(locations['parent'])
|
|
|
|
# Create a new child and attach it to parent
|
|
self.store.create_child(
|
|
self.user_id,
|
|
locations['parent'],
|
|
'vertical',
|
|
block_id='new_child',
|
|
)
|
|
|
|
# Verify that the ancestors now have changes
|
|
assert self._has_changes(locations['grandparent'])
|
|
assert self._has_changes(locations['parent'])
|
|
|
|
# Remove the child from the parent
|
|
parent = self.store.get_item(locations['parent'])
|
|
parent.children = [locations['child'], locations['child_sibling']]
|
|
self.store.update_item(parent, user_id=self.user_id)
|
|
|
|
# Verify that ancestors now have no changes
|
|
assert not self._has_changes(locations['grandparent'])
|
|
assert not self._has_changes(locations['parent'])
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_has_changes_non_direct_only_children(self, default_ms):
|
|
"""
|
|
Tests that has_changes() returns true after editing the child of a vertical (both not direct only categories).
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
parent = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='parent',
|
|
)
|
|
child = self.store.create_child(
|
|
self.user_id,
|
|
parent.location,
|
|
'html',
|
|
block_id='child',
|
|
)
|
|
self.store.publish(parent.location, self.user_id)
|
|
|
|
# Verify that there are no changes
|
|
assert not self._has_changes(parent.location)
|
|
assert not self._has_changes(child.location)
|
|
|
|
# Change the child
|
|
child.display_name = 'Changed Display Name'
|
|
self.store.update_item(child, user_id=self.user_id)
|
|
|
|
# Verify that both parent and child have changes
|
|
assert self._has_changes(parent.location)
|
|
assert self._has_changes(child.location)
|
|
|
|
@ddt.data(*itertools.product(
|
|
(ModuleStoreEnum.Type.split,),
|
|
(ModuleStoreEnum.Branch.draft_preferred, ModuleStoreEnum.Branch.published_only)
|
|
))
|
|
@ddt.unpack
|
|
def test_has_changes_missing_child(self, default_ms, default_branch):
|
|
"""
|
|
Tests that has_changes() does not throw an exception when a child doesn't exist.
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
with self.store.branch_setting(default_branch, self.course.id):
|
|
# Create the parent and point it to a fake child
|
|
parent = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='parent',
|
|
)
|
|
parent.children += [self.course.id.make_usage_key('vertical', 'does_not_exist')]
|
|
parent = self.store.update_item(parent, self.user_id)
|
|
|
|
# Check the parent for changes should return True and not throw an exception
|
|
assert self.store.has_changes(parent)
|
|
|
|
# Split
|
|
# mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record,
|
|
# check CONTENT_TAGGING_AUTO CourseWaffleFlag
|
|
# Find: active_versions, 2 structures (published & draft), definition (unnecessary)
|
|
# Sends: updated draft and published structures and active_versions
|
|
@ddt.data((ModuleStoreEnum.Type.split, 5, 2, 3))
|
|
@ddt.unpack
|
|
def test_delete_item(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Delete should reject on r/o db and work on r/w one
|
|
"""
|
|
self.initdb(default_ms)
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.writable_chapter_location.course_key): # lint-amnesty, pylint: disable=line-too-long
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
self.store.delete_item(self.writable_chapter_location, self.user_id)
|
|
|
|
# verify it's gone
|
|
with pytest.raises(ItemNotFoundError):
|
|
self.store.get_item(self.writable_chapter_location)
|
|
# verify it's gone from published too
|
|
with pytest.raises(ItemNotFoundError):
|
|
self.store.get_item(self.writable_chapter_location, revision=ModuleStoreEnum.RevisionOption.published_only)
|
|
|
|
# Split:
|
|
# mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record,
|
|
# check CONTENT_TAGGING_AUTO CourseWaffleFlag
|
|
# find: draft and published structures, definition (unnecessary)
|
|
# sends: update published (why?), draft, and active_versions
|
|
@ddt.data((ModuleStoreEnum.Type.split, 5, 3, 3))
|
|
@ddt.unpack
|
|
def test_delete_private_vertical(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Because old mongo treated verticals as the first layer which could be draft, it has some interesting
|
|
behavioral properties which this deletion test gets at.
|
|
"""
|
|
self.initdb(default_ms)
|
|
# create and delete a private vertical with private children
|
|
private_vert = self.store.create_child(
|
|
# don't use course_location as it may not be the repr
|
|
self.user_id, self.course_locations[self.MONGO_COURSEID],
|
|
'vertical', block_id='private'
|
|
)
|
|
private_leaf = self.store.create_child(
|
|
# don't use course_location as it may not be the repr
|
|
self.user_id, private_vert.location, 'html', block_id='private_leaf'
|
|
)
|
|
|
|
# verify pre delete state (just to verify that the test is valid)
|
|
if hasattr(private_vert.location, 'version_guid'):
|
|
# change to the HEAD version
|
|
vert_loc = private_vert.location.for_version(private_leaf.location.version_guid)
|
|
else:
|
|
vert_loc = private_vert.location
|
|
assert self.store.has_item(vert_loc)
|
|
assert self.store.has_item(private_leaf.location)
|
|
course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key, 0)
|
|
assert vert_loc in course.children
|
|
|
|
# delete the vertical and ensure the course no longer points to it
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
self.store.delete_item(vert_loc, self.user_id)
|
|
course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key, 0)
|
|
if hasattr(private_vert.location, 'version_guid'):
|
|
# change to the HEAD version
|
|
vert_loc = private_vert.location.for_version(course.location.version_guid)
|
|
leaf_loc = private_leaf.location.for_version(course.location.version_guid)
|
|
else:
|
|
vert_loc = private_vert.location
|
|
leaf_loc = private_leaf.location
|
|
assert not self.store.has_item(vert_loc)
|
|
assert not self.store.has_item(leaf_loc)
|
|
assert vert_loc not in course.children
|
|
|
|
# Split:
|
|
# mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record,
|
|
# check CONTENT_TAGGING_AUTO CourseWaffleFlag
|
|
# find: structure (cached)
|
|
# send: update structure and active_versions
|
|
@ddt.data((ModuleStoreEnum.Type.split, 5, 1, 2))
|
|
@ddt.unpack
|
|
def test_delete_draft_vertical(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Test deleting a draft vertical which has a published version.
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
# reproduce bug STUD-1965
|
|
# create and delete a private vertical with private children
|
|
private_vert = self.store.create_child(
|
|
# don't use course_location as it may not be the repr
|
|
self.user_id, self.course_locations[self.MONGO_COURSEID], 'vertical', block_id='publish'
|
|
)
|
|
private_leaf = self.store.create_child(
|
|
self.user_id, private_vert.location, 'html', block_id='bug_leaf'
|
|
)
|
|
|
|
# verify that an error is raised when the revision is not valid
|
|
with pytest.raises(UnsupportedRevisionError):
|
|
self.store.delete_item(
|
|
private_leaf.location,
|
|
self.user_id,
|
|
revision=ModuleStoreEnum.RevisionOption.draft_preferred
|
|
)
|
|
|
|
self.store.publish(private_vert.location, self.user_id)
|
|
private_leaf.display_name = 'change me'
|
|
private_leaf = self.store.update_item(private_leaf, self.user_id)
|
|
# test succeeds if delete succeeds w/o error
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
self.store.delete_item(private_leaf.location, self.user_id)
|
|
|
|
# Split:
|
|
# mysql: 3 selects on SplitModulestoreCourseIndex - 1 to get all courses, 2 to get specific course (this query is
|
|
# executed twice, possibly unnecessarily)
|
|
# find: 2 reads of structure, definition (s/b lazy; so, unnecessary),
|
|
# plus 1 wildcard find in draft mongo which has none
|
|
@ddt.data((ModuleStoreEnum.Type.split, 2, 3, 0))
|
|
@ddt.unpack
|
|
def test_get_courses(self, default_ms, num_mysql, max_find, max_send):
|
|
self.initdb(default_ms)
|
|
# we should have one course across all stores
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
courses = self.store.get_courses()
|
|
course_ids = [course.location for course in courses]
|
|
assert len(courses) == 1, f'Not one course: {course_ids}'
|
|
assert self.course_locations[self.MONGO_COURSEID] in course_ids
|
|
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
draft_courses = self.store.get_courses(remove_branch=True)
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
published_courses = self.store.get_courses(remove_branch=True)
|
|
assert [c.id for c in draft_courses] == [c.id for c in published_courses]
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_create_child_detached_tabs(self, default_ms):
|
|
"""
|
|
test 'create_child' method with a detached category ('static_tab')
|
|
to check that new static tab is not a direct child of the course
|
|
"""
|
|
self.initdb(default_ms)
|
|
mongo_course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key)
|
|
assert len(mongo_course.children) == 1
|
|
|
|
# create a static tab of the course
|
|
self.store.create_child(
|
|
self.user_id,
|
|
self.course.location,
|
|
'static_tab'
|
|
)
|
|
|
|
# now check that the course has same number of children
|
|
mongo_course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key)
|
|
assert len(mongo_course.children) == 1
|
|
|
|
# split: active_versions (mysql), structure, definition (to load course wiki string)
|
|
@ddt.data((ModuleStoreEnum.Type.split, 1, 2, 0))
|
|
@ddt.unpack
|
|
def test_get_course(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
This test is here for the performance comparison not functionality. It tests the performance
|
|
of getting an item whose scope.content fields are looked at.
|
|
"""
|
|
self.initdb(default_ms)
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
course = self.store.get_item(self.course_locations[self.MONGO_COURSEID])
|
|
assert course.id == self.course_locations[self.MONGO_COURSEID].course_key
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_get_library(self, default_ms):
|
|
"""
|
|
Test that create_library and get_library work regardless of the default modulestore.
|
|
Other tests of MixedModulestore support are in test_libraries.py but this one must
|
|
be done here so we can test the configuration where Draft/old is the first modulestore.
|
|
"""
|
|
self.initdb(default_ms)
|
|
with self.store.default_store(ModuleStoreEnum.Type.split): # The CMS also wraps create_library like this
|
|
library = self.store.create_library("org", "lib", self.user_id, {"display_name": "Test Library"})
|
|
library_key = library.location.library_key
|
|
assert isinstance(library_key, LibraryLocator)
|
|
# Now load with get_library and make sure it works:
|
|
library = self.store.get_library(library_key)
|
|
assert library.location.library_key == library_key
|
|
|
|
# Clear the mappings so we can test get_library code path without mapping set:
|
|
self.store.mappings.clear()
|
|
library = self.store.get_library(library_key)
|
|
assert library.location.library_key == library_key
|
|
|
|
# notice this doesn't test getting a public item via draft_preferred which draft would have 2 hits (split
|
|
# still only 2)
|
|
# Split: active_versions, structure
|
|
@ddt.data((ModuleStoreEnum.Type.split, 1, 1, 0))
|
|
@ddt.unpack
|
|
def test_get_parent_locations(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Test a simple get parent for a direct only category (i.e, always published)
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
parent = self.store.get_parent_location(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
assert parent == self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
|
|
def verify_get_parent_locations_results(self, expected_results):
|
|
"""
|
|
Verifies the results of calling get_parent_locations matches expected_results.
|
|
"""
|
|
for child_location, parent_location, revision in expected_results:
|
|
assert parent_location.for_branch(None) if parent_location else parent_location == \
|
|
self.store.get_parent_location(child_location, revision=revision)
|
|
|
|
def verify_item_parent(self, item_location, expected_parent_location, old_parent_location, is_reverted=False):
|
|
"""
|
|
Verifies that item is placed under expected parent.
|
|
|
|
Arguments:
|
|
item_location (BlockUsageLocator) : Locator of item.
|
|
expected_parent_location (BlockUsageLocator) : Expected parent block locator.
|
|
old_parent_location (BlockUsageLocator) : Old parent block locator.
|
|
is_reverted (Boolean) : A flag to notify that item was reverted.
|
|
"""
|
|
with self.store.bulk_operations(self.course.id):
|
|
source_item = self.store.get_item(item_location)
|
|
old_parent = self.store.get_item(old_parent_location)
|
|
expected_parent = self.store.get_item(expected_parent_location)
|
|
|
|
assert expected_parent_location == source_item.get_parent().location
|
|
|
|
# If an item is reverted, it means it's actual parent was the one that is the current parent now
|
|
# i.e expected_parent_location otherwise old_parent_location.
|
|
published_parent_location = expected_parent_location if is_reverted else old_parent_location
|
|
|
|
# Check parent locations wrt branches
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
assert expected_parent_location == self.store.get_item(item_location).get_parent().location
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
assert published_parent_location == self.store.get_item(item_location).get_parent().location
|
|
|
|
# Make location specific to published branch for verify_get_parent_locations_results call.
|
|
published_parent_location = published_parent_location.for_branch(ModuleStoreEnum.BranchName.published)
|
|
|
|
# Verify expected item parent locations
|
|
self.verify_get_parent_locations_results([
|
|
(item_location, expected_parent_location, None),
|
|
(item_location, expected_parent_location, ModuleStoreEnum.RevisionOption.draft_preferred),
|
|
(item_location, published_parent_location, ModuleStoreEnum.RevisionOption.published_only),
|
|
])
|
|
|
|
# Also verify item.parent has correct parent location set.
|
|
assert source_item.parent == expected_parent_location
|
|
assert source_item.parent == self.store.get_parent_location(item_location)
|
|
|
|
# Item should be present in new parent's children list but not in old parent's children list.
|
|
assert item_location in expected_parent.children
|
|
assert item_location not in old_parent.children
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_update_item_parent(self, store_type):
|
|
"""
|
|
Test that when we move an item from old to new parent, the item should be present in new parent.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Publish the course.
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Move child problem_x1a_1 to vertical_y1a.
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_revert(self, store_type):
|
|
"""
|
|
Test that when we move an item to new parent and then discard the original parent, the item should be present
|
|
back in original parent.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Publish the course
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Move child problem_x1a_1 to vertical_y1a.
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Now discard changes in old_parent_location i.e original parent.
|
|
self.store.revert_to_published(old_parent_location, self.user_id)
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=old_parent_location,
|
|
old_parent_location=new_parent_location,
|
|
is_reverted=True
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_delete_revert(self, store_type):
|
|
"""
|
|
Test that when we move an item and delete it and then discard changes for original parent, item should be
|
|
present back in original parent.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Publish the course
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Move child problem_x1a_1 to vertical_y1a.
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Now delete the item.
|
|
self.store.delete_item(item_location, self.user_id)
|
|
|
|
# Now discard changes in old_parent_location i.e original parent.
|
|
self.store.revert_to_published(old_parent_location, self.user_id)
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=old_parent_location,
|
|
old_parent_location=new_parent_location,
|
|
is_reverted=True
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_revert_move(self, store_type):
|
|
"""
|
|
Test that when we move an item to new parent and discard changes for the old parent, then the item should be
|
|
present in the old parent and then moving an item from old parent to new parent should place that item under
|
|
new parent.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Publish the course
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Move child problem_x1a_1 to vertical_y1a.
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Now discard changes in old_parent_location i.e original parent.
|
|
self.store.revert_to_published(old_parent_location, self.user_id)
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=old_parent_location,
|
|
old_parent_location=new_parent_location,
|
|
is_reverted=True
|
|
)
|
|
|
|
# Again try to move from x1 to y1
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_edited_revert(self, store_type):
|
|
"""
|
|
Test that when we move an edited item from old parent to new parent and then discard changes in old parent,
|
|
item should be placed under original parent with initial state.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Publish the course.
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Move child problem_x1a_1 to vertical_y1a.
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
|
|
problem = self.store.get_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
orig_display_name = problem.display_name
|
|
|
|
# Change display name of problem and update just it.
|
|
problem.display_name = 'updated'
|
|
self.store.update_item(problem, self.user_id)
|
|
|
|
updated_problem = self.store.get_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
assert updated_problem.display_name == 'updated'
|
|
|
|
# Now, move from x1 to y1.
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Now discard changes in old_parent_location i.e original parent.
|
|
self.store.revert_to_published(old_parent_location, self.user_id)
|
|
|
|
# Check that problem has the original name back.
|
|
reverted_problem = self.store.get_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
assert orig_display_name == reverted_problem.display_name
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_1_moved_1_unchanged(self, store_type):
|
|
"""
|
|
Test that when we move an item from an old parent which have multiple items then only moved item's parent
|
|
is changed while other items are still present inside old parent.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Create some children in vertical_x1a
|
|
problem_item2 = self.store.create_child(self.user_id, self.vertical_x1a, 'problem', 'Problem_Item2') # lint-amnesty, pylint: disable=no-member
|
|
|
|
# Publish the course.
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
|
|
# Move problem_x1a_1 from x1 to y1.
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Check that problem_item2 is still present in vertical_x1a
|
|
problem_item2 = self.store.get_item(problem_item2.location)
|
|
assert problem_item2.parent == self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
assert problem_item2.location in problem_item2.get_parent().children
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_1_moved_1_edited(self, store_type):
|
|
"""
|
|
Test that when we move an item inside an old parent having multiple items, we edit one item and move
|
|
other item from old to new parent, then discard changes in old parent would discard the changes of the
|
|
edited item and move back the moved item to old location.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Create some children in vertical_x1a
|
|
problem_item2 = self.store.create_child(self.user_id, self.vertical_x1a, 'problem', 'Problem_Item2') # lint-amnesty, pylint: disable=no-member
|
|
orig_display_name = problem_item2.display_name
|
|
|
|
# Publish the course.
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Edit problem_item2.
|
|
problem_item2.display_name = 'updated'
|
|
self.store.update_item(problem_item2, self.user_id)
|
|
|
|
updated_problem2 = self.store.get_item(problem_item2.location)
|
|
assert updated_problem2.display_name == 'updated'
|
|
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
|
|
# Move problem_x1a_1 from x1 to y1.
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Now discard changes in old_parent_location i.e original parent.
|
|
self.store.revert_to_published(old_parent_location, self.user_id)
|
|
|
|
# Check that problem_item2 has the original name back.
|
|
reverted_problem2 = self.store.get_item(problem_item2.location)
|
|
assert orig_display_name == reverted_problem2.display_name
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_move_1_moved_1_deleted(self, store_type):
|
|
"""
|
|
Test that when we move an item inside an old parent having multiple items, we delete one item and move
|
|
other item from old to new parent, then discard changes in old parent would undo delete the deleted
|
|
item and move back the moved item to old location.
|
|
"""
|
|
self.initdb(store_type)
|
|
self._create_block_hierarchy()
|
|
|
|
# Create some children in vertical_x1a
|
|
problem_item2 = self.store.create_child(self.user_id, self.vertical_x1a, 'problem', 'Problem_Item2') # lint-amnesty, pylint: disable=no-member
|
|
orig_display_name = problem_item2.display_name # lint-amnesty, pylint: disable=unused-variable
|
|
|
|
# Publish the course.
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
# Now delete other problem problem_item2.
|
|
self.store.delete_item(problem_item2.location, self.user_id)
|
|
|
|
# Move child problem_x1a_1 to vertical_y1a.
|
|
item_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
|
|
# Move problem_x1a_1 from x1 to y1.
|
|
updated_item_location = self.store.update_item_parent(
|
|
item_location, new_parent_location, old_parent_location, self.user_id
|
|
)
|
|
assert updated_item_location == item_location
|
|
|
|
self.verify_item_parent(
|
|
item_location=item_location,
|
|
expected_parent_location=new_parent_location,
|
|
old_parent_location=old_parent_location
|
|
)
|
|
|
|
# Now discard changes in old_parent_location i.e original parent.
|
|
self.store.revert_to_published(old_parent_location, self.user_id)
|
|
|
|
# Check that problem_item2 is also back in vertical_x1a
|
|
problem_item2 = self.store.get_item(problem_item2.location)
|
|
assert problem_item2.parent == self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
assert problem_item2.location in problem_item2.get_parent().children
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_get_parent_locations_moved_child(self, default_ms):
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
# publish the course
|
|
self.course = self.store.publish(self.course.location, self.user_id) # lint-amnesty, pylint: disable=attribute-defined-outside-init
|
|
|
|
with self.store.bulk_operations(self.course.id):
|
|
# make drafts of verticals
|
|
self.store.convert_to_draft(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
self.store.convert_to_draft(self.vertical_y1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
|
|
# move child problem_x1a_1 to vertical_y1a
|
|
child_to_move_location = self.problem_x1a_1 # lint-amnesty, pylint: disable=no-member
|
|
new_parent_location = self.vertical_y1a # lint-amnesty, pylint: disable=no-member
|
|
old_parent_location = self.vertical_x1a # lint-amnesty, pylint: disable=no-member
|
|
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
old_parent = self.store.get_item(child_to_move_location).get_parent()
|
|
|
|
assert old_parent_location == old_parent.location
|
|
|
|
child_to_move_contextualized = child_to_move_location.map_into_course(old_parent.location.course_key)
|
|
old_parent.children.remove(child_to_move_contextualized)
|
|
self.store.update_item(old_parent, self.user_id)
|
|
|
|
new_parent = self.store.get_item(new_parent_location)
|
|
new_parent.children.append(child_to_move_location)
|
|
self.store.update_item(new_parent, self.user_id)
|
|
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
assert new_parent_location == self.store.get_item(child_to_move_location).get_parent().location
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
assert old_parent_location == self.store.get_item(child_to_move_location).get_parent().location
|
|
old_parent_published_location = old_parent_location.for_branch(ModuleStoreEnum.BranchName.published)
|
|
self.verify_get_parent_locations_results([
|
|
(child_to_move_location, new_parent_location, None),
|
|
(child_to_move_location, new_parent_location, ModuleStoreEnum.RevisionOption.draft_preferred),
|
|
(child_to_move_location, old_parent_published_location, ModuleStoreEnum.RevisionOption.published_only),
|
|
])
|
|
|
|
# publish the course again
|
|
self.store.publish(self.course.location, self.user_id)
|
|
new_parent_published_location = new_parent_location.for_branch(ModuleStoreEnum.BranchName.published)
|
|
self.verify_get_parent_locations_results([
|
|
(child_to_move_location, new_parent_location, None),
|
|
(child_to_move_location, new_parent_location, ModuleStoreEnum.RevisionOption.draft_preferred),
|
|
(child_to_move_location, new_parent_published_location, ModuleStoreEnum.RevisionOption.published_only),
|
|
])
|
|
|
|
# Split: loading structure from mongo (also loads active version from MySQL, not tracked here)
|
|
@ddt.data((ModuleStoreEnum.Type.split, [1, 0], [2, 1], 0))
|
|
@ddt.unpack
|
|
def test_path_to_location(self, default_ms, num_mysql, num_finds, num_sends):
|
|
"""
|
|
Make sure that path_to_location works
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
course_key = self.course_locations[self.MONGO_COURSEID].course_key
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
|
self._create_block_hierarchy()
|
|
|
|
should_work = (
|
|
(self.problem_x1a_2, # lint-amnesty, pylint: disable=no-member
|
|
(course_key, "Chapter_x", "Sequential_x1", 'Vertical_x1a', '1', self.problem_x1a_2)), # lint-amnesty, pylint: disable=no-member
|
|
(self.chapter_x, # lint-amnesty, pylint: disable=no-member
|
|
(course_key, "Chapter_x", None, None, None, self.chapter_x)), # lint-amnesty, pylint: disable=no-member
|
|
)
|
|
|
|
for location, expected in should_work:
|
|
# each iteration has different find count, pop this iter's find count
|
|
with check_mongo_calls(num_finds.pop(0), num_sends), self.assertNumQueries(num_mysql.pop(0)):
|
|
path = path_to_location(self.store, location)
|
|
assert path == expected
|
|
|
|
not_found = (
|
|
course_key.make_usage_key('video', 'WelcomeX'),
|
|
course_key.make_usage_key('course', 'NotHome'),
|
|
)
|
|
for location in not_found:
|
|
with pytest.raises(ItemNotFoundError):
|
|
path_to_location(self.store, location)
|
|
|
|
# Orphaned items should not be found.
|
|
orphan = course_key.make_usage_key('chapter', 'OrphanChapter')
|
|
self.store.create_item(
|
|
self.user_id,
|
|
orphan.course_key,
|
|
orphan.block_type,
|
|
block_id=orphan.block_id
|
|
)
|
|
|
|
with pytest.raises(NoPathToItem):
|
|
path_to_location(self.store, orphan)
|
|
|
|
def test_navigation_index(self):
|
|
"""
|
|
Make sure that navigation_index correctly parses the various position values that we might get from calls to
|
|
path_to_location
|
|
"""
|
|
assert 1 == navigation_index('1')
|
|
assert 10 == navigation_index('10')
|
|
assert navigation_index(None) is None
|
|
assert 1 == navigation_index('1_2')
|
|
assert 5 == navigation_index('5_2')
|
|
assert 7 == navigation_index('7_3_5_6_')
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_revert_to_published_root_draft(self, default_ms):
|
|
"""
|
|
Test calling revert_to_published on draft vertical.
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
vertical_children_num = len(vertical.children)
|
|
|
|
self.store.publish(self.course.location, self.user_id)
|
|
assert not self._has_changes(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
|
|
# delete leaf problem (will make parent vertical a draft)
|
|
self.store.delete_item(self.problem_x1a_1, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
assert self._has_changes(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
|
|
draft_parent = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
assert (vertical_children_num - 1) == len(draft_parent.children)
|
|
published_parent = self.store.get_item(
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
revision=ModuleStoreEnum.RevisionOption.published_only
|
|
)
|
|
assert vertical_children_num == len(published_parent.children)
|
|
|
|
self.store.revert_to_published(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
reverted_parent = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
assert vertical_children_num == len(published_parent.children)
|
|
self.assertBlocksEqualByFields(reverted_parent, published_parent)
|
|
assert not self._has_changes(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_revert_to_published_root_published(self, default_ms):
|
|
"""
|
|
Test calling revert_to_published on a published vertical with a draft child.
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
self.store.publish(self.course.location, self.user_id)
|
|
|
|
problem = self.store.get_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
orig_display_name = problem.display_name
|
|
|
|
# Change display name of problem and update just it (so parent remains published)
|
|
problem.display_name = "updated before calling revert"
|
|
self.store.update_item(problem, self.user_id)
|
|
self.store.revert_to_published(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
|
|
reverted_problem = self.store.get_item(self.problem_x1a_1) # lint-amnesty, pylint: disable=no-member
|
|
assert orig_display_name == reverted_problem.display_name
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_revert_to_published_no_draft(self, default_ms):
|
|
"""
|
|
Test calling revert_to_published on vertical with no draft content does nothing.
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
self.store.publish(self.course.location, self.user_id)
|
|
|
|
orig_vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
self.store.revert_to_published(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
reverted_vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
|
|
self.assertBlocksEqualByFields(orig_vertical, reverted_vertical)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_revert_to_published_no_published(self, default_ms):
|
|
"""
|
|
Test calling revert_to_published on vertical with no published version errors.
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
with pytest.raises(InvalidVersionError):
|
|
self.store.revert_to_published(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_revert_to_published_direct_only(self, default_ms):
|
|
"""
|
|
Test calling revert_to_published on a direct-only item is a no-op.
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
num_children = len(self.store.get_item(self.sequential_x1).children) # lint-amnesty, pylint: disable=no-member
|
|
self.store.revert_to_published(self.sequential_x1, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
reverted_parent = self.store.get_item(self.sequential_x1) # lint-amnesty, pylint: disable=no-member
|
|
# It does not discard the child vertical, even though that child is a draft (with no published version)
|
|
assert num_children == len(reverted_parent.children)
|
|
|
|
def test_reset_course_to_version(self):
|
|
"""
|
|
Test calling `DraftVersioningModuleStore.test_reset_course_to_version`.
|
|
"""
|
|
# Set up test course.
|
|
self.initdb(ModuleStoreEnum.Type.split) # Old Mongo does not support this operation.
|
|
self._create_block_hierarchy()
|
|
self.store.publish(self.course.location, self.user_id)
|
|
|
|
# Get children of a vertical as a set.
|
|
# We will use this set as a basis for content comparision in this test.
|
|
original_vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
original_vertical_children = set(original_vertical.children)
|
|
|
|
# Find the version_guid of our course by diving into Split Mongo.
|
|
split = self._get_split_modulestore()
|
|
course_index = split.get_course_index(self.course.location.course_key)
|
|
log.warning(f"Banana course index: {course_index}")
|
|
original_version_guid = course_index["versions"]["published-branch"]
|
|
|
|
# Reset course to currently-published version.
|
|
# This should be a no-op.
|
|
self.store.reset_course_to_version(
|
|
self.course.location.course_key,
|
|
original_version_guid,
|
|
self.user_id,
|
|
)
|
|
noop_reset_vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
assert set(noop_reset_vertical.children) == original_vertical_children
|
|
|
|
# Delete a problem from the vertical and publish.
|
|
# Vertical should have one less problem than before.
|
|
self.store.delete_item(self.problem_x1a_1, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
self.store.publish(self.course.location, self.user_id)
|
|
modified_vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
assert set(modified_vertical.children) == (
|
|
original_vertical_children - {self.problem_x1a_1} # lint-amnesty, pylint: disable=no-member
|
|
)
|
|
|
|
# Add a couple more children to the vertical.
|
|
# and publish a couple more times.
|
|
# We want to make sure we can restore from something a few versions back.
|
|
self.store.create_child(
|
|
self.user_id,
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
'problem',
|
|
block_id='new_child1',
|
|
)
|
|
self.store.publish(self.course.location, self.user_id)
|
|
self.store.create_child(
|
|
self.user_id,
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
'problem',
|
|
block_id='new_child2',
|
|
)
|
|
self.store.publish(self.course.location, self.user_id)
|
|
|
|
# Add another child, but don't publish.
|
|
# We want to make sure that this works with a dirty draft branch.
|
|
self.store.create_child(
|
|
self.user_id,
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
'problem',
|
|
block_id='new_child3',
|
|
)
|
|
|
|
# Reset course to original version.
|
|
# The restored vertical should have the same children as it did originally.
|
|
self.store.reset_course_to_version(
|
|
self.course.location.course_key,
|
|
original_version_guid,
|
|
self.user_id,
|
|
)
|
|
restored_vertical = self.store.get_item(self.vertical_x1a) # lint-amnesty, pylint: disable=no-member
|
|
assert set(restored_vertical.children) == original_vertical_children
|
|
|
|
def _get_split_modulestore(self):
|
|
"""
|
|
Grab the SplitMongo modulestore instance from within the Mixed modulestore.
|
|
|
|
Assumption: There is a SplitMongo modulestore within the Mixed modulestore.
|
|
This assumpion is hacky, but it seems OK because we're removing the
|
|
Old (non-Split) Mongo modulestores soon.
|
|
|
|
Returns: SplitMongoModuleStore
|
|
"""
|
|
for store in self.store.modulestores:
|
|
if isinstance(store, SplitMongoModuleStore):
|
|
return store
|
|
assert False, "SplitMongoModuleStore was not found in MixedModuleStore"
|
|
|
|
# Split: active_versions (mysql), structure (mongo)
|
|
@ddt.data((ModuleStoreEnum.Type.split, 1, 1, 0))
|
|
@ddt.unpack
|
|
def test_get_orphans(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Test finding orphans.
|
|
"""
|
|
self.initdb(default_ms)
|
|
course_id = self.course_locations[self.MONGO_COURSEID].course_key
|
|
|
|
# create parented children
|
|
self._create_block_hierarchy()
|
|
|
|
# orphans
|
|
orphan_locations = [
|
|
course_id.make_usage_key('chapter', 'OrphanChapter'),
|
|
course_id.make_usage_key('vertical', 'OrphanVertical'),
|
|
course_id.make_usage_key('problem', 'OrphanProblem'),
|
|
course_id.make_usage_key('html', 'OrphanHTML'),
|
|
]
|
|
|
|
# detached items (not considered as orphans)
|
|
detached_locations = [
|
|
course_id.make_usage_key('static_tab', 'StaticTab'),
|
|
course_id.make_usage_key('course_info', 'updates'),
|
|
]
|
|
|
|
for location in orphan_locations + detached_locations:
|
|
self.store.create_item(
|
|
self.user_id,
|
|
location.course_key,
|
|
location.block_type,
|
|
block_id=location.block_id
|
|
)
|
|
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
found_orphans = self.store.get_orphans(self.course_locations[self.MONGO_COURSEID].course_key)
|
|
self.assertCountEqual(found_orphans, orphan_locations)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_create_item_populates_edited_info(self, default_ms):
|
|
self.initdb(default_ms)
|
|
block = self.store.create_item(
|
|
self.user_id,
|
|
self.course.location.course_key,
|
|
'problem'
|
|
)
|
|
assert self.user_id == block.edited_by
|
|
assert datetime.datetime.now(UTC) > block.edited_on
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_create_item_populates_subtree_edited_info(self, default_ms):
|
|
self.initdb(default_ms)
|
|
block = self.store.create_item(
|
|
self.user_id,
|
|
self.course.location.course_key,
|
|
'problem'
|
|
)
|
|
assert self.user_id == block.subtree_edited_by
|
|
assert datetime.datetime.now(UTC) > block.subtree_edited_on
|
|
|
|
# Split: wildcard search of draft (find) and split (mysql)
|
|
@ddt.data((ModuleStoreEnum.Type.split, 1, 1, 0))
|
|
@ddt.unpack
|
|
def test_get_courses_for_wiki(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Test the get_courses_for_wiki method
|
|
"""
|
|
self.initdb(default_ms)
|
|
# Test Mongo wiki
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
wiki_courses = self.store.get_courses_for_wiki('999')
|
|
assert len(wiki_courses) == 1
|
|
assert self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None) in wiki_courses
|
|
|
|
assert len(self.store.get_courses_for_wiki('edX.simple.2012_Fall')) == 0
|
|
assert len(self.store.get_courses_for_wiki('no_such_wiki')) == 0
|
|
|
|
# Split:
|
|
# MySQL SplitModulestoreCourseIndex:
|
|
# 1. Select by course ID
|
|
# 2. Select by objectid
|
|
# 3-4. Update index version, update historical record
|
|
# Find: 2 structures (pre & post published?)
|
|
# Sends:
|
|
# 1. insert structure
|
|
# 2. write index entry
|
|
@ddt.data((ModuleStoreEnum.Type.split, 4, 2, 2))
|
|
@ddt.unpack
|
|
def test_unpublish(self, default_ms, num_mysql, max_find, max_send):
|
|
"""
|
|
Test calling unpublish
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
# publish
|
|
self.store.publish(self.course.location, self.user_id)
|
|
published_xblock = self.store.get_item(
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
revision=ModuleStoreEnum.RevisionOption.published_only
|
|
)
|
|
assert published_xblock is not None
|
|
|
|
# unpublish
|
|
with check_mongo_calls(max_find, max_send), self.assertNumQueries(num_mysql):
|
|
self.store.unpublish(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
|
|
with pytest.raises(ItemNotFoundError):
|
|
self.store.get_item(
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
revision=ModuleStoreEnum.RevisionOption.published_only
|
|
)
|
|
|
|
# make sure draft version still exists
|
|
draft_xblock = self.store.get_item(
|
|
self.vertical_x1a, # lint-amnesty, pylint: disable=no-member
|
|
revision=ModuleStoreEnum.RevisionOption.draft_only
|
|
)
|
|
assert draft_xblock is not None
|
|
|
|
# Split: active_versions from MySQL, structure from mongo
|
|
@ddt.data((ModuleStoreEnum.Type.split, 1, 1, 0))
|
|
@ddt.unpack
|
|
def test_has_published_version(self, default_ms, mysql_queries, max_find, max_send):
|
|
"""
|
|
Test the has_published_version method
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
# start off as Private
|
|
item = self.store.create_child(self.user_id, self.writable_chapter_location, 'problem', 'test_compute_publish_state') # lint-amnesty, pylint: disable=line-too-long
|
|
item_location = item.location
|
|
with self.assertNumQueries(mysql_queries), check_mongo_calls(max_find, max_send):
|
|
assert not self.store.has_published_version(item)
|
|
|
|
# Private -> Public
|
|
self.store.publish(item_location, self.user_id)
|
|
item = self.store.get_item(item_location)
|
|
assert self.store.has_published_version(item)
|
|
|
|
# Public -> Private
|
|
self.store.unpublish(item_location, self.user_id)
|
|
item = self.store.get_item(item_location)
|
|
assert not self.store.has_published_version(item)
|
|
|
|
# Private -> Public
|
|
self.store.publish(item_location, self.user_id)
|
|
item = self.store.get_item(item_location)
|
|
assert self.store.has_published_version(item)
|
|
|
|
# Public -> Draft with NO changes
|
|
self.store.convert_to_draft(item_location, self.user_id)
|
|
item = self.store.get_item(item_location)
|
|
assert self.store.has_published_version(item)
|
|
|
|
# Draft WITH changes
|
|
item.display_name = 'new name'
|
|
item = self.store.update_item(item, self.user_id)
|
|
assert self.store.has_changes(item)
|
|
assert self.store.has_published_version(item)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_update_edit_info_ancestors(self, default_ms):
|
|
"""
|
|
Tests that edited_on, edited_by, subtree_edited_on, and subtree_edited_by are set correctly during update
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
def check_node(location_key, after, before, edited_by, subtree_after, subtree_before, subtree_by):
|
|
"""
|
|
Checks that the node given by location_key matches the given edit_info constraints.
|
|
"""
|
|
node = self.store.get_item(location_key)
|
|
if after:
|
|
assert after < node.edited_on
|
|
assert node.edited_on < before
|
|
assert node.edited_by == edited_by
|
|
if subtree_after:
|
|
assert subtree_after < node.subtree_edited_on
|
|
assert node.subtree_edited_on < subtree_before
|
|
assert node.subtree_edited_by == subtree_by
|
|
|
|
with self.store.bulk_operations(test_course.id):
|
|
# Create a dummy vertical & html to test against
|
|
component = self.store.create_child(
|
|
self.user_id,
|
|
test_course.location,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
child = self.store.create_child(
|
|
self.user_id,
|
|
component.location,
|
|
'html',
|
|
block_id='test_html'
|
|
)
|
|
sibling = self.store.create_child(
|
|
self.user_id,
|
|
component.location,
|
|
'html',
|
|
block_id='test_html_no_change'
|
|
)
|
|
|
|
after_create = datetime.datetime.now(UTC)
|
|
# Verify that all nodes were last edited in the past by create_user
|
|
for block in [component, child, sibling]:
|
|
check_node(block.location, None, after_create, self.user_id, None, after_create, self.user_id)
|
|
|
|
# Change the component, then check that there now are changes
|
|
component.display_name = 'Changed Display Name'
|
|
|
|
editing_user = self.user_id - 2
|
|
with self.store.bulk_operations(test_course.id): # TNL-764 bulk ops disabled ancestor updates
|
|
component = self.store.update_item(component, editing_user)
|
|
after_edit = datetime.datetime.now(UTC)
|
|
check_node(component.location, after_create, after_edit, editing_user, after_create, after_edit, editing_user)
|
|
# but child didn't change
|
|
check_node(child.location, None, after_create, self.user_id, None, after_create, self.user_id)
|
|
|
|
# Change the child
|
|
child = self.store.get_item(child.location)
|
|
child.display_name = 'Changed Display Name'
|
|
self.store.update_item(child, user_id=editing_user)
|
|
|
|
after_edit = datetime.datetime.now(UTC)
|
|
|
|
# Verify that child was last edited between after_create and after_edit by edit_user
|
|
check_node(child.location, after_create, after_edit, editing_user, after_create, after_edit, editing_user)
|
|
|
|
# Verify that ancestors edit info is unchanged, but their subtree edit info matches child
|
|
check_node(test_course.location, None, after_create, self.user_id, after_create, after_edit, editing_user)
|
|
|
|
# Verify that others have unchanged edit info
|
|
check_node(sibling.location, None, after_create, self.user_id, None, after_create, self.user_id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_update_edit_info(self, default_ms):
|
|
"""
|
|
Tests that edited_on and edited_by are set correctly during an update
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
# Create a dummy component to test against
|
|
component = self.store.create_child(
|
|
self.user_id,
|
|
test_course.location,
|
|
'vertical',
|
|
)
|
|
|
|
# Store the current edit time and verify that user created the component
|
|
assert component.edited_by == self.user_id
|
|
old_edited_on = component.edited_on
|
|
|
|
edit_user = self.user_id - 2
|
|
# Change the component
|
|
component.display_name = 'Changed'
|
|
self.store.update_item(component, edit_user)
|
|
updated_component = self.store.get_item(component.location)
|
|
|
|
# Verify the ordering of edit times and that dummy_user made the edit
|
|
assert old_edited_on < updated_component.edited_on
|
|
assert updated_component.edited_by == edit_user
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_update_published_info(self, default_ms):
|
|
"""
|
|
Tests that published_on and published_by are set correctly
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
|
|
publish_user = 456
|
|
|
|
# Create a dummy component to test against
|
|
component = self.store.create_child(
|
|
self.user_id,
|
|
test_course.location,
|
|
'vertical',
|
|
)
|
|
|
|
# Store the current time, then publish
|
|
old_time = datetime.datetime.now(UTC)
|
|
self.store.publish(component.location, publish_user)
|
|
updated_component = self.store.get_item(component.location)
|
|
|
|
# Verify the time order and that publish_user caused publication
|
|
assert old_time <= updated_component.published_on
|
|
assert updated_component.published_by == publish_user
|
|
|
|
# Verify that changing the item doesn't unset the published info
|
|
updated_component.display_name = 'changed'
|
|
self.store.update_item(updated_component, self.user_id)
|
|
updated_component = self.store.get_item(updated_component.location)
|
|
assert old_time <= updated_component.published_on
|
|
assert updated_component.published_by == publish_user
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_auto_publish(self, default_ms):
|
|
"""
|
|
Test that the correct things have been published automatically
|
|
Assumptions:
|
|
* we auto-publish courses, chapters, sequentials
|
|
* we don't auto-publish problems
|
|
"""
|
|
|
|
self.initdb(default_ms)
|
|
|
|
# test create_course to make sure we are autopublishing
|
|
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
|
|
assert self.store.has_published_version(test_course)
|
|
|
|
test_course_key = test_course.id
|
|
|
|
# test create_item of direct-only category to make sure we are autopublishing
|
|
chapter = self.store.create_child(self.user_id, test_course.location, 'chapter', 'Overview')
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
assert chapter.location in self.store.get_item(test_course.location).children
|
|
assert self.store.has_published_version(chapter)
|
|
|
|
chapter_location = chapter.location
|
|
|
|
# test create_child of direct-only category to make sure we are autopublishing
|
|
sequential = self.store.create_child(self.user_id, chapter_location, 'sequential', 'Sequence')
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
assert sequential.location in self.store.get_item(chapter_location).children
|
|
assert self.store.has_published_version(sequential)
|
|
|
|
# test update_item of direct-only category to make sure we are autopublishing
|
|
sequential.display_name = 'sequential1'
|
|
sequential = self.store.update_item(sequential, self.user_id)
|
|
assert self.store.has_published_version(sequential)
|
|
|
|
# test delete_item of direct-only category to make sure we are autopublishing
|
|
self.store.delete_item(sequential.location, self.user_id, revision=ModuleStoreEnum.RevisionOption.all)
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
assert sequential.location not in self.store.get_item(chapter_location).children
|
|
chapter = self.store.get_item(chapter.location.for_branch(None))
|
|
assert self.store.has_published_version(chapter)
|
|
|
|
# test create_child of NOT direct-only category to make sure we aren't autopublishing
|
|
problem_child = self.store.create_child(self.user_id, chapter_location, 'problem', 'Problem_Child')
|
|
assert not self.store.has_published_version(problem_child)
|
|
|
|
# test create_item of NOT direct-only category to make sure we aren't autopublishing
|
|
problem_item = self.store.create_item(self.user_id, test_course_key, 'problem', 'Problem_Item')
|
|
assert not self.store.has_published_version(problem_item)
|
|
|
|
# test update_item of NOT direct-only category to make sure we aren't autopublishing
|
|
problem_item.display_name = 'Problem_Item1'
|
|
problem_item = self.store.update_item(problem_item, self.user_id)
|
|
assert not self.store.has_published_version(problem_item)
|
|
|
|
# test delete_item of NOT direct-only category to make sure we aren't autopublishing
|
|
self.store.delete_item(problem_child.location, self.user_id)
|
|
chapter = self.store.get_item(chapter.location.for_branch(None))
|
|
assert self.store.has_published_version(chapter)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_get_courses_for_wiki_shared(self, default_ms):
|
|
"""
|
|
Test two courses sharing the same wiki
|
|
"""
|
|
self.initdb(default_ms)
|
|
|
|
# verify initial state - initially, we should have a wiki for the Mongo course
|
|
wiki_courses = self.store.get_courses_for_wiki('999')
|
|
assert self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None) in wiki_courses
|
|
|
|
# set Mongo course to share the wiki with simple course
|
|
mongo_course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key)
|
|
mongo_course.wiki_slug = 'simple'
|
|
self.store.update_item(mongo_course, self.user_id)
|
|
|
|
# now mongo_course should not be retrievable with old wiki_slug
|
|
wiki_courses = self.store.get_courses_for_wiki('999')
|
|
assert len(wiki_courses) == 0
|
|
|
|
# but there should be one course with wiki_slug 'simple'
|
|
wiki_courses = self.store.get_courses_for_wiki('simple')
|
|
assert len(wiki_courses) == 1
|
|
assert self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None) in wiki_courses
|
|
|
|
# configure mongo course to use unique wiki_slug.
|
|
mongo_course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key)
|
|
mongo_course.wiki_slug = 'MITx.999.2013_Spring'
|
|
self.store.update_item(mongo_course, self.user_id)
|
|
# it should be retrievable with its new wiki_slug
|
|
wiki_courses = self.store.get_courses_for_wiki('MITx.999.2013_Spring')
|
|
assert len(wiki_courses) == 1
|
|
assert self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None) in wiki_courses
|
|
# and NOT retriveable with its old wiki_slug
|
|
wiki_courses = self.store.get_courses_for_wiki('simple')
|
|
assert len(wiki_courses) == 0
|
|
assert self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None) not in wiki_courses
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_branch_setting(self, default_ms):
|
|
"""
|
|
Test the branch_setting context manager
|
|
"""
|
|
self.initdb(default_ms)
|
|
self._create_block_hierarchy()
|
|
|
|
problem_location = self.problem_x1a_1.for_branch(None) # lint-amnesty, pylint: disable=no-member
|
|
problem_original_name = 'Problem_x1a_1'
|
|
|
|
course_key = problem_location.course_key
|
|
problem_new_name = 'New Problem Name'
|
|
|
|
def assertNumProblems(display_name, expected_number):
|
|
"""
|
|
Asserts the number of problems with the given display name is the given expected number.
|
|
"""
|
|
assert len(self.store.get_items(course_key.for_branch(None), settings={'display_name': display_name})) ==\
|
|
expected_number
|
|
|
|
def assertProblemNameEquals(expected_display_name):
|
|
"""
|
|
Asserts the display_name of the xblock at problem_location matches the given expected value.
|
|
"""
|
|
# check the display_name of the problem
|
|
problem = self.store.get_item(problem_location)
|
|
assert problem.display_name == expected_display_name
|
|
|
|
# there should be only 1 problem with the expected_display_name
|
|
assertNumProblems(expected_display_name, 1)
|
|
|
|
# verify Draft problem
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key):
|
|
assert self.store.has_item(problem_location)
|
|
assertProblemNameEquals(problem_original_name)
|
|
|
|
# verify Published problem doesn't exist
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
|
assert not self.store.has_item(problem_location)
|
|
with pytest.raises(ItemNotFoundError):
|
|
self.store.get_item(problem_location)
|
|
|
|
# PUBLISH the problem
|
|
self.store.publish(self.vertical_x1a, self.user_id) # lint-amnesty, pylint: disable=no-member
|
|
self.store.publish(problem_location, self.user_id)
|
|
|
|
# verify Published problem
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
|
assert self.store.has_item(problem_location)
|
|
assertProblemNameEquals(problem_original_name)
|
|
|
|
# verify Draft-preferred
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key):
|
|
assertProblemNameEquals(problem_original_name)
|
|
|
|
# EDIT name
|
|
problem = self.store.get_item(problem_location)
|
|
problem.display_name = problem_new_name
|
|
self.store.update_item(problem, self.user_id)
|
|
|
|
# verify Draft problem has new name
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key):
|
|
assertProblemNameEquals(problem_new_name)
|
|
|
|
# verify Published problem still has old name
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
|
assertProblemNameEquals(problem_original_name)
|
|
# there should be no published problems with the new name
|
|
assertNumProblems(problem_new_name, 0)
|
|
|
|
# PUBLISH the problem
|
|
self.store.publish(problem_location, self.user_id)
|
|
|
|
# verify Published problem has new name
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
|
assertProblemNameEquals(problem_new_name)
|
|
# there should be no published problems with the old name
|
|
assertNumProblems(problem_original_name, 0)
|
|
|
|
# verify branch setting is published-only in manager
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
assert self.store.get_branch_setting() == ModuleStoreEnum.Branch.published_only
|
|
|
|
# verify branch setting is draft-preferred in manager
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
assert self.store.get_branch_setting() == ModuleStoreEnum.Branch.draft_preferred
|
|
|
|
def verify_default_store(self, store_type):
|
|
"""
|
|
Verifies the default_store property
|
|
"""
|
|
assert self.store.default_modulestore.get_modulestore_type() == store_type
|
|
|
|
# verify internal helper method
|
|
store = self.store._get_modulestore_for_courselike() # pylint: disable=protected-access
|
|
assert store.get_modulestore_type() == store_type
|
|
|
|
# verify store used for creating a course
|
|
course = self.store.create_course("org", "course{}".format(uuid4().hex[:5]), "run", self.user_id)
|
|
assert course.runtime.modulestore.get_modulestore_type() == store_type
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_default_store(self, default_ms):
|
|
"""
|
|
Test the default store context manager
|
|
"""
|
|
# initialize the mixed modulestore
|
|
self._initialize_mixed(mappings={})
|
|
|
|
with self.store.default_store(default_ms):
|
|
self.verify_default_store(default_ms)
|
|
|
|
def test_default_store_fake(self):
|
|
"""
|
|
Test the default store context manager, asking for a fake store
|
|
"""
|
|
# initialize the mixed modulestore
|
|
self._initialize_mixed(mappings={})
|
|
|
|
fake_store = "fake"
|
|
with self.assertRaisesRegex(Exception, f"Cannot find store of type {fake_store}"):
|
|
with self.store.default_store(fake_store):
|
|
pass # pragma: no cover
|
|
|
|
def save_asset(self, asset_key):
|
|
"""
|
|
Load and save the given file. (taken from test_contentstore)
|
|
"""
|
|
with open(f"{DATA_DIR}/static/{asset_key.block_id}", "rb") as f:
|
|
content = StaticContent(
|
|
asset_key, "Funky Pix", mimetypes.guess_type(asset_key.block_id)[0], f.read(),
|
|
)
|
|
self.store.contentstore.save(content)
|
|
|
|
@ddt.data(
|
|
[ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.split]
|
|
)
|
|
@ddt.unpack
|
|
def test_clone_course(self, source_modulestore, destination_modulestore):
|
|
"""
|
|
Test clone course
|
|
"""
|
|
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
# initialize the mixed modulestore
|
|
self._initialize_mixed(contentstore=contentstore, mappings={})
|
|
|
|
with self.store.default_store(source_modulestore):
|
|
|
|
source_course_key = self.store.make_course_key("org.source", "course.source", "run.source")
|
|
self._create_course(source_course_key)
|
|
self.save_asset(source_course_key.make_asset_key('asset', 'picture1.jpg'))
|
|
|
|
with self.store.default_store(destination_modulestore):
|
|
dest_course_id = self.store.make_course_key("org.other", "course.other", "run.other")
|
|
self.store.clone_course(source_course_key, dest_course_id, self.user_id)
|
|
|
|
# pylint: disable=protected-access
|
|
source_store = self.store._get_modulestore_by_type(source_modulestore)
|
|
dest_store = self.store._get_modulestore_by_type(destination_modulestore)
|
|
self.assertCoursesEqual(source_store, source_course_key, dest_store, dest_course_id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_bulk_operations_signal_firing(self, default):
|
|
""" Signals should be fired right before bulk_operations() exits. """
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Course creation and publication should fire the signal
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
signal_handler.reset_mock()
|
|
|
|
course_key = course.id
|
|
|
|
def _clear_bulk_ops_record(course_key): # pylint: disable=unused-argument
|
|
"""
|
|
Check if the signal has been fired.
|
|
The course_published signal fires before the _clear_bulk_ops_record.
|
|
"""
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
with patch.object(
|
|
self.store.thread_cache.default_store, '_clear_bulk_ops_record', wraps=_clear_bulk_ops_record
|
|
) as mock_clear_bulk_ops_record:
|
|
|
|
with self.store.bulk_operations(course_key):
|
|
categories = DIRECT_ONLY_CATEGORIES
|
|
for block_type in categories:
|
|
self.store.create_item(self.user_id, course_key, block_type)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
assert mock_clear_bulk_ops_record.call_count == 1
|
|
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_publish_signal_direct_firing(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Course creation and publication should fire the signal
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
course_key = course.id
|
|
|
|
# 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)
|
|
signal_handler.reset_mock()
|
|
block = self.store.create_item(self.user_id, course_key, block_type)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
signal_handler.reset_mock()
|
|
block.display_name = block_type
|
|
self.store.update_item(block, self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
signal_handler.reset_mock()
|
|
self.store.publish(block.location, self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_publish_signal_rerun_firing(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Course creation and publication should fire the signal
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
course_key = course.id
|
|
|
|
# Test course re-runs
|
|
signal_handler.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)
|
|
signal_handler.send.assert_called_with('course_published', course_key=dest_course_id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_publish_signal_import_firing(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Test course imports
|
|
# Note: The signal is fired once when the course is created and
|
|
# a second time after the actual data import.
|
|
import_course_from_xml(
|
|
self.store, self.user_id, DATA_DIR, ['toy'], load_error_blocks=False,
|
|
static_content_store=contentstore,
|
|
create_if_not_present=True,
|
|
)
|
|
signal_handler.send.assert_has_calls([
|
|
call('pre_publish', course_key=self.store.make_course_key('edX', 'toy', '2012_Fall')),
|
|
call('course_published', course_key=self.store.make_course_key('edX', 'toy', '2012_Fall')),
|
|
call('pre_publish', course_key=self.store.make_course_key('edX', 'toy', '2012_Fall')),
|
|
call('course_published', course_key=self.store.make_course_key('edX', 'toy', '2012_Fall')),
|
|
])
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_publish_signal_publish_firing(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Course creation and publication should fire the signal
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
# Test a draftable block type, which needs to be explicitly published, and nest it within the
|
|
# normal structure - this is important because some implementors change the parent when adding a
|
|
# non-published child; if parent is in DIRECT_ONLY_CATEGORIES then this should not fire the event
|
|
signal_handler.reset_mock()
|
|
section = self.store.create_item(self.user_id, course.id, 'chapter')
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
signal_handler.reset_mock()
|
|
subsection = self.store.create_child(self.user_id, section.location, 'sequential')
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
# 'units' and 'blocks' are draftable types
|
|
signal_handler.reset_mock()
|
|
unit = self.store.create_child(self.user_id, subsection.location, 'vertical')
|
|
signal_handler.send.assert_not_called()
|
|
|
|
block = self.store.create_child(self.user_id, unit.location, 'problem')
|
|
signal_handler.send.assert_not_called()
|
|
|
|
self.store.update_item(block, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
signal_handler.reset_mock()
|
|
self.store.publish(unit.location, self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
signal_handler.reset_mock()
|
|
self.store.unpublish(unit.location, self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
signal_handler.reset_mock()
|
|
self.store.delete_item(unit.location, self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_bulk_course_publish_signal_direct_firing(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Course creation and publication should fire the signal
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
course_key = course.id
|
|
|
|
# Test non-draftable block types. No signals should be received until
|
|
signal_handler.reset_mock()
|
|
with self.store.bulk_operations(course_key):
|
|
categories = DIRECT_ONLY_CATEGORIES
|
|
for block_type in categories:
|
|
log.debug('Testing with block type %s', block_type)
|
|
block = self.store.create_item(self.user_id, course_key, block_type)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
block.display_name = block_type
|
|
self.store.update_item(block, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
self.store.publish(block.location, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_bulk_course_publish_signal_publish_firing(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Course creation and publication should fire the signal
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
course_key = course.id
|
|
|
|
# Test a draftable block type, which needs to be explicitly published, and nest it within the
|
|
# normal structure - this is important because some implementors change the parent when adding a
|
|
# non-published child; if parent is in DIRECT_ONLY_CATEGORIES then this should not fire the event
|
|
signal_handler.reset_mock()
|
|
with self.store.bulk_operations(course_key):
|
|
section = self.store.create_item(self.user_id, course_key, 'chapter')
|
|
signal_handler.send.assert_not_called()
|
|
|
|
subsection = self.store.create_child(self.user_id, section.location, 'sequential')
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# 'units' and 'blocks' are draftable types
|
|
unit = self.store.create_child(self.user_id, subsection.location, 'vertical')
|
|
signal_handler.send.assert_not_called()
|
|
|
|
block = self.store.create_child(self.user_id, unit.location, 'problem')
|
|
signal_handler.send.assert_not_called()
|
|
|
|
self.store.update_item(block, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
self.store.publish(unit.location, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
# Test editing draftable block type without publish
|
|
signal_handler.reset_mock()
|
|
with self.store.bulk_operations(course_key):
|
|
unit = self.store.create_child(self.user_id, subsection.location, 'vertical')
|
|
signal_handler.send.assert_not_called()
|
|
block = self.store.create_child(self.user_id, unit.location, 'problem')
|
|
signal_handler.send.assert_not_called()
|
|
self.store.publish(unit.location, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
signal_handler.send.assert_called_with('course_published', course_key=course.id)
|
|
|
|
signal_handler.reset_mock()
|
|
with self.store.bulk_operations(course_key):
|
|
signal_handler.send.assert_not_called()
|
|
unit.display_name = "Change this unit"
|
|
self.store.update_item(unit, self.user_id)
|
|
signal_handler.send.assert_not_called()
|
|
signal_handler.send.assert_not_called()
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_course_deleted_signal(self, default):
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
signal_handler = Mock(name='signal_handler')
|
|
self.store = MixedModuleStore(
|
|
contentstore=contentstore,
|
|
create_modulestore_instance=create_modulestore_instance,
|
|
mappings={},
|
|
signal_handler=signal_handler,
|
|
**self.OPTIONS
|
|
)
|
|
self.addCleanup(self.store.close_all_connections)
|
|
|
|
with self.store.default_store(default):
|
|
assert self.store.thread_cache.default_store.signal_handler is not None
|
|
|
|
signal_handler.send.assert_not_called()
|
|
|
|
# Create a course
|
|
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
|
course_key = course.id
|
|
|
|
# Delete the course
|
|
course = self.store.delete_course(course_key, self.user_id)
|
|
|
|
# Verify that the signal was emitted
|
|
signal_handler.send.assert_called_with('course_deleted', course_key=course_key)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_delete_published_item_orphans(self, default_store):
|
|
"""
|
|
Tests delete published item dont create any oprhans in course
|
|
"""
|
|
self.initdb(default_store)
|
|
course_locator = self.course.id
|
|
|
|
chapter = self.store.create_child(
|
|
self.user_id, self.course.location, 'chapter', block_id='section_one'
|
|
)
|
|
|
|
sequential = self.store.create_child(
|
|
self.user_id, chapter.location, 'sequential', block_id='subsection_one'
|
|
)
|
|
|
|
vertical = self.store.create_child(
|
|
self.user_id, sequential.location, 'vertical', block_id='moon_unit'
|
|
)
|
|
|
|
problem = self.store.create_child(
|
|
self.user_id, vertical.location, 'problem', block_id='problem'
|
|
)
|
|
|
|
self.store.publish(chapter.location, self.user_id)
|
|
# Verify that there are no changes
|
|
assert not self._has_changes(chapter.location)
|
|
assert not self._has_changes(sequential.location)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(problem.location)
|
|
|
|
# No orphans in course
|
|
course_orphans = self.store.get_orphans(course_locator)
|
|
assert len(course_orphans) == 0
|
|
self.store.delete_item(vertical.location, self.user_id)
|
|
|
|
# No orphans in course after delete, except
|
|
# in old mongo, which still creates orphans
|
|
course_orphans = self.store.get_orphans(course_locator)
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
assert len(course_orphans) == 1
|
|
else:
|
|
assert len(course_orphans) == 0
|
|
|
|
course_locator_publish = course_locator.for_branch(ModuleStoreEnum.BranchName.published)
|
|
# No published oprhans after delete, except
|
|
# in old mongo, which still creates orphans
|
|
course_publish_orphans = self.store.get_orphans(course_locator_publish)
|
|
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
assert len(course_publish_orphans) == 1
|
|
else:
|
|
assert len(course_publish_orphans) == 0
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_delete_draft_item_orphans(self, default_store):
|
|
"""
|
|
Tests delete draft item create no orphans in course
|
|
"""
|
|
self.initdb(default_store)
|
|
course_locator = self.course.id
|
|
|
|
chapter = self.store.create_child(
|
|
self.user_id, self.course.location, 'chapter', block_id='section_one'
|
|
)
|
|
|
|
sequential = self.store.create_child(
|
|
self.user_id, chapter.location, 'sequential', block_id='subsection_one'
|
|
)
|
|
|
|
vertical = self.store.create_child(
|
|
self.user_id, sequential.location, 'vertical', block_id='moon_unit'
|
|
)
|
|
|
|
problem = self.store.create_child(
|
|
self.user_id, vertical.location, 'problem', block_id='problem'
|
|
)
|
|
|
|
self.store.publish(chapter.location, self.user_id)
|
|
# Verify that there are no changes
|
|
assert not self._has_changes(chapter.location)
|
|
assert not self._has_changes(sequential.location)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(problem.location)
|
|
|
|
# No orphans in course
|
|
course_orphans = self.store.get_orphans(course_locator)
|
|
assert len(course_orphans) == 0
|
|
|
|
problem.display_name = 'changed'
|
|
problem = self.store.update_item(problem, self.user_id)
|
|
assert self._has_changes(vertical.location)
|
|
assert self._has_changes(problem.location)
|
|
|
|
self.store.delete_item(vertical.location, self.user_id)
|
|
# No orphans in course after delete, except
|
|
# in old mongo, which still creates them
|
|
course_orphans = self.store.get_orphans(course_locator)
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
assert len(course_orphans) == 1
|
|
else:
|
|
assert len(course_orphans) == 0
|
|
|
|
course_locator_publish = course_locator.for_branch(ModuleStoreEnum.BranchName.published)
|
|
# No published orphans after delete, except
|
|
# in old mongo, which still creates them
|
|
course_publish_orphans = self.store.get_orphans(course_locator_publish)
|
|
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
assert len(course_publish_orphans) == 1
|
|
else:
|
|
assert len(course_publish_orphans) == 0
|
|
|
|
|
|
@ddt.ddt
|
|
@attr('mongo')
|
|
class TestPublishOverExportImport(CommonMixedModuleStoreSetup):
|
|
"""
|
|
Tests which publish (or don't publish) items - and then export/import the course,
|
|
checking the state of the imported items.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""
|
|
Set up the database for testing
|
|
"""
|
|
super().setUp()
|
|
|
|
self.user_id = ModuleStoreEnum.UserID.test
|
|
self.export_dir = mkdtemp()
|
|
self.addCleanup(rmtree, self.export_dir, ignore_errors=True)
|
|
|
|
def _export_import_course_round_trip(self, modulestore, contentstore, source_course_key, export_dir):
|
|
"""
|
|
Export the course from a modulestore and then re-import the course.
|
|
"""
|
|
top_level_export_dir = 'exported_source_course'
|
|
export_course_to_xml(
|
|
modulestore,
|
|
contentstore,
|
|
source_course_key,
|
|
export_dir,
|
|
top_level_export_dir,
|
|
)
|
|
|
|
import_course_from_xml(
|
|
modulestore,
|
|
'test_user',
|
|
export_dir,
|
|
source_dirs=[top_level_export_dir],
|
|
static_content_store=contentstore,
|
|
target_id=source_course_key,
|
|
create_if_not_present=True,
|
|
raise_on_failure=True,
|
|
)
|
|
|
|
@contextmanager
|
|
def _build_store(self, default_ms):
|
|
"""
|
|
Perform the modulestore-building and course creation steps for a mixed modulestore test.
|
|
"""
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
# initialize the mixed modulestore
|
|
self._initialize_mixed(contentstore=contentstore, mappings={})
|
|
with self.store.default_store(default_ms):
|
|
source_course_key = self.store.make_course_key("org.source", "course.source", "run.source")
|
|
self._create_course(source_course_key)
|
|
yield contentstore, source_course_key
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_draft_has_changes_before_export_and_after_import(self, default_ms):
|
|
"""
|
|
Tests that an unpublished unit remains with no changes across export and re-import.
|
|
"""
|
|
with self._build_store(default_ms) as (contentstore, source_course_key):
|
|
|
|
# Create a dummy component to test against and don't publish it.
|
|
draft_xblock = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
# Not yet published, so changes are present
|
|
assert self._has_changes(draft_xblock.location)
|
|
|
|
self._export_import_course_round_trip(
|
|
self.store, contentstore, source_course_key, self.export_dir
|
|
)
|
|
|
|
# Verify that the imported block still is a draft, i.e. has changes.
|
|
assert self._has_changes(draft_xblock.location)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_published_has_changes_before_export_and_after_import(self, default_ms):
|
|
"""
|
|
Tests that an published unit remains published across export and re-import.
|
|
"""
|
|
with self._build_store(default_ms) as (contentstore, source_course_key):
|
|
|
|
# Create a dummy component to test against and publish it.
|
|
published_xblock = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
self.store.publish(published_xblock.location, self.user_id)
|
|
|
|
# Retrieve the published block and make sure it's published.
|
|
assert not self._has_changes(published_xblock.location)
|
|
|
|
self._export_import_course_round_trip(
|
|
self.store, contentstore, source_course_key, self.export_dir
|
|
)
|
|
|
|
# Get the published xblock from the imported course.
|
|
# Verify that it still is published, i.e. has no changes.
|
|
assert not self._has_changes(published_xblock.location)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_changed_published_has_changes_before_export_and_after_import(self, default_ms):
|
|
"""
|
|
Tests that an published unit with an unpublished draft remains published across export and re-import.
|
|
"""
|
|
with self._build_store(default_ms) as (contentstore, source_course_key):
|
|
|
|
# Create a dummy component to test against and publish it.
|
|
published_xblock = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
self.store.publish(published_xblock.location, self.user_id)
|
|
|
|
# Retrieve the published block and make sure it's published.
|
|
assert not self._has_changes(published_xblock.location)
|
|
|
|
updated_display_name = 'Changed Display Name'
|
|
component = self.store.get_item(published_xblock.location)
|
|
component.display_name = updated_display_name
|
|
component = self.store.update_item(component, self.user_id)
|
|
assert self.store.has_changes(component)
|
|
|
|
self._export_import_course_round_trip(
|
|
self.store, contentstore, source_course_key, self.export_dir
|
|
)
|
|
|
|
# Get the published xblock from the imported course.
|
|
# Verify that the published block still has a draft block, i.e. has changes.
|
|
assert self._has_changes(published_xblock.location)
|
|
|
|
# Verify that the changes in the draft vertical still exist.
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, source_course_key):
|
|
component = self.store.get_item(published_xblock.location)
|
|
assert component.display_name == updated_display_name
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_seq_with_unpublished_vertical_has_changes_before_export_and_after_import(self, default_ms):
|
|
"""
|
|
Tests that an published unit with an unpublished draft remains published across export and re-import.
|
|
"""
|
|
with self._build_store(default_ms) as (contentstore, source_course_key):
|
|
|
|
# create chapter
|
|
chapter = self.store.create_child(
|
|
self.user_id, self.course.location, 'chapter', block_id='section_one'
|
|
)
|
|
self.store.publish(chapter.location, self.user_id)
|
|
|
|
# create sequential
|
|
sequential = self.store.create_child(
|
|
self.user_id, chapter.location, 'sequential', block_id='subsection_one'
|
|
)
|
|
self.store.publish(sequential.location, self.user_id)
|
|
|
|
# create vertical - don't publish it!
|
|
vertical = self.store.create_child(
|
|
self.user_id, sequential.location, 'vertical', block_id='moon_unit'
|
|
)
|
|
|
|
# Retrieve the published block and make sure it's published.
|
|
# Chapter is published - but the changes in vertical below means it "has_changes".
|
|
assert self._has_changes(chapter.location)
|
|
# Sequential is published - but the changes in vertical below means it "has_changes".
|
|
assert self._has_changes(sequential.location)
|
|
# Vertical is unpublished - so it "has_changes".
|
|
assert self._has_changes(vertical.location)
|
|
|
|
self._export_import_course_round_trip(
|
|
self.store, contentstore, source_course_key, self.export_dir
|
|
)
|
|
|
|
# Get the published xblock from the imported course.
|
|
# Verify that the published block still has a draft block, i.e. has changes.
|
|
assert self._has_changes(chapter.location)
|
|
assert self._has_changes(sequential.location)
|
|
assert self._has_changes(vertical.location)
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_vertical_with_draft_and_published_unit_has_changes_before_export_and_after_import(self, default_ms):
|
|
"""
|
|
Tests that an published unit with an unpublished draft remains published across export and re-import.
|
|
"""
|
|
with self._build_store(default_ms) as (contentstore, source_course_key):
|
|
|
|
# create chapter
|
|
chapter = self.store.create_child(
|
|
self.user_id, self.course.location, 'chapter', block_id='section_one'
|
|
)
|
|
self.store.publish(chapter.location, self.user_id)
|
|
|
|
# create sequential
|
|
sequential = self.store.create_child(
|
|
self.user_id, chapter.location, 'sequential', block_id='subsection_one'
|
|
)
|
|
self.store.publish(sequential.location, self.user_id)
|
|
|
|
# create vertical
|
|
vertical = self.store.create_child(
|
|
self.user_id, sequential.location, 'vertical', block_id='moon_unit'
|
|
)
|
|
# Vertical has changes until it is actually published.
|
|
assert self._has_changes(vertical.location)
|
|
self.store.publish(vertical.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
|
|
# create unit
|
|
unit = self.store.create_child(
|
|
self.user_id, vertical.location, 'html', block_id='html_unit'
|
|
)
|
|
# Vertical has a new child -and- unit is unpublished. So both have changes.
|
|
assert self._has_changes(vertical.location)
|
|
assert self._has_changes(unit.location)
|
|
|
|
# Publishing the vertical also publishes its unit child.
|
|
self.store.publish(vertical.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(unit.location)
|
|
|
|
# Publishing the unit separately has no effect on whether it has changes - it's already published.
|
|
self.store.publish(unit.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(unit.location)
|
|
|
|
# Retrieve the published block and make sure it's published.
|
|
self.store.publish(chapter.location, self.user_id)
|
|
assert not self._has_changes(chapter.location)
|
|
assert not self._has_changes(sequential.location)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(unit.location)
|
|
|
|
# Now make changes to the unit - but don't publish them.
|
|
component = self.store.get_item(unit.location)
|
|
updated_display_name = 'Changed Display Name'
|
|
component.display_name = updated_display_name
|
|
component = self.store.update_item(component, self.user_id)
|
|
assert self._has_changes(component.location)
|
|
|
|
# Export the course - then import the course export.
|
|
self._export_import_course_round_trip(
|
|
self.store, contentstore, source_course_key, self.export_dir
|
|
)
|
|
|
|
# Get the published xblock from the imported course.
|
|
# Verify that the published block still has a draft block, i.e. has changes.
|
|
assert self._has_changes(chapter.location)
|
|
assert self._has_changes(sequential.location)
|
|
assert self._has_changes(vertical.location)
|
|
assert self._has_changes(unit.location)
|
|
|
|
# Verify that the changes in the draft unit still exist.
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, source_course_key):
|
|
component = self.store.get_item(unit.location)
|
|
assert component.display_name == updated_display_name
|
|
|
|
# Verify that the draft changes don't exist in the published unit - it still uses the default name.
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, source_course_key):
|
|
component = self.store.get_item(unit.location)
|
|
assert component.display_name == 'Text'
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
def test_vertical_with_published_unit_remains_published_before_export_and_after_import(self, default_ms):
|
|
"""
|
|
Tests that an published unit remains published across export and re-import.
|
|
"""
|
|
with self._build_store(default_ms) as (contentstore, source_course_key):
|
|
|
|
# create chapter
|
|
chapter = self.store.create_child(
|
|
self.user_id, self.course.location, 'chapter', block_id='section_one'
|
|
)
|
|
self.store.publish(chapter.location, self.user_id)
|
|
|
|
# create sequential
|
|
sequential = self.store.create_child(
|
|
self.user_id, chapter.location, 'sequential', block_id='subsection_one'
|
|
)
|
|
self.store.publish(sequential.location, self.user_id)
|
|
|
|
# create vertical
|
|
vertical = self.store.create_child(
|
|
self.user_id, sequential.location, 'vertical', block_id='moon_unit'
|
|
)
|
|
# Vertical has changes until it is actually published.
|
|
assert self._has_changes(vertical.location)
|
|
self.store.publish(vertical.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
|
|
# create unit
|
|
unit = self.store.create_child(
|
|
self.user_id, vertical.location, 'html', block_id='html_unit'
|
|
)
|
|
# Now make changes to the unit.
|
|
updated_display_name = 'Changed Display Name'
|
|
unit.display_name = updated_display_name
|
|
unit = self.store.update_item(unit, self.user_id)
|
|
assert self._has_changes(unit.location)
|
|
|
|
# Publishing the vertical also publishes its unit child.
|
|
self.store.publish(vertical.location, self.user_id)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(unit.location)
|
|
|
|
# Export the course - then import the course export.
|
|
self._export_import_course_round_trip(
|
|
self.store, contentstore, source_course_key, self.export_dir
|
|
)
|
|
|
|
# Get the published xblock from the imported course.
|
|
# Verify that the published block still has a draft block, i.e. has changes.
|
|
assert not self._has_changes(chapter.location)
|
|
assert not self._has_changes(sequential.location)
|
|
assert not self._has_changes(vertical.location)
|
|
assert not self._has_changes(unit.location)
|
|
|
|
# Verify that the published changes exist in the published unit.
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, source_course_key):
|
|
component = self.store.get_item(unit.location)
|
|
assert component.display_name == updated_display_name
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
@XBlockAside.register_temp_plugin(AsideTestType, 'test_aside')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside'])
|
|
def test_aside_crud(self, default_store):
|
|
"""
|
|
Check that asides could be imported from XML and the modulestores handle asides crud
|
|
"""
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
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_store):
|
|
dest_course_key = self.store.make_course_key('edX', "aside_test", "2012_Fall")
|
|
courses = import_course_from_xml(
|
|
self.store, self.user_id, DATA_DIR, ['aside'],
|
|
load_error_blocks=False,
|
|
static_content_store=contentstore,
|
|
target_id=dest_course_key,
|
|
create_if_not_present=True,
|
|
)
|
|
|
|
# check that the imported blocks have the right asides and values
|
|
def check_block(block):
|
|
"""
|
|
Check whether block has the expected aside w/ its fields and then recurse to the block's children
|
|
"""
|
|
asides = block.runtime.get_asides(block)
|
|
|
|
assert len(asides) == 1, f'Found {asides} asides but expected only test_aside'
|
|
assert isinstance(asides[0], AsideTestType)
|
|
category = block.scope_ids.block_type
|
|
assert asides[0].data_field == f'{category} aside data'
|
|
assert asides[0].content == f'{category.capitalize()} Aside'
|
|
|
|
for child in block.get_children():
|
|
check_block(child)
|
|
|
|
check_block(courses[0])
|
|
|
|
# create a new block and ensure its aside magically appears with the right fields
|
|
new_chapter = self.store.create_child(self.user_id, courses[0].location, 'chapter', 'new_chapter')
|
|
asides = new_chapter.runtime.get_asides(new_chapter)
|
|
|
|
assert len(asides) == 1, f'Found {asides} asides but expected only test_aside'
|
|
chapter_aside = asides[0]
|
|
assert isinstance(chapter_aside, AsideTestType)
|
|
assert not chapter_aside.fields['data_field'].is_set_on(chapter_aside), \
|
|
f"data_field says it's assigned to {chapter_aside.data_field}"
|
|
assert not chapter_aside.fields['content'].is_set_on(chapter_aside), \
|
|
f"content says it's assigned to {chapter_aside.content}"
|
|
|
|
# now update the values
|
|
chapter_aside.data_field = 'new value'
|
|
self.store.update_item(new_chapter, self.user_id, asides=[chapter_aside])
|
|
|
|
new_chapter = self.store.get_item(new_chapter.location)
|
|
chapter_aside = new_chapter.runtime.get_asides(new_chapter)[0]
|
|
assert 'new value' == chapter_aside.data_field
|
|
|
|
# update the values the second time
|
|
chapter_aside.data_field = 'another one value'
|
|
self.store.update_item(new_chapter, self.user_id, asides=[chapter_aside])
|
|
|
|
new_chapter2 = self.store.get_item(new_chapter.location)
|
|
chapter_aside2 = new_chapter2.runtime.get_asides(new_chapter2)[0]
|
|
assert 'another one value' == chapter_aside2.data_field
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
@XBlockAside.register_temp_plugin(AsideTestType, 'test_aside')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside'])
|
|
def test_export_course_with_asides(self, default_store):
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
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_store):
|
|
dest_course_key = self.store.make_course_key('edX', "aside_test", "2012_Fall")
|
|
dest_course_key2 = self.store.make_course_key('edX', "aside_test_2", "2012_Fall_2")
|
|
|
|
courses = import_course_from_xml(
|
|
self.store,
|
|
self.user_id,
|
|
DATA_DIR,
|
|
['aside'],
|
|
load_error_blocks=False,
|
|
static_content_store=contentstore,
|
|
target_id=dest_course_key,
|
|
create_if_not_present=True,
|
|
)
|
|
|
|
def update_block_aside(block):
|
|
"""
|
|
Check whether block has the expected aside w/ its fields and then recurse to the block's children
|
|
"""
|
|
asides = block.runtime.get_asides(block)
|
|
asides[0].data_field = ''.join(['Exported data_field ', asides[0].data_field])
|
|
asides[0].content = ''.join(['Exported content ', asides[0].content])
|
|
|
|
self.store.update_item(block, self.user_id, asides=[asides[0]])
|
|
|
|
for child in block.get_children():
|
|
update_block_aside(child)
|
|
|
|
update_block_aside(courses[0])
|
|
|
|
# export course to xml
|
|
top_level_export_dir = 'exported_source_course_with_asides'
|
|
export_course_to_xml(
|
|
self.store,
|
|
contentstore,
|
|
dest_course_key,
|
|
self.export_dir,
|
|
top_level_export_dir,
|
|
)
|
|
|
|
# and restore the new one from the exported xml
|
|
courses2 = import_course_from_xml(
|
|
self.store,
|
|
self.user_id,
|
|
self.export_dir,
|
|
source_dirs=[top_level_export_dir],
|
|
static_content_store=contentstore,
|
|
target_id=dest_course_key2,
|
|
create_if_not_present=True,
|
|
raise_on_failure=True,
|
|
)
|
|
|
|
assert 1 == len(courses2)
|
|
|
|
# check that the imported blocks have the right asides and values
|
|
def check_block(block):
|
|
"""
|
|
Check whether block has the expected aside w/ its fields and then recurse to the block's children
|
|
"""
|
|
asides = block.runtime.get_asides(block)
|
|
|
|
assert len(asides) == 1, f'Found {asides} asides but expected only test_aside'
|
|
assert isinstance(asides[0], AsideTestType)
|
|
category = block.scope_ids.block_type
|
|
assert asides[0].data_field == f'Exported data_field {category} aside data'
|
|
assert asides[0].content == f'Exported content {category.capitalize()} Aside'
|
|
|
|
for child in block.get_children():
|
|
check_block(child)
|
|
|
|
check_block(courses2[0])
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
@XBlockAside.register_temp_plugin(AsideTestType, 'test_aside')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside'])
|
|
def test_export_course_after_creating_new_items_with_asides(self, default_store): # pylint: disable=too-many-statements
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
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_store):
|
|
dest_course_key = self.store.make_course_key('edX', "aside_test", "2012_Fall")
|
|
dest_course_key2 = self.store.make_course_key('edX', "aside_test_2", "2012_Fall_2")
|
|
|
|
courses = import_course_from_xml(
|
|
self.store,
|
|
self.user_id,
|
|
DATA_DIR,
|
|
['aside'],
|
|
load_error_blocks=False,
|
|
static_content_store=contentstore,
|
|
target_id=dest_course_key,
|
|
create_if_not_present=True,
|
|
)
|
|
|
|
# create new chapter and modify aside for it
|
|
new_chapter_display_name = 'New Chapter'
|
|
new_chapter = self.store.create_child(self.user_id, courses[0].location, 'chapter', 'new_chapter')
|
|
new_chapter.display_name = new_chapter_display_name
|
|
asides = new_chapter.runtime.get_asides(new_chapter)
|
|
|
|
assert len(asides) == 1, f'Found {asides} asides but expected only test_aside'
|
|
chapter_aside = asides[0]
|
|
assert isinstance(chapter_aside, AsideTestType)
|
|
chapter_aside.data_field = 'new value'
|
|
self.store.update_item(new_chapter, self.user_id, asides=[chapter_aside])
|
|
|
|
# create new problem and modify aside for it
|
|
sequence = courses[0].get_children()[0].get_children()[0]
|
|
new_problem_display_name = 'New Problem'
|
|
new_problem = self.store.create_child(self.user_id, sequence.location, 'problem', 'new_problem')
|
|
new_problem.display_name = new_problem_display_name
|
|
asides = new_problem.runtime.get_asides(new_problem)
|
|
|
|
assert len(asides) == 1, f'Found {asides} asides but expected only test_aside'
|
|
problem_aside = asides[0]
|
|
assert isinstance(problem_aside, AsideTestType)
|
|
problem_aside.data_field = 'new problem value'
|
|
problem_aside.content = 'new content value'
|
|
self.store.update_item(new_problem, self.user_id, asides=[problem_aside])
|
|
|
|
# export course to xml
|
|
top_level_export_dir = 'exported_source_course_with_asides'
|
|
export_course_to_xml(
|
|
self.store,
|
|
contentstore,
|
|
dest_course_key,
|
|
self.export_dir,
|
|
top_level_export_dir,
|
|
)
|
|
|
|
# and restore the new one from the exported xml
|
|
courses2 = import_course_from_xml(
|
|
self.store,
|
|
self.user_id,
|
|
self.export_dir,
|
|
source_dirs=[top_level_export_dir],
|
|
static_content_store=contentstore,
|
|
target_id=dest_course_key2,
|
|
create_if_not_present=True,
|
|
raise_on_failure=True,
|
|
)
|
|
|
|
assert 1 == len(courses2)
|
|
|
|
# check that aside for the new chapter was exported/imported properly
|
|
chapters = courses2[0].get_children()
|
|
assert 2 == len(chapters)
|
|
assert new_chapter_display_name in [item.display_name for item in chapters]
|
|
|
|
found = False
|
|
for child in chapters:
|
|
if new_chapter.display_name == child.display_name:
|
|
found = True
|
|
asides = child.runtime.get_asides(child)
|
|
assert len(asides) == 1
|
|
child_aside = asides[0]
|
|
assert isinstance(child_aside, AsideTestType)
|
|
assert child_aside.data_field == 'new value'
|
|
break
|
|
|
|
assert found, 'new_chapter not found'
|
|
|
|
# check that aside for the new problem was exported/imported properly
|
|
sequence_children = courses2[0].get_children()[0].get_children()[0].get_children()
|
|
assert 2 == len(sequence_children)
|
|
assert new_problem_display_name in [item.display_name for item in sequence_children]
|
|
|
|
found = False
|
|
for child in sequence_children:
|
|
if new_problem.display_name == child.display_name:
|
|
found = True
|
|
asides = child.runtime.get_asides(child)
|
|
assert len(asides) == 1
|
|
child_aside = asides[0]
|
|
assert isinstance(child_aside, AsideTestType)
|
|
assert child_aside.data_field == 'new problem value'
|
|
assert child_aside.content == 'new content value'
|
|
break
|
|
|
|
assert found, 'new_chapter not found'
|
|
|
|
|
|
@ddt.ddt
|
|
@attr('mongo')
|
|
class TestAsidesWithMixedModuleStore(CommonMixedModuleStoreSetup):
|
|
"""
|
|
Tests of the MixedModulestore interface methods with XBlock asides.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""
|
|
Setup environment for testing
|
|
"""
|
|
super().setUp()
|
|
key_store = DictKeyValueStore()
|
|
field_data = KvsFieldData(key_store)
|
|
self.runtime = TestRuntime(services={'field-data': field_data})
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
|
@XBlockAside.register_temp_plugin(AsideBar, 'test_aside2')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside1', 'test_aside2'])
|
|
def test_get_and_update_asides(self, default_store):
|
|
"""
|
|
Tests that connected asides could be stored, received and updated along with connected course items
|
|
"""
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
|
|
self.initdb(default_store)
|
|
|
|
block_type1 = 'test_aside1'
|
|
def_id = self.runtime.id_generator.create_definition(block_type1)
|
|
usage_id = self.runtime.id_generator.create_usage(def_id)
|
|
|
|
# the first aside item
|
|
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
|
aside1.field11 = 'new_value11'
|
|
aside1.field12 = 'new_value12'
|
|
|
|
block_type2 = 'test_aside2'
|
|
def_id = self.runtime.id_generator.create_definition(block_type1)
|
|
usage_id = self.runtime.id_generator.create_usage(def_id)
|
|
|
|
# the second aside item
|
|
aside2 = AsideBar(scope_ids=ScopeIds('user', block_type2, def_id, usage_id), runtime=self.runtime)
|
|
aside2.field21 = 'new_value21'
|
|
|
|
# create new item with two asides
|
|
published_xblock = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='test_vertical',
|
|
asides=[aside1, aside2]
|
|
)
|
|
|
|
def _check_asides(asides, field11, field12, field21, field22):
|
|
""" Helper function to check asides """
|
|
assert len(asides) == 2
|
|
assert {type(asides[0]), type(asides[1])} == {AsideFoo, AsideBar}
|
|
assert asides[0].field11 == field11
|
|
assert asides[0].field12 == field12
|
|
assert asides[1].field21 == field21
|
|
assert asides[1].field22 == field22
|
|
|
|
# get saved item and check asides
|
|
component = self.store.get_item(published_xblock.location)
|
|
asides = component.runtime.get_asides(component)
|
|
_check_asides(asides, 'new_value11', 'new_value12', 'new_value21', 'aside2_default_value2')
|
|
|
|
asides[0].field11 = 'other_value11'
|
|
|
|
# update the first aside item and check that it was stored correctly
|
|
self.store.update_item(component, self.user_id, asides=[asides[0]])
|
|
cached_asides = component.runtime.get_asides(component)
|
|
_check_asides(cached_asides, 'other_value11', 'new_value12', 'new_value21', 'aside2_default_value2')
|
|
|
|
new_component = self.store.get_item(published_xblock.location)
|
|
new_asides = new_component.runtime.get_asides(new_component)
|
|
_check_asides(new_asides, 'other_value11', 'new_value12', 'new_value21', 'aside2_default_value2')
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside1'])
|
|
def test_clone_course_with_asides(self, default_store):
|
|
"""
|
|
Tests that connected asides will be cloned together with the parent courses
|
|
"""
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
|
|
with MongoContentstoreBuilder().build() as contentstore:
|
|
# initialize the mixed modulestore
|
|
self._initialize_mixed(contentstore=contentstore, mappings={})
|
|
|
|
with self.store.default_store(default_store):
|
|
block_type1 = 'test_aside1'
|
|
def_id = self.runtime.id_generator.create_definition(block_type1)
|
|
usage_id = self.runtime.id_generator.create_usage(def_id)
|
|
|
|
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
|
aside1.field11 = 'test1'
|
|
|
|
source_course_key = self.store.make_course_key("org.source", "course.source", "run.source")
|
|
self._create_course(source_course_key, asides=[aside1])
|
|
|
|
dest_course_id = self.store.make_course_key("org.other", "course.other", "run.other")
|
|
self.store.clone_course(source_course_key, dest_course_id, self.user_id)
|
|
|
|
source_store = self.store._get_modulestore_by_type(default_store) # pylint: disable=protected-access
|
|
self.assertCoursesEqual(source_store, source_course_key, source_store, dest_course_id)
|
|
|
|
# after clone get connected aside and check that it was cloned correctly
|
|
actual_items = source_store.get_items(dest_course_id,
|
|
revision=ModuleStoreEnum.RevisionOption.published_only)
|
|
chapter_is_found = False
|
|
|
|
for block in actual_items:
|
|
if block.scope_ids.block_type == 'chapter':
|
|
asides = block.runtime.get_asides(block)
|
|
assert len(asides) == 1
|
|
assert asides[0].field11 == 'test1'
|
|
assert asides[0].field12 == 'aside1_default_value2'
|
|
chapter_is_found = True
|
|
break
|
|
|
|
assert chapter_is_found
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.split)
|
|
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside1'])
|
|
def test_delete_item_with_asides(self, default_store):
|
|
"""
|
|
Tests that connected asides will be removed together with the connected items
|
|
"""
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
|
|
self.initdb(default_store)
|
|
|
|
block_type1 = 'test_aside1'
|
|
def_id = self.runtime.id_generator.create_definition(block_type1)
|
|
usage_id = self.runtime.id_generator.create_usage(def_id)
|
|
|
|
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
|
aside1.field11 = 'new_value11'
|
|
aside1.field12 = 'new_value12'
|
|
|
|
published_xblock = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='test_vertical',
|
|
asides=[aside1]
|
|
)
|
|
|
|
asides = published_xblock.runtime.get_asides(published_xblock)
|
|
assert asides[0].field11 == 'new_value11'
|
|
assert asides[0].field12 == 'new_value12'
|
|
|
|
# remove item
|
|
self.store.delete_item(published_xblock.location, self.user_id)
|
|
|
|
# create item again
|
|
published_xblock2 = self.store.create_item(
|
|
self.user_id,
|
|
self.course.id,
|
|
'vertical',
|
|
block_id='test_vertical'
|
|
)
|
|
|
|
# check that aside has default values
|
|
asides2 = published_xblock2.runtime.get_asides(published_xblock2)
|
|
assert asides2[0].field11 == 'aside1_default_value1'
|
|
assert asides2[0].field12 == 'aside1_default_value2'
|
|
|
|
@ddt.data((ModuleStoreEnum.Type.split, 1, 0))
|
|
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
|
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
|
lambda self, block: ['test_aside1'])
|
|
@ddt.unpack
|
|
def test_published_and_unpublish_item_with_asides(self, default_store, max_find, max_send):
|
|
"""
|
|
Tests that public/unpublish doesn't affect connected stored asides
|
|
"""
|
|
if default_store == ModuleStoreEnum.Type.mongo:
|
|
pytest.skip("asides not supported in old mongo")
|
|
|
|
self.initdb(default_store)
|
|
|
|
block_type1 = 'test_aside1'
|
|
def_id = self.runtime.id_generator.create_definition(block_type1)
|
|
usage_id = self.runtime.id_generator.create_usage(def_id)
|
|
|
|
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
|
aside1.field11 = 'new_value11'
|
|
aside1.field12 = 'new_value12'
|
|
|
|
def _check_asides(item):
|
|
""" Helper function to check asides """
|
|
asides = item.runtime.get_asides(item)
|
|
assert asides[0].field11 == 'new_value11'
|
|
assert asides[0].field12 == 'new_value12'
|
|
|
|
# start off as Private
|
|
item = self.store.create_child(self.user_id, self.writable_chapter_location, 'problem',
|
|
'test_compute_publish_state', asides=[aside1])
|
|
item_location = item.location
|
|
with check_mongo_calls(max_find, max_send):
|
|
assert not self.store.has_published_version(item)
|
|
_check_asides(item)
|
|
|
|
# Private -> Public
|
|
published_block = self.store.publish(item_location, self.user_id)
|
|
_check_asides(published_block)
|
|
|
|
item = self.store.get_item(item_location)
|
|
assert self.store.has_published_version(item)
|
|
_check_asides(item)
|
|
|
|
# Public -> Private
|
|
unpublished_block = self.store.unpublish(item_location, self.user_id)
|
|
_check_asides(unpublished_block)
|
|
|
|
item = self.store.get_item(item_location)
|
|
assert not self.store.has_published_version(item)
|
|
_check_asides(item)
|