From 7987b16a181da57b988c276de50369bfbf8fa8c5 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Mon, 21 Dec 2015 12:28:59 -0500 Subject: [PATCH 001/105] Remove XML modulestore code - discovery work --- .../tests/test_courseware_index.py | 9 ----- .../lib/xmodule/xmodule/modulestore/mixed.py | 11 +------ .../xmodule/modulestore/tests/django_utils.py | 25 -------------- .../tests/test_mixed_modulestore.py | 9 ----- .../courseware/tests/test_module_render.py | 33 ++----------------- lms/djangoapps/courseware/tests/tests.py | 19 ----------- lms/envs/common.py | 8 ----- 7 files changed, 3 insertions(+), 111 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_courseware_index.py b/cms/djangoapps/contentstore/tests/test_courseware_index.py index 6e3191c27e..155b5956d3 100644 --- a/cms/djangoapps/contentstore/tests/test_courseware_index.py +++ b/cms/djangoapps/contentstore/tests/test_courseware_index.py @@ -124,15 +124,6 @@ class MixedWithOptionsTestCase(MixedSplitTestCase): 'DOC_STORE_CONFIG': DOC_STORE_CONFIG, 'OPTIONS': modulestore_options }, - { - 'NAME': 'xml', - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': DATA_DIR, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - 'xblock_mixins': modulestore_options['xblock_mixins'], - } - }, ], 'xblock_mixins': modulestore_options['xblock_mixins'], } diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index adceb85aa6..78eb0fd36c 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -1,7 +1,7 @@ """ MixedModuleStore allows for aggregation between multiple modulestores. -In this way, courses can be served up both - say - XMLModuleStore or MongoModuleStore +In this way, courses can be served up via either SplitMongoModuleStore or MongoModuleStore. """ @@ -169,15 +169,6 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): for store_settings in stores: key = store_settings['NAME'] - is_xml = 'XMLModuleStore' in store_settings['ENGINE'] - if is_xml: - # restrict xml to only load courses in mapping - store_settings['OPTIONS']['course_ids'] = [ - course_key.to_deprecated_string() - for course_key, store_key in self.mappings.iteritems() - if store_key == key - ] - store = create_modulestore_instance( store_settings['ENGINE'], self.contentstore, diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 5d5859fb90..101e909e96 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -140,28 +140,6 @@ def split_mongo_store_config(data_dir): return store -def xml_store_config(data_dir, source_dirs=None): - """ - Defines default module store using XMLModuleStore. - - Note: you should pass in a list of source_dirs that you care about, - otherwise all courses in the data_dir will be processed. - """ - store = { - 'default': { - 'NAME': 'xml', - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - 'source_dirs': source_dirs, - } - } - } - - return store - - @patch('xmodule.modulestore.django.create_modulestore_instance', autospec=True) def drop_mongo_collections(mock_create): """ @@ -180,9 +158,6 @@ def drop_mongo_collections(mock_create): TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -# This is an XML only modulestore with only the toy course loaded -TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR, source_dirs=['toy']) - # This modulestore will provide both a mixed mongo editable modulestore, and # an XML store with just the toy course loaded. TEST_DATA_MIXED_TOY_MODULESTORE = mixed_store_config( diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 63cad4a892..42f3559408 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -110,15 +110,6 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): 'DOC_STORE_CONFIG': DOC_STORE_CONFIG, 'OPTIONS': modulestore_options }, - { - 'NAME': ModuleStoreEnum.Type.xml, - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': DATA_DIR, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - 'xblock_mixins': modulestore_options['xblock_mixins'], - } - }, ], 'xblock_mixins': modulestore_options['xblock_mixins'], } diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 0c1224667d..ff8a7e0b90 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -44,8 +44,8 @@ from openedx.core.lib.gating import api as gating_api from student.models import anonymous_id_for_user from xmodule.modulestore.tests.django_utils import ( TEST_DATA_MIXED_TOY_MODULESTORE, - TEST_DATA_XML_MODULESTORE, - SharedModuleStoreTestCase) + SharedModuleStoreTestCase +) from xmodule.lti_module import LTIDescriptor from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -1369,17 +1369,6 @@ class ViewInStudioTest(ModuleStoreTestCase): # pylint: disable=attribute-defined-outside-init self.child_module = self._get_module(course.id, child_descriptor, child_descriptor.location) - def setup_xml_course(self): - """ - Define the XML backed course to use. - Toy courses are already loaded in XML and mixed modulestores. - """ - course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') - location = course_key.make_usage_key('chapter', 'Overview') - descriptor = modulestore().get_item(location) - - self.module = self._get_module(course_key, descriptor, location) - @attr('shard_1') class MongoViewInStudioTest(ViewInStudioTest): @@ -1428,24 +1417,6 @@ class MixedViewInStudioTest(ViewInStudioTest): result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context) self.assertNotIn('View Unit in Studio', result_fragment.content) - def test_view_in_studio_link_xml_backed(self): - """Course in XML only modulestore should not see 'View in Studio' links.""" - self.setup_xml_course() - result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context) - self.assertNotIn('View Unit in Studio', result_fragment.content) - - -@attr('shard_1') -class XmlViewInStudioTest(ViewInStudioTest): - """Test the 'View in Studio' link visibility in an xml backed course.""" - MODULESTORE = TEST_DATA_XML_MODULESTORE - - def test_view_in_studio_link_xml_backed(self): - """Course in XML only modulestore should not see 'View in Studio' links.""" - self.setup_xml_course() - result_fragment = self.module.render(STUDENT_VIEW) - self.assertNotIn('View Unit in Studio', result_fragment.content) - @XBlock.tag("detached") class DetachedXBlock(XBlock): diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 85cb97b423..32e658d9aa 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -10,7 +10,6 @@ from nose.plugins.attrib import attr from opaque_keys.edx.locations import SlashSeparatedCourseKey from courseware.tests.helpers import LoginEnrollmentTestCase -from xmodule.modulestore.tests.django_utils import TEST_DATA_XML_MODULESTORE as XML_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE as TOY_MODULESTORE from lms.djangoapps.lms_xblock.field_data import LmsFieldData from xmodule.error_module import ErrorDescriptor @@ -122,24 +121,6 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self.assertNotIsInstance(descriptor, ErrorDescriptor) -@attr('shard_1') -class TestXmlCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): - """ - Check that all pages in test courses load properly from XML. - """ - MODULESTORE = XML_MODULESTORE - - def setUp(self): - super(TestXmlCoursesLoad, self).setUp() - self.setup_user() - - def test_toy_course_loads(self): - # Load one of the XML based courses - # Our test mapping rules allow the MixedModuleStore - # to load this course from XML, not Mongo. - self.check_all_pages_load(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')) - - @attr('shard_1') class TestMongoCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): """ diff --git a/lms/envs/common.py b/lms/envs/common.py index 8da0003274..ebe48f03b0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -739,14 +739,6 @@ MODULESTORE = { 'fs_root': DATA_DIR, 'render_template': 'edxmako.shortcuts.render_to_string', } - }, - { - 'NAME': 'xml', - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': DATA_DIR, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - } } ] } From 0aa9f9062becdf995e9f6942ccea5c5604e6564e Mon Sep 17 00:00:00 2001 From: John Eskew Date: Mon, 21 Dec 2015 14:29:26 -0500 Subject: [PATCH 002/105] Remove more XML modulestore test code. --- .../xmodule/modulestore/tests/django_utils.py | 5 +- .../tests/test_mixed_modulestore.py | 162 ++---------------- 2 files changed, 12 insertions(+), 155 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 101e909e96..861e03fe23 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -29,13 +29,13 @@ from openedx.core.djangoapps.bookmarks.signals import trigger_update_xblocks_cac class StoreConstructors(object): """Enumeration of store constructor types.""" - draft, split, xml = range(3) + draft, split = range(2) def mixed_store_config(data_dir, mappings, include_xml=False, xml_source_dirs=None, store_order=None): """ Return a `MixedModuleStore` configuration, which provides - access to both Mongo- and XML-backed courses. + access to both Mongo-backed courses. Args: data_dir (string): the directory from which to load XML-backed courses. @@ -70,7 +70,6 @@ def mixed_store_config(data_dir, mappings, include_xml=False, xml_source_dirs=No store_constructors = { StoreConstructors.split: split_mongo_store_config(data_dir)['default'], StoreConstructors.draft: draft_mongo_store_config(data_dir)['default'], - StoreConstructors.xml: xml_store_config(data_dir, source_dirs=xml_source_dirs)['default'], } store = { diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 42f3559408..585eb8ba25 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -74,9 +74,6 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): RENDER_TEMPLATE = lambda t_n, d, ctx=None, nsp='main': '' MONGO_COURSEID = 'MITx/999/2013_Spring' - XML_COURSEID1 = 'edX/toy/2012_Fall' - XML_COURSEID2 = 'edX/simple/2012_Fall' - BAD_COURSE_ID = 'edX/simple' modulestore_options = { 'default_class': DEFAULT_CLASS, @@ -91,11 +88,7 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): 'collection': COLLECTION, 'asset_collection': ASSET_COLLECTION, } - MAPPINGS = { - XML_COURSEID1: 'xml', - XML_COURSEID2: 'xml', - BAD_COURSE_ID: 'xml', - } + MAPPINGS = {} OPTIONS = { 'stores': [ { @@ -148,7 +141,7 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): self.addTypeEqualityFunc(BlockUsageLocator, '_compare_ignore_version') self.addTypeEqualityFunc(CourseLocator, '_compare_ignore_version') # define attrs which get set in initdb to quell pylint - self.writable_chapter_location = self.store = self.fake_location = self.xml_chapter_location = None + self.writable_chapter_location = self.store = self.fake_location = None self.course_locations = {} self.user_id = ModuleStoreEnum.UserID.test @@ -275,7 +268,7 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): # convert to CourseKeys self.course_locations = { course_id: CourseLocator.from_string(course_id) - for course_id in [self.MONGO_COURSEID, self.XML_COURSEID1, self.XML_COURSEID2] + for course_id in [self.MONGO_COURSEID] } # and then to the root UsageKey self.course_locations = { @@ -286,10 +279,6 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): mongo_course_key = self.course_locations[self.MONGO_COURSEID].course_key self.fake_location = self.store.make_course_key(mongo_course_key.org, mongo_course_key.course, mongo_course_key.run).make_usage_key('vertical', 'fake') - self.xml_chapter_location = self.course_locations[self.XML_COURSEID1].replace( - category='chapter', name='Overview' - ) - self._create_course(self.course_locations[self.MONGO_COURSEID].course_key) self.assertEquals(default, self.store.get_modulestore_type(self.course.id)) @@ -337,12 +326,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): Make sure we get back the store type we expect for given mappings """ self.initdb(default_ms) - self.assertEqual(self.store.get_modulestore_type( - self._course_key_from_string(self.XML_COURSEID1)), ModuleStoreEnum.Type.xml - ) - self.assertEqual(self.store.get_modulestore_type( - self._course_key_from_string(self.XML_COURSEID2)), ModuleStoreEnum.Type.xml - ) self.assertEqual(self.store.get_modulestore_type( self._course_key_from_string(self.MONGO_COURSEID)), default_ms ) @@ -392,15 +375,10 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): self.initdb(default_ms) self._create_block_hierarchy() - self.assertTrue(self.store.has_item(self.course_locations[self.XML_COURSEID1])) - with check_mongo_calls(max_find.pop(0), max_send): self.assertTrue(self.store.has_item(self.problem_x1a_1)) # try negative cases - self.assertFalse(self.store.has_item( - self.course_locations[self.XML_COURSEID1].replace(name='not_findable', category='problem') - )) with check_mongo_calls(max_find.pop(0), max_send): self.assertFalse(self.store.has_item(self.fake_location)) @@ -420,16 +398,10 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): self.initdb(default_ms) self._create_block_hierarchy() - self.assertIsNotNone(self.store.get_item(self.course_locations[self.XML_COURSEID1])) - with check_mongo_calls(max_find.pop(0), max_send): self.assertIsNotNone(self.store.get_item(self.problem_x1a_1)) # try negative cases - with self.assertRaises(ItemNotFoundError): - self.store.get_item( - self.course_locations[self.XML_COURSEID1].replace(name='not_findable', category='problem') - ) with check_mongo_calls(max_find.pop(0), max_send): with self.assertRaises(ItemNotFoundError): self.store.get_item(self.fake_location) @@ -448,12 +420,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): self.initdb(default_ms) self._create_block_hierarchy() - course_locn = self.course_locations[self.XML_COURSEID1] - # NOTE: use get_course if you just want the course. get_items is expensive - modules = self.store.get_items(course_locn.course_key, qualifiers={'category': 'course'}) - self.assertEqual(len(modules), 1) - self.assertEqual(modules[0].location, course_locn) - course_locn = self.course_locations[self.MONGO_COURSEID] with check_mongo_calls(max_find, max_send): modules = self.store.get_items(course_locn.course_key, qualifiers={'category': 'problem'}) @@ -536,18 +502,10 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): @ddt.unpack def test_update_item(self, default_ms, max_find, max_send): """ - Update should fail for r/o dbs and succeed for r/w ones + Update should succeed for r/w dbs """ self.initdb(default_ms) self._create_block_hierarchy() - course = self.store.get_course(self.course_locations[self.XML_COURSEID1].course_key) - # if following raised, then the test is really a noop, change it - self.assertFalse(course.show_calculator, "Default changed making test meaningless") - course.show_calculator = True - with self.assertRaises(NotImplementedError): # ensure it doesn't allow writing - self.store.update_item(course, self.user_id) - - # now do it for a r/w db problem = self.store.get_item(self.problem_x1a_1) # if following raised, then the test is really a noop, change it self.assertNotEqual(problem.max_attempts, 2, "Default changed making test meaningless") @@ -944,10 +902,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): if default_ms == ModuleStoreEnum.Type.mongo and mongo_uses_error_check(self.store): max_find += 1 - # r/o try deleting the chapter (is here to ensure it can't be deleted) - with self.assertRaises(NotImplementedError): - self.store.delete_item(self.xml_chapter_location, self.user_id) - with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.writable_chapter_location.course_key): with check_mongo_calls(max_find, max_send): self.store.delete_item(self.writable_chapter_location, self.user_id) @@ -1066,14 +1020,12 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): @ddt.unpack def test_get_courses(self, default_ms, max_find, max_send): self.initdb(default_ms) - # we should have 3 total courses across all stores + # we should have one course across all stores with check_mongo_calls(max_find, max_send): courses = self.store.get_courses() course_ids = [course.location for course in courses] - self.assertEqual(len(courses), 3, "Not 3 courses: {}".format(course_ids)) + self.assertEqual(len(courses), 1, "Not one course: {}".format(course_ids)) self.assertIn(self.course_locations[self.MONGO_COURSEID], course_ids) - self.assertIn(self.course_locations[self.XML_COURSEID1], course_ids) - self.assertIn(self.course_locations[self.XML_COURSEID2], course_ids) with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): draft_courses = self.store.get_courses(remove_branch=True) @@ -1102,30 +1054,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): mongo_course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key) self.assertEqual(len(mongo_course.children), 1) - def test_xml_get_courses(self): - """ - Test that the xml modulestore only loaded the courses from the maps. - """ - self.initdb(ModuleStoreEnum.Type.mongo) - xml_store = self.store._get_modulestore_by_type(ModuleStoreEnum.Type.xml) # pylint: disable=protected-access - courses = xml_store.get_courses() - self.assertEqual(len(courses), 2) - course_ids = [course.id for course in courses] - self.assertIn(self.course_locations[self.XML_COURSEID1].course_key, course_ids) - self.assertIn(self.course_locations[self.XML_COURSEID2].course_key, course_ids) - # this course is in the directory from which we loaded courses but not in the map - self.assertNotIn("edX/toy/TT_2012_Fall", course_ids) - - def test_xml_no_write(self): - """ - Test that the xml modulestore doesn't allow write ops. - """ - self.initdb(ModuleStoreEnum.Type.mongo) - xml_store = self.store._get_modulestore_by_type(ModuleStoreEnum.Type.xml) # pylint: disable=protected-access - # the important thing is not which exception it raises but that it raises an exception - with self.assertRaises(AttributeError): - xml_store.create_course("org", "course", "run", self.user_id) - # draft is 2: find out which ms owns course, get item # split: active_versions, structure, definition (to load course wiki string) @ddt.data((ModuleStoreEnum.Type.mongo, 2, 0), (ModuleStoreEnum.Type.split, 3, 0)) @@ -1140,9 +1068,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): course = self.store.get_item(self.course_locations[self.MONGO_COURSEID]) self.assertEqual(course.id, self.course_locations[self.MONGO_COURSEID].course_key) - course = self.store.get_item(self.course_locations[self.XML_COURSEID1]) - self.assertEqual(course.id, self.course_locations[self.XML_COURSEID1].course_key) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_get_library(self, default_ms): """ @@ -1181,9 +1106,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): parent = self.store.get_parent_location(self.problem_x1a_1) self.assertEqual(parent, self.vertical_x1a) - parent = self.store.get_parent_location(self.xml_chapter_location) - self.assertEqual(parent, self.course_locations[self.XML_COURSEID1]) - def verify_get_parent_locations_results(self, expected_results): """ Verifies the results of calling get_parent_locations matches expected_results. @@ -1364,34 +1286,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): with self.assertRaises(NoPathToItem): path_to_location(self.store, orphan) - def test_xml_path_to_location(self): - """ - Make sure that path_to_location works: should be passed a modulestore - with the toy and simple courses loaded. - """ - # only needs course_locations set - self.initdb(ModuleStoreEnum.Type.mongo) - course_key = self.course_locations[self.XML_COURSEID1].course_key - video_key = course_key.make_usage_key('video', 'Welcome') - chapter_key = course_key.make_usage_key('chapter', 'Overview') - should_work = ( - (video_key, - (course_key, "Overview", "Welcome", None, None, video_key)), - (chapter_key, - (course_key, "Overview", None, None, None, chapter_key)), - ) - - for location, expected in should_work: - self.assertEqual(path_to_location(self.store, location), expected) - - not_found = ( - course_key.make_usage_key('video', 'WelcomeX'), - course_key.make_usage_key('course', 'NotHome'), - ) - for location in not_found: - with self.assertRaises(ItemNotFoundError): - path_to_location(self.store, location) - def test_navigation_index(self): """ Make sure that navigation_index correctly parses the various position values that we might get from calls to @@ -1643,15 +1537,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): Test the get_courses_for_wiki method """ self.initdb(default_ms) - # Test XML wikis - wiki_courses = self.store.get_courses_for_wiki('toy') - self.assertEqual(len(wiki_courses), 1) - self.assertIn(self.course_locations[self.XML_COURSEID1].course_key, wiki_courses) - - wiki_courses = self.store.get_courses_for_wiki('simple') - self.assertEqual(len(wiki_courses), 1) - self.assertIn(self.course_locations[self.XML_COURSEID2].course_key, wiki_courses) - # Test Mongo wiki with check_mongo_calls(max_find, max_send): wiki_courses = self.store.get_courses_for_wiki('999') @@ -1986,14 +1871,13 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): wiki_courses = self.store.get_courses_for_wiki('999') self.assertEqual(len(wiki_courses), 0) - # but there should be two courses with wiki_slug 'simple' + # but there should be one course with wiki_slug 'simple' wiki_courses = self.store.get_courses_for_wiki('simple') - self.assertEqual(len(wiki_courses), 2) + self.assertEqual(len(wiki_courses), 1) self.assertIn( self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None), wiki_courses ) - self.assertIn(self.course_locations[self.XML_COURSEID2].course_key, wiki_courses) # configure mongo course to use unique wiki_slug. mongo_course = self.store.get_course(self.course_locations[self.MONGO_COURSEID].course_key) @@ -2008,15 +1892,11 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): ) # and NOT retriveable with its old wiki_slug wiki_courses = self.store.get_courses_for_wiki('simple') - self.assertEqual(len(wiki_courses), 1) + self.assertEqual(len(wiki_courses), 0) self.assertNotIn( self.course_locations[self.MONGO_COURSEID].course_key.replace(branch=None), wiki_courses ) - self.assertIn( - self.course_locations[self.XML_COURSEID2].course_key, - wiki_courses - ) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_branch_setting(self, default_ms): @@ -2117,7 +1997,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): except NotImplementedError: self.assertEquals(store_type, ModuleStoreEnum.Type.xml) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.xml) + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_default_store(self, default_ms): """ Test the default store context manager @@ -2139,9 +2019,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): self.verify_default_store(ModuleStoreEnum.Type.mongo) with self.store.default_store(ModuleStoreEnum.Type.split): self.verify_default_store(ModuleStoreEnum.Type.split) - with self.store.default_store(ModuleStoreEnum.Type.xml): - self.verify_default_store(ModuleStoreEnum.Type.xml) - self.verify_default_store(ModuleStoreEnum.Type.split) self.verify_default_store(ModuleStoreEnum.Type.mongo) def test_default_store_fake(self): @@ -2196,25 +2073,6 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): dest_store = self.store._get_modulestore_by_type(destination_modulestore) self.assertCoursesEqual(source_store, source_course_key, dest_store, dest_course_id) - def test_clone_xml_split(self): - """ - Can clone xml courses to split; so, test it. - """ - with MongoContentstoreBuilder().build() as contentstore: - # initialize the mixed modulestore - self._initialize_mixed(contentstore=contentstore, mappings={self.XML_COURSEID2: 'xml', }) - source_course_key = CourseKey.from_string(self.XML_COURSEID2) - with self.store.default_store(ModuleStoreEnum.Type.split): - dest_course_id = CourseLocator("org.other", "course.other", "run.other") - self.store.clone_course( - source_course_key, dest_course_id, ModuleStoreEnum.UserID.test - ) - - # pylint: disable=protected-access - source_store = self.store._get_modulestore_by_type(ModuleStoreEnum.Type.xml) - dest_store = self.store._get_modulestore_by_type(ModuleStoreEnum.Type.split) - self.assertCoursesEqual(source_store, source_course_key, dest_store, dest_course_id) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_bulk_operations_signal_firing(self, default): """ Signals should be fired right before bulk_operations() exits. """ From 14a8314adcb4149d6da460f54a64402b5db20616 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Mon, 21 Dec 2015 14:53:35 -0500 Subject: [PATCH 003/105] Remove include_xml option. --- common/djangoapps/embargo/tests/test_api.py | 4 +--- .../xmodule/modulestore/tests/django_utils.py | 20 +++++++------------ .../commands/tests/test_dump_course.py | 1 - 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/common/djangoapps/embargo/tests/test_api.py b/common/djangoapps/embargo/tests/test_api.py index e48940fe10..52fb829596 100644 --- a/common/djangoapps/embargo/tests/test_api.py +++ b/common/djangoapps/embargo/tests/test_api.py @@ -34,9 +34,7 @@ from embargo.exceptions import InvalidAccessPoint from mock import patch -# Since we don't need any XML course fixtures, use a modulestore configuration -# that disables the XML modulestore. -MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}) @ddt.ddt diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 861e03fe23..286810069a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -32,7 +32,7 @@ class StoreConstructors(object): draft, split = range(2) -def mixed_store_config(data_dir, mappings, include_xml=False, xml_source_dirs=None, store_order=None): +def mixed_store_config(data_dir, mappings, xml_source_dirs=None, store_order=None): """ Return a `MixedModuleStore` configuration, which provides access to both Mongo-backed courses. @@ -51,11 +51,9 @@ def mixed_store_config(data_dir, mappings, include_xml=False, xml_source_dirs=No Keyword Args: - include_xml (boolean): If True, include an XML modulestore in the configuration. xml_source_dirs (list): The directories containing XML courses to load from disk. note: For the courses to be loaded into the XML modulestore and accessible do the following: - * include_xml should be True * xml_source_dirs should be the list of directories (relative to data_dir) containing the courses you want to load * mappings should be configured, pointing the xml courses to the xml modulestore @@ -64,9 +62,6 @@ def mixed_store_config(data_dir, mappings, include_xml=False, xml_source_dirs=No if store_order is None: store_order = [StoreConstructors.draft, StoreConstructors.split] - if include_xml and StoreConstructors.xml not in store_order: - store_order.append(StoreConstructors.xml) - store_constructors = { StoreConstructors.split: split_mongo_store_config(data_dir)['default'], StoreConstructors.draft: draft_mongo_store_config(data_dir)['default'], @@ -160,25 +155,25 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT # This modulestore will provide both a mixed mongo editable modulestore, and # an XML store with just the toy course loaded. TEST_DATA_MIXED_TOY_MODULESTORE = mixed_store_config( - TEST_DATA_DIR, {'edX/toy/2012_Fall': 'xml', }, include_xml=True, xml_source_dirs=['toy'] + TEST_DATA_DIR, {}, xml_source_dirs=['toy'] ) # This modulestore will provide both a mixed mongo editable modulestore, and # an XML store with common/test/data/2014 loaded, which is a course that is closed. TEST_DATA_MIXED_CLOSED_MODULESTORE = mixed_store_config( - TEST_DATA_DIR, {'edX/detached_pages/2014': 'xml', }, include_xml=True, xml_source_dirs=['2014'] + TEST_DATA_DIR, {}, xml_source_dirs=['2014'] ) # This modulestore will provide both a mixed mongo editable modulestore, and # an XML store with common/test/data/graded loaded, which is a course that is graded. TEST_DATA_MIXED_GRADED_MODULESTORE = mixed_store_config( - TEST_DATA_DIR, {'edX/graded/2012_Fall': 'xml', }, include_xml=True, xml_source_dirs=['graded'] + TEST_DATA_DIR, {}, xml_source_dirs=['graded'] ) # All store requests now go through mixed # Use this modulestore if you specifically want to test mongo and not a mocked modulestore. # This modulestore definition below will not load any xml courses. -TEST_DATA_MONGO_MODULESTORE = mixed_store_config(mkdtemp_clean(), {}, include_xml=False) +TEST_DATA_MONGO_MODULESTORE = mixed_store_config(mkdtemp_clean(), {}) # All store requests now go through mixed # Use this modulestore if you specifically want to test split-mongo and not a mocked modulestore. @@ -186,7 +181,6 @@ TEST_DATA_MONGO_MODULESTORE = mixed_store_config(mkdtemp_clean(), {}, include_xm TEST_DATA_SPLIT_MODULESTORE = mixed_store_config( mkdtemp_clean(), {}, - include_xml=False, store_order=[StoreConstructors.split, StoreConstructors.draft] ) @@ -239,7 +233,7 @@ class SharedModuleStoreTestCase(TestCase): In Django 1.8, we will be able to use setUpTestData() to do class level init for Django ORM models that will get cleaned up properly. """ - MODULESTORE = mixed_store_config(mkdtemp_clean(), {}, include_xml=False) + MODULESTORE = mixed_store_config(mkdtemp_clean(), {}) # Tell Django to clean out all databases, not just default multi_db = True @@ -403,7 +397,7 @@ class ModuleStoreTestCase(TestCase): your `setUp()` method. """ - MODULESTORE = mixed_store_config(mkdtemp_clean(), {}, include_xml=False) + MODULESTORE = mixed_store_config(mkdtemp_clean(), {}) # Tell Django to clean out all databases, not just default multi_db = True diff --git a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py index bbff7ff15e..53a5b87a68 100644 --- a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py +++ b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py @@ -24,7 +24,6 @@ from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.xml_importer import import_course_from_xml DATA_DIR = settings.COMMON_TEST_DATA_ROOT -XML_COURSE_DIRS = ['toy', 'simple'] @attr('shard_1') From 775723b98ad51dff01f2868df49a34fefcd92753 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 10:18:10 -0500 Subject: [PATCH 004/105] Use ToyCourseFactory instead of the XMLModuleStore-backed toy course. --- openedx/core/djangoapps/course_groups/tests/test_cohorts.py | 5 +++-- .../djangoapps/course_groups/tests/test_partition_scheme.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py index 1a0986df9c..0e5f23a033 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -17,6 +17,7 @@ from student.models import CourseEnrollment from student.tests.factories import UserFactory from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ToyCourseFactory from ..models import CourseUserGroup, CourseCohort, CourseUserGroupPartitionGroup from .. import cohorts @@ -145,7 +146,7 @@ class TestCohorts(ModuleStoreTestCase): Make sure that course is reloaded every time--clear out the modulestore. """ super(TestCohorts, self).setUp() - self.toy_course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall") + self.toy_course_key = ToyCourseFactory.create().id def _create_cohort(self, course_id, cohort_name, assignment_type): """ @@ -740,7 +741,7 @@ class TestCohortsAndPartitionGroups(ModuleStoreTestCase): """ super(TestCohortsAndPartitionGroups, self).setUp() - self.test_course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall") + self.test_course_key = ToyCourseFactory.create().id self.course = modulestore().get_course(self.test_course_key) self.first_cohort = CohortFactory(course_id=self.course.id, name="FirstCohort") diff --git a/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py b/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py index df76894633..f76d1ebec8 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py +++ b/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py @@ -16,6 +16,7 @@ from student.tests.factories import UserFactory from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE +from xmodule.modulestore.tests.factories import ToyCourseFactory from opaque_keys.edx.locations import SlashSeparatedCourseKey from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme @@ -40,7 +41,7 @@ class TestCohortPartitionScheme(ModuleStoreTestCase): """ super(TestCohortPartitionScheme, self).setUp() - self.course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall") + self.course_key = ToyCourseFactory.create().id self.course = modulestore().get_course(self.course_key) config_course_cohorts(self.course, is_cohorted=True) @@ -286,7 +287,7 @@ class TestGetCohortedUserPartition(ModuleStoreTestCase): and a student for each test. """ super(TestGetCohortedUserPartition, self).setUp() - self.course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall") + self.course_key = ToyCourseFactory.create().id self.course = modulestore().get_course(self.course_key) self.student = UserFactory.create() From dd159bce129c7179b6fcf141f320266cde6aa9f9 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 11:25:52 -0500 Subject: [PATCH 005/105] Import test course instead of using XML-backed course. --- lms/djangoapps/courseware/tests/test_about.py | 32 +++++++++++---- .../courseware/tests/test_course_info.py | 36 ++++++++++++----- .../courseware/tests/test_courses.py | 16 ++++++-- lms/djangoapps/courseware/tests/test_tabs.py | 40 ++++++++++++++----- 4 files changed, 93 insertions(+), 31 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index 3ed5e67c39..1bed645f5d 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -15,6 +15,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from course_modes.models import CourseMode from track.tests import EventTrackingTestCase from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE +from xmodule.modulestore.tests.utils import TEST_DATA_DIR +from xmodule.modulestore.xml_importer import import_course_from_xml from student.models import CourseEnrollment from student.tests.factories import AdminFactory, CourseEnrollmentAllowedFactory, UserFactory @@ -201,14 +203,30 @@ class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): """ MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE - # The following XML test course (which lives at common/test/data/2014) - # is closed; we're testing that an about page still appears when - # the course is already closed - xml_course_id = SlashSeparatedCourseKey('edX', 'detached_pages', '2014') + def setUp(self): + """ + Set up the tests + """ + super(AboutTestCaseXML, self).setUp() - # this text appears in that course's about page - # common/test/data/2014/about/overview.html - xml_data = "about page 463139" + # The following test course (which lives at common/test/data/2014) + # is closed; we're testing that an about page still appears when + # the course is already closed + self.xml_course_id = self.store.make_course_key('edX', 'detached_pages', '2014') + import_course_from_xml( + self.store, + 'test_user', + TEST_DATA_DIR, + source_dirs=['2014'], + static_content_store=None, + target_id=self.xml_course_id, + raise_on_failure=True, + create_if_not_present=True, + ) + + # this text appears in that course's about page + # common/test/data/2014/about/overview.html + self.xml_data = "about page 463139" @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) def test_logged_in_xml(self): diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py index 952e1bc21f..490e353121 100644 --- a/lms/djangoapps/courseware/tests/test_course_info.py +++ b/lms/djangoapps/courseware/tests/test_course_info.py @@ -17,9 +17,11 @@ from util.date_utils import strftime_localized from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, SharedModuleStoreTestCase, - TEST_DATA_SPLIT_MODULESTORE + TEST_DATA_SPLIT_MODULESTORE, + TEST_DATA_MIXED_CLOSED_MODULESTORE ) -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE +from xmodule.modulestore.tests.utils import TEST_DATA_DIR +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls from student.models import CourseEnrollment from student.tests.factories import AdminFactory @@ -214,14 +216,30 @@ class CourseInfoTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): """ MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE - # The following XML test course (which lives at common/test/data/2014) - # is closed; we're testing that a course info page still appears when - # the course is already closed - xml_course_key = SlashSeparatedCourseKey('edX', 'detached_pages', '2014') + def setUp(self): + """ + Set up the tests + """ + super(CourseInfoTestCaseXML, self).setUp() - # this text appears in that course's course info page - # common/test/data/2014/info/updates.html - xml_data = "course info 463139" + # The following test course (which lives at common/test/data/2014) + # is closed; we're testing that a course info page still appears when + # the course is already closed + self.xml_course_key = self.store.make_course_key('edX', 'detached_pages', '2014') + import_course_from_xml( + self.store, + 'test_user', + TEST_DATA_DIR, + source_dirs=['2014'], + static_content_store=None, + target_id=self.xml_course_key, + raise_on_failure=True, + create_if_not_present=True, + ) + + # this text appears in that course's course info page + # common/test/data/2014/info/updates.html + self.xml_data = "course info 463139" @mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) def test_logged_in_xml(self): diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 32788027a6..fde9442fb3 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -32,9 +32,12 @@ from student.tests.factories import UserFactory from xmodule.modulestore.django import _get_modulestore_branch_setting, modulestore from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.xml_importer import import_course_from_xml -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, ItemFactory, ToyCourseFactory, check_mongo_calls +) from xmodule.tests.xml import factories as xml from xmodule.tests.xml import XModuleXmlImportTest @@ -308,7 +311,12 @@ class XmlCoursesRenderTest(ModuleStoreTestCase): """Test methods related to rendering courses content for an XML course.""" MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE - toy_course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') + def setUp(self): + """ + Make sure that course is reloaded every time--clear out the modulestore. + """ + super(XmlCoursesRenderTest, self).setUp() + self.toy_course_key = ToyCourseFactory.create().id def test_get_course_info_section_render(self): course = get_course_by_id(self.toy_course_key) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index d94de34be5..abc34a64fb 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -27,10 +27,12 @@ from util.milestones_helpers import ( from milestones.tests.utils import MilestonesTestCaseMixin from xmodule import tabs as xmodule_tabs from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE, - SharedModuleStoreTestCase) -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + ModuleStoreTestCase, + SharedModuleStoreTestCase +) from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.utils import TEST_DATA_DIR +from xmodule.modulestore.xml_importer import import_course_from_xml class TabTestCase(SharedModuleStoreTestCase): @@ -289,15 +291,31 @@ class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE - # The following XML test course (which lives at common/test/data/2014) - # is closed; we're testing that tabs still appear when - # the course is already closed - xml_course_key = SlashSeparatedCourseKey('edX', 'detached_pages', '2014') + def setUp(self): + """ + Set up the tests + """ + super(StaticTabDateTestCaseXML, self).setUp() - # this text appears in the test course's tab - # common/test/data/2014/tabs/8e4cce2b4aaf4ba28b1220804619e41f.html - xml_data = "static 463139" - xml_url = "8e4cce2b4aaf4ba28b1220804619e41f" + # The following XML test course (which lives at common/test/data/2014) + # is closed; we're testing that tabs still appear when + # the course is already closed + self.xml_course_key = self.store.make_course_key('edX', 'detached_pages', '2014') + import_course_from_xml( + self.store, + 'test_user', + TEST_DATA_DIR, + source_dirs=['2014'], + static_content_store=None, + target_id=self.xml_course_key, + raise_on_failure=True, + create_if_not_present=True, + ) + + # this text appears in the test course's tab + # common/test/data/2014/tabs/8e4cce2b4aaf4ba28b1220804619e41f.html + self.xml_data = "static 463139" + self.xml_url = "8e4cce2b4aaf4ba28b1220804619e41f" @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) def test_logged_in_xml(self): From 71d7f1c81ba9a66b3e033224669c5411ca916767 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 14:48:19 -0500 Subject: [PATCH 006/105] Skip test with static tabs - seems broken. --- common/lib/xmodule/xmodule/modulestore/tests/factories.py | 1 + lms/djangoapps/courseware/tests/test_tabs.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 48c1cc7d59..b33ed1d65e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -233,6 +233,7 @@ class ToyCourseFactory(SampleCourseFactory): user_id, toy_course.id, "course_info", "handouts", fields={"data": "Sample"} ) + ## TODO: Broken! These static tabs are never added to course.tabs? store.create_item( user_id, toy_course.id, "static_tab", "resources", fields={"display_name": "Resources"}, diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index abc34a64fb..099732a4ec 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -7,6 +7,7 @@ from django.http import Http404 from mock import MagicMock, Mock, patch from nose.plugins.attrib import attr from opaque_keys.edx.locations import SlashSeparatedCourseKey +from unittest import skip from courseware.courses import get_course_by_id from courseware.tabs import ( @@ -241,9 +242,6 @@ class StaticTabDateTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): cls.course.save() cls.toy_course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') - def setUp(self): - super(StaticTabDateTestCase, self).setUp() - def test_logged_in(self): self.setup_user() url = reverse('static_tab', args=[self.course.id.to_deprecated_string(), 'new_tab']) @@ -263,6 +261,7 @@ class StaticTabDateTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): with self.assertRaises(Http404): static_tab(request, course_id='edX/toy', tab_slug='new_tab') + @skip("Broken! Never finds the 'resources' static tab when created by the ToyCourseFactory.") def test_get_static_tab_contents(self): self.setup_user() course = get_course_by_id(self.toy_course_key) From 4106528e1a2207257e83be23803e2cbbe58b6087 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 15:26:06 -0500 Subject: [PATCH 007/105] Fix toy course textbook test - use Mongo-backed test course. --- common/lib/xmodule/xmodule/course_module.py | 2 +- .../xmodule/xmodule/modulestore/tests/factories.py | 3 ++- lms/djangoapps/courseware/tests/tests.py | 12 +++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 85188e7ddb..36fd65d294 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -175,7 +175,7 @@ class CourseFields(object): scope=Scope.settings ) textbooks = TextbookList( - help=_("List of pairs of (title, url) for textbooks used in this course"), + help=_("List of Textbook objects with (title, url) for textbooks used in this course"), default=[], scope=Scope.content ) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index b33ed1d65e..66e4ea081d 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -25,6 +25,7 @@ from xmodule.modulestore import prefer_xmodules, ModuleStoreEnum from xmodule.modulestore.tests.sample_courses import default_block_info_tree, TOY_BLOCK_INFO_TREE from xmodule.tabs import CourseTab from xmodule.x_module import DEPRECATION_VSCOMPAT_EVENT +from xmodule.course_module import Textbook class Dummy(object): @@ -190,7 +191,7 @@ class ToyCourseFactory(SampleCourseFactory): fields = { 'block_info_tree': TOY_BLOCK_INFO_TREE, - 'textbooks': [["Textbook", "path/to/a/text_book"]], + 'textbooks': [Textbook("Textbook", "path/to/a/text_book")], 'wiki_slug': "toy", 'graded': True, 'discussion_topics': {"General": {"id": "i4x-edX-toy-course-2012_Fall"}}, diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 32e658d9aa..5189f143c7 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -10,11 +10,13 @@ from nose.plugins.attrib import attr from opaque_keys.edx.locations import SlashSeparatedCourseKey from courseware.tests.helpers import LoginEnrollmentTestCase -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE as TOY_MODULESTORE from lms.djangoapps.lms_xblock.field_data import LmsFieldData from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE +) +from xmodule.modulestore.tests.factories import ToyCourseFactory @attr('shard_1') @@ -126,11 +128,12 @@ class TestMongoCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): """ Check that all pages in test courses load properly from Mongo. """ - MODULESTORE = TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE def setUp(self): super(TestMongoCoursesLoad, self).setUp() self.setup_user() + self.toy_course_key = ToyCourseFactory.create().id @mock.patch('xmodule.course_module.requests.get') def test_toy_textbooks_loads(self, mock_get): @@ -139,8 +142,7 @@ class TestMongoCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): """).strip() - - location = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall').make_usage_key('course', '2012_Fall') + location = self.toy_course_key.make_usage_key('course', '2012_Fall') course = self.store.get_item(location) self.assertGreater(len(course.textbooks), 0) From 134fb9c2696d2fec66b511e6503fe4080657c1da Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 16:28:35 -0500 Subject: [PATCH 008/105] Import test course instead of using XML-backed course. --- lms/djangoapps/django_comment_client/tests/test_models.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/django_comment_client/tests/test_models.py b/lms/djangoapps/django_comment_client/tests/test_models.py index 4093252990..17fa747e5e 100644 --- a/lms/djangoapps/django_comment_client/tests/test_models.py +++ b/lms/djangoapps/django_comment_client/tests/test_models.py @@ -5,9 +5,11 @@ from django.test.testcases import TestCase from nose.plugins.attrib import attr from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE import django_comment_common.models as models -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_MIXED_TOY_MODULESTORE, ModuleStoreTestCase +) +from xmodule.modulestore.tests.factories import ToyCourseFactory @attr('shard_1') @@ -23,7 +25,7 @@ class RoleClassTestCase(ModuleStoreTestCase): # For course ID, syntax edx/classname/classdate is important # because xmodel.course_module.id_to_location looks for a string to split - self.course_id = CourseKey.from_string("edX/toy/2012_Fall") + self.course_id = ToyCourseFactory.create().id self.student_role = models.Role.objects.get_or_create(name="Student", course_id=self.course_id)[0] self.student_role.add_permission("delete_thread") From 2090f0734450108a396eea3d44b4584fde5b7cfc Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 16:36:40 -0500 Subject: [PATCH 009/105] Remove XML-backed course-specific test. --- lms/djangoapps/bulk_email/tests/test_forms.py | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/lms/djangoapps/bulk_email/tests/test_forms.py b/lms/djangoapps/bulk_email/tests/test_forms.py index 13180d4e52..f2b308ef8b 100644 --- a/lms/djangoapps/bulk_email/tests/test_forms.py +++ b/lms/djangoapps/bulk_email/tests/test_forms.py @@ -127,32 +127,6 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): form.save() -class CourseAuthorizationXMLFormTest(ModuleStoreTestCase): - """Check that XML courses cannot be authorized for email.""" - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE - - @patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True}) - def test_xml_course_authorization(self): - course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') - # Assert this is an XML course - self.assertEqual(modulestore().get_modulestore_type(course_id), ModuleStoreEnum.Type.xml) - - form_data = {'course_id': course_id.to_deprecated_string(), 'email_enabled': True} - form = CourseAuthorizationAdminForm(data=form_data) - # Validation shouldn't work - self.assertFalse(form.is_valid()) - - msg = u"Course Email feature is only available for courses authored in Studio. " - msg += u'"{0}" appears to be an XML backed course.'.format(course_id.to_deprecated_string()) - self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access - - with self.assertRaisesRegexp( - ValueError, - "The CourseAuthorization could not be created because the data didn't validate." - ): - form.save() - - class CourseEmailTemplateFormTest(ModuleStoreTestCase): """Test the CourseEmailTemplateForm that is used in the Django admin subsystem.""" From 39ac2579dc7dddfd11c5c603597d4de5c40ff78a Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 16:36:53 -0500 Subject: [PATCH 010/105] Import test course instead of using XML-backed course. --- lms/djangoapps/django_comment_client/tests/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index 677144e8da..1d3a123bba 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -25,7 +25,7 @@ from openedx.core.djangoapps.content.course_structures.models import CourseStruc from openedx.core.djangoapps.util.testing import ContentGroupTestCase from student.roles import CourseStaffRole from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyCourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE from xmodule.modulestore.django import modulestore from opaque_keys.edx.locator import CourseLocator @@ -1255,7 +1255,7 @@ class IsCommentableCohortedTestCase(ModuleStoreTestCase): Make sure that course is reloaded every time--clear out the modulestore. """ super(IsCommentableCohortedTestCase, self).setUp() - self.toy_course_key = CourseLocator("edX", "toy", "2012_Fall", deprecated=True) + self.toy_course_key = ToyCourseFactory.create().id def test_is_commentable_cohorted(self): course = modulestore().get_course(self.toy_course_key) From 7aa3ee8df95607757f5d5ded76b0c4dc14cd8e8c Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 22 Dec 2015 17:16:33 -0500 Subject: [PATCH 011/105] Remove unused xml_source_dirs from mixed modulestore config. --- .../xmodule/xmodule/modulestore/tests/django_utils.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 286810069a..1a1298373c 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -32,7 +32,7 @@ class StoreConstructors(object): draft, split = range(2) -def mixed_store_config(data_dir, mappings, xml_source_dirs=None, store_order=None): +def mixed_store_config(data_dir, mappings, store_order=None): """ Return a `MixedModuleStore` configuration, which provides access to both Mongo-backed courses. @@ -51,13 +51,8 @@ def mixed_store_config(data_dir, mappings, xml_source_dirs=None, store_order=Non Keyword Args: - xml_source_dirs (list): The directories containing XML courses to load from disk. - - note: For the courses to be loaded into the XML modulestore and accessible do the following: - * xml_source_dirs should be the list of directories (relative to data_dir) - containing the courses you want to load - * mappings should be configured, pointing the xml courses to the xml modulestore - + store_order (list): List of StoreConstructors providing order of modulestores + to use in creating courses. """ if store_order is None: store_order = [StoreConstructors.draft, StoreConstructors.split] From 2380fa3c2c730cf597b0be39dec534e0813153ed Mon Sep 17 00:00:00 2001 From: John Eskew Date: Wed, 23 Dec 2015 10:43:59 -0500 Subject: [PATCH 012/105] Unify usage of a single test mixed modulestore called: TEST_DATA_MIXED_MODULESTORE Remove these test mixed modulestores: TEST_DATA_MIXED_TOY_MODULESTORE TEST_DATA_MIXED_CLOSED_MODULESTORE TEST_DATA_MIXED_GRADED_MODULESTORE --- .../student/tests/test_bulk_email_settings.py | 4 ++-- .../xmodule/modulestore/tests/django_utils.py | 24 +++++++------------ lms/djangoapps/bulk_email/tests/test_forms.py | 1 - lms/djangoapps/courseware/tests/test_about.py | 4 ++-- .../courseware/tests/test_course_info.py | 4 ++-- .../courseware/tests/test_courses.py | 2 +- .../courseware/tests/test_module_render.py | 8 +++---- lms/djangoapps/courseware/tests/test_tabs.py | 11 +++++---- lms/djangoapps/courseware/tests/test_views.py | 4 ++-- lms/djangoapps/courseware/tests/tests.py | 4 ++-- .../tests/test_models.py | 4 ++-- .../django_comment_client/tests/test_utils.py | 4 ++-- lms/djangoapps/instructor/tests/test_email.py | 4 ++-- lms/lib/xblock/test/test_mixin.py | 4 ++-- .../course_groups/tests/test_cohorts.py | 6 ++--- .../tests/test_partition_scheme.py | 6 ++--- 16 files changed, 43 insertions(+), 51 deletions(-) diff --git a/common/djangoapps/student/tests/test_bulk_email_settings.py b/common/djangoapps/student/tests/test_bulk_email_settings.py index 522064df90..b2bca30f0b 100644 --- a/common/djangoapps/student/tests/test_bulk_email_settings.py +++ b/common/djangoapps/student/tests/test_bulk_email_settings.py @@ -13,7 +13,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from student.tests.factories import UserFactory, CourseEnrollmentFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE +from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory # This import is for an lms djangoapp. @@ -90,7 +90,7 @@ class TestStudentDashboardEmailViewXMLBacked(SharedModuleStoreTestCase): """ Check for email view on student dashboard, with XML backed course. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): super(TestStudentDashboardEmailViewXMLBacked, self).setUp() diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 1a1298373c..faac630bd1 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -147,22 +147,14 @@ def drop_mongo_collections(mock_create): TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -# This modulestore will provide both a mixed mongo editable modulestore, and -# an XML store with just the toy course loaded. -TEST_DATA_MIXED_TOY_MODULESTORE = mixed_store_config( - TEST_DATA_DIR, {}, xml_source_dirs=['toy'] -) - -# This modulestore will provide both a mixed mongo editable modulestore, and -# an XML store with common/test/data/2014 loaded, which is a course that is closed. -TEST_DATA_MIXED_CLOSED_MODULESTORE = mixed_store_config( - TEST_DATA_DIR, {}, xml_source_dirs=['2014'] -) - -# This modulestore will provide both a mixed mongo editable modulestore, and -# an XML store with common/test/data/graded loaded, which is a course that is graded. -TEST_DATA_MIXED_GRADED_MODULESTORE = mixed_store_config( - TEST_DATA_DIR, {}, xml_source_dirs=['graded'] +# This modulestore will provide a mixed mongo editable modulestore. +# If your test uses the 'toy' course, use the the ToyCourseFactory to construct it. +# If your test needs a closed course to test against, import the common/test/data/2014 +# test course into this modulestore. +# If your test needs a graded course to test against, import the common/test/data/graded +# test course into this modulestore. +TEST_DATA_MIXED_MODULESTORE = mixed_store_config( + TEST_DATA_DIR, {} ) # All store requests now go through mixed diff --git a/lms/djangoapps/bulk_email/tests/test_forms.py b/lms/djangoapps/bulk_email/tests/test_forms.py index f2b308ef8b..5d465a05ec 100644 --- a/lms/djangoapps/bulk_email/tests/test_forms.py +++ b/lms/djangoapps/bulk_email/tests/test_forms.py @@ -8,7 +8,6 @@ from nose.plugins.attrib import attr from bulk_email.models import CourseAuthorization, CourseEmailTemplate from bulk_email.forms import CourseAuthorizationAdminForm, CourseEmailTemplateForm -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE from opaque_keys.edx.locations import SlashSeparatedCourseKey from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index 1bed645f5d..5530cd9dcc 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -14,7 +14,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from course_modes.models import CourseMode from track.tests import EventTrackingTestCase -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_CLOSED_MODULESTORE +from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.utils import TEST_DATA_DIR from xmodule.modulestore.xml_importer import import_course_from_xml @@ -201,7 +201,7 @@ class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): """ Tests for the course about page """ - MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py index 490e353121..069547e506 100644 --- a/lms/djangoapps/courseware/tests/test_course_info.py +++ b/lms/djangoapps/courseware/tests/test_course_info.py @@ -18,7 +18,7 @@ from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE, - TEST_DATA_MIXED_CLOSED_MODULESTORE + TEST_DATA_MIXED_MODULESTORE ) from xmodule.modulestore.tests.utils import TEST_DATA_DIR from xmodule.modulestore.xml_importer import import_course_from_xml @@ -214,7 +214,7 @@ class CourseInfoTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): """ Tests for the Course Info page for an XML course """ - MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index fde9442fb3..6976e376ca 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -309,7 +309,7 @@ class CoursesRenderTest(ModuleStoreTestCase): @attr('shard_1') class XmlCoursesRenderTest(ModuleStoreTestCase): """Test methods related to rendering courses content for an XML course.""" - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index ff8a7e0b90..2484c98ded 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -43,13 +43,13 @@ from openedx.core.lib.courses import course_image_url from openedx.core.lib.gating import api as gating_api from student.models import anonymous_id_for_user from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_MIXED_TOY_MODULESTORE, - SharedModuleStoreTestCase + ModuleStoreTestCase, + SharedModuleStoreTestCase, + TEST_DATA_MIXED_MODULESTORE ) from xmodule.lti_module import LTIDescriptor from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory, ToyCourseFactory, check_mongo_calls from xmodule.x_module import XModuleDescriptor, XModule, STUDENT_VIEW, CombinedSystem @@ -1403,7 +1403,7 @@ class MongoViewInStudioTest(ViewInStudioTest): class MixedViewInStudioTest(ViewInStudioTest): """Test the 'View in Studio' link visibility in a mixed mongo backed course.""" - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def test_view_in_studio_link_mongo_backed(self): """Mixed mongo courses that are mongo backed should see 'View in Studio' links.""" diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 099732a4ec..f11d7c12d1 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -29,7 +29,8 @@ from milestones.tests.utils import MilestonesTestCaseMixin from xmodule import tabs as xmodule_tabs from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, - SharedModuleStoreTestCase + SharedModuleStoreTestCase, + TEST_DATA_MIXED_MODULESTORE ) from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import TEST_DATA_DIR @@ -228,7 +229,7 @@ class TextbooksTestCase(TabTestCase): class StaticTabDateTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): """Test cases for Static Tab Dates.""" - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE @classmethod def setUpClass(cls): @@ -288,7 +289,7 @@ class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): Tests for the static tab dates of an XML course """ - MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ @@ -338,7 +339,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi """ Validate tab behavior when dealing with Entrance Exams """ - MODULESTORE = TEST_DATA_MIXED_CLOSED_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) def setUp(self): @@ -445,7 +446,7 @@ class TextBookCourseViewsTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest """ Validate tab behavior when dealing with textbooks. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE @classmethod def setUpClass(cls): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 88e84c4874..83a52fb897 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -51,7 +51,7 @@ from util.url import reload_django_url_config from util.views import ensure_valid_course_key from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE +from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls from openedx.core.djangoapps.credit.api import set_credit_requirements @@ -63,7 +63,7 @@ class TestJumpTo(ModuleStoreTestCase): """ Check the jumpto link for a course. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): super(TestJumpTo, self).setUp() diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 5189f143c7..d376896dcb 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -14,7 +14,7 @@ from lms.djangoapps.lms_xblock.field_data import LmsFieldData from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE + ModuleStoreTestCase, TEST_DATA_MIXED_MODULESTORE ) from xmodule.modulestore.tests.factories import ToyCourseFactory @@ -128,7 +128,7 @@ class TestMongoCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): """ Check that all pages in test courses load properly from Mongo. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): super(TestMongoCoursesLoad, self).setUp() diff --git a/lms/djangoapps/django_comment_client/tests/test_models.py b/lms/djangoapps/django_comment_client/tests/test_models.py index 17fa747e5e..5429e68661 100644 --- a/lms/djangoapps/django_comment_client/tests/test_models.py +++ b/lms/djangoapps/django_comment_client/tests/test_models.py @@ -7,7 +7,7 @@ from opaque_keys.edx.keys import CourseKey import django_comment_common.models as models from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_MIXED_TOY_MODULESTORE, ModuleStoreTestCase + TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase ) from xmodule.modulestore.tests.factories import ToyCourseFactory @@ -17,7 +17,7 @@ class RoleClassTestCase(ModuleStoreTestCase): """ Tests for roles of the comment client service integration """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): super(RoleClassTestCase, self).setUp() diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index 1d3a123bba..dd14861c71 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -26,7 +26,7 @@ from openedx.core.djangoapps.util.testing import ContentGroupTestCase from student.roles import CourseStaffRole from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyCourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.django import modulestore from opaque_keys.edx.locator import CourseLocator from lms.djangoapps.teams.tests.factories import CourseTeamFactory @@ -1248,7 +1248,7 @@ class IsCommentableCohortedTestCase(ModuleStoreTestCase): Test the is_commentable_cohorted function. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ diff --git a/lms/djangoapps/instructor/tests/test_email.py b/lms/djangoapps/instructor/tests/test_email.py index 7f1bc0633f..3d16b31c5a 100644 --- a/lms/djangoapps/instructor/tests/test_email.py +++ b/lms/djangoapps/instructor/tests/test_email.py @@ -12,7 +12,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from bulk_email.models import CourseAuthorization from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_MIXED_TOY_MODULESTORE, SharedModuleStoreTestCase + TEST_DATA_MIXED_MODULESTORE, SharedModuleStoreTestCase ) from student.tests.factories import AdminFactory from xmodule.modulestore.tests.factories import CourseFactory @@ -112,7 +112,7 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase): Check for email view on the new instructor dashboard """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE @classmethod def setUpClass(cls): diff --git a/lms/lib/xblock/test/test_mixin.py b/lms/lib/xblock/test/test_mixin.py index 64360c4a5f..6cb4d3c52c 100644 --- a/lms/lib/xblock/test/test_mixin.py +++ b/lms/lib/xblock/test/test_mixin.py @@ -6,7 +6,7 @@ import ddt from xblock.validation import ValidationMessage from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.factories import CourseFactory, ToyCourseFactory, ItemFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_MODULESTORE from xmodule.partitions.partitions import Group, UserPartition @@ -157,7 +157,7 @@ class XBlockGetParentTest(LmsXBlockMixinTestCase): Test that XBlock.get_parent returns correct results with each modulestore backend. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.xml) def test_parents(self, modulestore_type): diff --git a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py index 0e5f23a033..0df8acedfd 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -16,7 +16,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from student.models import CourseEnrollment from student.tests.factories import UserFactory from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory from ..models import CourseUserGroup, CourseCohort, CourseUserGroupPartitionGroup @@ -139,7 +139,7 @@ class TestCohorts(ModuleStoreTestCase): """ Test the cohorts feature """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ @@ -733,7 +733,7 @@ class TestCohortsAndPartitionGroups(ModuleStoreTestCase): """ Test Cohorts and Partitions Groups. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ diff --git a/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py b/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py index f76d1ebec8..b6b1971747 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py +++ b/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py @@ -15,7 +15,7 @@ from courseware.tests.test_masquerade import StaffMasqueradeTestCase from student.tests.factories import UserFactory from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.factories import ToyCourseFactory from opaque_keys.edx.locations import SlashSeparatedCourseKey @@ -32,7 +32,7 @@ class TestCohortPartitionScheme(ModuleStoreTestCase): """ Test the logic for linking a user to a partition group based on their cohort. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ @@ -279,7 +279,7 @@ class TestGetCohortedUserPartition(ModuleStoreTestCase): """ Test that `get_cohorted_user_partition` returns the first user_partition with scheme `CohortPartitionScheme`. """ - MODULESTORE = TEST_DATA_MIXED_TOY_MODULESTORE + MODULESTORE = TEST_DATA_MIXED_MODULESTORE def setUp(self): """ From 971a198fb91e07d154e0a1aecf7c0df74236b226 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Wed, 23 Dec 2015 14:00:36 -0500 Subject: [PATCH 013/105] Remove ModuleStoreEnum.Type.xml --- .../commands/tests/test_create_course.py | 4 ---- common/djangoapps/static_replace/__init__.py | 3 +-- common/djangoapps/student/views.py | 1 - .../xmodule/xmodule/modulestore/__init__.py | 1 - .../tests/test_mixed_modulestore.py | 7 ++----- .../xmodule/modulestore/tests/test_xml.py | 4 ---- common/lib/xmodule/xmodule/modulestore/xml.py | 4 ++-- .../xmodule/modulestore/xml_exporter.py | 4 +--- lms/djangoapps/bulk_email/forms.py | 7 ------- lms/djangoapps/courseware/courses.py | 4 +--- lms/djangoapps/dashboard/sysadmin.py | 18 +----------------- lms/djangoapps/instructor/views/tools.py | 3 +-- lms/lib/xblock/test/test_mixin.py | 8 ++------ openedx/core/lib/xblock_utils.py | 3 +-- 14 files changed, 12 insertions(+), 59 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py index 1bde9f7a07..5ae88a9345 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py @@ -29,10 +29,6 @@ class TestArgParsing(unittest.TestCase): with self.assertRaises(CommandError): self.command.handle("foo", "user@foo.org", "org", "course", "run") - def test_xml_store(self): - with self.assertRaises(CommandError): - self.command.handle(ModuleStoreEnum.Type.xml, "user@foo.org", "org", "course", "run") - def test_nonexistent_user_id(self): errstring = "No user 99 found" with self.assertRaisesRegexp(CommandError, errstring): diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index 84c81bd187..5a12317ea9 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -164,8 +164,7 @@ def replace_static_urls(text, data_directory=None, course_id=None, static_asset_ return original # if we're running with a MongoBacked store course_namespace is not None, then use studio style urls elif (not static_asset_path) \ - and course_id \ - and modulestore().get_modulestore_type(course_id) != ModuleStoreEnum.Type.xml: + and course_id: # first look in the static file pipeline and see if we are trying to reference # a piece of static content which is in the edx-platform repo (e.g. JS associated with an xmodule) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e8ee1362dd..2bdc2db017 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -649,7 +649,6 @@ def dashboard(request): show_email_settings_for = frozenset( enrollment.course_id for enrollment in course_enrollments if ( settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and - modulestore().get_modulestore_type(enrollment.course_id) != ModuleStoreEnum.Type.xml and CourseAuthorization.instructor_email_enabled(enrollment.course_id) ) ) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 4d2b3ae714..6e0e8671e6 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -55,7 +55,6 @@ class ModuleStoreEnum(object): """ split = 'split' mongo = 'mongo' - xml = 'xml' class RevisionOption(object): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 585eb8ba25..1fec0bfe2e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -1991,11 +1991,8 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): self.assertEquals(store.get_modulestore_type(), store_type) # verify store used for creating a course - try: - course = self.store.create_course("org", "course{}".format(uuid4().hex[:5]), "run", self.user_id) - self.assertEquals(course.system.modulestore.get_modulestore_type(), store_type) - except NotImplementedError: - self.assertEquals(store_type, ModuleStoreEnum.Type.xml) + course = self.store.create_course("org", "course{}".format(uuid4().hex[:5]), "run", self.user_id) + self.assertEquals(course.system.modulestore.get_modulestore_type(), store_type) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_default_store(self, default_ms): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py index f029de4283..4da26acfd0 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py @@ -31,10 +31,6 @@ class TestXMLModuleStore(unittest.TestCase): """ Test around the XML modulestore """ - def test_xml_modulestore_type(self): - store = XMLModuleStore(DATA_DIR, source_dirs=[]) - self.assertEqual(store.get_modulestore_type(), ModuleStoreEnum.Type.xml) - @patch('xmodule.tabs.CourseTabList.initialize_default', Mock()) def test_unicode_chars_in_xml_content(self): # edX/full/6.002_Spring_2012 has non-ASCII chars, and during diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index c6b7b748ed..01aab51a5b 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -875,7 +875,7 @@ class XMLModuleStore(ModuleStoreReadBase): Args: course_key: just for signature compatibility """ - return ModuleStoreEnum.Type.xml + return None #ModuleStoreEnum.Type.xml def get_courses_for_wiki(self, wiki_slug, **kwargs): """ @@ -893,7 +893,7 @@ class XMLModuleStore(ModuleStoreReadBase): Returns the course count """ - return {ModuleStoreEnum.Type.xml: True} + return {'xml': True} @contextmanager def branch_setting(self, branch_setting, course_id=None): # pylint: disable=unused-argument diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index 91066d485d..7468e6755a 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -279,9 +279,7 @@ class CourseExportManager(ExportManager): policy = {'course/' + courselike.location.name: own_metadata(courselike)} course_policy.write(dumps(policy, cls=EdxJSONEncoder, sort_keys=True, indent=4)) - # xml backed courses don't support drafts! - if courselike.runtime.modulestore.get_modulestore_type() != ModuleStoreEnum.Type.xml: - _export_drafts(self.modulestore, self.courselike_key, export_fs, xml_centric_courselike_key) + _export_drafts(self.modulestore, self.courselike_key, export_fs, xml_centric_courselike_key) class LibraryExportManager(ExportManager): diff --git a/lms/djangoapps/bulk_email/forms.py b/lms/djangoapps/bulk_email/forms.py index 6fdec1459f..32d91443c5 100644 --- a/lms/djangoapps/bulk_email/forms.py +++ b/lms/djangoapps/bulk_email/forms.py @@ -100,11 +100,4 @@ class CourseAuthorizationAdminForm(forms.ModelForm): msg += 'Please recheck that you have supplied a valid course id.' raise forms.ValidationError(msg) - # Now, try and discern if it is a Studio course - HTML editor doesn't work with XML courses - is_studio_course = modulestore().get_modulestore_type(course_key) != ModuleStoreEnum.Type.xml - if not is_studio_course: - msg = "Course Email feature is only available for courses authored in Studio. " - msg += '"{0}" appears to be an XML backed course.'.format(course_key.to_deprecated_string()) - raise forms.ValidationError(msg) - return course_key diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 252bb598cf..1b63685156 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -453,10 +453,8 @@ def get_studio_url(course, page): Args: course (CourseDescriptor) """ - is_studio_course = course.course_edit_method == "Studio" - is_mongo_course = modulestore().get_modulestore_type(course.id) != ModuleStoreEnum.Type.xml studio_link = None - if is_studio_course and is_mongo_course: + if course.course_edit_method == "Studio": studio_link = get_cms_course_link(course, page) return studio_link diff --git a/lms/djangoapps/dashboard/sysadmin.py b/lms/djangoapps/dashboard/sysadmin.py index 3d89a86f77..4273035d9e 100644 --- a/lms/djangoapps/dashboard/sysadmin.py +++ b/lms/djangoapps/dashboard/sysadmin.py @@ -491,23 +491,7 @@ class Courses(SysadminDashboardView): escape(str(err)) ) - is_xml_course = (modulestore().get_modulestore_type(course_key) == ModuleStoreEnum.Type.xml) - if course_found and is_xml_course: - cdir = course.data_dir - self.def_ms.courses.pop(cdir) - - # now move the directory (don't actually delete it) - new_dir = "{course_dir}_deleted_{timestamp}".format( - course_dir=cdir, - timestamp=int(time.time()) - ) - os.rename(settings.DATA_DIR / cdir, settings.DATA_DIR / new_dir) - - self.msg += (u"Deleted " - u"{0} = {1} ({2})".format( - cdir, course.id, course.display_name)) - - elif course_found and not is_xml_course: + if course_found: # delete course that is stored with mongodb backend self.def_ms.delete_course(course.id, request.user.id) # don't delete user permission groups, though diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index d1bbb66269..1bfb96d910 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -66,10 +66,9 @@ def bulk_email_is_enabled_for_course(course_id): """ bulk_email_enabled_globally = (settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] is True) - is_studio_course = (modulestore().get_modulestore_type(course_id) != ModuleStoreEnum.Type.xml) bulk_email_enabled_for_course = CourseAuthorization.instructor_email_enabled(course_id) - if bulk_email_enabled_globally and is_studio_course and bulk_email_enabled_for_course: + if bulk_email_enabled_globally and bulk_email_enabled_for_course: return True return False diff --git a/lms/lib/xblock/test/test_mixin.py b/lms/lib/xblock/test/test_mixin.py index 6cb4d3c52c..3b501b3737 100644 --- a/lms/lib/xblock/test/test_mixin.py +++ b/lms/lib/xblock/test/test_mixin.py @@ -159,19 +159,15 @@ class XBlockGetParentTest(LmsXBlockMixinTestCase): """ MODULESTORE = TEST_DATA_MIXED_MODULESTORE - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.xml) + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_parents(self, modulestore_type): with self.store.default_store(modulestore_type): # setting up our own local course tree here, since it needs to be # created with the correct modulestore type. - if modulestore_type == 'xml': - course_key = self.store.make_course_key('edX', 'toy', '2012_Fall') - else: - course_key = ToyCourseFactory.create(run='2012_Fall_copy').id + course_key = ToyCourseFactory.create().id course = self.store.get_course(course_key) - self.assertIsNone(course.get_parent()) def recurse(parent): diff --git a/openedx/core/lib/xblock_utils.py b/openedx/core/lib/xblock_utils.py index ab25b27639..2a0232436b 100644 --- a/openedx/core/lib/xblock_utils.py +++ b/openedx/core/lib/xblock_utils.py @@ -300,10 +300,9 @@ def add_staff_markup(user, has_instructor_access, disable_staff_debug_info, bloc # TODO: make this more general, eg use an XModule attribute instead if isinstance(block, VerticalBlock) and (not context or not context.get('child_of_vertical', False)): # check that the course is a mongo backed Studio course before doing work - is_mongo_course = modulestore().get_modulestore_type(block.location.course_key) != ModuleStoreEnum.Type.xml is_studio_course = block.course_edit_method == "Studio" - if is_studio_course and is_mongo_course: + if is_studio_course: # build edit link to unit in CMS. Can't use reverse here as lms doesn't load cms's urls.py edit_link = "//" + settings.CMS_BASE + '/container/' + unicode(block.location) From 23f80d8fc5a21b60d9ec963c0afad1ad06a350b6 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Mon, 28 Dec 2015 10:29:00 -0500 Subject: [PATCH 014/105] Fix PEP8 error --- common/lib/xmodule/xmodule/modulestore/xml.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 01aab51a5b..e839206250 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -875,7 +875,8 @@ class XMLModuleStore(ModuleStoreReadBase): Args: course_key: just for signature compatibility """ - return None #ModuleStoreEnum.Type.xml + # return ModuleStoreEnum.Type.xml + return None def get_courses_for_wiki(self, wiki_slug, **kwargs): """ From 2c20ac7c00b0d0c3a5ae6bd635aaf9f7586a638d Mon Sep 17 00:00:00 2001 From: John Eskew Date: Fri, 4 Mar 2016 15:15:58 -0500 Subject: [PATCH 015/105] Remove comments that refer to XML courses. --- common/lib/xmodule/xmodule/modulestore/tests/django_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index faac630bd1..8e91f8d920 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -159,12 +159,10 @@ TEST_DATA_MIXED_MODULESTORE = mixed_store_config( # All store requests now go through mixed # Use this modulestore if you specifically want to test mongo and not a mocked modulestore. -# This modulestore definition below will not load any xml courses. TEST_DATA_MONGO_MODULESTORE = mixed_store_config(mkdtemp_clean(), {}) # All store requests now go through mixed # Use this modulestore if you specifically want to test split-mongo and not a mocked modulestore. -# This modulestore definition below will not load any xml courses. TEST_DATA_SPLIT_MODULESTORE = mixed_store_config( mkdtemp_clean(), {}, From a08f4d3cc3bd67888ae5d564b5db197116739c99 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Sat, 5 Mar 2016 11:03:07 -0500 Subject: [PATCH 016/105] Remove non-existent import. --- lms/djangoapps/courseware/tests/test_courses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 6976e376ca..b2ecc49e80 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -33,7 +33,8 @@ from xmodule.modulestore.django import _get_modulestore_branch_setting, modulest from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE + ModuleStoreTestCase, + TEST_DATA_MIXED_MODULESTORE ) from xmodule.modulestore.tests.factories import ( CourseFactory, ItemFactory, ToyCourseFactory, check_mongo_calls From f0c131804151a4dd9d4b70a3debc53d858a517d3 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 12 Apr 2016 13:42:32 -0400 Subject: [PATCH 017/105] Add XML_COURSE_DIRS back in. --- .../courseware/management/commands/tests/test_dump_course.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py index 53a5b87a68..bbff7ff15e 100644 --- a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py +++ b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py @@ -24,6 +24,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.xml_importer import import_course_from_xml DATA_DIR = settings.COMMON_TEST_DATA_ROOT +XML_COURSE_DIRS = ['toy', 'simple'] @attr('shard_1') From 6ea91d42b52b822e78b1ce10c04bdf254738e609 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 12 Apr 2016 15:16:00 -0400 Subject: [PATCH 018/105] Remove another XML course test. --- .../tests/test_mixed_modulestore.py | 3 +- .../courseware/tests/test_courses.py | 36 ++----------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 1fec0bfe2e..4d560915b5 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -88,7 +88,6 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): 'collection': COLLECTION, 'asset_collection': ASSET_COLLECTION, } - MAPPINGS = {} OPTIONS = { 'stores': [ { @@ -241,7 +240,7 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): return self.store.has_changes(self.store.get_item(location)) # pylint: disable=dangerous-default-value - def _initialize_mixed(self, mappings=MAPPINGS, contentstore=None): + def _initialize_mixed(self, mappings={}, contentstore=None): """ initializes the mixed modulestore. """ diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index b2ecc49e80..a3cbf06be4 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -32,12 +32,9 @@ from student.tests.factories import UserFactory from xmodule.modulestore.django import _get_modulestore_branch_setting, modulestore from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.xml_importer import import_course_from_xml -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - TEST_DATA_MIXED_MODULESTORE -) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import ( - CourseFactory, ItemFactory, ToyCourseFactory, check_mongo_calls + CourseFactory, ItemFactory, check_mongo_calls ) from xmodule.tests.xml import factories as xml from xmodule.tests.xml import XModuleXmlImportTest @@ -307,35 +304,6 @@ class CoursesRenderTest(ModuleStoreTestCase): self.assertIn("this module is temporarily unavailable", course_about) -@attr('shard_1') -class XmlCoursesRenderTest(ModuleStoreTestCase): - """Test methods related to rendering courses content for an XML course.""" - MODULESTORE = TEST_DATA_MIXED_MODULESTORE - - def setUp(self): - """ - Make sure that course is reloaded every time--clear out the modulestore. - """ - super(XmlCoursesRenderTest, self).setUp() - self.toy_course_key = ToyCourseFactory.create().id - - def test_get_course_info_section_render(self): - course = get_course_by_id(self.toy_course_key) - request = get_request_for_user(UserFactory.create()) - - # Test render works okay. Note the href is different in XML courses. - course_info = get_course_info_section(request, request.user, course, 'handouts') - self.assertEqual(course_info, "Sample") - - # Test when render raises an exception - with mock.patch('courseware.courses.get_module') as mock_module_render: - mock_module_render.return_value = mock.MagicMock( - render=mock.Mock(side_effect=Exception('Render failed!')) - ) - course_info = get_course_info_section(request, request.user, course, 'handouts') - self.assertIn("this module is temporarily unavailable", course_info) - - @attr('shard_1') @ddt.ddt class CourseInstantiationTests(ModuleStoreTestCase): From c3c7ce8e9a3ddff15d607dc50b80e073cca53c6b Mon Sep 17 00:00:00 2001 From: John Eskew Date: Thu, 21 Apr 2016 11:56:23 -0400 Subject: [PATCH 019/105] Address DaveO's comments. --- common/djangoapps/static_replace/__init__.py | 3 +-- .../xmodule/modulestore/tests/test_mixed_modulestore.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index 5a12317ea9..d98bea1b90 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -163,8 +163,7 @@ def replace_static_urls(text, data_directory=None, course_id=None, static_asset_ if settings.DEBUG and finders.find(rest, True): return original # if we're running with a MongoBacked store course_namespace is not None, then use studio style urls - elif (not static_asset_path) \ - and course_id: + elif (not static_asset_path) and course_id: # first look in the static file pipeline and see if we are trying to reference # a piece of static content which is in the edx-platform repo (e.g. JS associated with an xmodule) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 4d560915b5..37f4c96c79 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -240,10 +240,11 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): return self.store.has_changes(self.store.get_item(location)) # pylint: disable=dangerous-default-value - def _initialize_mixed(self, mappings={}, contentstore=None): + def _initialize_mixed(self, mappings=None, contentstore=None): """ initializes the mixed modulestore. """ + mappings = mappings or {} self.store = MixedModuleStore( contentstore, create_modulestore_instance=create_modulestore_instance, mappings=mappings, From 607ff409db75b701991a9144c57278074ad61a62 Mon Sep 17 00:00:00 2001 From: David Adams Date: Wed, 20 Apr 2016 16:23:25 -0700 Subject: [PATCH 020/105] Remove LMS_ROOT / static from cms/common.py --- cms/envs/common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 4fc14fdcb6..d5288db117 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -485,7 +485,6 @@ STATIC_ROOT = ENV_ROOT / "staticfiles" / EDX_PLATFORM_REVISION STATICFILES_DIRS = [ COMMON_ROOT / "static", PROJECT_ROOT / "static", - LMS_ROOT / "static", # This is how you would use the textbook images locally # ("book", ENV_ROOT / "book_images"), From e1d8347c211e185a00d00d16f1657bb9301d3d3f Mon Sep 17 00:00:00 2001 From: John Eskew Date: Thu, 21 Apr 2016 14:39:29 -0400 Subject: [PATCH 021/105] Fix static tabs test. --- common/lib/xmodule/xmodule/modulestore/tests/factories.py | 1 - lms/djangoapps/courseware/tests/test_tabs.py | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 66e4ea081d..c7f6e91e68 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -234,7 +234,6 @@ class ToyCourseFactory(SampleCourseFactory): user_id, toy_course.id, "course_info", "handouts", fields={"data": "Sample"} ) - ## TODO: Broken! These static tabs are never added to course.tabs? store.create_item( user_id, toy_course.id, "static_tab", "resources", fields={"display_name": "Resources"}, diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index f11d7c12d1..f7105cb3d6 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -241,7 +241,6 @@ class StaticTabDateTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): ) cls.course.tabs.append(xmodule_tabs.CourseTab.load('static_tab', name='New Tab', url_slug='new_tab')) cls.course.save() - cls.toy_course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') def test_logged_in(self): self.setup_user() @@ -262,16 +261,15 @@ class StaticTabDateTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): with self.assertRaises(Http404): static_tab(request, course_id='edX/toy', tab_slug='new_tab') - @skip("Broken! Never finds the 'resources' static tab when created by the ToyCourseFactory.") def test_get_static_tab_contents(self): self.setup_user() - course = get_course_by_id(self.toy_course_key) + course = get_course_by_id(self.course.id) request = get_request_for_user(self.user) - tab = xmodule_tabs.CourseTabList.get_tab_by_slug(course.tabs, 'resources') + tab = xmodule_tabs.CourseTabList.get_tab_by_slug(course.tabs, 'new_tab') # Test render works okay tab_content = get_static_tab_contents(request, course, tab) - self.assertIn(self.toy_course_key.to_deprecated_string(), tab_content) + self.assertIn(self.course.id.to_deprecated_string(), tab_content) self.assertIn('static_tab', tab_content) # Test when render raises an exception From e78497402703294f565d3fa136157de6a801c2ab Mon Sep 17 00:00:00 2001 From: John Eskew Date: Thu, 21 Apr 2016 14:40:02 -0400 Subject: [PATCH 022/105] Remove unneeded list comprehension. --- .../tests/test_mixed_modulestore.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 37f4c96c79..826c89b0c2 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -265,21 +265,14 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): break self._initialize_mixed() - # convert to CourseKeys - self.course_locations = { - course_id: CourseLocator.from_string(course_id) - for course_id in [self.MONGO_COURSEID] - } - # and then to the root UsageKey - self.course_locations = { - course_id: course_key.make_usage_key('course', course_key.run) - for course_id, course_key in self.course_locations.iteritems() - } - - mongo_course_key = self.course_locations[self.MONGO_COURSEID].course_key - self.fake_location = self.store.make_course_key(mongo_course_key.org, mongo_course_key.course, mongo_course_key.run).make_usage_key('vertical', 'fake') - - self._create_course(self.course_locations[self.MONGO_COURSEID].course_key) + test_course_key = CourseLocator.from_string(self.MONGO_COURSEID) + test_course_key = test_course_key.make_usage_key('course', test_course_key.run).course_key + self.fake_location = self.store.make_course_key( + test_course_key.org, + test_course_key.course, + test_course_key.run + ).make_usage_key('vertical', 'fake') + self._create_course(test_course_key) self.assertEquals(default, self.store.get_modulestore_type(self.course.id)) From 6a01f18e172c62132c9c05335c351c379be05217 Mon Sep 17 00:00:00 2001 From: "J. Clifford Dyer" Date: Mon, 11 Apr 2016 20:52:16 +0000 Subject: [PATCH 023/105] Update seq_* to edx.ui.lms.sequence.* format seq_next, seq_prev, and seq_goto events are all renamed, and maintain legacy compatibility. This PR also introduces new EventTransformer framework to shim events based on name prefix. MA-2221 --- cms/envs/common.py | 9 +- common/djangoapps/track/shim.py | 122 +---- common/djangoapps/track/tests/test_shim.py | 105 +++- common/djangoapps/track/transformers.py | 502 ++++++++++++++++++ .../track/views/tests/test_segmentio.py | 32 +- .../xmodule/js/src/sequence/display.coffee | 75 ++- .../tests/lms/test_lms_courseware.py | 89 +++- lms/envs/common.py | 2 +- 8 files changed, 795 insertions(+), 141 deletions(-) create mode 100644 common/djangoapps/track/transformers.py diff --git a/cms/envs/common.py b/cms/envs/common.py index d5288db117..bb0b13c631 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -684,8 +684,11 @@ REQUIRE_DEBUG = False REQUIRE_EXCLUDE = ("build.txt",) # The execution environment in which to run r.js: auto, node or rhino. -# auto will autodetect the environment and make use of node if available and rhino if not. -# It can also be a path to a custom class that subclasses require.environments.Environment and defines some "args" function that returns a list with the command arguments to execute. +# auto will autodetect the environment and make use of node if available and +# rhino if not. +# It can also be a path to a custom class that subclasses +# require.environments.Environment and defines some "args" function that +# returns a list with the command arguments to execute. REQUIRE_ENVIRONMENT = "node" @@ -956,7 +959,7 @@ EVENT_TRACKING_BACKENDS = { }, 'processors': [ {'ENGINE': 'track.shim.LegacyFieldMappingProcessor'}, - {'ENGINE': 'track.shim.VideoEventProcessor'} + {'ENGINE': 'track.shim.PrefixedEventProcessor'} ] } }, diff --git a/common/djangoapps/track/shim.py b/common/djangoapps/track/shim.py index 311b2cacc1..76e4a40a93 100644 --- a/common/djangoapps/track/shim.py +++ b/common/djangoapps/track/shim.py @@ -1,14 +1,10 @@ """Map new event context values to old top-level field values. Ensures events can be parsed by legacy parsers.""" import json -import logging -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey +from .transformers import EventTransformerRegistry -log = logging.getLogger(__name__) - CONTEXT_FIELDS_TO_INCLUDE = [ 'username', 'session', @@ -63,6 +59,9 @@ class LegacyFieldMappingProcessor(object): def remove_shim_context(event): + """ + Remove obsolete fields from event context. + """ if 'context' in event: context = event['context'] # These fields are present elsewhere in the event at this point @@ -74,100 +73,6 @@ def remove_shim_context(event): del context[field] -NAME_TO_EVENT_TYPE_MAP = { - 'edx.video.played': 'play_video', - 'edx.video.paused': 'pause_video', - 'edx.video.stopped': 'stop_video', - 'edx.video.loaded': 'load_video', - 'edx.video.position.changed': 'seek_video', - 'edx.video.seeked': 'seek_video', - 'edx.video.transcript.shown': 'show_transcript', - 'edx.video.transcript.hidden': 'hide_transcript', -} - - -class VideoEventProcessor(object): - """ - Converts new format video events into the legacy video event format. - - Mobile devices cannot actually emit events that exactly match their counterparts emitted by the LMS javascript - video player. Instead of attempting to get them to do that, we instead insert a shim here that converts the events - they *can* easily emit and converts them into the legacy format. - - TODO: Remove this shim and perform the conversion as part of some batch canonicalization process. - - """ - - def __call__(self, event): - name = event.get('name') - if not name: - return - - if name not in NAME_TO_EVENT_TYPE_MAP: - return - - # Convert edx.video.seeked to edx.video.position.changed because edx.video.seeked was not intended to actually - # ever be emitted. - if name == "edx.video.seeked": - event['name'] = "edx.video.position.changed" - - event['event_type'] = NAME_TO_EVENT_TYPE_MAP[name] - - if 'event' not in event: - return - payload = event['event'] - - if 'module_id' in payload: - module_id = payload['module_id'] - try: - usage_key = UsageKey.from_string(module_id) - except InvalidKeyError: - log.warning('Unable to parse module_id "%s"', module_id, exc_info=True) - else: - payload['id'] = usage_key.html_id() - - del payload['module_id'] - - if 'current_time' in payload: - payload['currentTime'] = payload.pop('current_time') - - if 'context' in event: - context = event['context'] - - # Converts seek_type to seek and skip|slide to onSlideSeek|onSkipSeek - if 'seek_type' in payload: - seek_type = payload['seek_type'] - if seek_type == 'slide': - payload['type'] = "onSlideSeek" - elif seek_type == 'skip': - payload['type'] = "onSkipSeek" - del payload['seek_type'] - - # For the iOS build that is returning a +30 for back skip 30 - if ( - context['application']['version'] == "1.0.02" and - context['application']['name'] == "edx.mobileapp.iOS" - ): - if 'requested_skip_interval' in payload and 'type' in payload: - if ( - payload['requested_skip_interval'] == 30 and - payload['type'] == "onSkipSeek" - ): - payload['requested_skip_interval'] = -30 - - # For the Android build that isn't distinguishing between skip/seek - if 'requested_skip_interval' in payload: - if abs(payload['requested_skip_interval']) != 30: - if 'type' in payload: - payload['type'] = 'onSlideSeek' - - if 'open_in_browser_url' in context: - page, _sep, _tail = context.pop('open_in_browser_url').rpartition('/') - event['page'] = page - - event['event'] = json.dumps(payload) - - class GoogleAnalyticsProcessor(object): """Adds course_id as label, and sets nonInteraction property""" @@ -184,3 +89,22 @@ class GoogleAnalyticsProcessor(object): copied_event['nonInteraction'] = 1 return copied_event + + +class PrefixedEventProcessor(object): + """ + Process any events whose name or prefix (ending with a '.') is registered + as an EventTransformer. + """ + + def __call__(self, event): + """ + If the event is registered with the EventTransformerRegistry, transform + it. Otherwise do nothing to it, and continue processing. + """ + try: + event = EventTransformerRegistry.create_transformer(event) + except KeyError: + return + event.transform() + return event diff --git a/common/djangoapps/track/tests/test_shim.py b/common/djangoapps/track/tests/test_shim.py index f9e0eada98..039a0d6cbd 100644 --- a/common/djangoapps/track/tests/test_shim.py +++ b/common/djangoapps/track/tests/test_shim.py @@ -1,10 +1,16 @@ """Ensure emitted events contain the fields legacy processors expect to find.""" +from collections import namedtuple + +import ddt from mock import sentinel from django.test.utils import override_settings from openedx.core.lib.tests.assertions.events import assert_events_equal -from track.tests import EventTrackingTestCase, FROZEN_TIME + +from . import EventTrackingTestCase, FROZEN_TIME +from ..shim import PrefixedEventProcessor +from .. import transformers LEGACY_SHIM_PROCESSOR = [ @@ -216,3 +222,100 @@ class MultipleShimGoogleAnalyticsProcessorTestCase(EventTrackingTestCase): 'timestamp': FROZEN_TIME, } assert_events_equal(expected_event, log_emitted_event) + + +SequenceDDT = namedtuple('SequenceDDT', ['action', 'tab_count', 'current_tab', 'legacy_event_type']) + + +@ddt.ddt +class EventTransformerRegistryTestCase(EventTrackingTestCase): + """ + Test the behavior of the event registry + """ + + def setUp(self): + super(EventTransformerRegistryTestCase, self).setUp() + self.registry = transformers.EventTransformerRegistry() + + @ddt.data( + ('edx.ui.lms.sequence.next_selected', transformers.NextSelectedEventTransformer), + ('edx.ui.lms.sequence.previous_selected', transformers.PreviousSelectedEventTransformer), + ('edx.ui.lms.sequence.tab_selected', transformers.SequenceTabSelectedEventTransformer), + ('edx.video.foo.bar', transformers.VideoEventTransformer), + ) + @ddt.unpack + def test_event_registry_dispatch(self, event_name, expected_transformer): + event = {'name': event_name} + transformer = self.registry.create_transformer(event) + self.assertIsInstance(transformer, expected_transformer) + + @ddt.data( + 'edx.ui.lms.sequence.next_selected.what', + 'edx', + 'unregistered_event', + ) + def test_dispatch_to_nonexistent_events(self, event_name): + event = {'name': event_name} + with self.assertRaises(KeyError): + self.registry.create_transformer(event) + + +@ddt.ddt +class PrefixedEventProcessorTestCase(EventTrackingTestCase): + """ + Test PrefixedEventProcessor + """ + + @ddt.data( + SequenceDDT(action=u'next', tab_count=5, current_tab=3, legacy_event_type=u'seq_next'), + SequenceDDT(action=u'next', tab_count=5, current_tab=5, legacy_event_type=None), + SequenceDDT(action=u'previous', tab_count=5, current_tab=3, legacy_event_type=u'seq_prev'), + SequenceDDT(action=u'previous', tab_count=5, current_tab=1, legacy_event_type=None), + ) + def test_sequence_linear_navigation(self, sequence_ddt): + event_name = u'edx.ui.lms.sequence.{}_selected'.format(sequence_ddt.action) + + event = { + u'name': event_name, + u'event': { + u'current_tab': sequence_ddt.current_tab, + u'tab_count': sequence_ddt.tab_count, + u'id': u'ABCDEFG', + } + } + + process_event_shim = PrefixedEventProcessor() + result = process_event_shim(event) + + # Legacy fields get added when needed + if sequence_ddt.action == u'next': + offset = 1 + else: + offset = -1 + if sequence_ddt.legacy_event_type: + self.assertEqual(result[u'event_type'], sequence_ddt.legacy_event_type) + self.assertEqual(result[u'event'][u'old'], sequence_ddt.current_tab) + self.assertEqual(result[u'event'][u'new'], sequence_ddt.current_tab + offset) + else: + self.assertNotIn(u'event_type', result) + self.assertNotIn(u'old', result[u'event']) + self.assertNotIn(u'new', result[u'event']) + + def test_sequence_tab_navigation(self): + event_name = u'edx.ui.lms.sequence.tab_selected' + event = { + u'name': event_name, + u'event': { + u'current_tab': 2, + u'target_tab': 5, + u'tab_count': 9, + u'id': u'block-v1:abc', + u'widget_placement': u'top', + } + } + + process_event_shim = PrefixedEventProcessor() + result = process_event_shim(event) + self.assertEqual(result[u'event_type'], u'seq_goto') + self.assertEqual(result[u'event'][u'old'], 2) + self.assertEqual(result[u'event'][u'new'], 5) diff --git a/common/djangoapps/track/transformers.py b/common/djangoapps/track/transformers.py new file mode 100644 index 0000000000..471defe9ce --- /dev/null +++ b/common/djangoapps/track/transformers.py @@ -0,0 +1,502 @@ +""" +EventTransformers are data structures that represents events, and modify those +events to match the format desired for the tracking logs. They are registered +by name (or name prefix) in the EventTransformerRegistry, which is used to +apply them to the appropriate events. +""" + +import json +import logging + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey + +log = logging.getLogger(__name__) + + +class DottedPathMapping(object): + """ + Dictionary-like object for creating keys of dotted paths. + + If a key is created that ends with a dot, it will be treated as a path + prefix. Any value whose prefix matches the dotted path can be used + as a key for that value, but only the most specific match will + be used. + """ + + # TODO: The current implementation of the prefix registry requires + # O(number of prefix event transformers) to access an event. If we get a + # large number of EventTransformers, it may be worth writing a tree-based + # map structure where each node is a segment of the match key, which would + # reduce access time to O(len(match.key.split('.'))), or essentially constant + # time. + + def __init__(self, registry=None): + self._match_registry = {} + self._prefix_registry = {} + self.update(registry or {}) + + def __contains__(self, key): + try: + _ = self[key] + return True + except KeyError: + return False + + def __getitem__(self, key): + if key in self._match_registry: + return self._match_registry[key] + if isinstance(key, basestring): + # Reverse-sort the keys to find the longest matching prefix. + for prefix in sorted(self._prefix_registry, reverse=True): + if key.startswith(prefix): + return self._prefix_registry[prefix] + raise KeyError('Key {} not found in {}'.format(key, type(self))) + + def __setitem__(self, key, value): + if key.endswith('.'): + self._prefix_registry[key] = value + else: + self._match_registry[key] = value + + def __delitem__(self, key): + if key.endswith('.'): + del self._prefix_registry[key] + else: + del self._match_registry[key] + + def get(self, key, default=None): + """ + Return `self[key]` if it exists, otherwise, return `None` or `default` + if it is specified. + """ + try: + self[key] + except KeyError: + return default + + def update(self, dict_): + """ + Update the mapping with the values in the supplied `dict`. + """ + for key, value in dict_: + self[key] = value + + def keys(self): + """ + Return the keys of the mapping, including both exact matches and + prefix matches. + """ + return self._match_registry.keys() + self._prefix_registry.keys() + + +class EventTransformerRegistry(object): + """ + Registry to track which EventTransformers handle which events. The + EventTransformer must define a `match_key` attribute which contains the + name or prefix of the event names it tracks. Any `match_key` that ends + with a `.` will match all events that share its prefix. A transformer name + without a trailing dot only processes exact matches. + """ + mapping = DottedPathMapping() + + @classmethod + def register(cls, transformer): + """ + Decorator to register an EventTransformer. It must have a `match_key` + class attribute defined. + """ + cls.mapping[transformer.match_key] = transformer + return transformer + + @classmethod + def create_transformer(cls, event): + """ + Create an EventTransformer of the given event. + + If no transformer is registered to handle the event, this raises a + KeyError. + """ + name = event.get(u'name') + return cls.mapping[name](event) + + +class EventTransformer(dict): + """ + Creates a transformer to modify analytics events based on event type. + + To use the transformer, instantiate it using the + `EventTransformer.create_transformer()` classmethod with the event + dictionary as the sole argument, and then call `transformer.transform()` on + the created object to modify the event to the format required for output. + + Custom transformers will want to define some or all of the following values + + Attributes: + + match_key: + This is the name of the event you want to transform. If the name + ends with a `'.'`, it will be treated as a *prefix transformer*. + All other names denote *exact transformers*. + + A *prefix transformer* will handle any event whose name begins with + the name of the prefix transformer. Only the most specific match + will be used, so if a transformer exists with a name of + `'edx.ui.lms.'` and another transformer has the name + `'edx.ui.lms.sequence.'` then an event called + `'edx.ui.lms.sequence.tab_selected'` will be handled by the + `'edx.ui.lms.sequence.'` transformer. + + An *exact transformer* will only handle events whose name matches + name of the transformer exactly. + + Exact transformers always take precedence over prefix transformers. + + Transformers without a name will not be added to the registry, and + cannot be accessed via the `EventTransformer.create_transformer()` + classmethod. + + is_legacy_event: + If an event is a legacy event, it needs to set event_type to the + legacy name for the event, and may need to set certain event fields + to maintain backward compatiblity. If an event needs to provide + legacy support in some contexts, `is_legacy_event` can be defined + as a property to add dynamic behavior. + + Default: False + + legacy_event_type: + If the event is or can be a legacy event, it should define + the legacy value for the event_type field here. + + Processing methods. Override these to provide the behavior needed for your + particular EventTransformer: + + self.process_legacy_fields(): + This method should modify the event payload in any way necessary to + support legacy event types. It will only be run if + `is_legacy_event` returns a True value. + + self.process_event() + This method modifies the event payload unconditionally. It will + always be run. + """ + def __init__(self, *args, **kwargs): + super(EventTransformer, self).__init__(*args, **kwargs) + self.load_payload() + + # Properties to be overridden + + is_legacy_event = False + + @property + def legacy_event_type(self): + """ + Override this as an attribute or property to provide the value for + the event's `event_type`, if it does not match the event's `name`. + """ + raise NotImplementedError + + # Convenience properties + + @property + def name(self): + """ + Returns the event's name. + """ + return self[u'name'] + + @property + def context(self): + """ + Returns the event's context dict. + """ + return self.get(u'context', {}) + + # Transform methods + + def load_payload(self): + """ + Create a data version of self[u'event'] at self.event + """ + if u'event' in self: + if isinstance(self[u'event'], basestring): + self.event = json.loads(self[u'event']) + else: + self.event = self[u'event'] + + def dump_payload(self): + """ + Write self.event back to self[u'event']. + + Keep the same format we were originally given. + """ + if isinstance(self.get(u'event'), basestring): + self[u'event'] = json.dumps(self.event) + else: + self[u'event'] = self.event + + def transform(self): + """ + Transform the event with legacy fields and other necessary + modifications. + """ + if self.is_legacy_event: + self._set_legacy_event_type() + self.process_legacy_fields() + self.process_event() + self.dump_payload() + + def _set_legacy_event_type(self): + """ + Update the event's `event_type` to the value specified by + `self.legacy_event_type`. + """ + self['event_type'] = self.legacy_event_type + + def process_legacy_fields(self): + """ + Override this method to specify how to update event fields to maintain + compatibility with legacy events. + """ + pass + + def process_event(self): + """ + Override this method to make unconditional modifications to event + fields. + """ + pass + + +@EventTransformerRegistry.register +class SequenceTabSelectedEventTransformer(EventTransformer): + """ + Transformer to maintain backward compatiblity with seq_goto events. + """ + + match_key = u'edx.ui.lms.sequence.tab_selected' + is_legacy_event = True + legacy_event_type = u'seq_goto' + + def process_legacy_fields(self): + self.event[u'old'] = self.event[u'current_tab'] + self.event[u'new'] = self.event[u'target_tab'] + + +class _BaseLinearSequenceEventTransformer(EventTransformer): + """ + Common functionality for transforming + `edx.ui.lms.sequence.{next,previous}_selected` events. + """ + + offset = None + + @property + def is_legacy_event(self): + """ + Linear sequence events are legacy events if the origin and target lie + within the same sequence. + """ + return not self.crosses_boundary() + + def process_legacy_fields(self): + """ + Set legacy payload fields: + old: equal to the new current_tab field + new: the tab to which the user is navigating + """ + self.event[u'old'] = self.event[u'current_tab'] + self.event[u'new'] = self.event[u'current_tab'] + self.offset + + def crosses_boundary(self): + """ + Returns true if the navigation takes the focus out of the current + sequence. + """ + raise NotImplementedError + + +@EventTransformerRegistry.register +class NextSelectedEventTransformer(_BaseLinearSequenceEventTransformer): + """ + Transformer to maintain backward compatiblity with seq_next events. + """ + + match_key = u'edx.ui.lms.sequence.next_selected' + offset = 1 + legacy_event_type = u'seq_next' + + def crosses_boundary(self): + """ + Returns true if the navigation moves the focus to the next sequence. + """ + return self.event[u'current_tab'] == self.event[u'tab_count'] + + +@EventTransformerRegistry.register +class PreviousSelectedEventTransformer(_BaseLinearSequenceEventTransformer): + """ + Transformer to maintain backward compatiblity with seq_prev events. + """ + + match_key = u'edx.ui.lms.sequence.previous_selected' + offset = -1 + legacy_event_type = u'seq_prev' + + def crosses_boundary(self): + """ + Returns true if the navigation moves the focus to the previous + sequence. + """ + return self.event[u'current_tab'] == 1 + + +@EventTransformerRegistry.register +class VideoEventTransformer(EventTransformer): + """ + Converts new format video events into the legacy video event format. + + Mobile devices cannot actually emit events that exactly match their + counterparts emitted by the LMS javascript video player. Instead of + attempting to get them to do that, we instead insert a transformer here + that converts the events they *can* easily emit and converts them into the + legacy format. + """ + match_key = u'edx.video.' + + name_to_event_type_map = { + u'edx.video.played': u'play_video', + u'edx.video.paused': u'pause_video', + u'edx.video.stopped': u'stop_video', + u'edx.video.loaded': u'load_video', + u'edx.video.position.changed': u'seek_video', + u'edx.video.seeked': u'seek_video', + u'edx.video.transcript.shown': u'show_transcript', + u'edx.video.transcript.hidden': u'hide_transcript', + } + + is_legacy_event = True + + @property + def legacy_event_type(self): + """ + Return the legacy event_type of the current event + """ + return self.name_to_event_type_map[self.name] + + def transform(self): + """ + Transform the event with necessary modifications if it is one of the + expected types of events. + """ + if self.name in self.name_to_event_type_map: + super(VideoEventTransformer, self).transform() + + def process_event(self): + """ + Modify event fields. + """ + + # Convert edx.video.seeked to edx.video.position.changed because edx.video.seeked was not intended to actually + # ever be emitted. + if self.name == "edx.video.seeked": + self['name'] = "edx.video.position.changed" + + if not self.event: + return + + self.set_id_to_usage_key() + self.capcase_current_time() + + self.convert_seek_type() + self.disambiguate_skip_and_seek() + self.set_page_to_browser_url() + self.handle_ios_seek_bug() + + def set_id_to_usage_key(self): + """ + Validate that the module_id is a valid usage key, and set the id field + accordingly. + """ + if 'module_id' in self.event: + module_id = self.event['module_id'] + try: + usage_key = UsageKey.from_string(module_id) + except InvalidKeyError: + log.warning('Unable to parse module_id "%s"', module_id, exc_info=True) + else: + self.event['id'] = usage_key.html_id() + + del self.event['module_id'] + + def capcase_current_time(self): + """ + Convert the current_time field to currentTime. + """ + if 'current_time' in self.event: + self.event['currentTime'] = self.event.pop('current_time') + + def convert_seek_type(self): + """ + Converts seek_type to seek and converts skip|slide to + onSlideSeek|onSkipSeek. + """ + if 'seek_type' in self.event: + seek_type = self.event['seek_type'] + if seek_type == 'slide': + self.event['type'] = "onSlideSeek" + elif seek_type == 'skip': + self.event['type'] = "onSkipSeek" + del self.event['seek_type'] + + def disambiguate_skip_and_seek(self): + """ + For the Android build that isn't distinguishing between skip/seek. + """ + if 'requested_skip_interval' in self.event: + if abs(self.event['requested_skip_interval']) != 30: + if 'type' in self.event: + self.event['type'] = 'onSlideSeek' + + def set_page_to_browser_url(self): + """ + If `open_in_browser_url` is specified, set the page to the base of the + specified url. + """ + if 'open_in_browser_url' in self.context: + self['page'] = self.context.pop('open_in_browser_url').rpartition('/')[0] + + def handle_ios_seek_bug(self): + """ + Handle seek bug in iOS. + + iOS build 1.0.02 has a bug where it returns a +30 second skip when + it should be returning -30. + """ + if self._build_requests_plus_30_for_minus_30(): + if self._user_requested_plus_30_skip(): + self.event[u'requested_skip_interval'] = -30 + + def _build_requests_plus_30_for_minus_30(self): + """ + Returns True if this build contains the seek bug + """ + if u'application' in self.context: + if all(key in self.context[u'application'] for key in (u'version', u'name')): + app_version = self.context[u'application'][u'version'] + app_name = self.context[u'application'][u'name'] + return app_version == u'1.0.02' and app_name == u'edx.mobileapp.iOS' + return False + + def _user_requested_plus_30_skip(self): + """ + If the user requested a +30 second skip, return True. + """ + + if u'requested_skip_interval' in self.event and u'type' in self.event: + interval = self.event[u'requested_skip_interval'] + action = self.event[u'type'] + return interval == 30 and action == u'onSkipSeek' + else: + return False diff --git a/common/djangoapps/track/views/tests/test_segmentio.py b/common/djangoapps/track/views/tests/test_segmentio.py index 2ee9641e80..dfb009282a 100644 --- a/common/djangoapps/track/views/tests/test_segmentio.py +++ b/common/djangoapps/track/views/tests/test_segmentio.py @@ -21,12 +21,8 @@ ENDPOINT = '/segmentio/test/event' USER_ID = 10 MOBILE_SHIM_PROCESSOR = [ - { - 'ENGINE': 'track.shim.LegacyFieldMappingProcessor' - }, - { - 'ENGINE': 'track.shim.VideoEventProcessor' - } + {'ENGINE': 'track.shim.LegacyFieldMappingProcessor'}, + {'ENGINE': 'track.shim.PrefixedEventProcessor'}, ] @@ -411,19 +407,29 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase): assert_event_matches(expected_event, actual_event) @data( - # Verify positive slide case. Verify slide to onSlideSeek. Verify edx.video.seeked emitted from iOS v1.0.02 is changed to edx.video.position.changed. + # Verify positive slide case. Verify slide to onSlideSeek. Verify + # edx.video.seeked emitted from iOS v1.0.02 is changed to + # edx.video.position.changed. (1, 1, "seek_type", "slide", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify negative slide case. Verify slide to onSlideSeek. Verify edx.video.seeked to edx.video.position.changed. + # Verify negative slide case. Verify slide to onSlideSeek. Verify + # edx.video.seeked to edx.video.position.changed. (-2, -2, "seek_type", "slide", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify +30 is changed to -30 which is incorrectly emitted in iOS v1.0.02. Verify skip to onSkipSeek + # Verify +30 is changed to -30 which is incorrectly emitted in iOS + # v1.0.02. Verify skip to onSkipSeek (30, -30, "seek_type", "skip", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify the correct case of -30 is also handled as well. Verify skip to onSkipSeek + # Verify the correct case of -30 is also handled as well. Verify skip + # to onSkipSeek (-30, -30, "seek_type", "skip", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify positive slide case where onSkipSeek is changed to onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is changed to edx.video.position.changed. + # Verify positive slide case where onSkipSeek is changed to + # onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is + # changed to edx.video.position.changed. (1, 1, "type", "onSkipSeek", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), - # Verify positive slide case where onSkipSeek is changed to onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is changed to edx.video.position.changed. + # Verify positive slide case where onSkipSeek is changed to + # onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is + # changed to edx.video.position.changed. (-2, -2, "type", "onSkipSeek", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), - # Verify positive skip case where onSkipSeek is not changed and does not become negative. + # Verify positive skip case where onSkipSeek is not changed and does + # not become negative. (30, 30, "type", "onSkipSeek", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), # Verify positive skip case where onSkipSeek is not changed. (-30, -30, "type", "onSkipSeek", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02') diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 574b67d791..fd976a4b84 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -132,14 +132,26 @@ class @Sequence @$('.sequence-nav-button').unbind('click') # previous button - first_tab = @position == 1 + is_first_tab = @position == 1 previous_button_class = '.sequence-nav-button.button-previous' - @updateButtonState(previous_button_class, @previous, 'Previous', first_tab, @prevUrl) + @updateButtonState( + previous_button_class, # bound element + @selectPrevious, # action + 'Previous', # label prefix + is_first_tab, # is boundary? + @prevUrl # boundary_url + ) # next button - last_tab = @position >= @contents.length # use inequality in case contents.length is 0 and position is 1. + is_last_tab = @position >= @contents.length # use inequality in case contents.length is 0 and position is 1. next_button_class = '.sequence-nav-button.button-next' - @updateButtonState(next_button_class, @next, 'Next', last_tab, @nextUrl) + @updateButtonState( + next_button_class, # bound element + @selectNext, # action + 'Next', # label prefix + is_last_tab, # is boundary? + @nextUrl # boundary_url + ) render: (new_position) -> if @position != new_position @@ -180,7 +192,7 @@ class @Sequence @el.find('.path').text(@el.find('.nav-item.active').data('path')) - @sr_container.focus(); + @sr_container.focus() goto: (event) => event.preventDefault() @@ -190,7 +202,17 @@ class @Sequence new_position = $(event.currentTarget).data('element') if (1 <= new_position) and (new_position <= @num_contents) - Logger.log "seq_goto", old: @position, new: new_position, id: @id + is_bottom_nav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0 + if is_bottom_nav + widget_placement = 'bottom' + else + widget_placement = 'top' + Logger.log "edx.ui.lms.sequence.tab_selected", # Formerly known as seq_goto + current_tab: @position + target_tab: new_position + tab_count: @num_contents + id: @id + widget_placement: widget_placement # On Sequence change, destroy any existing polling thread # for queued submissions, see ../capa/display.coffee @@ -204,32 +226,43 @@ class @Sequence alert_text = interpolate(alert_template, {tab_name: new_position}, true) alert alert_text - next: (event) => @_change_sequential 'seq_next', event - previous: (event) => @_change_sequential 'seq_prev', event + selectNext: (event) => @_change_sequential 'next', event - # `direction` can be 'seq_prev' or 'seq_next' + selectPrevious: (event) => @_change_sequential 'previous', event + + # `direction` can be 'previous' or 'next' _change_sequential: (direction, event) => # silently abort if direction is invalid. - return unless direction in ['seq_prev', 'seq_next'] + return unless direction in ['previous', 'next'] event.preventDefault() - offset = - seq_next: 1 - seq_prev: -1 - new_position = @position + offset[direction] - Logger.log direction, - old: @position - new: new_position - id: @id - if (direction == "seq_next") and (@position == @contents.length) + analytics_event_name = "edx.ui.lms.sequence.#{direction}_selected" + is_bottom_nav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0 + + if is_bottom_nav + widget_placement = 'bottom' + else + widget_placement = 'top' + + Logger.log analytics_event_name, # Formerly known as seq_next and seq_prev + id: @id + current_tab: @position + tab_count: @num_contents + widget_placement: widget_placement + + if (direction == 'next') and (@position == @contents.length) window.location.href = @nextUrl - else if (direction == "seq_prev") and (@position == 1) + else if (direction == 'previous') and (@position == 1) window.location.href = @prevUrl else # If the bottom nav is used, scroll to the top of the page on change. - if $(event.target).closest('nav[class="sequence-bottom"]').length > 0 + if is_bottom_nav $.scrollTo 0, 150 + offset = + next: 1 + previous: -1 + new_position = @position + offset[direction] @render new_position link_for: (position) -> diff --git a/common/test/acceptance/tests/lms/test_lms_courseware.py b/common/test/acceptance/tests/lms/test_lms_courseware.py index 4f551f378f..91d23f7d28 100644 --- a/common/test/acceptance/tests/lms/test_lms_courseware.py +++ b/common/test/acceptance/tests/lms/test_lms_courseware.py @@ -2,10 +2,12 @@ """ End-to-end tests for the LMS. """ + +import json from nose.plugins.attrib import attr -from ..helpers import UniqueCourseTest from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory +from ..helpers import UniqueCourseTest, EventsTestMixin from ...pages.studio.auto_auth import AutoAuthPage from ...pages.lms.create_mode import ModeCreationPage from ...pages.studio.overview import CourseOutlinePage @@ -66,7 +68,7 @@ class CoursewareTest(UniqueCourseTest): Open problem page with assertion. """ self.courseware_page.visit() - self.problem_page = ProblemPage(self.browser) + self.problem_page = ProblemPage(self.browser) # pylint: disable=attribute-defined-outside-init self.assertEqual(self.problem_page.problem_name, 'Test Problem 1') def _create_breadcrumb(self, index): @@ -394,7 +396,7 @@ class ProctoredExamTest(UniqueCourseTest): self.assertTrue(self.course_outline.time_allotted_field_visible()) -class CoursewareMultipleVerticalsTest(UniqueCourseTest): +class CoursewareMultipleVerticalsTest(UniqueCourseTest, EventsTestMixin): """ Test courseware with multiple verticals """ @@ -476,6 +478,87 @@ class CoursewareMultipleVerticalsTest(UniqueCourseTest): self.courseware_page.click_previous_button_on_bottom() self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 2, next_enabled=True, prev_enabled=True) + # test UI events emitted by navigation + filter_sequence_ui_event = lambda event: event.get('name', '').startswith('edx.ui.lms.sequence.') + + sequence_ui_events = self.wait_for_events(event_filter=filter_sequence_ui_event, timeout=2) + legacy_events = [ev for ev in sequence_ui_events if ev['event_type'] in {'seq_next', 'seq_prev', 'seq_goto'}] + nonlegacy_events = [ev for ev in sequence_ui_events if ev not in legacy_events] + + self.assertTrue(all('old' in json.loads(ev['event']) for ev in legacy_events)) + self.assertTrue(all('new' in json.loads(ev['event']) for ev in legacy_events)) + self.assertFalse(any('old' in json.loads(ev['event']) for ev in nonlegacy_events)) + self.assertFalse(any('new' in json.loads(ev['event']) for ev in nonlegacy_events)) + + self.assert_events_match( + [ + { + 'event_type': 'seq_next', + 'event': { + 'old': 1, + 'new': 2, + 'current_tab': 1, + 'tab_count': 4, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'seq_goto', + 'event': { + 'old': 2, + 'new': 4, + 'current_tab': 2, + 'target_tab': 4, + 'tab_count': 4, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.next_selected', + 'event': { + 'current_tab': 4, + 'tab_count': 4, + 'widget_placement': 'bottom', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.next_selected', + 'event': { + 'current_tab': 1, + 'tab_count': 1, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.previous_selected', + 'event': { + 'current_tab': 1, + 'tab_count': 1, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.previous_selected', + 'event': { + 'current_tab': 1, + 'tab_count': 1, + 'widget_placement': 'bottom', + } + }, + { + 'event_type': 'seq_prev', + 'event': { + 'old': 4, + 'new': 3, + 'current_tab': 4, + 'tab_count': 4, + 'widget_placement': 'bottom', + } + }, + ], + sequence_ui_events + ) + def assert_navigation_state( self, section_title, subsection_title, subsection_position, next_enabled, prev_enabled ): diff --git a/lms/envs/common.py b/lms/envs/common.py index ebe48f03b0..12fde12ca5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -625,7 +625,7 @@ EVENT_TRACKING_BACKENDS = { }, 'processors': [ {'ENGINE': 'track.shim.LegacyFieldMappingProcessor'}, - {'ENGINE': 'track.shim.VideoEventProcessor'} + {'ENGINE': 'track.shim.PrefixedEventProcessor'} ] } }, From 0f0636387bad46ab2fc277760d338281bfcec336 Mon Sep 17 00:00:00 2001 From: Christine Lytwynec Date: Fri, 22 Apr 2016 11:47:08 -0400 Subject: [PATCH 024/105] Fix start url format for paver pa11ycrawler --- pavelib/paver_tests/test_paver_bok_choy_cmds.py | 2 +- pavelib/utils/test/suites/bokchoy_suite.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pavelib/paver_tests/test_paver_bok_choy_cmds.py b/pavelib/paver_tests/test_paver_bok_choy_cmds.py index d9290c017f..0573565146 100644 --- a/pavelib/paver_tests/test_paver_bok_choy_cmds.py +++ b/pavelib/paver_tests/test_paver_bok_choy_cmds.py @@ -200,7 +200,7 @@ class TestPaverPa11yCrawlerCmd(unittest.TestCase): '--pa11y-reporter="1.0-json" ' '--depth-limit=6 ' ).format( - start_urls=start_urls, + start_urls=' '.join(start_urls), report_dir=report_dir, ) return expected_statement diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index fc6a66a407..cca35aa704 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -360,7 +360,7 @@ class Pa11yCrawler(BokChoyTestSuite): '--pa11y-reporter="{reporter}" ' '--depth-limit={depth} ' ).format( - start_urls=self.start_urls, + start_urls=' '.join(self.start_urls), allowed_domains='localhost', report_dir=self.pa11y_report_dir, reporter="1.0-json", From 85767b3be7c78ad099a349039a0df78d93b3a74e Mon Sep 17 00:00:00 2001 From: Christine Lytwynec Date: Fri, 22 Apr 2016 12:48:34 -0400 Subject: [PATCH 025/105] Fix default args for paver pa11ycrawler --- pavelib/bok_choy.py | 4 ++-- pavelib/utils/test/suites/bokchoy_suite.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pavelib/bok_choy.py b/pavelib/bok_choy.py index 1a206c1aa5..885a92f571 100644 --- a/pavelib/bok_choy.py +++ b/pavelib/bok_choy.py @@ -152,8 +152,8 @@ def pa11ycrawler(options): opts = parse_bokchoy_opts(options) opts['report_dir'] = Env.PA11YCRAWLER_REPORT_DIR opts['coveragerc'] = Env.PA11YCRAWLER_COVERAGERC - opts['should_fetch_course'] = getattr(options, 'should_fetch_course', None) - opts['course_key'] = getattr(options, 'course-key', None) + opts['should_fetch_course'] = getattr(options, 'should_fetch_course', not opts['fasttest']) + opts['course_key'] = getattr(options, 'course-key', "course-v1:edX+Test101+course") test_suite = Pa11yCrawler('a11y_crawler', **opts) test_suite.run() diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index cca35aa704..19d51da7b5 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -268,7 +268,7 @@ class Pa11yCrawler(BokChoyTestSuite): def __init__(self, *args, **kwargs): super(Pa11yCrawler, self).__init__(*args, **kwargs) - self.course_key = kwargs.get('course_key', "course-v1:edX+Test101+course") + self.course_key = kwargs.get('course_key') if self.imports_dir: # If imports_dir has been specified, assume the files are # already there -- no need to fetch them from github. This @@ -279,7 +279,7 @@ class Pa11yCrawler(BokChoyTestSuite): # Otherwise, obey `--skip-fetch` command and use the default # test course. Note that the fetch will also be skipped when # using `--fast`. - self.should_fetch_course = kwargs.get('should_fetch_course', not self.fasttest) + self.should_fetch_course = kwargs.get('should_fetch_course') self.imports_dir = path('test_root/courses/') self.pa11y_report_dir = os.path.join(self.report_dir, 'pa11ycrawler_reports') From 79332ed5861ae8429d8c113d6841c0118cd64ee6 Mon Sep 17 00:00:00 2001 From: Simon Chen Date: Fri, 22 Apr 2016 13:23:06 -0400 Subject: [PATCH 026/105] ECOM-4272 Add the cosmetic changes to the program listing page --- .../program_card_view_spec.js | 2 +- lms/static/sass/elements/_program-card.scss | 4 ++-- lms/static/sass/shared/_header.scss | 18 ++++++++++++------ .../learner_dashboard/program_card.underscore | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/lms/static/js/spec/learner_dashboard/program_card_view_spec.js b/lms/static/js/spec/learner_dashboard/program_card_view_spec.js index 43eac9542f..98b4ef1fee 100644 --- a/lms/static/js/spec/learner_dashboard/program_card_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/program_card_view_spec.js @@ -56,7 +56,7 @@ define([ expect($cards).toBeDefined(); expect($cards.find('.title').html().trim()).toEqual(program.name); expect($cards.find('.category span').html().trim()).toEqual('XSeries Program'); - expect($cards.find('.organization').html().trim()).toEqual(program.organizations[0].display_name); + expect($cards.find('.organization').html().trim()).toEqual(program.organizations[0].key); expect($cards.find('.card-link').attr('href')).toEqual(program.marketing_url); }); diff --git a/lms/static/sass/elements/_program-card.scss b/lms/static/sass/elements/_program-card.scss index 8c64e443dd..01ebe86490 100644 --- a/lms/static/sass/elements/_program-card.scss +++ b/lms/static/sass/elements/_program-card.scss @@ -19,7 +19,7 @@ left: 0; border: 0; z-index: 1; - opacity: 0.5; + opacity: 0.8; &:hover, &:focus{ opacity: 1; @@ -69,7 +69,7 @@ } .title{ font-size: em(24); - color: $gray-l1; + color: $gray-d2; margin-bottom: 10px; line-height: 1.2; } diff --git a/lms/static/sass/shared/_header.scss b/lms/static/sass/shared/_header.scss index f1ee639a4e..8a7c3118fa 100644 --- a/lms/static/sass/shared/_header.scss +++ b/lms/static/sass/shared/_header.scss @@ -263,13 +263,16 @@ header.global { list-style: none; @include float(left); .tab-nav-item{ - display: inline; + display: flex; margin: 0px; + text-transform: none; + @include float(left); + justify-content: center; .tab-nav-link{ font-size: em(16); color: $gray; - padding: 26px 15px; - display: inline; + padding: 5px 25px 23px; + display: inline-block; &:hover, &:focus{ border-bottom: 4px solid $courseware-border-bottom-color; @@ -393,13 +396,16 @@ header.global-new { .nav-global { @include float(left); .tab-nav-item{ - display: inline; + display: flex; margin: 0px; + text-transform: none; + @include float(left); + justify-content: center; .tab-nav-link{ font-size: em(16); color: $gray; - display: inline; - padding: 25px 15px; + display: inline-block; + padding: 5px 25px 19px; &:hover, &:focus{ border-bottom: 4px solid $courseware-border-bottom-color; diff --git a/lms/templates/learner_dashboard/program_card.underscore b/lms/templates/learner_dashboard/program_card.underscore index bf26e2e231..061bd8eb52 100644 --- a/lms/templates/learner_dashboard/program_card.underscore +++ b/lms/templates/learner_dashboard/program_card.underscore @@ -14,7 +14,7 @@
<% _.each(organizations, function(org){ %> - <%- gettext(org.display_name) %> + <%- gettext(org.key) %> <% }); %>
From d2200f2ba30a15ae533fdbe489be8a4bce1065e1 Mon Sep 17 00:00:00 2001 From: Ben Patterson Date: Mon, 7 Dec 2015 15:02:53 -0500 Subject: [PATCH 027/105] Update to latest for selenium. --- requirements/edx/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 871d04e725..7ed41225ed 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -163,7 +163,7 @@ python-subunit==0.0.16 pyquery==1.2.9 radon==1.2 rednose==0.4.3 -selenium==2.42.1 +selenium==2.48.0 splinter==0.5.4 testtools==0.9.34 testfixtures==4.5.0 From c42409f77415e154aef72493db844b33b48a9fb9 Mon Sep 17 00:00:00 2001 From: Ben Patterson Date: Tue, 22 Dec 2015 21:01:29 -0500 Subject: [PATCH 028/105] Use custom firefox path for bok-choy tests (firefox 42) This is a backwards-compatible window for this upgrade. It will later be replaced with using the default location (whatever's on PATH) --- scripts/generic-ci-tests.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/generic-ci-tests.sh b/scripts/generic-ci-tests.sh index b434480208..ce7022a392 100755 --- a/scripts/generic-ci-tests.sh +++ b/scripts/generic-ci-tests.sh @@ -158,6 +158,13 @@ case "$TEST_SUITE" in ;; "bok-choy") + + # Back compatibility support for firefox upgrade: + # Copy newer firefox version to project root, + # set that as the path for bok-choy to use. + cp -R $HOME/firefox/ firefox/ + export SELENIUM_FIREFOX_PATH=firefox/firefox + case "$SHARD" in "all") From 5dfb01cea7dd92ce69e854a8b0ddd2756e195164 Mon Sep 17 00:00:00 2001 From: Muddasser Date: Thu, 7 Apr 2016 19:41:18 +0500 Subject: [PATCH 029/105] assertScreenshot for firefox 42 --- common/test/acceptance/tests/lms/test_lms.py | 4 ++++ screenshots/baseline/hinted-login.png | Bin 14626 -> 14521 bytes screenshots/baseline/login-providers.png | Bin 5299 -> 5284 bytes screenshots/baseline/register-providers.png | Bin 5889 -> 5873 bytes 4 files changed, 4 insertions(+) diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 973cb7e225..12b61e48b9 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -9,6 +9,7 @@ from unittest import skip from nose.plugins.attrib import attr import pytz import urllib +from ..helpers import skip_if_browser from bok_choy.promise import EmptyPromise from ..helpers import ( @@ -158,6 +159,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest): self.login_page.wait_for_errors() ) + @skip_if_browser('chrome') # TODO Need to fix this for chrome browser. def test_third_party_login(self): """ Test that we can login using third party credentials, and that the @@ -195,6 +197,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest): self._unlink_dummy_account() + @skip_if_browser('chrome') # TODO Need to fix this for chrome browser. def test_hinted_login(self): """ Test the login page when coming from course URL that specified which third party provider to use """ # Create a user account and link it to third party auth with the dummy provider: @@ -328,6 +331,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): self.register_page.visit().toggle_form() self.assertEqual(self.register_page.current_form, "login") + @skip_if_browser('chrome') # TODO Need to fix this for chrome browser. def test_third_party_register(self): """ Test that we can register using third party credentials, and that the diff --git a/screenshots/baseline/hinted-login.png b/screenshots/baseline/hinted-login.png index fe581ce31af24b6e1805aabe99a18944166342bb..e5ea1f8fbde458a50f2f27fb65292e5246b4b6aa 100644 GIT binary patch literal 14521 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 J0{Y?8e*qHZ%JBdI literal 14626 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 A*8l(j diff --git a/screenshots/baseline/login-providers.png b/screenshots/baseline/login-providers.png index 70c19ca2262e8ccf0768480bccd243e1c174bc75..91498bff9d754af7445e95720c975d6e08d208fa 100644 GIT binary patch literal 5284 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 LwUp4{*B|}^M*$wu literal 5299 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 delta 5512 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 Date: Fri, 8 Apr 2016 15:44:27 -0400 Subject: [PATCH 030/105] selenium 2.53.1 --- requirements/edx/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7ed41225ed..66afdb145b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -163,7 +163,7 @@ python-subunit==0.0.16 pyquery==1.2.9 radon==1.2 rednose==0.4.3 -selenium==2.48.0 +selenium==2.53.1 splinter==0.5.4 testtools==0.9.34 testfixtures==4.5.0 From c2a48214f57f329394173ecf33b395c1cf213e60 Mon Sep 17 00:00:00 2001 From: Awais Jibran Date: Wed, 6 Apr 2016 15:19:06 +0500 Subject: [PATCH 031/105] Safe tempaltes --- cms/static/js/views/settings/advanced.js | 16 +++-- cms/static/js/views/settings/grader.js | 19 ++++-- cms/static/js/views/settings/grading.js | 68 +++++++++++-------- cms/static/js/views/validation.js | 18 +++-- .../js/course_grade_cutoff.underscore | 5 ++ cms/templates/settings_graders.html | 2 +- 6 files changed, 84 insertions(+), 44 deletions(-) create mode 100644 cms/templates/js/course_grade_cutoff.underscore diff --git a/cms/static/js/views/settings/advanced.js b/cms/static/js/views/settings/advanced.js index dd51134aa1..3c1eba2fb2 100644 --- a/cms/static/js/views/settings/advanced.js +++ b/cms/static/js/views/settings/advanced.js @@ -1,5 +1,11 @@ -define(["js/views/validation", "jquery", "underscore", "gettext", "codemirror", "js/views/modals/validation_error_modal"], - function(ValidatingView, $, _, gettext, CodeMirror, ValidationErrorModal) { +define(["js/views/validation", + "jquery", + "underscore", + "gettext", + "codemirror", + "js/views/modals/validation_error_modal", + 'edx-ui-toolkit/js/utils/html-utils'], + function(ValidatingView, $, _, gettext, CodeMirror, ValidationErrorModal, HtmlUtils) { var AdvancedView = ValidatingView.extend({ error_saving : "error_saving", @@ -13,7 +19,9 @@ var AdvancedView = ValidatingView.extend({ // TODO enable/disable save based on validation (currently enabled whenever there are changes) }, initialize : function() { - this.template = _.template($("#advanced_entry-tpl").text()); + this.template = HtmlUtils.template( + $("#advanced_entry-tpl").text() + ); this.listenTo(this.model, 'invalid', this.handleValidationError); this.render(); }, @@ -33,7 +41,7 @@ var AdvancedView = ValidatingView.extend({ _.each(_.sortBy(_.keys(this.model.attributes), function(key) { return self.model.get(key).display_name; }), function(key) { if (self.render_deprecated || !self.model.get(key).deprecated) { - listEle$.append(self.renderTemplate(key, self.model.get(key))); + HtmlUtils.append(listEle$, self.renderTemplate(key, self.model.get(key))); } }); diff --git a/cms/static/js/views/settings/grader.js b/cms/static/js/views/settings/grader.js index 3a125f67d8..cf936f4ff8 100644 --- a/cms/static/js/views/settings/grader.js +++ b/cms/static/js/views/settings/grader.js @@ -1,4 +1,10 @@ -define(["js/views/validation", "underscore", "jquery"], function(ValidatingView, _, $) { +define(["js/views/validation", + 'gettext', + 'edx-ui-toolkit/js/utils/string-utils', + "edx-ui-toolkit/js/utils/html-utils", + "underscore", + "jquery"], + function(ValidatingView, gettext, StringUtils, HtmlUtils, _, $) { var GraderView = ValidatingView.extend({ // Model class is CMS.Models.Settings.CourseGrader @@ -49,9 +55,14 @@ var GraderView = ValidatingView.extend({ if (this.setField(event) != this.oldName && !_.isEmpty(this.oldName)) { // overload the error display logic this._cacheValidationErrors.push(event.currentTarget); - $(event.currentTarget).parent().append( - this.errorTemplate({message : 'For grading to work, you must change all "' + this.oldName + - '" subsections to "' + this.model.get('type') + '".'})); + var message = StringUtils.interpolate( + gettext('For grading to work, you must change all {oldName} subsections to {newName}.'), + { + oldName: this.oldName, + newName: this.model.get('type') + } + ); + HtmlUtils.append($(event.currentTarget).parent(), this.errorTemplate({message : message})); } break; default: diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index 8a3bab4fc6..184cba06d2 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -1,5 +1,12 @@ -define(["js/views/validation", "underscore", "jquery", "jquery.ui", "js/views/settings/grader"], - function(ValidatingView, _, $, ui, GraderView) { +define(["js/views/validation", + "underscore", + "jquery", + "jquery.ui", + "js/views/settings/grader", + 'edx-ui-toolkit/js/utils/string-utils', + 'edx-ui-toolkit/js/utils/html-utils', + ], + function(ValidatingView, _, $, ui, GraderView, StringUtils, HtmlUtils) { var GradingView = ValidatingView.extend({ // Model class is CMS.Models.Settings.CourseGradingPolicy @@ -21,13 +28,12 @@ var GradingView = ValidatingView.extend({ initialize : function() { // load template for grading view var self = this; - this.template = _.template($("#course_grade_policy-tpl").text()); - this.gradeCutoffTemplate = _.template('
  • ' + - '<%= descriptor %>' + - '' + - '<% if (removable) {%>remove<% ;} %>' + - '
  • '); - + this.template = HtmlUtils.template( + $("#course_grade_policy-tpl").text() + ); + this.gradeCutoffTemplate = HtmlUtils.template( + $("#course_grade_cutoff-tpl").text() + ); this.setupCutoffs(); this.listenTo(this.model, 'invalid', this.handleValidationError); @@ -68,7 +74,7 @@ var GradingView = ValidatingView.extend({ }, this); gradeCollection.each(function(gradeModel) { - $(gradelist).append(self.template({model : gradeModel })); + HtmlUtils.append(gradelist, self.template({model : gradeModel })); var newEle = gradelist.children().last(); var newView = new GraderView({el: newEle, model : gradeModel, collection : gradeCollection }); @@ -147,7 +153,7 @@ var GradingView = ValidatingView.extend({ gradeBarWidth : null, // cache of value since it won't change (more certain) renderCutoffBar: function() { - var gradeBar =this.$el.find('.grade-bar'); + var gradeBar = this.$el.find('.grade-bar'); this.gradeBarWidth = gradeBar.width(); var gradelist = gradeBar.children('.grades'); // HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x @@ -156,15 +162,15 @@ var GradingView = ValidatingView.extend({ // Can probably be simplified to one variable now. var removable = false; var draggable = false; // first and last are not removable, first is not draggable - _.each(this.descendingCutoffs, - function(cutoff, index) { - var newBar = this.gradeCutoffTemplate({ - descriptor : cutoff['designation'] , + _.each(this.descendingCutoffs, function(cutoff) { + HtmlUtils.append(gradelist, this.gradeCutoffTemplate({ + descriptor : cutoff.designation, width : nextWidth, - removable : removable }); - gradelist.append(newBar); + contenteditable: true, + removable : removable}) + ); if (draggable) { - newBar = gradelist.children().last(); // get the dom object not the unparsed string + var newBar = gradelist.children().last(); // get the dom object not the unparsed string newBar.resizable({ handles: "e", containment : "parent", @@ -174,19 +180,18 @@ var GradingView = ValidatingView.extend({ }); } // prepare for next - nextWidth = cutoff['cutoff']; + nextWidth = cutoff.cutoff; removable = true; // first is not removable, all others are draggable = true; }, this); - // add fail which is not in data - var failBar = $(this.gradeCutoffTemplate({ + // Add fail which is not in data + HtmlUtils.append(gradelist, this.gradeCutoffTemplate({ descriptor : this.failLabel(), width : nextWidth, + contenteditable: false, removable : false })); - failBar.find("span[contenteditable=true]").attr("contenteditable", false); - gradelist.append(failBar); gradelist.children().last().resizable({ handles: "e", containment : "parent", @@ -298,10 +303,13 @@ var GradingView = ValidatingView.extend({ this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth}); this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth); - var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength], - width : targetWidth, removable : true }); + var newGradeHtml = this.gradeCutoffTemplate({ + descriptor : this.GRADES[gradeLength], + width : targetWidth, + contenteditable: true, + removable : true }); var gradeDom = this.$el.find('.grades'); - gradeDom.children().last().before($newGradeBar); + gradeDom.children().last().before(HtmlUtils.ensureHtml(newGradeHtml).toString()); var newEle = gradeDom.children()[gradeLength]; $(newEle).resizable({ handles: "e", @@ -313,8 +321,8 @@ var GradingView = ValidatingView.extend({ // Munge existing grade labels? // If going from Pass/Fail to 3 levels, change to Pass to A - if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') { - this.descendingCutoffs[0]['designation'] = this.GRADES[0]; + if (gradeLength === 1 && this.descendingCutoffs[0].designation === 'Pass') { + this.descendingCutoffs[0].designation = this.GRADES[0]; this.setTopGradeLabel(); } this.setFailLabel(); @@ -349,10 +357,10 @@ var GradingView = ValidatingView.extend({ else return 'F'; }, setFailLabel: function() { - this.$el.find('.grades .letter-grade').last().html(this.failLabel()); + this.$el.find('.grades .letter-grade').last().text(this.failLabel()); }, setTopGradeLabel: function() { - this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']); + this.$el.find('.grades .letter-grade').first().text(this.descendingCutoffs[0].designation); }, setupCutoffs: function() { // Instrument grading scale diff --git a/cms/static/js/views/validation.js b/cms/static/js/views/validation.js index d0b21e9810..afbe6bedc7 100644 --- a/cms/static/js/views/validation.js +++ b/cms/static/js/views/validation.js @@ -1,5 +1,13 @@ -define(["js/views/baseview", "underscore", "jquery", "gettext", "common/js/components/views/feedback_notification", "common/js/components/views/feedback_alert", "js/views/baseview", "jquery.smoothScroll"], - function(BaseView, _, $, gettext, NotificationView, AlertView) { +define(["edx-ui-toolkit/js/utils/html-utils", + "js/views/baseview", + "underscore", + "jquery", + "gettext", + "common/js/components/views/feedback_notification", + "common/js/components/views/feedback_alert", + "js/views/baseview", + "jquery.smoothScroll"], + function(HtmlUtils, BaseView, _, $, gettext, NotificationView, AlertView) { var ValidatingView = BaseView.extend({ // Intended as an abstract class which catches validation errors on the model and @@ -10,7 +18,7 @@ var ValidatingView = BaseView.extend({ this.selectorToField = _.invert(this.fieldToSelectorMap); }, - errorTemplate : _.template('<%= message %>'), + errorTemplate : HtmlUtils.template('<%- message %>'), save_title: gettext("You've made some changes"), save_message: gettext("Your changes will not take effect until you save your progress."), @@ -34,7 +42,7 @@ var ValidatingView = BaseView.extend({ var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); this._cacheValidationErrors.push(ele); this.getInputElements(ele).addClass('error'); - $(ele).parent().append(this.errorTemplate({message : error[field]})); + HtmlUtils.append($(ele).parent(), this.errorTemplate({message : error[field]})); } $('.wrapper-notification-warning').addClass('wrapper-notification-warning-w-errors'); $('.action-save').addClass('is-disabled'); @@ -60,7 +68,7 @@ var ValidatingView = BaseView.extend({ // Set model field and return the new value. this.clearValidationErrors(); var field = this.selectorToField[event.currentTarget.id]; - var newVal = '' + var newVal = ''; if(event.currentTarget.type == 'checkbox'){ newVal = $(event.currentTarget).is(":checked").toString(); }else{ diff --git a/cms/templates/js/course_grade_cutoff.underscore b/cms/templates/js/course_grade_cutoff.underscore new file mode 100644 index 0000000000..1a85dd30f7 --- /dev/null +++ b/cms/templates/js/course_grade_cutoff.underscore @@ -0,0 +1,5 @@ +
  • + <%- descriptor %> + + <% if (removable) {%>remove<% ;} %> +
  • diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index 44989d201d..20ca1bc3de 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -15,7 +15,7 @@ %> <%block name="header_extras"> -% for template_name in ["course_grade_policy"]: +% for template_name in ["course_grade_policy", "course_grade_cutoff"]: From f7c28f653d661d582e31d6f3f720f4e338b88be2 Mon Sep 17 00:00:00 2001 From: Chris Rodriguez Date: Wed, 20 Apr 2016 09:14:31 -0400 Subject: [PATCH 032/105] AC-385 adding events for closed captions --- .../js/spec/video/video_events_plugin_spec.js | 24 +++++++++++++++++-- .../xmodule/js/src/video/09_events_plugin.js | 18 +++++++++++--- .../xmodule/js/src/video/09_video_caption.js | 8 +++++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js index 82f64b8c8d..4124265792 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js @@ -135,7 +135,7 @@ }); it('can emit "show_transcript" event', function () { - state.el.trigger('captions:show'); + state.el.trigger('transcript:show'); expect(Logger.log).toHaveBeenCalledWith('show_transcript', { id: 'id', code: 'html5', @@ -144,7 +144,7 @@ }); it('can emit "hide_transcript" event', function () { - state.el.trigger('captions:hide'); + state.el.trigger('transcript:hide'); expect(Logger.log).toHaveBeenCalledWith('hide_transcript', { id: 'id', code: 'html5', @@ -152,6 +152,24 @@ }); }); + it('can emit "edx.video.closed_captions.shown" event', function () { + state.el.trigger('captions:show'); + expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.shown', { + id: 'id', + code: 'html5', + current_time: 10 + }); + }); + + it('can emit "edx.video.closed_captions.hidden" event', function () { + state.el.trigger('captions:hide'); + expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.hidden', { + id: 'id', + code: 'html5', + current_time: 10 + }); + }); + it('can destroy itself', function () { var plugin = state.videoEventsPlugin; spyOn($.fn, 'off').and.callThrough(); @@ -167,6 +185,8 @@ 'speedchange': plugin.onSpeedChange, 'language_menu:show': plugin.onShowLanguageMenu, 'language_menu:hide': plugin.onHideLanguageMenu, + 'transcript:show': plugin.onShowTranscript, + 'transcript:hide': plugin.onHideTranscript, 'captions:show': plugin.onShowCaptions, 'captions:hide': plugin.onHideCaptions, 'destroy': plugin.destroy diff --git a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js index 7c5b080621..938453f67f 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js @@ -17,7 +17,9 @@ define('video/09_events_plugin.js', [], function() { _.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onEnded', 'onSeek', 'onSpeedChange', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip', - 'onShowCaptions', 'onHideCaptions', 'destroy'); + 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions', + 'destroy'); + this.state = state; this.options = _.extend({}, options); this.state.videoEventsPlugin = this; @@ -45,6 +47,8 @@ define('video/09_events_plugin.js', [], function() { 'speedchange': this.onSpeedChange, 'language_menu:show': this.onShowLanguageMenu, 'language_menu:hide': this.onHideLanguageMenu, + 'transcript:show': this.onShowTranscript, + 'transcript:hide': this.onHideTranscript, 'captions:show': this.onShowCaptions, 'captions:hide': this.onHideCaptions, 'destroy': this.destroy @@ -108,14 +112,22 @@ define('video/09_events_plugin.js', [], function() { this.log('video_hide_cc_menu', { language: this.getCurrentLanguage() }); }, - onShowCaptions: function () { + onShowTranscript: function () { this.log('show_transcript', {current_time: this.getCurrentTime()}); }, - onHideCaptions: function () { + onHideTranscript: function () { this.log('hide_transcript', {current_time: this.getCurrentTime()}); }, + onShowCaptions: function () { + this.log('edx.video.closed_captions.shown', {current_time: this.getCurrentTime()}); + }, + + onHideCaptions: function () { + this.log('edx.video.closed_captions.hidden', {current_time: this.getCurrentTime()}); + }, + getCurrentTime: function () { var player = this.state.videoPlayer; return player ? player.currentTime : 0; diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index cace102c01..592379964f 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -1131,6 +1131,8 @@ this.captionDisplayEl .text(gettext('(Caption will be displayed when you start playing the video.)')); } + + this.state.el.trigger('captions:show'); }, hideClosedCaptions: function() { @@ -1144,6 +1146,8 @@ .removeClass('is-active') .find('.control-text') .text(gettext('Turn on closed captioning')); + + this.state.el.trigger('captions:hide'); }, updateCaptioningCookie: function(method) { @@ -1191,7 +1195,7 @@ state.el.addClass('closed'); text = gettext('Turn on transcripts'); if (trigger_event) { - this.state.el.trigger('captions:hide'); + this.state.el.trigger('transcript:hide'); } transcriptControlEl @@ -1204,7 +1208,7 @@ this.scrollCaption(); text = gettext('Turn off transcripts'); if (trigger_event) { - this.state.el.trigger('captions:show'); + this.state.el.trigger('transcript:show'); } transcriptControlEl From 921dc602baf57839739e472836698ea0bc47b762 Mon Sep 17 00:00:00 2001 From: Waheed Ahmed Date: Thu, 21 Apr 2016 14:06:21 +0500 Subject: [PATCH 033/105] Automatically enable Self Generating Certificates for Self Paced Courses. ECOM-3437 --- lms/djangoapps/certificates/__init__.py | 3 ++ lms/djangoapps/certificates/signals.py | 26 ++++++++++++ .../certificates/tests/test_signals.py | 31 ++++++++++++++ .../instructor/views/instructor_dashboard.py | 1 + .../instructor_dashboard_2/certificates.html | 42 ++++++++++--------- 5 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 lms/djangoapps/certificates/signals.py create mode 100644 lms/djangoapps/certificates/tests/test_signals.py diff --git a/lms/djangoapps/certificates/__init__.py b/lms/djangoapps/certificates/__init__.py index e69de29bb2..755f73d708 100644 --- a/lms/djangoapps/certificates/__init__.py +++ b/lms/djangoapps/certificates/__init__.py @@ -0,0 +1,3 @@ +""" Certificates app """ +# this is here to support registering the signals in signals.py +from . import signals diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py new file mode 100644 index 0000000000..27b2e2fe84 --- /dev/null +++ b/lms/djangoapps/certificates/signals.py @@ -0,0 +1,26 @@ +""" Signal handler for enabling self-generated certificates by default +for self-paced courses. +""" +from celery.task import task +from django.dispatch.dispatcher import receiver + +from certificates.models import CertificateGenerationCourseSetting +from xmodule.modulestore.django import SignalHandler, modulestore + + +@receiver(SignalHandler.course_published) +def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument + """ Catches the signal that a course has been published in Studio and + enable the self-generated certificates by default for self-paced + courses. + """ + enable_self_generated_certs.delay(course_key) + + +@task() +def enable_self_generated_certs(course_key): + """Enable the self-generated certificates by default for self-paced courses.""" + course = modulestore().get_course(course_key) + is_enabled_for_course = CertificateGenerationCourseSetting.is_enabled_for_course(course_key) + if course.self_paced and not is_enabled_for_course: + CertificateGenerationCourseSetting.set_enabled_for_course(course_key, True) diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py new file mode 100644 index 0000000000..7e27c496b0 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -0,0 +1,31 @@ +""" Unit tests for enabling self-generated certificates by default +for self-paced courses. +""" +from certificates import api as certs_api +from certificates.models import CertificateGenerationConfiguration +from certificates.signals import _listen_for_course_publish +from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class SelfGeneratedCertsSignalTest(ModuleStoreTestCase): + """ Tests for enabling self-generated certificates by default + for self-paced courses. + """ + + def setUp(self): + super(SelfGeneratedCertsSignalTest, self).setUp() + SelfPacedConfiguration(enabled=True).save() + self.course = CourseFactory.create(self_paced=True) + # Enable the feature + CertificateGenerationConfiguration.objects.create(enabled=True) + + def test_cert_generation_enabled_for_self_paced(self): + """ Verify the signal enable the self-generated certificates by default for + self-paced courses. + """ + self.assertFalse(certs_api.cert_generation_enabled(self.course.id)) + + _listen_for_course_publish('store', self.course.id) + self.assertTrue(certs_api.cert_generation_enabled(self.course.id)) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 04f59d898c..bce031e887 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -327,6 +327,7 @@ def _section_certificates(course): 'example_certificate_status': example_cert_status, 'can_enable_for_course': can_enable_for_course, 'enabled_for_course': certs_api.cert_generation_enabled(course.id), + 'is_self_paced': course.self_paced, 'instructor_generation_enabled': instructor_generation_enabled, 'html_cert_enabled': html_cert_enabled, 'active_certificate': certs_api.get_active_web_certificate(course), diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html index 8b4f4a3fc5..40e44d8d9e 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificates.html +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -50,27 +50,29 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str % endif
    -
    + % if not section_data['is_self_paced']: +
    -
    -

    ${_("Student-Generated Certificates")}

    - % if section_data['enabled_for_course']: -
    - - - -
    - % elif section_data['can_enable_for_course']: -
    - - - -
    - % else: -

    ${_("You must successfully generate example certificates before you enable student-generated certificates.")}

    - - % endif -
    +
    +

    ${_("Student-Generated Certificates")}

    + % if section_data['enabled_for_course']: +
    + + + +
    + % elif section_data['can_enable_for_course']: +
    + + + +
    + % else: +

    ${_("You must successfully generate example certificates before you enable student-generated certificates.")}

    + + % endif +
    + % endif % if section_data['instructor_generation_enabled'] and not (section_data['enabled_for_course'] and section_data['html_cert_enabled']):
    From d68e50a5024fe7988519dfb2b1beb882d099ded2 Mon Sep 17 00:00:00 2001 From: Chris Rodriguez Date: Fri, 22 Apr 2016 12:27:54 -0400 Subject: [PATCH 034/105] Adding legacy event_type for language menu events --- common/djangoapps/track/transformers.py | 2 ++ .../xmodule/js/spec/video/video_events_plugin_spec.js | 8 ++++---- .../lib/xmodule/xmodule/js/src/video/09_events_plugin.js | 4 ++-- .../lib/xmodule/xmodule/js/src/video/09_video_caption.js | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/common/djangoapps/track/transformers.py b/common/djangoapps/track/transformers.py index 471defe9ce..75a943f773 100644 --- a/common/djangoapps/track/transformers.py +++ b/common/djangoapps/track/transformers.py @@ -374,6 +374,8 @@ class VideoEventTransformer(EventTransformer): u'edx.video.seeked': u'seek_video', u'edx.video.transcript.shown': u'show_transcript', u'edx.video.transcript.hidden': u'hide_transcript', + u'edx.video.language_menu.shown': u'video_show_cc_menu', + u'edx.video.language_menu.hidden': u'video_hide_cc_menu', } is_legacy_event = True diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js index 4124265792..92a967ff3c 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js @@ -117,17 +117,17 @@ }); }); - it('can emit "video_show_cc_menu" event', function () { + it('can emit "edx.video.language_menu.shown" event', function () { state.el.trigger('language_menu:show'); - expect(Logger.log).toHaveBeenCalledWith('video_show_cc_menu', { + expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.shown', { id: 'id', code: 'html5' }); }); - it('can emit "video_hide_cc_menu" event', function () { + it('can emit "edx.video.language_menu.hidden" event', function () { state.el.trigger('language_menu:hide'); - expect(Logger.log).toHaveBeenCalledWith('video_hide_cc_menu', { + expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.hidden', { id: 'id', code: 'html5', language: 'en' diff --git a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js index 938453f67f..5ed7ed77a2 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js @@ -105,11 +105,11 @@ define('video/09_events_plugin.js', [], function() { }, onShowLanguageMenu: function () { - this.log('video_show_cc_menu'); + this.log('edx.video.language_menu.shown'); }, onHideLanguageMenu: function () { - this.log('video_hide_cc_menu', { language: this.getCurrentLanguage() }); + this.log('edx.video.language_menu.hidden', { language: this.getCurrentLanguage() }); }, onShowTranscript: function () { diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index 592379964f..42e9e2034d 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -398,7 +398,7 @@ // present instead of on the container hover, since it wraps // the "CC" and "Transcript" buttons as well. if ($(event.currentTarget).find('.lang').length) { - this.state.el.trigger('language_menu:show'); + this.state.el.trigger('language_menu:hide'); } }, From 5a780e80192719c59c55505f12fa26888c3a87cf Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Thu, 14 Apr 2016 11:33:57 -0400 Subject: [PATCH 035/105] Generate API client credentials. ECOM-3946 --- lms/static/sass/views/_api-access.scss | 23 +++++- .../api_admin/api_access_request_form.html | 2 +- lms/templates/api_admin/status.html | 55 ++++++++++--- .../core/djangoapps/api_admin/decorators.py | 23 ++++-- .../migrations/0005_auto_20160414_1232.py | 20 +++++ openedx/core/djangoapps/api_admin/models.py | 2 +- .../djangoapps/api_admin/tests/factories.py | 13 ++++ .../djangoapps/api_admin/tests/test_views.py | 78 ++++++++++++++++++- openedx/core/djangoapps/api_admin/views.py | 71 +++++++++++++++-- 9 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 openedx/core/djangoapps/api_admin/migrations/0005_auto_20160414_1232.py diff --git a/lms/static/sass/views/_api-access.scss b/lms/static/sass/views/_api-access.scss index 570eb6afd3..5fdfc5efc3 100644 --- a/lms/static/sass/views/_api-access.scss +++ b/lms/static/sass/views/_api-access.scss @@ -26,13 +26,21 @@ &.request-pending { border-top: 2px solid $orange; } + + &.request-denied { + border-top: 2px solid $red; + } + + &.request-approved { + border-top: 2px solid $green; + } } #api-access-status { @extend %t-copy-base; } - #api-access-request { + .api-management-form { padding: 0 $baseline $baseline $baseline; @@ -91,4 +99,17 @@ text-transform: none; } } + + .application-info { + margin: $baseline 0; + + p { + @extend %t-copy-base; + margin: $baseline/2 0; + + .application-label { + @extend %t-weight4; + } + } + } } diff --git a/lms/templates/api_admin/api_access_request_form.html b/lms/templates/api_admin/api_access_request_form.html index 6e051ef26f..ef1789a20a 100644 --- a/lms/templates/api_admin/api_access_request_form.html +++ b/lms/templates/api_admin/api_access_request_form.html @@ -12,7 +12,7 @@ ${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)} -
    + ${form.as_p() | n} diff --git a/lms/templates/api_admin/status.html b/lms/templates/api_admin/status.html index 644fbb3870..b09c965d5e 100644 --- a/lms/templates/api_admin/status.html +++ b/lms/templates/api_admin/status.html @@ -11,17 +11,52 @@ from openedx.core.djangolib.markup import Text, HTML

    ${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}

    - % if status == ApiAccessRequest.PENDING: - ## Translators: "platform_name" is the name of this Open edX installation. "link_start" and "link_end" are the HTML for a link to the API documentation. "api_support_email_link" is HTML for a link to email the API support staff. -

    ${Text(_('Your request to access the {platform_name} Course Catalog API is being processed. You will receive a message at the email address in your profile when processing is complete. You can also return to this page to see the status of your API access request. To learn more about the {platform_name} Course Catalog API, visit {link_start}our API documentation page{link_end}. For questions about using this API, visit our FAQ page or contact {api_support_email_link}.')).format( - platform_name=Text(settings.PLATFORM_NAME), - link_start=HTML('').format(Text(api_support_link)), - link_end=HTML(''), - api_support_email_link=HTML('{email}').format(email=Text(api_support_email)) - )}

    - % endif +

    + % if status == ApiAccessRequest.PENDING: + ## Translators: "platform_name" is the name of this Open edX installation. + ${Text(_('Your request to access the {platform_name} Course Catalog API is being processed. You will receive a message at the email address in your profile when processing is complete. You can also return to this page to see the status of your API access request.')).format( + platform_name=Text(settings.PLATFORM_NAME) + )} - ## TODO (ECOM-3946): Add status text for 'active' and 'denied', as well as API client creation. + % elif status == ApiAccessRequest.DENIED: + ## Translators: "platform_name" is the name of this Open edX installation. "api_support_email_link" is HTML for a link to email the API support staff. + ${Text(_('Your request to access the {platform_name} Course Catalog API has been denied. If you think this is an error, or for other questions about using this API, contact {api_support_email_link}.')).format( + platform_name=Text(settings.PLATFORM_NAME), + api_support_email_link=HTML('{email}').format(email=Text(api_support_email)) + )} + % elif status == ApiAccessRequest.APPROVED: + ${Text(_('Your request to access the {platform_name} Course Catalog API has been approved.')).format( + platform_name=Text(settings.PLATFORM_NAME) + )} + + % if application: +

    +

    ${_("Application Name") + ":"} ${application.name}

    +

    ${_("API Client ID") + ":"} ${application.client_id}

    +

    ${_("API Client Secret") + ":"} ${application.client_secret}

    +

    ${_("Redirect URLs") + ":"} ${application.redirect_uris}

    +
    + +

    ${_('If you would like to regenerate your API client information, please use the form below.')}

    + + % endif + + + ${form.as_p() | n} + + + % endif +

    + +

    + ## Translators: "platform_name" is the name of this Open edX installation. "link_start" and "link_end" are the HTML for a link to the API documentation. "api_support_email_link" is HTML for a link to email the API support staff. + ${Text(_('To learn more about the {platform_name} Course Catalog API, visit {link_start}our API documentation page{link_end}. For questions about using this API, contact {api_support_email_link}.')).format( + platform_name=Text(settings.PLATFORM_NAME), + link_start=HTML('').format(Text(api_support_link)), + link_end=HTML(''), + api_support_email_link=HTML('{email}').format(email=Text(api_support_email)) + )} +

    diff --git a/openedx/core/djangoapps/api_admin/decorators.py b/openedx/core/djangoapps/api_admin/decorators.py index e6db2cabbe..3c406932bc 100644 --- a/openedx/core/djangoapps/api_admin/decorators.py +++ b/openedx/core/djangoapps/api_admin/decorators.py @@ -1,17 +1,30 @@ """Decorators for API access management.""" from functools import wraps +from django.core.urlresolvers import reverse from django.http import HttpResponseNotFound +from django.shortcuts import redirect -from openedx.core.djangoapps.api_admin.models import ApiAccessConfig +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig -def api_access_enabled_or_404(view): +def api_access_enabled_or_404(view_func): """If API access management feature is not enabled, return a 404.""" - @wraps(view) - def wrapped_view(request, *args, **kwargs): + @wraps(view_func) + def wrapped_view(view_obj, *args, **kwargs): """Wrapper for the view function.""" if ApiAccessConfig.current().enabled: - return view(request, *args, **kwargs) + return view_func(view_obj, *args, **kwargs) return HttpResponseNotFound() return wrapped_view + + +def require_api_access(view_func): + """If the requesting user does not have API access, bounce them to the request form.""" + @wraps(view_func) + def wrapped_view(view_obj, *args, **kwargs): + """Wrapper for the view function.""" + if ApiAccessRequest.has_api_access(args[0].user): + return view_func(view_obj, *args, **kwargs) + return redirect(reverse('api_admin:api-request')) + return wrapped_view diff --git a/openedx/core/djangoapps/api_admin/migrations/0005_auto_20160414_1232.py b/openedx/core/djangoapps/api_admin/migrations/0005_auto_20160414_1232.py new file mode 100644 index 0000000000..5e1c094cea --- /dev/null +++ b/openedx/core/djangoapps/api_admin/migrations/0005_auto_20160414_1232.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('api_admin', '0004_auto_20160412_1506'), + ] + + operations = [ + migrations.AlterField( + model_name='apiaccessrequest', + name='user', + field=models.OneToOneField(related_name='api_access_request', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/openedx/core/djangoapps/api_admin/models.py b/openedx/core/djangoapps/api_admin/models.py index 5133c4f5b5..57ed3f6178 100644 --- a/openedx/core/djangoapps/api_admin/models.py +++ b/openedx/core/djangoapps/api_admin/models.py @@ -32,7 +32,7 @@ class ApiAccessRequest(TimeStampedModel): (DENIED, _('Denied')), (APPROVED, _('Approved')), ) - user = models.OneToOneField(User) + user = models.OneToOneField(User, related_name='api_access_request') status = models.CharField( max_length=255, choices=STATUS_CHOICES, diff --git a/openedx/core/djangoapps/api_admin/tests/factories.py b/openedx/core/djangoapps/api_admin/tests/factories.py index d421b6de91..a6cf0305fe 100644 --- a/openedx/core/djangoapps/api_admin/tests/factories.py +++ b/openedx/core/djangoapps/api_admin/tests/factories.py @@ -1,12 +1,16 @@ """Factories for API management.""" import factory from factory.django import DjangoModelFactory +from oauth2_provider.models import get_application_model from microsite_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from student.tests.factories import UserFactory +Application = get_application_model() # pylint: disable=invalid-name + + class ApiAccessRequestFactory(DjangoModelFactory): """Factory for ApiAccessRequest objects.""" class Meta(object): @@ -14,3 +18,12 @@ class ApiAccessRequestFactory(DjangoModelFactory): user = factory.SubFactory(UserFactory) site = factory.SubFactory(SiteFactory) + + +class ApplicationFactory(DjangoModelFactory): + """Factory for OAuth Application objects.""" + class Meta(object): + model = Application + + authorization_grant_type = Application.GRANT_CLIENT_CREDENTIALS + client_type = Application.CLIENT_CONFIDENTIAL diff --git a/openedx/core/djangoapps/api_admin/tests/test_views.py b/openedx/core/djangoapps/api_admin/tests/test_views.py index d85ce4fca4..efa3c2ed06 100644 --- a/openedx/core/djangoapps/api_admin/tests/test_views.py +++ b/openedx/core/djangoapps/api_admin/tests/test_views.py @@ -1,16 +1,22 @@ #pylint: disable=missing-docstring import unittest +import ddt from django.conf import settings from django.core.urlresolvers import reverse from django.test import TestCase +from django.test.utils import override_settings +from oauth2_provider.models import get_application_model from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig -from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory +from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory, ApplicationFactory from openedx.core.djangoapps.api_admin.tests.utils import VALID_DATA from student.tests.factories import UserFactory +Application = get_application_model() # pylint: disable=invalid-name + + class ApiAdminTest(TestCase): def setUp(self): @@ -86,6 +92,8 @@ class ApiRequestViewTest(ApiAdminTest): @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@override_settings(PLATFORM_NAME='edX') +@ddt.ddt class ApiRequestStatusViewTest(ApiAdminTest): def setUp(self): @@ -103,14 +111,35 @@ class ApiRequestStatusViewTest(ApiAdminTest): response = self.client.get(self.url) self.assertRedirects(response, reverse('api_admin:api-request')) - def test_get_with_request(self): + @ddt.data( + (ApiAccessRequest.APPROVED, 'Your request to access the edX Course Catalog API has been approved.'), + (ApiAccessRequest.PENDING, 'Your request to access the edX Course Catalog API is being processed.'), + (ApiAccessRequest.DENIED, 'Your request to access the edX Course Catalog API has been denied.'), + ) + @ddt.unpack + def test_get_with_request(self, status, expected): """ Verify that users who have requested access can see a message regarding their request status. """ - ApiAccessRequestFactory(user=self.user) + ApiAccessRequestFactory(user=self.user, status=status) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) + self.assertIn(expected, response.content) + + def test_get_with_existing_application(self): + """ + Verify that if the user has created their client credentials, they + are shown on the status page. + """ + ApiAccessRequestFactory(user=self.user, status=ApiAccessRequest.APPROVED) + application = ApplicationFactory(user=self.user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + unicode_content = response.content.decode('utf-8') + self.assertIn(application.client_secret, unicode_content) # pylint: disable=no-member + self.assertIn(application.client_id, unicode_content) # pylint: disable=no-member + self.assertIn(application.redirect_uris, unicode_content) # pylint: disable=no-member def test_get_anonymous(self): """Verify that users must be logged in to see the page.""" @@ -124,6 +153,49 @@ class ApiRequestStatusViewTest(ApiAdminTest): response = self.client.get(self.url) self.assertEqual(response.status_code, 404) + @ddt.data( + (ApiAccessRequest.APPROVED, True, True), + (ApiAccessRequest.DENIED, True, False), + (ApiAccessRequest.PENDING, True, False), + (ApiAccessRequest.APPROVED, False, True), + (ApiAccessRequest.DENIED, False, False), + (ApiAccessRequest.PENDING, False, False), + ) + @ddt.unpack + def test_post(self, status, application_exists, new_application_created): + """ + Verify that posting the form creates an application if the user is + approved, and does not otherwise. Also ensure that if the user + already has an application, it is deleted before a new + application is created. + """ + if application_exists: + old_application = ApplicationFactory(user=self.user) + ApiAccessRequestFactory(user=self.user, status=status) + self.client.post(self.url, { + 'name': 'test.com', + 'redirect_uris': 'http://example.com' + }) + applications = Application.objects.filter(user=self.user) + if application_exists and new_application_created: + self.assertEqual(applications.count(), 1) + self.assertNotEqual(old_application, applications[0]) + elif application_exists: + self.assertEqual(applications.count(), 1) + self.assertEqual(old_application, applications[0]) + elif new_application_created: + self.assertEqual(applications.count(), 1) + else: + self.assertEqual(applications.count(), 0) + + def test_post_with_errors(self): + ApiAccessRequestFactory(user=self.user, status=ApiAccessRequest.APPROVED) + response = self.client.post(self.url, { + 'name': 'test.com', + 'redirect_uris': 'not a url' + }) + self.assertIn('Enter a valid URL.', response.content) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class ApiTosViewTest(ApiAdminTest): diff --git a/openedx/core/djangoapps/api_admin/views.py b/openedx/core/djangoapps/api_admin/views.py index b99a81cba4..55343e6d2d 100644 --- a/openedx/core/djangoapps/api_admin/views.py +++ b/openedx/core/djangoapps/api_admin/views.py @@ -9,13 +9,19 @@ from django.utils.translation import ugettext as _ from django.views.generic import View from django.views.generic.base import TemplateView from django.views.generic.edit import CreateView -from edxmako.shortcuts import render_to_response +from oauth2_provider.generators import generate_client_secret, generate_client_id +from oauth2_provider.models import get_application_model +from oauth2_provider.views import ApplicationRegistration +from edxmako.shortcuts import render_to_response +from openedx.core.djangoapps.api_admin.decorators import require_api_access from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm from openedx.core.djangoapps.api_admin.models import ApiAccessRequest log = logging.getLogger(__name__) +Application = get_application_model() # pylint: disable=invalid-name + class ApiRequestView(CreateView): """Form view for requesting API access.""" @@ -38,23 +44,72 @@ class ApiRequestView(CreateView): return super(ApiRequestView, self).form_valid(form) -class ApiRequestStatusView(View): +class ApiRequestStatusView(ApplicationRegistration): """View for confirming our receipt of an API request.""" - def get(self, request): + success_url = reverse_lazy('api_admin:api-status') + + def get(self, request, form=None): # pylint: disable=arguments-differ """ If the user has not created an API request, redirect them to the - request form. Otherwise, display the status of their API request. + request form. Otherwise, display the status of their API + request. We take `form` as an optional argument so that we can + display validation errors correctly on the page. """ - status = ApiAccessRequest.api_access_status(request.user) - if status is None: + if form is None: + form = self.get_form_class()() + + user = request.user + try: + api_request = ApiAccessRequest.objects.get(user=user) + except ApiAccessRequest.DoesNotExist: return redirect(reverse('api_admin:api-request')) + try: + application = Application.objects.get(user=user) + except Application.DoesNotExist: + application = None + + # We want to fill in a few fields ourselves, so remove them + # from the form so that the user doesn't see them. + for field in ('client_type', 'client_secret', 'client_id', 'authorization_grant_type'): + form.fields.pop(field) + return render_to_response('api_admin/status.html', { - 'status': status, - 'api_support_link': _('TODO'), + 'status': api_request.status, + 'api_support_link': settings.API_DOCUMENTATION_URL, 'api_support_email': settings.API_ACCESS_MANAGER_EMAIL, + 'form': form, + 'application': application, }) + def get_form(self, form_class=None): + form = super(ApiRequestStatusView, self).get_form(form_class) + # Copy the data, since it's an immutable QueryDict. + copied_data = form.data.copy() + # Now set the fields that were removed earlier. We give them + # confidential client credentials, and generate their client + # ID and secret. + copied_data.update({ + 'authorization_grant_type': Application.GRANT_CLIENT_CREDENTIALS, + 'client_type': Application.CLIENT_CONFIDENTIAL, + 'client_secret': generate_client_secret(), + 'client_id': generate_client_id(), + }) + form.data = copied_data + return form + + def form_valid(self, form): + # Delete any existing applications if the user has decided to regenerate their credentials + Application.objects.filter(user=self.request.user).delete() + return super(ApiRequestStatusView, self).form_valid(form) + + def form_invalid(self, form): + return self.get(self.request, form) + + @require_api_access + def post(self, request): + return super(ApiRequestStatusView, self).post(request) + class ApiTosView(TemplateView): """View to show the API Terms of Service.""" From 717b56a7b259f17f7f2c5a210380f7b34d0d1521 Mon Sep 17 00:00:00 2001 From: Chris Rodriguez Date: Fri, 8 Apr 2016 15:45:10 -0400 Subject: [PATCH 036/105] Sending skip links to single main, making more relevant --- cms/templates/base.html | 10 +- cms/templates/course-create-rerun.html | 2 +- lms/static/js/fixtures/edxnotes/edxnotes.html | 4 +- lms/templates/ccx/coach_dashboard.html | 115 +++--- lms/templates/courseware/courses.html | 82 ++-- .../courseware/courseware-chromeless.html | 2 - lms/templates/courseware/courseware.html | 10 +- lms/templates/courseware/info.html | 103 ++--- lms/templates/courseware/progress.html | 352 +++++++++--------- lms/templates/courseware/static_tab.html | 12 +- lms/templates/dashboard.html | 217 +++++------ lms/templates/discussion/index.html | 12 +- lms/templates/edxnotes/edxnotes.html | 4 +- lms/templates/index.html | 78 ++-- .../instructor_dashboard_2.html | 64 ++-- lms/templates/learner_dashboard/programs.html | 11 +- lms/templates/main.html | 4 +- lms/templates/main_django.html | 4 +- lms/templates/static_templates/404.html | 18 +- lms/templates/static_templates/about.html | 10 +- lms/templates/static_templates/blog.html | 10 +- lms/templates/static_templates/contact.html | 10 +- lms/templates/static_templates/donate.html | 10 +- lms/templates/static_templates/embargo.html | 24 +- lms/templates/static_templates/faq.html | 10 +- lms/templates/static_templates/help.html | 10 +- lms/templates/static_templates/honor.html | 10 +- lms/templates/static_templates/jobs.html | 10 +- lms/templates/static_templates/media-kit.html | 10 +- lms/templates/static_templates/news.html | 10 +- lms/templates/static_templates/press.html | 10 +- lms/templates/static_templates/privacy.html | 10 +- .../static_templates/server-down.html | 28 +- .../static_templates/server-error.html | 30 +- .../static_templates/server-overloaded.html | 28 +- lms/templates/static_templates/tos.html | 10 +- .../student_account/account_settings.html | 3 +- .../account_settings.underscore | 2 + .../student_account/login_and_register.html | 4 +- .../student_profile/learner_profile.html | 11 +- lms/templates/wiki/article.html | 19 +- lms/templates/wiki/base.html | 4 +- themes/edx.org/lms/templates/dashboard.html | 5 +- 43 files changed, 723 insertions(+), 669 deletions(-) diff --git a/cms/templates/base.html b/cms/templates/base.html index 82e4100119..92e66608ba 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -54,7 +54,7 @@ from openedx.core.djangolib.js_utils import ( <%block name="view_notes"> - ${_("Skip to main content")} + ${_("Skip to main content")} @@ -37,188 +36,189 @@ from django.utils.http import urlquote_plus
    -
    - % if staff_access and studio_url is not None: - - % endif - -
    -

    ${_("Course Progress for Student '{username}' ({email})").format(username=student.username, email=student.email) | h}

    -
    - - %if show_generate_cert_btn: -
    -
    - %if passed: -
    -
    - <% post_url = reverse('generate_user_cert', args=[unicode(course.id)]) %> - % if is_downloadable: -
    -

    ${_("Your certificate is available")}

    -

    - ${_("You can keep working for a higher grade, or request your certificate now.")} -

    -
    -
    - %if show_cert_web_view and cert_web_view_url: - - ${_("View Certificate")} - - %elif download_url: - - ${_("Download Your Certificate")} - - %endif -
    - %elif is_generating: -
    - ## Translators: This message appears to users when the system is processessing course certificates, which can take a few hours. -

    ${_("We're working on it...")}

    -

    ${_("We're creating your certificate. You can keep working in your courses and a link to it will appear here and on your Dashboard when it is ready.")}

    -
    -
    - %else: -
    -

    ${_("Congratulations, you qualified for a certificate!")}

    -

    ${_("You can keep working for a higher grade, or request your certificate now.")}

    -
    -
    - -
    - %endif -
    -
    - %endif -
    - %endif - - %if not course.disable_progress_graph: - - %endif - - % if credit_course_requirements: -
    -
    -
    -

    ${_("Requirements for Course Credit")}

    -
    - %if credit_course_requirements['eligibility_status'] == 'not_eligible': - ${_("{student_name}, you are no longer eligible for credit in this course.").format(student_name=student.profile.name) | h} - %elif credit_course_requirements['eligibility_status'] == 'eligible': - ${_("{student_name}, you have met the requirements for credit in this course.").format(student_name=student.profile.name) | h} - ${_("{a_start}Go to your dashboard{a_end} to purchase course credit.").format( - a_start=u"".format(url=reverse('dashboard')), - a_end="" - )} - - %elif credit_course_requirements['eligibility_status'] == 'partial_eligible': - ${_("{student_name}, you have not yet met the requirements for credit.").format(student_name=student.profile.name) | h} - %endif - ${_("Information about course credit requirements")}
    -
    - %for requirement in credit_course_requirements['requirements']: -
    -
    - ${_(requirement['display_name']) | h} - %if requirement['namespace'] == 'grade': - ${int(requirement['criteria']['min_grade'] * 100) | h}% - %endif -
    -
    - %if requirement['status']: - %if requirement['status'] == 'submitted': - - %elif requirement['status'] == 'failed': - - ${_("Verification Failed" )} - %elif requirement['status'] == 'declined': - - ${_("Verification Declined" )} - %elif requirement['status'] == 'satisfied': - - ${_("Completed by")} ${get_time_display(requirement['status_date'], DEFAULT_SHORT_DATE_FORMAT, settings.TIME_ZONE)} - %endif - %else: - ${_("Upcoming")} - %endif -
    -
    - %endfor -
    - +
    +
    + % if staff_access and studio_url is not None: + -
    - %endif + % endif -
    - %for chapter in courseware_summary: - %if not chapter['display_name'] == "hidden": -
    -

    ${ chapter['display_name'] | h}

    +
    +

    ${_("Course Progress for Student '{username}' ({email})").format(username=student.username, email=student.email) | h}

    +
    -
    - %for section in chapter['sections']: -
    - <% - earned = section['section_total'].earned - total = section['section_total'].possible - percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 and total > 0 else "" - %> - -

    - ${ section['display_name'] | h} - %if total > 0 or earned > 0: - - ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total))) | h} - - %endif - - %if total > 0 or earned > 0: - ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString ) | h} - %endif -

    -

    - ${section['format'] | h} - - %if section.get('due') is not None: - <% - formatted_string = get_time_display(section['due'], course.due_date_display_format, coerce_tz=settings.TIME_ZONE_DISPLAYED_FOR_DEADLINES) - due_date = '' if len(formatted_string)==0 else _(u'due {date}').format(date=formatted_string) - %> - - ${due_date | h} - - %endif -

    - -
    - %if len(section['scores']) > 0: -

    ${ _("Problem Scores: ") if section['graded'] else _("Practice Scores: ")}

    -
      - %for score in section['scores']: -
    1. ${"{0:.3n}/{1:.3n}".format(float(score.earned),float(score.possible)) | h}
    2. - %endfor -
    - %else: -

    ${_("No problem scores in this section")}

    - %endif + %if show_generate_cert_btn: +
    +
    + %if passed: +
    +
    + <% post_url = reverse('generate_user_cert', args=[unicode(course.id)]) %> + % if is_downloadable: +
    +

    ${_("Your certificate is available")}

    +

    + ${_("You can keep working for a higher grade, or request your certificate now.")} +

    +
    +
    + %if show_cert_web_view and cert_web_view_url: + + ${_("View Certificate")} + + %elif download_url: + + ${_("Download Your Certificate")} + + %endif +
    + %elif is_generating: +
    + ## Translators: This message appears to users when the system is processessing course certificates, which can take a few hours. +

    ${_("We're working on it...")}

    +

    ${_("We're creating your certificate. You can keep working in your courses and a link to it will appear here and on your Dashboard when it is ready.")}

    +
    +
    + %else: +
    +

    ${_("Congratulations, you qualified for a certificate!")}

    +

    ${_("You can keep working for a higher grade, or request your certificate now.")}

    +
    +
    + +
    + %endif
    +
    + %endif +
    + %endif + + %if not course.disable_progress_graph: + + %endif + + % if credit_course_requirements: +
    +
    +
    +

    ${_("Requirements for Course Credit")}

    +
    + %if credit_course_requirements['eligibility_status'] == 'not_eligible': + ${_("{student_name}, you are no longer eligible for credit in this course.").format(student_name=student.profile.name) | h} + %elif credit_course_requirements['eligibility_status'] == 'eligible': + ${_("{student_name}, you have met the requirements for credit in this course.").format(student_name=student.profile.name) | h} + ${_("{a_start}Go to your dashboard{a_end} to purchase course credit.").format( + a_start=u"".format(url=reverse('dashboard')), + a_end="" + )} + + %elif credit_course_requirements['eligibility_status'] == 'partial_eligible': + ${_("{student_name}, you have not yet met the requirements for credit.").format(student_name=student.profile.name) | h} + %endif + ${_("Information about course credit requirements")}
    +
    + %for requirement in credit_course_requirements['requirements']: +
    +
    + ${_(requirement['display_name']) | h} + %if requirement['namespace'] == 'grade': + ${int(requirement['criteria']['min_grade'] * 100) | h}% + %endif +
    +
    + %if requirement['status']: + %if requirement['status'] == 'submitted': + + %elif requirement['status'] == 'failed': + + ${_("Verification Failed" )} + %elif requirement['status'] == 'declined': + + ${_("Verification Declined" )} + %elif requirement['status'] == 'satisfied': + + ${_("Completed by")} ${get_time_display(requirement['status_date'], DEFAULT_SHORT_DATE_FORMAT, settings.TIME_ZONE)} + %endif + %else: + ${_("Upcoming")} + %endif +
    +
    + %endfor +
    + +
    +
    + %endif + +
    + %for chapter in courseware_summary: + %if not chapter['display_name'] == "hidden": +
    +

    ${ chapter['display_name'] | h}

    + +
    + %for section in chapter['sections']: +
    + <% + earned = section['section_total'].earned + total = section['section_total'].possible + percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 and total > 0 else "" + %> + +

    + ${ section['display_name'] | h} + %if total > 0 or earned > 0: + + ${_("{earned} of {total} possible points").format(earned='{:.3n}'.format(float(earned)), total='{:.3n}'.format(float(total))) | h} + + %endif + + %if total > 0 or earned > 0: + ${"({0:.3n}/{1:.3n}) {2}".format( float(earned), float(total), percentageString ) | h} + %endif +

    +

    + ${section['format'] | h} + + %if section.get('due') is not None: + <% + formatted_string = get_time_display(section['due'], course.due_date_display_format, coerce_tz=settings.TIME_ZONE_DISPLAYED_FOR_DEADLINES) + due_date = '' if len(formatted_string)==0 else _(u'due {date}').format(date=formatted_string) + %> + + ${due_date | h} + + %endif +

    + +
    + %if len(section['scores']) > 0: +

    ${ _("Problem Scores: ") if section['graded'] else _("Practice Scores: ")}

    +
      + %for score in section['scores']: +
    1. ${"{0:.3n}/{1:.3n}".format(float(score.earned),float(score.possible)) | h}
    2. + %endfor +
    + %else: +

    ${_("No problem scores in this section")}

    + %endif +
    -
    +
    + %endfor +
    +
    + %endif %endfor -
    - - %endif - %endfor -
    +
    -
    +
    + - diff --git a/lms/templates/courseware/static_tab.html b/lms/templates/courseware/static_tab.html index 01ebd2fde7..56310061d0 100644 --- a/lms/templates/courseware/static_tab.html +++ b/lms/templates/courseware/static_tab.html @@ -16,8 +16,10 @@ <%include file="/courseware/course_navigation.html" args="active_page='static_tab_{0}'.format(tab['url_slug'])" /> -
    -
    - ${tab_contents} -
    -
    +
    +
    +
    + ${tab_contents} +
    +
    +
    diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index be5a13baa9..ecb8447cca 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -19,7 +19,6 @@ from openedx.core.djangolib.markup import Text, HTML <%block name="pagetitle">${_("Dashboard")} <%block name="bodyclass">view-dashboard is-authenticated -<%block name="nav_skip">#my-courses <%block name="header_extras"> % for template_name in ["donation"]: @@ -75,120 +74,122 @@ from openedx.core.djangolib.markup import Text, HTML %endif -
    -
    -
    -

    ${_("My Courses")}

    -
    +
    +
    +
    +
    +

    ${_("My Courses")}

    +
    - % if len(course_enrollments) > 0: -
      - <% share_settings = getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}) %> - % for dashboard_index, enrollment in enumerate(course_enrollments): - <% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %> - <% cert_status = cert_statuses.get(enrollment.course_id) %> - <% can_unenroll = (not cert_status) or cert_status.get('can_unenroll') %> - <% credit_status = credit_statuses.get(enrollment.course_id) %> - <% show_email_settings = (enrollment.course_id in show_email_settings_for) %> - <% course_mode_info = all_course_modes.get(enrollment.course_id) %> - <% show_refund_option = (enrollment.course_id in show_refund_option_for) %> - <% is_paid_course = (enrollment.course_id in enrolled_courses_either_paid) %> - <% is_course_blocked = (enrollment.course_id in block_courses) %> - <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> - <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> - <% course_program_info = course_programs.get(unicode(enrollment.course_id)) %> - <%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" /> - % endfor - -
    - % else: -
    -

    ${_("You are not enrolled in any courses yet.")}

    - - % if settings.FEATURES.get('COURSES_ARE_BROWSABLE'): - - ${_("Explore courses")} - - - %endif -
    - % endif - - % if staff_access and len(errored_courses) > 0: -
    -

    ${_("Course-loading errors")}

    - - % for course_dir, errors in errored_courses.items(): -

    ${course_dir}

    -
      - % for (msg, err) in errors: -
    • ${msg} -
      • ${err}
      -
    • - % endfor -
    - % endfor -
    - % endif -
    - - % if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'): - - % endif - - % if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'): -
    - % endif - -
    -
    - -
    - + + % if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'): + + % endif + + % if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'): +
    + % endif + +
    +
    + +
    + +
    + % if xseries_credentials: +
    +

    ${_("XSeries Program Certificates")}

    +

    ${_("You have received a certificate for the following XSeries programs:")}

    + +
    + % endif
    -
    - % if xseries_credentials: -
    -

    ${_("XSeries Program Certificates")}

    -

    ${_("You have received a certificate for the following XSeries programs:")}

    - -
    - % endif -
    + diff --git a/lms/templates/edxnotes/edxnotes.html b/lms/templates/edxnotes/edxnotes.html index 04a08dacbe..37146f4a4b 100644 --- a/lms/templates/edxnotes/edxnotes.html +++ b/lms/templates/edxnotes/edxnotes.html @@ -20,7 +20,7 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str
    - +

    @@ -93,7 +93,7 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str

    % endif
    - + diff --git a/lms/templates/index.html b/lms/templates/index.html index ddb0d66603..ea3ad096ef 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -5,48 +5,50 @@ from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse %> -
    -
    -
    -
    -
    - % if homepage_overlay_html: - ${homepage_overlay_html} - % else: - ## Translators: 'Open edX' is a brand, please keep this untranslated. See http://openedx.org for more information. -

    ${_("Welcome to Open edX!")}

    - ## Translators: 'Open edX' is a brand, please keep this untranslated. See http://openedx.org for more information. -

    ${_("It works! This is the default homepage for this Open edX instance.")}

    +
    +
    +
    +
    +
    +
    + % if homepage_overlay_html: + ${homepage_overlay_html} + % else: + ## Translators: 'Open edX' is a brand, please keep this untranslated. See http://openedx.org for more information. +

    ${_("Welcome to Open edX!")}

    + ## Translators: 'Open edX' is a brand, please keep this untranslated. See http://openedx.org for more information. +

    ${_("It works! This is the default homepage for this Open edX instance.")}

    + % endif +
    + % if settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): + + % endif + +
    + + % if show_homepage_promo_video: + +
    +
    +
    +
    % endif
    - % if settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): - - % endif -
    +
    + <%include file="${courses_list}" /> - % if show_homepage_promo_video: - -
    -
    -
    -
    - % endif - - - - <%include file="${courses_list}" /> - -
    + + % if show_homepage_promo_video: