Commit 63965891 by Don Mitchell

Refactor split migrator

LMS-2936

Also, a bunch of ancillary cleanups.

Conflicts:
	common/lib/xmodule/xmodule/modulestore/tests/test_publish.py

Conflicts:
	cms/djangoapps/contentstore/management/commands/migrate_to_split.py
	cms/djangoapps/contentstore/management/commands/tests/test_rollback_split_course.py
	common/lib/xmodule/xmodule/modulestore/__init__.py
	common/lib/xmodule/xmodule/modulestore/mixed.py
	common/lib/xmodule/xmodule/modulestore/mongo/draft.py
	common/lib/xmodule/xmodule/modulestore/split_migrator.py
	common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
	common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py
	common/lib/xmodule/xmodule/modulestore/tests/test_split_w_old_mongo.py
parent 9cf082e7
# pylint: disable=protected-access
"""
Django management command to migrate a course from the old Mongo modulestore
to the new split-Mongo modulestore.
......@@ -8,7 +6,6 @@ from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import User
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.split_migrator import SplitMigrator
from xmodule.modulestore.django import loc_mapper
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
......@@ -26,20 +23,21 @@ def user_from_str(identifier):
user_id = int(identifier)
except ValueError:
return User.objects.get(email=identifier)
else:
return User.objects.get(id=user_id)
return User.objects.get(id=user_id)
class Command(BaseCommand):
"Migrate a course from old-Mongo to split-Mongo"
"""
Migrate a course from old-Mongo to split-Mongo. It reuses the old course id except where overridden.
"""
help = "Migrate a course from old-Mongo to split-Mongo"
args = "course_key email <new org> <new offering>"
help = "Migrate a course from old-Mongo to split-Mongo. The new org, course, and run will default to the old one unless overridden"
args = "course_key email <new org> <new course> <new run>"
def parse_args(self, *args):
"""
Return a 4-tuple of (course_key, user, org, offering).
If the user didn't specify an org & offering, those will be None.
Return a 5-tuple of passed in values for (course_key, user, org, course, run).
"""
if len(args) < 2:
raise CommandError(
......@@ -57,21 +55,22 @@ class Command(BaseCommand):
except User.DoesNotExist:
raise CommandError("No user found identified by {}".format(args[1]))
org = course = run = None
try:
org = args[2]
offering = args[3]
course = args[3]
run = args[4]
except IndexError:
org = offering = None
pass
return course_key, user, org, offering
return course_key, user, org, course, run
def handle(self, *args, **options):
course_key, user, org, offering = self.parse_args(*args)
course_key, user, org, course, run = self.parse_args(*args)
migrator = SplitMigrator(
draft_modulestore=modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo),
source_modulestore=modulestore(),
split_modulestore=modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split),
loc_mapper=loc_mapper(),
)
migrator.migrate_mongo_course(course_key, user, org, offering)
migrator.migrate_mongo_course(course_key, user, org, course, run)
"""
Django management command to rollback a migration to split. The way to do this
is to delete the course from the split mongo datastore.
"""
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.modulestore.exceptions import ItemNotFoundError
from opaque_keys.edx.locator import CourseLocator
class Command(BaseCommand):
"Rollback a course that was migrated to the split Mongo datastore"
help = "Rollback a course that was migrated to the split Mongo datastore"
args = "org offering"
def handle(self, *args, **options):
if len(args) < 2:
raise CommandError(
"rollback_split_course requires 2 arguments (org offering)"
)
try:
locator = CourseLocator(org=args[0], offering=args[1])
except ValueError:
raise CommandError("Invalid org or offering string {}, {}".format(*args))
location = loc_mapper().translate_locator_to_location(locator, get_course=True)
if not location:
raise CommandError(
"This course does not exist in the old Mongo store. "
"This command is designed to rollback a course, not delete "
"it entirely."
)
old_mongo_course = modulestore('direct').get_item(location)
if not old_mongo_course:
raise CommandError(
"This course does not exist in the old Mongo store. "
"This command is designed to rollback a course, not delete "
"it entirely."
)
try:
modulestore('split').delete_course(locator)
except ItemNotFoundError:
raise CommandError("No course found with locator {}".format(locator))
print(
'Course rolled back successfully. To delete this course entirely, '
'call the "delete_course" management command.'
)
......@@ -84,6 +84,6 @@ class TestMigrateToSplit(ModuleStoreTestCase):
str(self.user.id),
"org.dept+name.run",
)
locator = CourseLocator(org="org.dept", offering="name.run", branch=ModuleStoreEnum.RevisionOption.published_only)
locator = CourseLocator(org="org.dept", course="name", run="run", branch=ModuleStoreEnum.RevisionOption.published_only)
course_from_split = modulestore('split').get_course(locator)
self.assertIsNotNone(course_from_split)
"""
Unittests for deleting a split mongo course
"""
import unittest
from StringIO import StringIO
from mock import patch
from django.contrib.auth.models import User
from django.core.management import CommandError, call_command
from contentstore.management.commands.rollback_split_course import Command
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.persistent_factories import PersistentCourseFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.split_migrator import SplitMigrator
from xmodule.modulestore import ModuleStoreEnum
# pylint: disable=E1101
# pylint: disable=W0212
@unittest.skip("Not fixing split mongo until we land opaque-keys 0.9")
class TestArgParsing(unittest.TestCase):
"""
Tests for parsing arguments for the `rollback_split_course` management command
"""
def setUp(self):
self.command = Command()
def test_no_args(self):
errstring = "rollback_split_course requires at least one argument"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle()
def test_invalid_locator(self):
errstring = "Invalid locator string !?!"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle("!?!")
@unittest.skip("Not fixing split mongo until we land opaque-keys 0.9")
class TestRollbackSplitCourseNoOldMongo(ModuleStoreTestCase):
"""
Unit tests for rolling back a split-mongo course from command line,
where the course doesn't exist in the old mongo store
"""
def setUp(self):
super(TestRollbackSplitCourseNoOldMongo, self).setUp()
self.course = PersistentCourseFactory()
def test_no_old_course(self):
locator = self.course.location
errstring = "course does not exist in the old Mongo store"
with self.assertRaisesRegexp(CommandError, errstring):
Command().handle(str(locator))
@unittest.skip("Not fixing split mongo until we land opaque-keys 0.9")
class TestRollbackSplitCourseNoSplitMongo(ModuleStoreTestCase):
"""
Unit tests for rolling back a split-mongo course from command line,
where the course doesn't exist in the split mongo store
"""
def setUp(self):
super(TestRollbackSplitCourseNoSplitMongo, self).setUp()
self.old_course = CourseFactory()
def test_nonexistent_locator(self):
locator = loc_mapper().translate_location(self.old_course.location)
errstring = "No course found with locator"
with self.assertRaisesRegexp(CommandError, errstring):
Command().handle(str(locator))
@unittest.skip("Not fixing split mongo until we land opaque-keys 0.9")
class TestRollbackSplitCourse(ModuleStoreTestCase):
"""
Unit tests for rolling back a split-mongo course from command line
"""
def setUp(self):
super(TestRollbackSplitCourse, self).setUp()
self.old_course = CourseFactory()
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
self.user = User.objects.create_user(uname, email, password)
# migrate old course to split
migrator = SplitMigrator(
draft_modulestore=modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo),
split_modulestore=modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split),
loc_mapper=loc_mapper(),
)
migrator.migrate_mongo_course(self.old_course.location, self.user)
self.course = modulestore('split').get_course(self.old_course.id)
@patch("sys.stdout", new_callable=StringIO)
def test_happy_path(self, mock_stdout):
course_id = self.course.id
call_command(
"rollback_split_course",
str(course_id),
)
with self.assertRaises(ItemNotFoundError):
modulestore('split').get_course(course_id)
self.assertIn("Course rolled back successfully", mock_stdout.getvalue())
"""
Unit tests for cloning a course between the same and different module stores.
"""
from django.utils.unittest.case import skipIf
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator
from xmodule.modulestore import ModuleStoreEnum
from contentstore.tests.utils import CourseTestCase
......@@ -12,8 +10,6 @@ class CloneCourseTest(CourseTestCase):
"""
Unit tests for cloning a course
"""
# TODO Don is fixing this on his branch of split migrator
@skipIf(True, "Don is still working on split migrator")
def test_clone_course(self):
"""Tests cloning of a course as follows: XML -> Mongo (+ data) -> Mongo -> Split -> Split"""
# 1. import and populate test toy course
......
......@@ -4,8 +4,7 @@ from xmodule import templates
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests import persistent_factories
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore, clear_existing_modulestores, _MIXED_MODULESTORE, \
loc_mapper, _loc_singleton
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.seq_module import SequenceDescriptor
from xmodule.capa_module import CapaDescriptor
from opaque_keys.edx.locator import BlockUsageLocator, LocalId
......@@ -225,38 +224,3 @@ class TemplateTests(unittest.TestCase):
version_history = self.split_store.get_block_generations(second_problem.location)
self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid)
class SplitAndLocMapperTests(unittest.TestCase):
"""
Test injection of loc_mapper into Split
"""
def test_split_inject_loc_mapper(self):
"""
Test loc_mapper created before split
"""
# ensure modulestore is not instantiated
self.assertIsNone(_MIXED_MODULESTORE)
# instantiate location mapper before split
mapper = loc_mapper()
# instantiate mixed modulestore and thus split
split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split)
# split must inject the same location mapper object since the mapper existed before it did
self.assertEqual(split_store.loc_mapper, mapper)
def test_loc_inject_into_split(self):
"""
Test split created before loc_mapper
"""
# ensure loc_mapper is not instantiated
self.assertIsNone(_loc_singleton)
# instantiate split before location mapper
split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split)
# split must have instantiated loc_mapper
mapper = loc_mapper()
self.assertEqual(split_store.loc_mapper, mapper)
......@@ -4,15 +4,12 @@ Utilities for contentstore tests
'''
import json
import re
from django.test.client import Client
from django.contrib.auth.models import User
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore import PublishState, ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import PublishState, ModuleStoreEnum, mongo
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
......@@ -20,6 +17,9 @@ from xmodule.modulestore.xml_importer import import_from_xml
from student.models import Registration
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation
from contentstore.utils import reverse_url
from xmodule.modulestore.mongo.draft import DraftModuleStore
from xblock.fields import Scope
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
def parse_json(response):
......@@ -249,14 +249,24 @@ class CourseTestCase(ModuleStoreTestCase):
for course1_item in course1_items:
course2_item_location = course1_item.location.map_into_course(course2_id)
if course1_item.location.category == 'course':
course2_item_location = course2_item_location.replace(name=course2_item_location.run)
# mongo uses the run as the name, split uses 'course'
store = self.store._get_modulestore_for_courseid(course2_id) # pylint: disable=protected-access
new_name = 'course' if isinstance(store, SplitMongoModuleStore) else course2_item_location.run
course2_item_location = course2_item_location.replace(name=new_name)
course2_item = self.store.get_item(course2_item_location)
# compare published state
self.assertEqual(
self.store.compute_publish_state(course1_item),
self.store.compute_publish_state(course2_item)
)
try:
# compare published state
self.assertEqual(
self.store.compute_publish_state(course1_item),
self.store.compute_publish_state(course2_item)
)
except AssertionError:
# old mongo calls things draft if draft exists even if it's != published; so, do more work
self.assertEqual(
self.compute_real_state(course1_item),
self.compute_real_state(course2_item)
)
# compare data
self.assertEqual(hasattr(course1_item, 'data'), hasattr(course2_item, 'data'))
......@@ -274,17 +284,18 @@ class CourseTestCase(ModuleStoreTestCase):
expected_children.append(
course1_item_child.map_into_course(course2_id)
)
self.assertEqual(expected_children, course2_item.children)
# also process course2_children just in case they have version guids
course2_children = [child.version_agnostic() for child in course2_item.children]
self.assertEqual(expected_children, course2_children)
# compare assets
content_store = contentstore()
content_store = self.store.contentstore
course1_assets, count_course1_assets = content_store.get_all_content_for_course(course1_id)
_, count_course2_assets = content_store.get_all_content_for_course(course2_id)
self.assertEqual(count_course1_assets, count_course2_assets)
for asset in course1_assets:
asset_id = asset.get('content_son', asset['_id'])
asset_key = StaticContent.compute_location(course1_id, asset_id['name'])
self.assertAssetsEqual(asset_key, course1_id, course2_id)
asset_son = asset.get('content_son', asset['_id'])
self.assertAssetsEqual(asset_son, course1_id, course2_id)
def check_verticals(self, items):
""" Test getting the editing HTML for each vertical. """
......@@ -294,20 +305,47 @@ class CourseTestCase(ModuleStoreTestCase):
resp = self.client.get_html(get_url('unit_handler', descriptor.location))
self.assertEqual(resp.status_code, 200)
def assertAssetsEqual(self, asset_key, course1_id, course2_id):
def assertAssetsEqual(self, asset_son, course1_id, course2_id):
"""Verifies the asset of the given key has the same attributes in both given courses."""
content_store = contentstore()
course1_asset_attrs = content_store.get_attrs(asset_key.map_into_course(course1_id))
course2_asset_attrs = content_store.get_attrs(asset_key.map_into_course(course2_id))
category = asset_son.block_type if hasattr(asset_son, 'block_type') else asset_son['category']
filename = asset_son.block_id if hasattr(asset_son, 'block_id') else asset_son['name']
course1_asset_attrs = content_store.get_attrs(course1_id.make_asset_key(category, filename))
course2_asset_attrs = content_store.get_attrs(course2_id.make_asset_key(category, filename))
self.assertEqual(len(course1_asset_attrs), len(course2_asset_attrs))
for key, value in course1_asset_attrs.iteritems():
if key == '_id':
self.assertEqual(value['name'], course2_asset_attrs[key]['name'])
elif key == 'filename' or key == 'uploadDate' or key == 'content_son' or key == 'thumbnail_location':
if key in ['_id', 'filename', 'uploadDate', 'content_son', 'thumbnail_location']:
pass
else:
self.assertEqual(value, course2_asset_attrs[key])
def compute_real_state(self, item):
"""
In draft mongo, compute_published_state can return draft when the draft == published, but in split,
it'll return public in that case
"""
supposed_state = self.store.compute_publish_state(item)
if supposed_state == PublishState.draft and isinstance(item.runtime.modulestore, DraftModuleStore):
# see if the draft differs from the published
published = self.store.get_item(item.location, revision=ModuleStoreEnum.RevisionOption.published_only)
if item.get_explicitly_set_fields_by_scope() != published.get_explicitly_set_fields_by_scope():
return supposed_state
if item.get_explicitly_set_fields_by_scope(Scope.settings) != published.get_explicitly_set_fields_by_scope(Scope.settings):
return supposed_state
if item.has_children and item.children != published.children:
return supposed_state
return PublishState.public
elif supposed_state == PublishState.public and item.location.category in mongo.base.DIRECT_ONLY_CATEGORIES:
if not all([
self.store.has_item(child_loc, revision=ModuleStoreEnum.RevisionOption.draft_only)
for child_loc in item.children
]):
return PublishState.draft
else:
return supposed_state
else:
return supposed_state
def get_url(handler_name, key_value, key_name='usage_key_string', kwargs=None):
"""
......
......@@ -109,7 +109,7 @@ def course_handler(request, course_key_string=None):
index entry.
PUT
json: update this course (index entry not xblock) such as repointing head, changing display name, org,
offering. Return same json as above.
course, run. Return same json as above.
DELETE
json: delete this branch from this course (leaving off /branch/draft would imply delete the course)
"""
......
......@@ -63,7 +63,7 @@ STATICFILES_DIRS += [
MODULESTORE['default']['OPTIONS']['stores'].append(
{
'NAME': 'split',
'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore',
'ENGINE': 'xmodule.modulestore.split_mongo.split.SplitMongoModuleStore',
'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
'OPTIONS': {
'render_template': 'edxmako.shortcuts.render_to_string',
......
......@@ -10,7 +10,6 @@ from django.contrib.auth import SESSION_KEY
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import Client
from django.test.client import RequestFactory
from django.test.utils import override_settings
......@@ -23,8 +22,6 @@ from opaque_keys import InvalidKeyError
from student.models import CourseEnrollment
from student.roles import CourseStaffRole
from student.tests.factories import UserFactory
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.exceptions import InsufficientSpecificationError
from xmodule.modulestore.tests.django_utils import (ModuleStoreTestCase,
mixed_store_config)
from xmodule.modulestore.tests.factories import CourseFactory
......
......@@ -12,8 +12,7 @@ from fs.osfs import OSFS
import os
import json
from bson.son import SON
from opaque_keys.edx.locator import AssetLocator
from opaque_keys.edx.locations import AssetLocation
from opaque_keys.edx.keys import AssetKey
class MongoContentStore(ContentStore):
......@@ -74,7 +73,7 @@ class MongoContentStore(ContentStore):
return content
def delete(self, location_or_id):
if isinstance(location_or_id, AssetLocator):
if isinstance(location_or_id, AssetKey):
location_or_id, _ = self.asset_db_key(location_or_id)
# Deletes of non-existent files are considered successful
self.fs.delete(location_or_id)
......@@ -272,7 +271,7 @@ class MongoContentStore(ContentStore):
# don't convert from string until fs access
source_content = self.fs.get(asset_key)
if isinstance(asset_key, basestring):
asset_key = AssetLocation.from_string(asset_key)
asset_key = AssetKey.from_string(asset_key)
__, asset_key = self.asset_db_key(asset_key)
asset_key['org'] = dest_course_key.org
asset_key['course'] = dest_course_key.course
......
......@@ -11,8 +11,8 @@ from contextlib import contextmanager
from opaque_keys import InvalidKeyError
from . import ModuleStoreWriteBase
from xmodule.modulestore import PublishState, ModuleStoreEnum, split_migrator
from xmodule.modulestore.django import create_modulestore_instance, loc_mapper
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import create_modulestore_instance
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
from opaque_keys.edx.keys import CourseKey, UsageKey
......@@ -20,6 +20,7 @@ from xmodule.modulestore.mongo.base import MongoModuleStore
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from opaque_keys.edx.locations import SlashSeparatedCourseKey
import itertools
from xmodule.modulestore.split_migrator import SplitMigrator
log = logging.getLogger(__name__)
......@@ -66,8 +67,6 @@ class MixedModuleStore(ModuleStoreWriteBase):
store_settings.get('OPTIONS', {}),
i18n_service=i18n_service,
)
if key == 'split':
store.loc_mapper = loc_mapper()
# replace all named pointers to the store into actual pointers
for course_key, store_name in self.mappings.iteritems():
if store_name == key:
......@@ -83,8 +82,8 @@ class MixedModuleStore(ModuleStoreWriteBase):
"""
if hasattr(course_id, 'version_agnostic'):
course_id = course_id.version_agnostic()
if hasattr(course_id, 'branch_agnostic'):
course_id = course_id.branch_agnostic()
if hasattr(course_id, 'branch'):
course_id = course_id.replace(branch=None)
return course_id
def _get_modulestore_for_courseid(self, course_id=None):
......@@ -185,26 +184,14 @@ class MixedModuleStore(ModuleStoreWriteBase):
# check if the course is not None - possible if the mappings file is outdated
# TODO - log an error if the course is None, but move it to an initialization method to keep it less noisy
if course is not None:
courses[course_id] = store.get_course(course_id)
courses[course_id] = course
has_locators = any(issubclass(CourseLocator, store.reference_type) for store in self.modulestores)
for store in self.modulestores:
# filter out ones which were fetched from earlier stores but locations may not be ==
for course in store.get_courses():
course_id = self._clean_course_id_for_mapping(course.id)
if course_id not in courses:
if has_locators and isinstance(course_id, CourseKey):
# see if a locator version of course is in the result
try:
course_locator = loc_mapper().translate_location_to_course_locator(course_id)
if course_locator in courses:
continue
except ItemNotFoundError:
# if there's no existing mapping, then the course can't have been in split
pass
# course is indeed unique. save it in result
courses[course_id] = course
......@@ -325,12 +312,9 @@ class MixedModuleStore(ModuleStoreWriteBase):
super(MixedModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
if dest_modulestore.get_modulestore_type() == ModuleStoreEnum.Type.split:
if not hasattr(self, 'split_migrator'):
self.split_migrator = split_migrator.SplitMigrator(
dest_modulestore, source_modulestore, loc_mapper()
)
self.split_migrator.migrate_mongo_course(
source_course_id, user_id, dest_course_id.org, dest_course_id.offering
split_migrator = SplitMigrator(dest_modulestore, source_modulestore)
split_migrator.migrate_mongo_course(
source_course_id, user_id, dest_course_id.org, dest_course_id.course, dest_course_id.run
)
def create_item(self, course_or_parent_loc, category, user_id, **kwargs):
......
......@@ -478,7 +478,8 @@ class MongoModuleStore(ModuleStoreWriteBase):
existing_children = results_by_url[location_url].get('definition', {}).get('children', [])
additional_children = result.get('definition', {}).get('children', [])
total_children = existing_children + additional_children
results_by_url[location_url].setdefault('definition', {})['children'] = total_children
# use set to get rid of duplicates. We don't care about order; so, it shouldn't matter.
results_by_url[location_url].setdefault('definition', {})['children'] = set(total_children)
else:
results_by_url[location_url] = result
if location.category == 'course':
......@@ -520,8 +521,8 @@ class MongoModuleStore(ModuleStoreWriteBase):
course_id = self.fill_in_run(course_id)
if not force_refresh:
# see if we are first in the request cache (if present)
if self.request_cache is not None and course_id in self.request_cache.data.get('metadata_inheritance', {}):
return self.request_cache.data['metadata_inheritance'][course_id]
if self.request_cache is not None and unicode(course_id) in self.request_cache.data.get('metadata_inheritance', {}):
return self.request_cache.data['metadata_inheritance'][unicode(course_id)]
# then look in any caching subsystem (e.g. memcached)
if self.metadata_inheritance_cache_subsystem is not None:
......@@ -548,7 +549,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
# defined
if 'metadata_inheritance' not in self.request_cache.data:
self.request_cache.data['metadata_inheritance'] = {}
self.request_cache.data['metadata_inheritance'][course_id] = tree
self.request_cache.data['metadata_inheritance'][unicode(course_id)] = tree
return tree
......@@ -560,6 +561,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
If given a runtime, it replaces the cached_metadata in that runtime. NOTE: failure to provide
a runtime may mean that some objects report old values for inherited data.
"""
course_id = course_id.for_branch(None)
if course_id not in self.ignore_write_events_on_courses:
# below is done for side effects when runtime is None
cached_metadata = self._get_cached_metadata_inheritance_tree(course_id, force_refresh=True)
......
......@@ -371,7 +371,11 @@ class DraftModuleStore(MongoModuleStore):
DuplicateItemError: if the source or any of its descendants already has a draft copy
"""
# delegating to internal b/c we don't want any public user to use the kwargs on the internal
return self._convert_to_draft(location, user_id)
self._convert_to_draft(location, user_id)
# return the new draft item (does another fetch)
# get_item will wrap_draft so don't call it here (otherwise, it would override the is_draft attribute)
return self.get_item(location)
def _convert_to_draft(self, location, user_id, delete_published=False, ignore_if_draft=False):
"""
......@@ -427,10 +431,6 @@ class DraftModuleStore(MongoModuleStore):
# convert the subtree using the original item as the root
self._breadth_first(convert_item, [location])
# return the new draft item (does another fetch)
# get_item will wrap_draft so don't call it here (otherwise, it would override the is_draft attribute)
return self.get_item(location)
def update_item(self, xblock, user_id, allow_not_found=False, force=False, isPublish=False):
"""
See superclass doc.
......@@ -551,6 +551,8 @@ class DraftModuleStore(MongoModuleStore):
first_tier = [as_func(location) for as_func in as_functions]
self._breadth_first(_delete_item, first_tier)
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(location.course_key)
def _breadth_first(self, function, root_usages):
"""
......@@ -579,8 +581,6 @@ class DraftModuleStore(MongoModuleStore):
_internal([root_usage.to_deprecated_son() for root_usage in root_usages])
self.collection.remove({'_id': {'$in': to_be_deleted}}, safe=self.collection.safe)
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(root_usages[0].course_key)
def has_changes(self, location):
"""
......@@ -682,7 +682,7 @@ class DraftModuleStore(MongoModuleStore):
to remove things from the published version
"""
self._verify_branch_setting(ModuleStoreEnum.Branch.draft_preferred)
return self._convert_to_draft(location, user_id, delete_published=True)
self._convert_to_draft(location, user_id, delete_published=True)
def revert_to_published(self, location, user_id=None):
"""
......
from split import SplitMongoModuleStore
"""
General utilities
"""
import urllib
def encode_key_for_mongo(fieldname):
"""
Fieldnames in mongo cannot have periods nor dollar signs. So encode them.
:param fieldname: an atomic field name. Note, don't pass structured paths as it will flatten them
"""
for char in [".", "$"]:
fieldname = fieldname.replace(char, '%{:02x}'.format(ord(char)))
return fieldname
def decode_key_from_mongo(fieldname):
"""
The inverse of encode_key_for_mongo
:param fieldname: with period and dollar escaped
"""
return urllib.unquote(fieldname)
import sys
import logging
from xmodule.mako_module import MakoDescriptorSystem
from xblock.runtime import KvsFieldData
from xblock.fields import ScopeIds
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xblock.runtime import KvsFieldData
from xmodule.modulestore.split_mongo import encode_key_for_mongo
from ..exceptions import ItemNotFoundError
from .split_mongo_kvs import SplitMongoKVS
from xblock.fields import ScopeIds
from xmodule.modulestore.loc_mapper_store import LocMapperStore
log = logging.getLogger(__name__)
......@@ -47,7 +47,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
modulestore.inherit_settings(
course_entry['structure'].get('blocks', {}),
course_entry['structure'].get('blocks', {}).get(
LocMapperStore.encode_key_for_mongo(course_entry['structure'].get('root'))
encode_key_for_mongo(course_entry['structure'].get('root'))
)
)
self.default_class = default_class
......
......@@ -197,6 +197,7 @@ class ModuleStoreTestCase(TestCase):
if hasattr(store, 'collection'):
connection = store.collection.database.connection
store.collection.drop()
connection.drop_database(store.collection.database.name)
connection.close()
elif hasattr(store, 'close_all_connections'):
store.close_all_connections()
......@@ -247,7 +248,7 @@ class ModuleStoreTestCase(TestCase):
"""
# Flush the Mongo modulestore
ModuleStoreTestCase.drop_mongo_collections()
self.drop_mongo_collections()
# Call superclass implementation
super(ModuleStoreTestCase, self)._pre_setup()
......@@ -256,7 +257,7 @@ class ModuleStoreTestCase(TestCase):
"""
Flush the ModuleStore after each test.
"""
ModuleStoreTestCase.drop_mongo_collections()
self.drop_mongo_collections()
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown()
import pymongo
from uuid import uuid4
import ddt
from mock import patch, Mock
from mock import patch
from importlib import import_module
from collections import namedtuple
import unittest
from xmodule.tests import DATA_DIR
from opaque_keys.edx.locations import Location
......@@ -11,20 +12,19 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.exceptions import InvalidVersionError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xmodule.modulestore.tests.test_location_mapper import LocMapperSetupSansDjango, loc_mapper
# 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.locations import SlashSeparatedCourseKey
from xmodule.modulestore.mongo.base import MongoRevisionKey
if not settings.configured:
settings.configure()
from xmodule.modulestore.mixed import MixedModuleStore
@ddt.ddt
class TestMixedModuleStore(LocMapperSetupSansDjango):
class TestMixedModuleStore(unittest.TestCase):
"""
Quasi-superclass which tests Location based apps against both split and mongo dbs (Locator and
Location-based dbs)
......@@ -67,7 +67,7 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
},
{
'NAME': 'split',
'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore',
'ENGINE': 'xmodule.modulestore.split_mongo.split.SplitMongoModuleStore',
'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
'OPTIONS': modulestore_options
},
......@@ -106,7 +106,6 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
patcher = patch.multiple(
'xmodule.modulestore.mixed',
loc_mapper=Mock(return_value=LocMapperSetupSansDjango.loc_store),
create_modulestore_instance=create_modulestore_instance,
)
patcher.start()
......@@ -221,7 +220,12 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
course_id: course_key.make_usage_key('course', course_key.run)
for course_id, course_key in self.course_locations.iteritems() # pylint: disable=maybe-no-member
}
self.fake_location = Location('foo', 'bar', 'slowly', 'vertical', 'baz')
if default == 'split':
self.fake_location = CourseLocator(
'foo', 'bar', 'slowly', branch=ModuleStoreEnum.BranchName.draft
).make_usage_key('vertical', 'baz')
else:
self.fake_location = Location('foo', 'bar', 'slowly', 'vertical', 'baz')
self.writable_chapter_location = self.course_locations[self.MONGO_COURSEID].replace(
category='chapter', name='Overview'
)
......@@ -229,9 +233,6 @@ class TestMixedModuleStore(LocMapperSetupSansDjango):
category='chapter', name='Overview'
)
# get Locators and set up the loc mapper if app is Locator based
if default == 'split':
self.fake_location = loc_mapper().translate_location(self.fake_location)
self._create_course(default, self.course_locations[self.MONGO_COURSEID].course_key)
......
......@@ -29,7 +29,7 @@ class TestOrphan(SplitWMongoCourseBoostrapper):
"""
Test that old mongo finds the orphans
"""
orphans = self.old_mongo.get_orphans(self.old_course_key)
orphans = self.draft_mongo.get_orphans(self.old_course_key)
self.assertEqual(len(orphans), 3, "Wrong # {}".format(orphans))
location = self.old_course_key.make_usage_key('chapter', 'OrphanChapter')
self.assertIn(location.to_deprecated_string(), orphans)
......
......@@ -4,6 +4,7 @@ Test the publish code (mostly testing that publishing doesn't result in orphans)
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.test_split_w_old_mongo import SplitWMongoCourseBoostrapper
from xmodule.modulestore.tests.factories import check_mongo_calls
from xmodule.modulestore import ModuleStoreEnum
class TestPublish(SplitWMongoCourseBoostrapper):
......@@ -15,18 +16,26 @@ class TestPublish(SplitWMongoCourseBoostrapper):
Create the course, publish all verticals
* some detached items
"""
# There should be 12 inserts and 11 updates (max_sends)
# Should be 1 to verify course unique, 11 parent fetches,
# and n per _create_item where n is the size of the course tree non-leaf nodes
# for inheritance computation (which is 7*4 + sum(1..4) = 38) (max_finds)
with check_mongo_calls(self.draft_mongo, 71, 27):
with check_mongo_calls(self.old_mongo, 70, 27):
super(TestPublish, self)._create_course(split=False)
# There are 12 created items and 7 parent updates
# create course: finds: 1 to verify uniqueness, 1 to find parents
# sends: 1 to create course, 1 to create overview
with check_mongo_calls(self.draft_mongo, 5, 2):
super(TestPublish, self)._create_course(split=False) # 2 inserts (course and overview)
# with bulk will delay all inheritance computations which won't be added into the mongo_calls
with self.draft_mongo.bulk_write_operations(self.old_course_key):
# finds: 1 for parent to add child, 1 for parent to update edit info
# sends: 1 for insert, 2 for parent (add child, change edit info)
with check_mongo_calls(self.draft_mongo, 5, 3):
self._create_item('chapter', 'Chapter1', {}, {'display_name': 'Chapter 1'}, 'course', 'runid', split=False)
with check_mongo_calls(self.draft_mongo, 5, 3):
self._create_item('chapter', 'Chapter2', {}, {'display_name': 'Chapter 2'}, 'course', 'runid', split=False)
# update info propagation is 2 levels. create looks for draft and then published and then creates
with check_mongo_calls(self.draft_mongo, 16, 8):
self._create_item('vertical', 'Vert1', {}, {'display_name': 'Vertical 1'}, 'chapter', 'Chapter1', split=False)
self._create_item('vertical', 'Vert2', {}, {'display_name': 'Vertical 2'}, 'chapter', 'Chapter1', split=False)
with check_mongo_calls(self.draft_mongo, 36, 36):
self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', split=False)
self._create_item(
'discussion', 'Discussion1',
......@@ -53,11 +62,11 @@ class TestPublish(SplitWMongoCourseBoostrapper):
'vertical', 'Vert2',
split=False
)
with check_mongo_calls(self.draft_mongo, 2, 2):
# 2 finds b/c looking for non-existent parents
self._create_item('static_tab', 'staticuno', "<p>tab</p>", {'display_name': 'Tab uno'}, None, None, split=False)
self._create_item('about', 'overview', "<p>overview</p>", {}, None, None, split=False)
self._create_item('course_info', 'updates', "<ol><li><h2>Sep 22</h2><p>test</p></li></ol>", {}, None, None, split=False)
def test_publish_draft_delete(self):
"""
To reproduce a bug (STUD-811) publish a vertical, convert to draft, delete a child, move a child, publish.
......@@ -93,7 +102,7 @@ class TestPublish(SplitWMongoCourseBoostrapper):
self.draft_mongo.update_item(other_vert, self.user_id)
# publish
self.draft_mongo.publish(vert_location, self.user_id)
item = self.old_mongo.get_item(vert_location, 0)
item = self.draft_mongo.get_item(draft_vert.location, revision=ModuleStoreEnum.RevisionOption.published_only)
self.assertNotIn(location, item.children)
self.assertIsNone(self.draft_mongo.get_parent_location(location))
with self.assertRaises(ItemNotFoundError):
......
......@@ -5,13 +5,9 @@ Tests for split_migrator
import uuid
import random
import mock
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.mongo.base import MongoRevisionKey
from xmodule.modulestore.loc_mapper_store import LocMapperStore
from xblock.fields import Reference, ReferenceList, ReferenceValueDict
from xmodule.modulestore.split_migrator import SplitMigrator
from xmodule.modulestore.tests import test_location_mapper
from xmodule.modulestore.tests.test_split_w_old_mongo import SplitWMongoCourseBoostrapper
from xblock.fields import Reference, ReferenceList, ReferenceValueDict
class TestMigration(SplitWMongoCourseBoostrapper):
......@@ -21,15 +17,7 @@ class TestMigration(SplitWMongoCourseBoostrapper):
def setUp(self):
super(TestMigration, self).setUp()
# pylint: disable=W0142
self.loc_mapper = LocMapperStore(test_location_mapper.TrivialCache(), **self.db_config)
self.split_mongo.loc_mapper = self.loc_mapper
self.migrator = SplitMigrator(self.split_mongo, self.draft_mongo, self.loc_mapper)
def tearDown(self):
dbref = self.loc_mapper.db
dbref.drop_collection(self.loc_mapper.location_map)
super(TestMigration, self).tearDown()
self.migrator = SplitMigrator(self.split_mongo, self.draft_mongo)
def _create_course(self):
"""
......@@ -64,7 +52,7 @@ class TestMigration(SplitWMongoCourseBoostrapper):
self.create_random_units(False, both_vert_loc)
draft_both = self.draft_mongo.get_item(both_vert_loc)
draft_both.display_name = 'Both vertical renamed'
self.draft_mongo.update_item(draft_both, ModuleStoreEnum.UserID.test)
self.draft_mongo.update_item(draft_both, self.user_id)
self.create_random_units(True, both_vert_loc)
# vertical in draft only (x2)
draft_vert_loc = self.old_course_key.make_usage_key('vertical', uuid.uuid4().hex)
......@@ -86,7 +74,7 @@ class TestMigration(SplitWMongoCourseBoostrapper):
live_vert_loc.category, live_vert_loc.name, {}, {'display_name': 'Live vertical end'}, 'chapter', chapter1_name,
draft=False, split=False
)
self.create_random_units(True, draft_vert_loc)
self.create_random_units(False, live_vert_loc)
# now the other chapter w/ the conditional
# create pointers to children (before the children exist)
......@@ -155,40 +143,28 @@ class TestMigration(SplitWMongoCourseBoostrapper):
draft=draft, split=False
)
def compare_courses(self, presplit, published):
def compare_courses(self, presplit, new_course_key, published):
# descend via children to do comparison
old_root = presplit.get_course(self.old_course_key)
new_root_locator = self.loc_mapper.translate_location_to_course_locator(
old_root.id, published
)
new_root = self.split_mongo.get_course(new_root_locator)
new_root = self.split_mongo.get_course(new_course_key)
self.compare_dags(presplit, old_root, new_root, published)
# grab the detached items to compare they should be in both published and draft
for category in ['conditional', 'about', 'course_info', 'static_tab']:
for conditional in presplit.get_items(self.old_course_key, category=category):
locator = self.loc_mapper.translate_location(
conditional.location,
published,
add_entry_if_missing=False
)
locator = new_course_key.make_usage_key(category, conditional.location.block_id)
self.compare_dags(presplit, conditional, self.split_mongo.get_item(locator), published)
def compare_dags(self, presplit, presplit_dag_root, split_dag_root, published):
# check that locations match
self.assertEqual(
presplit_dag_root.location,
self.loc_mapper.translate_locator_to_location(split_dag_root.location).replace(
revision=MongoRevisionKey.published
)
)
# compare all fields but children
if split_dag_root.category != 'course':
self.assertEqual(presplit_dag_root.location.block_id, split_dag_root.location.block_id)
# compare all fields but references
for name, field in presplit_dag_root.fields.iteritems():
if not isinstance(field, (Reference, ReferenceList, ReferenceValueDict)):
self.assertEqual(
getattr(presplit_dag_root, name),
getattr(split_dag_root, name),
"{}/{}: {} != {}".format(
u"{}/{}: {} != {}".format(
split_dag_root.location, name, getattr(presplit_dag_root, name), getattr(split_dag_root, name)
)
)
......@@ -198,7 +174,7 @@ class TestMigration(SplitWMongoCourseBoostrapper):
self.assertEqual(
# need get_children to filter out drafts
len(presplit_dag_root.get_children()), len(split_dag_root.children),
"{0.category} '{0.display_name}': children {1} != {2}".format(
u"{0.category} '{0.display_name}': children {1} != {2}".format(
presplit_dag_root, presplit_dag_root.children, split_dag_root.children
)
)
......@@ -207,7 +183,7 @@ class TestMigration(SplitWMongoCourseBoostrapper):
def test_migrator(self):
user = mock.Mock(id=1)
self.migrator.migrate_mongo_course(self.old_course_key, user)
new_course_key = self.migrator.migrate_mongo_course(self.old_course_key, user.id, new_run='new_run')
# now compare the migrated to the original course
self.compare_courses(self.old_mongo, True)
self.compare_courses(self.draft_mongo, False)
self.compare_courses(self.draft_mongo, new_course_key, True) # published
self.compare_courses(self.draft_mongo, new_course_key, False) # draft
......@@ -12,7 +12,7 @@ import random
from xblock.fields import Scope
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import (InsufficientSpecificationError, ItemNotFoundError, VersionConflictError,
from xmodule.modulestore.exceptions import (ItemNotFoundError, VersionConflictError,
DuplicateItemError, DuplicateCourseError)
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator, VersionTree, LocalId
from xmodule.modulestore.inheritance import InheritanceMixin
......@@ -45,7 +45,7 @@ class SplitModuleTest(unittest.TestCase):
}
MODULESTORE = {
'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore',
'ENGINE': 'xmodule.modulestore.split_mongo.split.SplitMongoModuleStore',
'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
'OPTIONS': modulestore_options
}
......@@ -667,7 +667,7 @@ class SplitModuleCourseTests(SplitModuleTest):
def test_get_course_negative(self):
# Now negative testing
with self.assertRaises(InsufficientSpecificationError):
with self.assertRaises(ItemNotFoundError):
modulestore().get_course(CourseLocator(org='edu', course='meh', run='blah'))
with self.assertRaises(ItemNotFoundError):
modulestore().get_course(CourseLocator(org='edu', course='nosuchthing', run="run", branch=BRANCH_NAME_DRAFT))
......
......@@ -7,8 +7,7 @@ import random
from xmodule.modulestore.inheritance import InheritanceMixin
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.mongo import MongoModuleStore, DraftMongoModuleStore
from xmodule.modulestore.mongo.draft import DIRECT_ONLY_CATEGORIES
from xmodule.modulestore.mongo import DraftMongoModuleStore
from xmodule.modulestore import ModuleStoreEnum
......@@ -22,7 +21,6 @@ class SplitWMongoCourseBoostrapper(unittest.TestCase):
Defines the following attrs on self:
* user_id: a random non-registered mock user id
* split_mongo: a pointer to the split mongo instance
* old_mongo: a pointer to the old_mongo instance
* draft_mongo: a pointer to the old draft instance
* split_course_key (CourseLocator): of the new course
* old_course_key: the SlashSpecifiedCourseKey for the course
......@@ -54,7 +52,6 @@ class SplitWMongoCourseBoostrapper(unittest.TestCase):
)
self.addCleanup(self.split_mongo.db.connection.close)
self.addCleanup(self.tear_down_split)
self.old_mongo = MongoModuleStore(None, self.db_config, **self.modulestore_options)
self.draft_mongo = DraftMongoModuleStore(
None, self.db_config, branch_setting_func=lambda: ModuleStoreEnum.Branch.draft_preferred, **self.modulestore_options
)
......@@ -78,19 +75,23 @@ class SplitWMongoCourseBoostrapper(unittest.TestCase):
"""
split_db = self.split_mongo.db
# old_mongo doesn't give a db attr, but all of the dbs are the same
split_db.drop_collection(self.old_mongo.collection)
split_db.drop_collection(self.draft_mongo.collection)
def _create_item(self, category, name, data, metadata, parent_category, parent_name, draft=True, split=True):
"""
Create the item of the given category and block id in split and old mongo, add it to the optional
parent. The parent category is only needed because old mongo requires it for the id.
Note: if draft = False, it will create the draft and then publish it; so, it will overwrite any
existing draft for both the new item and the parent
"""
location = self.old_course_key.make_usage_key(category, name)
if not draft or category in DIRECT_ONLY_CATEGORIES:
mongo = self.old_mongo
else:
mongo = self.draft_mongo
mongo.create_and_save_xmodule(location, self.user_id, definition_data=data, metadata=metadata, runtime=self.runtime)
self.draft_mongo.create_and_save_xmodule(
location, self.user_id, definition_data=data, metadata=metadata, runtime=self.runtime
)
if not draft:
self.draft_mongo.publish(location, self.user_id)
if isinstance(data, basestring):
fields = {'data': data}
else:
......@@ -99,13 +100,11 @@ class SplitWMongoCourseBoostrapper(unittest.TestCase):
if parent_name:
# add child to parent in mongo
parent_location = self.old_course_key.make_usage_key(parent_category, parent_name)
if not draft or parent_category in DIRECT_ONLY_CATEGORIES:
mongo = self.old_mongo
else:
mongo = self.draft_mongo
parent = mongo.get_item(parent_location)
parent = self.draft_mongo.get_item(parent_location)
parent.children.append(location)
mongo.update_item(parent, self.user_id)
self.draft_mongo.update_item(parent, self.user_id)
if not draft:
self.draft_mongo.publish(parent_location, self.user_id)
# create pointer for split
course_or_parent_locator = BlockUsageLocator(
course_key=self.split_course_key,
......@@ -137,6 +136,6 @@ class SplitWMongoCourseBoostrapper(unittest.TestCase):
self.split_mongo.create_course(
self.split_course_key.org, self.split_course_key.course, self.split_course_key.run, self.user_id, fields=fields, root_block_id='runid'
)
old_course = self.old_mongo.create_course(self.split_course_key.org, 'test_course', 'runid', self.user_id, fields=fields)
old_course = self.draft_mongo.create_course(self.split_course_key.org, 'test_course', 'runid', self.user_id, fields=fields)
self.old_course_key = old_course.id
self.runtime = old_course.runtime
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment