Commit 57ec4024 by Don Mitchell

Merge pull request #4397 from edx/split-migrator/lms-2936-ok

Refactor split migrator
parents 86f80c25 63965891
# 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):
"""
......
......@@ -6,8 +6,13 @@ Exists at the top level of modulestore b/c it needs to know about and access eac
In general, it's strategy is to treat the other modulestores as read-only and to never directly
manipulate storage but use existing api's.
'''
import logging
from xblock.fields import Reference, ReferenceList, ReferenceValueDict
from xmodule.modulestore import ModuleStoreEnum
from opaque_keys.edx.locator import CourseLocator
log = logging.getLogger(__name__)
class SplitMigrator(object):
......@@ -15,51 +20,53 @@ class SplitMigrator(object):
Copies courses from old mongo to split mongo and sets up location mapping so any references to the old
name will be able to find the new elements.
"""
def __init__(self, split_modulestore, draft_modulestore, loc_mapper):
def __init__(self, split_modulestore, source_modulestore):
super(SplitMigrator, self).__init__()
self.split_modulestore = split_modulestore
self.draft_modulestore = draft_modulestore
self.loc_mapper = loc_mapper
self.source_modulestore = source_modulestore
def migrate_mongo_course(self, course_key, user, new_org=None, new_offering=None):
def migrate_mongo_course(self, source_course_key, user_id, new_org=None, new_course=None, new_run=None):
"""
Create a new course in split_mongo representing the published and draft versions of the course from the
original mongo store. And return the new CourseLocator
If the new course already exists, this raises DuplicateItemError
:param course_location: a Location whose category is 'course' and points to the course
:param user: the user whose action is causing this migration
:param new_org: (optional) the Locator.org for the new course. Defaults to
whatever translate_location_to_locator returns
:param new_offering: (optional) the Locator.offering for the new course. Defaults to
whatever translate_location_to_locator returns
:param source_course_key: which course to migrate
:param user_id: the user whose action is causing this migration
:param new_org, new_course, new_run: (optional) identifiers for the new course. Defaults to
the source_course_key's values.
"""
new_course_locator = self.loc_mapper.create_map_entry(course_key, new_org, new_offering)
# the only difference in data between the old and split_mongo xblocks are the locations;
# so, any field which holds a location must change to a Locator; otherwise, the persistence
# layer and kvs's know how to store it.
# locations are in location, children, conditionals, course.tab
# create the course: set fields to explicitly_set for each scope, id_root = new_course_locator, master_branch = 'production'
original_course = self.draft_modulestore.get_course(course_key)
new_course_root_locator = self.loc_mapper.translate_location(original_course.location)
original_course = self.source_modulestore.get_course(source_course_key)
if new_org is None:
new_org = source_course_key.org
if new_course is None:
new_course = source_course_key.course
if new_run is None:
new_run = source_course_key.run
new_course_key = CourseLocator(new_org, new_course, new_run, branch=ModuleStoreEnum.BranchName.published)
new_course = self.split_modulestore.create_course(
new_course_root_locator.org,
new_course_root_locator.course,
new_course_root_locator.run,
user.id,
fields=self._get_json_fields_translate_references(original_course, course_key, True),
root_block_id=new_course_root_locator.block_id,
master_branch=new_course_root_locator.branch
new_org, new_course, new_run, user_id,
fields=self._get_json_fields_translate_references(original_course, new_course_key, None),
master_branch=ModuleStoreEnum.BranchName.published,
)
self._copy_published_modules_to_course(new_course, original_course.location, course_key, user)
self._add_draft_modules_to_course(new_course.id, course_key, user)
with self.split_modulestore.bulk_write_operations(new_course.id):
self._copy_published_modules_to_course(new_course, original_course.location, source_course_key, user_id)
# create a new version for the drafts
with self.split_modulestore.bulk_write_operations(new_course.id):
self._add_draft_modules_to_course(new_course.location, source_course_key, user_id)
return new_course_locator
return new_course.id
def _copy_published_modules_to_course(self, new_course, old_course_loc, course_key, user):
def _copy_published_modules_to_course(self, new_course, old_course_loc, source_course_key, user_id):
"""
Copy all of the modules from the 'direct' version of the course to the new split course.
"""
......@@ -67,21 +74,22 @@ class SplitMigrator(object):
# iterate over published course elements. Wildcarding rather than descending b/c some elements are orphaned (e.g.,
# course about pages, conditionals)
for module in self.draft_modulestore.get_items(course_key, revision=ModuleStoreEnum.RevisionOption.published_only):
# don't copy the course again. No drafts should get here
for module in self.source_modulestore.get_items(
source_course_key, revision=ModuleStoreEnum.RevisionOption.published_only
):
# don't copy the course again.
if module.location != old_course_loc:
# create split_xblock using split.create_item
# where block_id is computed by translate_location_to_locator
new_locator = self.loc_mapper.translate_location(
module.location, True, add_entry_if_missing=True
)
# NOTE: the below auto populates the children when it migrates the parent; so,
# it doesn't need the parent as the first arg. That is, it translates and populates
# the 'children' field as it goes.
_new_module = self.split_modulestore.create_item(
course_version_locator, module.category, user.id,
block_id=new_locator.block_id,
fields=self._get_json_fields_translate_references(module, course_key, True),
course_version_locator, module.category, user_id,
block_id=module.location.block_id,
fields=self._get_json_fields_translate_references(
module, course_version_locator, new_course.location.block_id
),
# TODO remove continue_version when bulk write is impl'd
continue_version=True
)
# after done w/ published items, add version for DRAFT pointing to the published structure
......@@ -94,20 +102,18 @@ class SplitMigrator(object):
# children which meant some pointers were to non-existent locations in 'direct'
self.split_modulestore.internal_clean_children(course_version_locator)
def _add_draft_modules_to_course(self, published_course_key, course_key, user):
def _add_draft_modules_to_course(self, published_course_usage_key, source_course_key, user_id):
"""
update each draft. Create any which don't exist in published and attach to their parents.
"""
# each true update below will trigger a new version of the structure. We may want to just have one new version
# but that's for a later date.
new_draft_course_loc = published_course_key.for_branch(ModuleStoreEnum.BranchName.draft)
new_draft_course_loc = published_course_usage_key.course_key.for_branch(ModuleStoreEnum.BranchName.draft)
# to prevent race conditions of grandchilden being added before their parents and thus having no parent to
# add to
awaiting_adoption = {}
for module in self.draft_modulestore.get_items(course_key, revision=ModuleStoreEnum.RevisionOption.draft_only):
new_locator = self.loc_mapper.translate_location(
module.location, False, add_entry_if_missing=True
)
for module in self.source_modulestore.get_items(source_course_key, revision=ModuleStoreEnum.RevisionOption.draft_only):
new_locator = new_draft_course_loc.make_usage_key(module.category, module.location.block_id)
if self.split_modulestore.has_item(new_locator):
# was in 'direct' so draft is a new version
split_module = self.split_modulestore.get_item(new_locator)
......@@ -115,27 +121,35 @@ class SplitMigrator(object):
for name, field in split_module.fields.iteritems():
if field.is_set_on(split_module) and not module.fields[name].is_set_on(module):
field.delete_from(split_module)
for field, value in self._get_fields_translate_references(module, course_key, True).iteritems():
# draft children will insert themselves and the others are here already; so, don't do it 2x
if field.name != 'children':
field.write_to(split_module, value)
for field, value in self._get_fields_translate_references(
module, new_draft_course_loc, published_course_usage_key.block_id
).iteritems():
field.write_to(split_module, value)
_new_module = self.split_modulestore.update_item(split_module, user.id)
_new_module = self.split_modulestore.update_item(split_module, user_id)
else:
# only a draft version (aka, 'private'). parent needs updated too.
# create a new course version just in case the current head is also the prod head
# only a draft version (aka, 'private').
_new_module = self.split_modulestore.create_item(
new_draft_course_loc, module.category, user.id,
new_draft_course_loc, module.category, user_id,
block_id=new_locator.block_id,
fields=self._get_json_fields_translate_references(module, course_key, True)
fields=self._get_json_fields_translate_references(
module, new_draft_course_loc, published_course_usage_key.block_id
)
)
awaiting_adoption[module.location] = new_locator
for draft_location, new_locator in awaiting_adoption.iteritems():
parent_loc = self.draft_modulestore.get_parent_location(draft_location)
old_parent = self.draft_modulestore.get_item(parent_loc)
new_parent = self.split_modulestore.get_item(
self.loc_mapper.translate_location(old_parent.location, False)
parent_loc = self.source_modulestore.get_parent_location(
draft_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred
)
if parent_loc is None:
log.warn(u'No parent found in source course for %s', draft_location)
continue
old_parent = self.source_modulestore.get_item(parent_loc)
split_parent_loc = new_draft_course_loc.make_usage_key(
parent_loc.category,
parent_loc.block_id if parent_loc.category != 'course' else published_course_usage_key.block_id
)
new_parent = self.split_modulestore.get_item(split_parent_loc)
# this only occurs if the parent was also awaiting adoption: skip this one, go to next
if any(new_locator == child.version_agnostic() for child in new_parent.children):
continue
......@@ -144,16 +158,16 @@ class SplitMigrator(object):
for old_child_loc in old_parent.children:
if old_child_loc == draft_location:
break # moved cursor enough, insert it here
sibling_loc = self.loc_mapper.translate_location(old_child_loc, False)
sibling_loc = new_draft_course_loc.make_usage_key(old_child_loc.category, old_child_loc.block_id)
# sibling may move cursor
for idx in range(new_parent_cursor, len(new_parent.children)):
if new_parent.children[idx].version_agnostic() == sibling_loc:
new_parent_cursor = idx + 1
break # skipped sibs enough, pick back up scan
new_parent.children.insert(new_parent_cursor, new_locator)
new_parent = self.split_modulestore.update_item(new_parent, user.id)
new_parent = self.split_modulestore.update_item(new_parent, user_id)
def _get_json_fields_translate_references(self, xblock, old_course_id, published):
def _get_json_fields_translate_references(self, xblock, new_course_key, course_block_id):
"""
Return the json repr for explicitly set fields but convert all references to their Locators
"""
......@@ -161,7 +175,10 @@ class SplitMigrator(object):
"""
Convert the location and add to loc mapper
"""
return self.loc_mapper.translate_location(location, published, add_entry_if_missing=True)
return new_course_key.make_usage_key(
location.category,
location.block_id if location.category != 'course' else course_block_id
)
result = {}
for field_name, field in xblock.fields.iteritems():
......@@ -183,7 +200,7 @@ class SplitMigrator(object):
return result
def _get_fields_translate_references(self, xblock, old_course_id, published):
def _get_fields_translate_references(self, xblock, new_course_key, course_block_id):
"""
Return a dictionary of field: value pairs for explicitly set fields
but convert all references to their BlockUsageLocators
......@@ -192,7 +209,10 @@ class SplitMigrator(object):
"""
Convert the location and add to loc mapper
"""
return self.loc_mapper.translate_location(location, published, add_entry_if_missing=True)
return new_course_key.make_usage_key(
location.category,
location.block_id if location.category != 'course' else course_block_id
)
result = {}
for field_name, field in xblock.fields.iteritems():
......
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
......
......@@ -6,7 +6,7 @@ Representation:
** '_id': a unique id which cannot change,
** 'org': the org's id. Only used for searching not identity,
** 'course': the course's catalog number
** 'run': the course's run id or whatever user decides,
** 'run': the course's run id,
** 'edited_by': user_id of user who created the original entry,
** 'edited_on': the datetime of the original creation,
** 'versions': versions_dict: {branch_id: structure_id, ...}
......@@ -53,7 +53,10 @@ from importlib import import_module
from path import path
import copy
from pytz import UTC
from bson.objectid import ObjectId
from xblock.core import XBlock
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
from xmodule.errortracker import null_error_tracker
from opaque_keys.edx.locator import (
BlockUsageLocator, DefinitionLocator, CourseLocator, VersionTree,
......@@ -61,19 +64,14 @@ from opaque_keys.edx.locator import (
)
from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError, DuplicateItemError, \
DuplicateCourseError
from xmodule.modulestore import (
inheritance, ModuleStoreWriteBase, ModuleStoreEnum
)
from xmodule.modulestore import inheritance, ModuleStoreWriteBase, ModuleStoreEnum, PublishState
from ..exceptions import ItemNotFoundError
from .definition_lazy_loader import DefinitionLazyLoader
from .caching_descriptor_system import CachingDescriptorSystem
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
from bson.objectid import ObjectId
from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection
from xblock.core import XBlock
from xmodule.modulestore.loc_mapper_store import LocMapperStore
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.split_mongo import encode_key_for_mongo, decode_key_from_mongo
log = logging.getLogger(__name__)
......@@ -110,7 +108,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
def __init__(self, contentstore, doc_store_config, fs_root, render_template,
default_class=None,
error_tracker=null_error_tracker,
loc_mapper=None,
i18n_service=None,
**kwargs):
"""
......@@ -118,8 +115,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
super(SplitMongoModuleStore, self).__init__(contentstore, **kwargs)
self.loc_mapper = loc_mapper
self.branch_setting_func = kwargs.pop('branch_setting_func', lambda: ModuleStoreEnum.Branch.published_only)
self.db_connection = MongoConnection(**doc_store_config)
self.db = self.db_connection.database
......@@ -267,7 +264,16 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param course_locator: any subclass of CourseLocator
'''
if course_locator.org and course_locator.course and course_locator.run and course_locator.branch:
if course_locator.org and course_locator.course and course_locator.run:
if course_locator.branch is None:
# default it based on branch_setting
# NAATODO move this to your mixin
if self.branch_setting_func() == ModuleStoreEnum.Branch.draft_preferred:
course_locator = course_locator.for_branch(ModuleStoreEnum.BranchName.draft)
elif self.branch_setting_func() == ModuleStoreEnum.Branch.published_only:
course_locator = course_locator.for_branch(ModuleStoreEnum.BranchName.published)
else:
raise InsufficientSpecificationError(course_locator)
# use the course id
index = self.db_connection.get_course_index(course_locator)
if index is None:
......@@ -491,9 +497,9 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if parent_id is None:
return None
return BlockUsageLocator.make_relative(
locator,
block_type=course['structure']['blocks'][parent_id].get('category'),
block_id=LocMapperStore.decode_key_from_mongo(parent_id),
locator,
block_type=course['structure']['blocks'][parent_id].get('category'),
block_id=decode_key_from_mongo(parent_id),
)
def get_orphans(self, course_key):
......@@ -502,13 +508,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
detached_categories = [name for name, __ in XBlock.load_tagged_classes("detached")]
course = self._lookup_course(course_key)
items = {LocMapperStore.decode_key_from_mongo(block_id) for block_id in course['structure']['blocks'].keys()}
items = {decode_key_from_mongo(block_id) for block_id in course['structure']['blocks'].keys()}
items.remove(course['structure']['root'])
blocks = course['structure']['blocks']
for block_id, block_data in blocks.iteritems():
items.difference_update(block_data.get('fields', {}).get('children', []))
if block_data['category'] in detached_categories:
items.discard(LocMapperStore.decode_key_from_mongo(block_id))
items.discard(decode_key_from_mongo(block_id))
return [
BlockUsageLocator(course_key=course_key, block_type=blocks[block_id]['category'], block_id=block_id)
for block_id in items
......@@ -816,7 +822,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# generate usage id
if block_id is not None:
if LocMapperStore.encode_key_for_mongo(block_id) in new_structure['blocks']:
if encode_key_for_mongo(block_id) in new_structure['blocks']:
raise DuplicateItemError(block_id, self, 'structures')
else:
new_block_id = block_id
......@@ -841,7 +847,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# if given parent, add new block as child and update parent's version
parent = None
if isinstance(course_or_parent_locator, BlockUsageLocator) and course_or_parent_locator.block_id is not None:
encoded_block_id = LocMapperStore.encode_key_for_mongo(course_or_parent_locator.block_id)
encoded_block_id = encode_key_for_mongo(course_or_parent_locator.block_id)
parent = new_structure['blocks'][encoded_block_id]
parent['fields'].setdefault('children', []).append(new_block_id)
if not continue_version or parent['edit_info']['update_version'] != structure['_id']:
......@@ -886,13 +892,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
super(SplitMongoModuleStore, self).clone_course(source_course_id, dest_course_id, user_id)
source_index = self.get_course_index_info(source_course_id)
return self.create_course(
dest_course_id.org, dest_course_id.offering, user_id, fields=None, # override start_date?
dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id, fields=None, # override start_date?
versions_dict=source_index['versions']
)
def create_course(
self, org, course, run, user_id, fields=None,
master_branch=ModuleStoreEnum.BranchName.draft, versions_dict=None, root_category='course',
master_branch=ModuleStoreEnum.BranchName.draft,
versions_dict=None, root_category='course',
root_block_id='course', **kwargs
):
"""
......@@ -984,7 +991,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if definition_fields or block_fields:
draft_structure = self._version_structure(draft_structure, user_id)
new_id = draft_structure['_id']
encoded_block_id = LocMapperStore.encode_key_for_mongo(draft_structure['root'])
encoded_block_id = encode_key_for_mongo(draft_structure['root'])
root_block = draft_structure['blocks'][encoded_block_id]
if block_fields is not None:
root_block['fields'].update(self._serialize_fields(root_category, block_fields))
......@@ -1179,12 +1186,12 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
block_id = getattr(xblock.scope_ids.usage_id.block_id, 'block_id', None)
if block_id is None:
block_id = self._generate_block_id(structure_blocks, xblock.category)
encoded_block_id = LocMapperStore.encode_key_for_mongo(block_id)
encoded_block_id = encode_key_for_mongo(block_id)
new_usage_id = xblock.scope_ids.usage_id.replace(block_id=block_id)
xblock.scope_ids = xblock.scope_ids._replace(usage_id=new_usage_id) # pylint: disable=protected-access
else:
is_new = False
encoded_block_id = LocMapperStore.encode_key_for_mongo(xblock.location.block_id)
encoded_block_id = encode_key_for_mongo(xblock.location.block_id)
children = []
if xblock.has_children:
......@@ -1370,7 +1377,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
Remove the subtree rooted at block_id
"""
encoded_block_id = LocMapperStore.encode_key_for_mongo(block_id)
encoded_block_id = encode_key_for_mongo(block_id)
for child in new_blocks[encoded_block_id]['fields'].get('children', []):
remove_subtree(child)
del new_blocks[encoded_block_id]
......@@ -1445,7 +1452,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
for child in block_fields.get('children', []):
try:
child = LocMapperStore.encode_key_for_mongo(child)
child = encode_key_for_mongo(child)
self.inherit_settings(block_map, block_map[child], inheriting_settings)
except KeyError:
# here's where we need logic for looking up in other structures when we allow cross pointers
......@@ -1460,7 +1467,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
(0 => this usage only, 1 => this usage and its children, etc...)
A depth of None returns all descendants
"""
encoded_block_id = LocMapperStore.encode_key_for_mongo(block_id)
encoded_block_id = encode_key_for_mongo(block_id)
if encoded_block_id not in block_map:
return descendent_map
......@@ -1509,7 +1516,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if 'fields' in block and 'children' in block['fields']:
block['fields']["children"] = [
block_id for block_id in block['fields']["children"]
if LocMapperStore.encode_key_for_mongo(block_id) in original_structure['blocks']
if encode_key_for_mongo(block_id) in original_structure['blocks']
]
self.db_connection.update_structure(original_structure)
# clear cache again b/c inheritance may be wrong over orphans
......@@ -1532,10 +1539,10 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
# if this was taken from cache, then its fields are already converted
if isinstance(block_id, BlockUsageLocator):
return block_id
return block_id.map_into_course(course_key)
try:
return course_key.make_usage_key(
blocks[LocMapperStore.encode_key_for_mongo(block_id)]['category'], block_id
blocks[encode_key_for_mongo(block_id)]['category'], block_id
)
except KeyError:
return course_key.make_usage_key('unknown', block_id)
......@@ -1569,7 +1576,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param continue_version: if True, assumes this operation requires a head version and will not create a new
version but instead continue an existing transaction on this version. This flag cannot be True if force is True.
"""
if locator.org is None or locator.course is None or locator. run is None or locator.branch is None:
if locator.org is None or locator.course is None or locator.run is None or locator.branch is None:
if continue_version:
raise InsufficientSpecificationError(
"To continue a version, the locator must point to one ({}).".format(locator)
......@@ -1647,7 +1654,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
]
elif isinstance(xblock_class.fields[field_name], ReferenceValueDict):
for key, subvalue in value.iteritems():
assert isinstance(subvalue, Location)
value[key] = subvalue.block_id
# I think these are obsolete conditions; so, I want to confirm that. Thus the warnings
......@@ -1668,7 +1674,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
new_id = ObjectId()
if root_category is not None:
encoded_root = LocMapperStore.encode_key_for_mongo(root_block_id)
encoded_root = encode_key_for_mongo(root_block_id)
blocks = {
encoded_root: self._new_block(
user_id, root_category, block_fields, definition_id, new_id
......@@ -1726,7 +1732,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
Return any newly discovered orphans (as a set)
"""
orphans = set()
encoded_block_id = LocMapperStore.encode_key_for_mongo(block_id)
encoded_block_id = encode_key_for_mongo(block_id)
destination_block = destination_blocks.get(encoded_block_id)
new_block = source_blocks[encoded_block_id]
if destination_block:
......@@ -1769,7 +1775,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
Delete the orphan and any of its descendants which no longer have parents.
"""
if self._get_parent_from_structure(orphan, structure) is None:
encoded_block_id = LocMapperStore.encode_key_for_mongo(orphan)
encoded_block_id = encode_key_for_mongo(orphan)
for child in structure['blocks'][encoded_block_id]['fields'].get('children', []):
self._delete_if_true_orphan(child, structure)
del structure['blocks'][encoded_block_id]
......@@ -1802,14 +1808,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
Encodes the block id before retrieving it from the structure to ensure it can
be a json dict key.
"""
return structure['blocks'].get(LocMapperStore.encode_key_for_mongo(block_id))
return structure['blocks'].get(encode_key_for_mongo(block_id))
def _update_block_in_structure(self, structure, block_id, content):
"""
Encodes the block id before accessing it in the structure to ensure it can
be a json dict key.
"""
structure['blocks'][LocMapperStore.encode_key_for_mongo(block_id)] = content
structure['blocks'][encode_key_for_mongo(block_id)] = content
def get_courses_for_wiki(self, wiki_slug):
"""
......@@ -1828,20 +1834,42 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
return {ModuleStoreEnum.Type.split: self.db_connection.heartbeat()}
def compute_publish_state(self, xblock):
"""
Returns whether this xblock is draft, public, or private.
Returns:
PublishState.draft - content is in the process of being edited, but still has a previous
version deployed to LMS
PublishState.public - content is locked and deployed to LMS
PublishState.private - content is editable and not deployed to LMS
"""
# TODO implement
raise NotImplementedError()
PublishState.draft - published exists and is different from draft
PublishState.public - published exists and is the same as draft
PublishState.private - no published version exists
"""
# TODO figure out what to say if xblock is not from the HEAD of its branch
def get_head(branch):
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch))['structure']
return self._get_block_from_structure(course_structure, xblock.location.block_id)
if xblock.location.branch is None:
raise ValueError(u'{} is not in a branch; so, this is nonsensical'.format(xblock.location))
if xblock.location.branch == ModuleStoreEnum.BranchName.draft:
other = get_head(ModuleStoreEnum.BranchName.published)
elif xblock.location.branch == ModuleStoreEnum.BranchName.published:
other = get_head(ModuleStoreEnum.BranchName.draft)
else:
raise ValueError(u'{} is not in a branch other than draft or published; so, this is nonsensical'.format(xblock.location))
if not other:
if xblock.location.branch == ModuleStoreEnum.BranchName.draft:
return PublishState.private
else:
return PublishState.public # a bit nonsensical
elif xblock.update_version != other['edit_info']['update_version']:
return PublishState.draft
else:
return PublishState.public
def convert_to_draft(self, location, user_id):
def convert_to_draft(self, location, user_id):
"""
Create a copy of the source and mark its revision as draft.
......
......@@ -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