Commit 878b9124 by Awais Jibran Committed by GitHub

Merge pull request #13111 from edx/aj/ECOM4890-make-studio-case-insensitive-for-create-course

Make studio case-insensitive for course keys for creating courses.
parents 220cf1b4 4bc5ded7
...@@ -61,3 +61,35 @@ class TestCreateCourse(ModuleStoreTestCase): ...@@ -61,3 +61,35 @@ class TestCreateCourse(ModuleStoreTestCase):
) )
# pylint: disable=protected-access # pylint: disable=protected-access
self.assertEqual(store, modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type()) self.assertEqual(store, modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type())
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_get_course_with_different_case(self, default_store):
"""
Tests that course can not be accessed with different case.
Scenario:
Create a course with lower case keys inside `bulk_operations` with `ignore_case=True`.
Verify that course is created.
Verify that get course from store using same course id but different case is not accessible.
"""
org = 'org1'
number = 'course1'
run = 'run1'
with self.store.default_store(default_store):
lowercase_course_id = self.store.make_course_key(org, number, run)
with self.store.bulk_operations(lowercase_course_id, ignore_case=True):
# Create course with lowercase key & Verify that store returns course.
self.store.create_course(
lowercase_course_id.org,
lowercase_course_id.course,
lowercase_course_id.run,
self.user.id
)
course = self.store.get_course(lowercase_course_id)
self.assertIsNotNone(course, 'Course not found using lowercase course key.')
self.assertEqual(unicode(course.id), unicode(lowercase_course_id))
# Verify store does not return course with different case.
uppercase_course_id = self.store.make_course_key(org.upper(), number.upper(), run.upper())
course = self.store.get_course(uppercase_course_id)
self.assertIsNone(course, 'Course should not be accessed with uppercase course id.')
...@@ -1151,6 +1151,9 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1151,6 +1151,9 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
""" """
Tests for the CMS ContentStore application. Tests for the CMS ContentStore application.
""" """
duplicate_course_error = ("There is already a course defined with the same organization and course number. "
"Please change either organization or course number to be unique.")
def setUp(self): def setUp(self):
super(ContentStoreTest, self).setUp() super(ContentStoreTest, self).setUp()
...@@ -1203,6 +1206,22 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1203,6 +1206,22 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
self.course_data['run'] = 'run.name' self.course_data['run'] = 'run.name'
self.assert_created_course() self.assert_created_course()
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_course_with_different_cases(self, default_store):
"""
Tests that course can not be created with different case using an AJAX request to
course handler.
"""
course_number = '99x'
with self.store.default_store(default_store):
# Verify create a course passes with lower case.
self.course_data['number'] = course_number.lower()
self.assert_created_course()
# Verify create a course fail when same course number is provided with different case.
self.course_data['number'] = course_number.upper()
self.assert_course_creation_failed(self.duplicate_course_error)
def test_create_course_check_forum_seeding(self): def test_create_course_check_forum_seeding(self):
"""Test new course creation and verify forum seeding """ """Test new course creation and verify forum seeding """
test_course_data = self.assert_created_course(number_suffix=uuid4().hex) test_course_data = self.assert_created_course(number_suffix=uuid4().hex)
...@@ -1289,7 +1308,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1289,7 +1308,7 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
def test_create_course_duplicate_course(self): def test_create_course_duplicate_course(self):
"""Test new course creation - error path""" """Test new course creation - error path"""
self.client.ajax_post('/course/', self.course_data) self.client.ajax_post('/course/', self.course_data)
self.assert_course_creation_failed('There is already a course defined with the same organization and course number. Please change either organization or course number to be unique.') self.assert_course_creation_failed(self.duplicate_course_error)
def assert_course_creation_failed(self, error_message): def assert_course_creation_failed(self, error_message):
""" """
...@@ -1318,21 +1337,38 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1318,21 +1337,38 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
self.course_data['display_name'] = 'Robot Super Course Two' self.course_data['display_name'] = 'Robot Super Course Two'
self.course_data['run'] = '2013_Summer' self.course_data['run'] = '2013_Summer'
self.assert_course_creation_failed('There is already a course defined with the same organization and course number. Please change either organization or course number to be unique.') self.assert_course_creation_failed(self.duplicate_course_error)
def test_create_course_case_change(self): @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_create_course_case_change(self, default_store):
"""Test new course creation - error path due to case insensitive name equality""" """Test new course creation - error path due to case insensitive name equality"""
self.course_data['number'] = 'capital' self.course_data['number'] = '99x'
self.client.ajax_post('/course/', self.course_data)
cache_current = self.course_data['org']
self.course_data['org'] = self.course_data['org'].lower()
self.assert_course_creation_failed('There is already a course defined with the same organization and course number. Please change either organization or course number to be unique.')
self.course_data['org'] = cache_current
self.client.ajax_post('/course/', self.course_data) with self.store.default_store(default_store):
cache_current = self.course_data['number']
self.course_data['number'] = self.course_data['number'].upper() # Verify that the course was created properly.
self.assert_course_creation_failed('There is already a course defined with the same organization and course number. Please change either organization or course number to be unique.') self.assert_created_course()
# Keep the copy of original org
cache_current = self.course_data['org']
# Change `org` to lower case and verify that course did not get created
self.course_data['org'] = self.course_data['org'].lower()
self.assert_course_creation_failed(self.duplicate_course_error)
# Replace the org with its actual value, and keep the copy of course number.
self.course_data['org'] = cache_current
cache_current = self.course_data['number']
self.course_data['number'] = self.course_data['number'].upper()
self.assert_course_creation_failed(self.duplicate_course_error)
# Replace the org with its actual value, and keep the copy of course number.
self.course_data['number'] = cache_current
__ = self.course_data['run']
self.course_data['run'] = self.course_data['run'].upper()
self.assert_course_creation_failed(self.duplicate_course_error)
def test_course_substring(self): def test_course_substring(self):
""" """
......
...@@ -178,7 +178,7 @@ class BulkOperationsMixin(object): ...@@ -178,7 +178,7 @@ class BulkOperationsMixin(object):
self.signal_handler = None self.signal_handler = None
@contextmanager @contextmanager
def bulk_operations(self, course_id, emit_signals=True): def bulk_operations(self, course_id, emit_signals=True, ignore_case=False):
""" """
A context manager for notifying the store of bulk operations. This affects only the current thread. A context manager for notifying the store of bulk operations. This affects only the current thread.
...@@ -186,10 +186,10 @@ class BulkOperationsMixin(object): ...@@ -186,10 +186,10 @@ class BulkOperationsMixin(object):
until the bulk operation is completed. until the bulk operation is completed.
""" """
try: try:
self._begin_bulk_operation(course_id) self._begin_bulk_operation(course_id, ignore_case)
yield yield
finally: finally:
self._end_bulk_operation(course_id, emit_signals) self._end_bulk_operation(course_id, emit_signals, ignore_case)
# the relevant type of bulk_ops_record for the mixin (overriding classes should override # the relevant type of bulk_ops_record for the mixin (overriding classes should override
# this variable) # this variable)
...@@ -206,10 +206,10 @@ class BulkOperationsMixin(object): ...@@ -206,10 +206,10 @@ class BulkOperationsMixin(object):
if ignore_case: if ignore_case:
for key, record in self._active_bulk_ops.records.iteritems(): for key, record in self._active_bulk_ops.records.iteritems():
# Shortcut: check basic equivalence for cases where org/course/run might be None. # Shortcut: check basic equivalence for cases where org/course/run might be None.
if key == course_key or ( if (key == course_key) or (
key.org.lower() == course_key.org.lower() and (key.org and key.org.lower() == course_key.org.lower()) and
key.course.lower() == course_key.course.lower() and (key.course and key.course.lower() == course_key.course.lower()) and
key.run.lower() == course_key.run.lower() (key.run and key.run.lower() == course_key.run.lower())
): ):
return record return record
...@@ -231,7 +231,7 @@ class BulkOperationsMixin(object): ...@@ -231,7 +231,7 @@ class BulkOperationsMixin(object):
if course_key.for_branch(None) in self._active_bulk_ops.records: if course_key.for_branch(None) in self._active_bulk_ops.records:
del self._active_bulk_ops.records[course_key.for_branch(None)] del self._active_bulk_ops.records[course_key.for_branch(None)]
def _start_outermost_bulk_operation(self, bulk_ops_record, course_key): def _start_outermost_bulk_operation(self, bulk_ops_record, course_key, ignore_case=False):
""" """
The outermost nested bulk_operation call: do the actual begin of the bulk operation. The outermost nested bulk_operation call: do the actual begin of the bulk operation.
...@@ -239,11 +239,11 @@ class BulkOperationsMixin(object): ...@@ -239,11 +239,11 @@ class BulkOperationsMixin(object):
""" """
pass pass
def _begin_bulk_operation(self, course_key): def _begin_bulk_operation(self, course_key, ignore_case=False):
""" """
Begin a bulk operation on course_key. Begin a bulk operation on course_key.
""" """
bulk_ops_record = self._get_bulk_ops_record(course_key) bulk_ops_record = self._get_bulk_ops_record(course_key, ignore_case)
# Increment the number of active bulk operations (bulk operations # Increment the number of active bulk operations (bulk operations
# on the same course can be nested) # on the same course can be nested)
...@@ -251,7 +251,7 @@ class BulkOperationsMixin(object): ...@@ -251,7 +251,7 @@ class BulkOperationsMixin(object):
# If this is the highest level bulk operation, then initialize it # If this is the highest level bulk operation, then initialize it
if bulk_ops_record.is_root: if bulk_ops_record.is_root:
self._start_outermost_bulk_operation(bulk_ops_record, course_key) self._start_outermost_bulk_operation(bulk_ops_record, course_key, ignore_case)
def _end_outermost_bulk_operation(self, bulk_ops_record, structure_key): def _end_outermost_bulk_operation(self, bulk_ops_record, structure_key):
""" """
...@@ -261,12 +261,12 @@ class BulkOperationsMixin(object): ...@@ -261,12 +261,12 @@ class BulkOperationsMixin(object):
""" """
pass pass
def _end_bulk_operation(self, structure_key, emit_signals=True): def _end_bulk_operation(self, structure_key, emit_signals=True, ignore_case=False):
""" """
End the active bulk operation on structure_key (course or library key). End the active bulk operation on structure_key (course or library key).
""" """
# If no bulk op is active, return # If no bulk op is active, return
bulk_ops_record = self._get_bulk_ops_record(structure_key) bulk_ops_record = self._get_bulk_ops_record(structure_key, ignore_case)
if not bulk_ops_record.active: if not bulk_ops_record.active:
return return
...@@ -1017,7 +1017,7 @@ class ModuleStoreRead(ModuleStoreAssetBase): ...@@ -1017,7 +1017,7 @@ class ModuleStoreRead(ModuleStoreAssetBase):
pass pass
@contextmanager @contextmanager
def bulk_operations(self, course_id, emit_signals=True): # pylint: disable=unused-argument def bulk_operations(self, course_id, emit_signals=True, ignore_case=False): # pylint: disable=unused-argument
""" """
A context manager for notifying the store of bulk operations. This affects only the current thread. A context manager for notifying the store of bulk operations. This affects only the current thread.
""" """
......
...@@ -984,13 +984,13 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -984,13 +984,13 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
yield yield
@contextmanager @contextmanager
def bulk_operations(self, course_id, emit_signals=True): def bulk_operations(self, course_id, emit_signals=True, ignore_case=False):
""" """
A context manager for notifying the store of bulk operations. A context manager for notifying the store of bulk operations.
If course_id is None, the default store is used. If course_id is None, the default store is used.
""" """
store = self._get_modulestore_for_courselike(course_id) store = self._get_modulestore_for_courselike(course_id)
with store.bulk_operations(course_id, emit_signals): with store.bulk_operations(course_id, emit_signals, ignore_case):
yield yield
def ensure_indexes(self): def ensure_indexes(self):
......
...@@ -482,7 +482,7 @@ class MongoBulkOpsMixin(BulkOperationsMixin): ...@@ -482,7 +482,7 @@ class MongoBulkOpsMixin(BulkOperationsMixin):
""" """
_bulk_ops_record_type = MongoBulkOpsRecord _bulk_ops_record_type = MongoBulkOpsRecord
def _start_outermost_bulk_operation(self, bulk_ops_record, course_key): def _start_outermost_bulk_operation(self, bulk_ops_record, course_key, ignore_case=False):
""" """
Prevent updating the meta-data inheritance cache for the given course Prevent updating the meta-data inheritance cache for the given course
""" """
......
...@@ -226,11 +226,11 @@ class SplitBulkWriteMixin(BulkOperationsMixin): ...@@ -226,11 +226,11 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
course_key.replace(org=None, course=None, run=None, branch=None) course_key.replace(org=None, course=None, run=None, branch=None)
] ]
def _start_outermost_bulk_operation(self, bulk_write_record, course_key): def _start_outermost_bulk_operation(self, bulk_write_record, course_key, ignore_case=False):
""" """
Begin a bulk write operation on course_key. Begin a bulk write operation on course_key.
""" """
bulk_write_record.initial_index = self.db_connection.get_course_index(course_key) bulk_write_record.initial_index = self.db_connection.get_course_index(course_key, ignore_case=ignore_case)
# Ensure that any edits to the index don't pollute the initial_index # Ensure that any edits to the index don't pollute the initial_index
bulk_write_record.index = copy.deepcopy(bulk_write_record.initial_index) bulk_write_record.index = copy.deepcopy(bulk_write_record.initial_index)
bulk_write_record.course_key = course_key bulk_write_record.course_key = course_key
...@@ -1884,7 +1884,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -1884,7 +1884,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
""" """
Internal code for creating a course or library Internal code for creating a course or library
""" """
index = self.get_course_index(locator) index = self.get_course_index(locator, ignore_case=True)
if index is not None: if index is not None:
raise DuplicateCourseError(locator, index) raise DuplicateCourseError(locator, index)
......
...@@ -33,7 +33,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli ...@@ -33,7 +33,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
Returns: a CourseDescriptor Returns: a CourseDescriptor
""" """
master_branch = kwargs.pop('master_branch', ModuleStoreEnum.BranchName.draft) master_branch = kwargs.pop('master_branch', ModuleStoreEnum.BranchName.draft)
with self.bulk_operations(CourseLocator(org, course, run)): with self.bulk_operations(CourseLocator(org, course, run), ignore_case=True):
item = super(DraftVersioningModuleStore, self).create_course( item = super(DraftVersioningModuleStore, self).create_course(
org, course, run, user_id, master_branch=master_branch, **kwargs org, course, run, user_id, master_branch=master_branch, **kwargs
) )
......
...@@ -357,6 +357,18 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): ...@@ -357,6 +357,18 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
with self.assertRaises(DuplicateCourseError): with self.assertRaises(DuplicateCourseError):
self.store.create_course('org_x', 'course_y', 'run_z', self.user_id) self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_duplicate_course_error_with_different_case_ids(self, default_store):
"""
Verify that course can not be created with same course_id with different case.
"""
self._initialize_mixed(mappings={})
with self.store.default_store(default_store):
self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
with self.assertRaises(DuplicateCourseError):
self.store.create_course('ORG_X', 'COURSE_Y', 'RUN_Z', self.user_id)
# Draft: # Draft:
# problem: One lookup to locate an item that exists # problem: One lookup to locate an item that exists
# fake: one w/ wildcard version # fake: one w/ wildcard version
......
...@@ -704,7 +704,7 @@ class TestBulkWriteMixinOpen(TestBulkWriteMixin): ...@@ -704,7 +704,7 @@ class TestBulkWriteMixinOpen(TestBulkWriteMixin):
from_index=self.conn.get_course_index.return_value, from_index=self.conn.get_course_index.return_value,
course_context=self.course_key, course_context=self.course_key,
) )
self.conn.get_course_index.assert_called_once_with(self.course_key) self.conn.get_course_index.assert_called_once_with(self.course_key, ignore_case=False)
class TestBulkWriteMixinOpenAfterPrevTransaction(TestBulkWriteMixinOpen, TestBulkWriteMixinPreviousTransaction): class TestBulkWriteMixinOpenAfterPrevTransaction(TestBulkWriteMixinOpen, TestBulkWriteMixinPreviousTransaction):
......
...@@ -296,8 +296,8 @@ class CCXModulestoreWrapper(object): ...@@ -296,8 +296,8 @@ class CCXModulestoreWrapper(object):
yield yield
@contextmanager @contextmanager
def bulk_operations(self, course_id, emit_signals=True): def bulk_operations(self, course_id, emit_signals=True, ignore_case=False):
"""See the docs for xmodule.modulestore.mixed.MixedModuleStore""" """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_id, _ = strip_ccx(course_id) course_id, _ = strip_ccx(course_id)
with self._modulestore.bulk_operations(course_id, emit_signals=emit_signals): with self._modulestore.bulk_operations(course_id, emit_signals=emit_signals, ignore_case=ignore_case):
yield yield
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