diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index ac1b15b032..fa22a9fbaa 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -36,7 +36,7 @@ from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.capa_module import CapaDescriptor -from xmodule.course_module import CourseDescriptor +from xmodule.course_module import CourseDescriptor, Textbook from xmodule.seq_module import SequenceDescriptor from contentstore.utils import delete_course_and_groups, reverse_url, reverse_course_url @@ -50,6 +50,7 @@ from contentstore.tests.utils import get_url from course_action_state.models import CourseRerunState, CourseRerunUIStateManager from course_action_state.managers import CourseActionStateItemNotFoundError +from xmodule.contentstore.content import StaticContent TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) @@ -63,197 +64,10 @@ class ContentStoreTestCase(CourseTestCase): """ -class ContentStoreToyCourseTest(ContentStoreTestCase): +class ImportRequiredTestCases(ContentStoreTestCase): """ - Tests that rely on the toy courses. - TODO: refactor using CourseFactory so they do not. + Tests which legitimately need to import a course """ - def check_components_on_page(self, component_types, expected_types): - """ - Ensure that the right types end up on the page. - - component_types is the list of advanced components. - - expected_types is the list of elements that should appear on the page. - - expected_types and component_types should be similar, but not - exactly the same -- for example, 'video' in - component_types should cause 'Video' to be present. - """ - store = self.store - course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) - course = course_items[0] - course.advanced_modules = component_types - store.update_item(course, self.user.id) - - # just pick one vertical - descriptor = store.get_items(course.id, qualifiers={'category': 'vertical'}) - resp = self.client.get_html(get_url('container_handler', descriptor[0].location)) - self.assertEqual(resp.status_code, 200) - - for expected in expected_types: - self.assertIn(expected, resp.content) - - def test_advanced_components_in_edit_unit(self): - # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page - # response HTML - self.check_components_on_page( - ADVANCED_COMPONENT_TYPES, - ['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation', 'Image Annotation', - 'Open Response Assessment', 'Peer Grading Interface', 'split_test'], - ) - - def test_advanced_components_require_two_clicks(self): - self.check_components_on_page(['word_cloud'], ['Word cloud']) - - def test_malformed_edit_unit_request(self): - store = self.store - course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) - - # just pick one vertical - usage_key = course_items[0].id.make_usage_key('vertical', None) - - resp = self.client.get_html(get_url('container_handler', usage_key)) - self.assertEqual(resp.status_code, 400) - - def check_edit_unit(self, test_course_name): - """Verifies the editing HTML in all the verticals in the given test course""" - course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', [test_course_name]) - - items = self.store.get_items(course_items[0].id, qualifiers={'category': 'vertical'}) - self._check_verticals(items) - - def test_edit_unit_toy(self): - self.check_edit_unit('toy') - - def _get_draft_counts(self, item): - cnt = 1 if getattr(item, 'is_draft', False) else 0 - for child in item.get_children(): - cnt = cnt + self._get_draft_counts(child) - - return cnt - - def test_get_items(self): - ''' - This verifies a bug we had where the None setting in get_items() meant 'wildcard' - Unfortunately, None = published for the revision field, so get_items() would return - both draft and non-draft copies. - ''' - store = self.store - course_items = import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) - course_key = course_items[0].id - html_usage_key = course_key.make_usage_key('html', 'test_html') - - html_module_from_draft_store = store.get_item(html_usage_key) - store.convert_to_draft(html_module_from_draft_store.location, self.user.id) - - # Query get_items() and find the html item. This should just return back a single item (not 2). - direct_store_items = store.get_items(course_key, revision=ModuleStoreEnum.RevisionOption.published_only) - html_items_from_direct_store = [item for item in direct_store_items if (item.location == html_usage_key)] - self.assertEqual(len(html_items_from_direct_store), 1) - self.assertFalse(getattr(html_items_from_direct_store[0], 'is_draft', False)) - - # Fetch from the draft store. - draft_store_items = store.get_items(course_key, revision=ModuleStoreEnum.RevisionOption.draft_only) - html_items_from_draft_store = [item for item in draft_store_items if (item.location == html_usage_key)] - self.assertEqual(len(html_items_from_draft_store), 1) - self.assertTrue(getattr(html_items_from_draft_store[0], 'is_draft', False)) - - - def test_draft_metadata(self): - ''' - This verifies a bug we had where inherited metadata was getting written to the - module as 'own-metadata' when publishing. Also verifies the metadata inheritance is - properly computed - ''' - draft_store = self.store - import_from_xml(draft_store, self.user.id, 'common/test/data/', ['simple']) - - course_key = SlashSeparatedCourseKey('edX', 'simple', '2012_Fall') - html_usage_key = course_key.make_usage_key('html', 'test_html') - course = draft_store.get_course(course_key) - html_module = draft_store.get_item(html_usage_key) - - self.assertEqual(html_module.graceperiod, course.graceperiod) - self.assertNotIn('graceperiod', own_metadata(html_module)) - - draft_store.convert_to_draft(html_module.location, self.user.id) - - # refetch to check metadata - html_module = draft_store.get_item(html_usage_key) - - self.assertEqual(html_module.graceperiod, course.graceperiod) - self.assertNotIn('graceperiod', own_metadata(html_module)) - - # publish module - draft_store.publish(html_module.location, self.user.id) - - # refetch to check metadata - html_module = draft_store.get_item(html_usage_key) - - self.assertEqual(html_module.graceperiod, course.graceperiod) - self.assertNotIn('graceperiod', own_metadata(html_module)) - - # put back in draft and change metadata and see if it's now marked as 'own_metadata' - draft_store.convert_to_draft(html_module.location, self.user.id) - html_module = draft_store.get_item(html_usage_key) - - new_graceperiod = timedelta(hours=1) - - self.assertNotIn('graceperiod', own_metadata(html_module)) - html_module.graceperiod = new_graceperiod - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - html_module.save() - self.assertIn('graceperiod', own_metadata(html_module)) - self.assertEqual(html_module.graceperiod, new_graceperiod) - - draft_store.update_item(html_module, self.user.id) - - # read back to make sure it reads as 'own-metadata' - html_module = draft_store.get_item(html_usage_key) - - self.assertIn('graceperiod', own_metadata(html_module)) - self.assertEqual(html_module.graceperiod, new_graceperiod) - - # republish - draft_store.publish(html_module.location, self.user.id) - - # and re-read and verify 'own-metadata' - draft_store.convert_to_draft(html_module.location, self.user.id) - html_module = draft_store.get_item(html_usage_key) - - self.assertIn('graceperiod', own_metadata(html_module)) - self.assertEqual(html_module.graceperiod, new_graceperiod) - - def test_get_depth_with_drafts(self): - store = self.store - import_from_xml(store, self.user.id, 'common/test/data/', ['simple']) - - course_key = SlashSeparatedCourseKey('edX', 'simple', '2012_Fall') - course = store.get_course(course_key) - - # make sure no draft items have been returned - num_drafts = self._get_draft_counts(course) - self.assertEqual(num_drafts, 0) - - problem_usage_key = course_key.make_usage_key('problem', 'ps01-simple') - problem = store.get_item(problem_usage_key) - - # put into draft - store.convert_to_draft(problem.location, self.user.id) - - # make sure we can query that item and verify that it is a draft - draft_problem = store.get_item(problem_usage_key) - self.assertTrue(getattr(draft_problem, 'is_draft', False)) - - # now requery with depth - course = store.get_course(course_key) - - # make sure just one draft item have been returned - num_drafts = self._get_draft_counts(course) - self.assertEqual(num_drafts, 1) - def test_no_static_link_rewrites_on_import(self): course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) course = course_items[0] @@ -266,87 +80,14 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): handouts = self.store.get_item(handouts_usage_key) self.assertIn('/static/', handouts.data) - @mock.patch('xmodule.course_module.requests.get') - def test_import_textbook_as_content_element(self, mock_get): - mock_get.return_value.text = dedent(""" - - - - """).strip() - - import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) - course = self.store.get_course(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')) - self.assertGreater(len(course.textbooks), 0) - - def test_import_polls(self): - course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) - course_key = course_items[0].id - - items = self.store.get_items(course_key, qualifiers={'category': 'poll_question'}) - found = len(items) > 0 - - self.assertTrue(found) - # check that there's actually content in the 'question' field - self.assertGreater(len(items[0].question), 0) - def test_xlint_fails(self): err_cnt = perform_xlint('common/test/data', ['toy']) self.assertGreater(err_cnt, 0) - @override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*']) - def test_module_preview_in_whitelist(self): - """ - Tests the ajax callback to render an XModule - """ - direct_store = self.store - course_items = import_from_xml(direct_store, self.user.id, 'common/test/data/', ['toy']) - usage_key = course_items[0].id.make_usage_key('vertical', 'vertical_test') - # also try a custom response which will trigger the 'is this course in whitelist' logic - resp = self.client.get_json( - get_url('xblock_view_handler', usage_key, kwargs={'view_name': 'container_preview'}) - ) - self.assertEqual(resp.status_code, 200) - - # These are the data-ids of the xblocks contained in the vertical. - self.assertContains(resp, 'edX/toy/video/sample_video') - self.assertContains(resp, 'edX/toy/video/separate_file_video') - self.assertContains(resp, 'edX/toy/video/video_with_end_time') - self.assertContains(resp, 'edX/toy/poll_question/T1_changemind_poll_foo_2') - - def test_delete(self): - store = self.store - course = CourseFactory.create() - - chapterloc = ItemFactory.create(parent_location=course.location, display_name="Chapter").location - ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential") - - sequential_key = course.id.make_usage_key('sequential', 'Sequential') - sequential = store.get_item(sequential_key) - chapter_key = course.id.make_usage_key('chapter', 'Chapter') - chapter = store.get_item(chapter_key) - - # make sure the parent points to the child object which is to be deleted - self.assertTrue(sequential.location in chapter.children) - - self.client.delete(get_url('xblock_handler', sequential_key)) - - found = False - try: - store.get_item(sequential_key) - found = True - except ItemNotFoundError: - pass - - self.assertFalse(found) - - chapter = store.get_item(chapter_key) - - # make sure the parent no longer points to the child object which was deleted - self.assertFalse(sequential.location in chapter.children) - def test_about_overrides(self): ''' - This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html + This test case verifies that a course can use specialized override for about data, + e.g. /about/Fall_2012/effort.html while there is a base definition in /about/effort.html ''' course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) @@ -407,93 +148,21 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): # # self.assertIsNotNone(thumbnail) - def test_asset_delete_and_restore(self): - ''' - This test will exercise the soft delete/restore functionality of the assets - ''' - content_store, trash_store, thumbnail_location, _location = self._delete_asset_in_course() - asset_location = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.txt') - - # now try to find it in store, but they should not be there any longer - content = content_store.find(asset_location, throw_on_not_found=False) - self.assertIsNone(content) - - if thumbnail_location: - thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) - self.assertIsNone(thumbnail) - - # now try to find it and the thumbnail in trashcan - should be in there - content = trash_store.find(asset_location, throw_on_not_found=False) - self.assertIsNotNone(content) - - if thumbnail_location: - thumbnail = trash_store.find(thumbnail_location, throw_on_not_found=False) - self.assertIsNotNone(thumbnail) - - # let's restore the asset - restore_asset_from_trashcan('/c4x/edX/toy/asset/sample_static.txt') - - # now try to find it in courseware store, and they should be back after restore - content = content_store.find(asset_location, throw_on_not_found=False) - self.assertIsNotNone(content) - - if thumbnail_location: - thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) - self.assertIsNotNone(thumbnail) - - def _delete_asset_in_course(self): - """ - Helper method for: - 1) importing course from xml - 2) finding asset in course (verifying non-empty) - 3) computing thumbnail location of asset - 4) deleting the asset from the course - """ - - content_store = contentstore() - trash_store = contentstore('trashcan') - course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'], static_content_store=content_store) - - # look up original (and thumbnail) in content store, should be there after import - location = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.txt') - content = content_store.find(location, throw_on_not_found=False) - thumbnail_location = content.thumbnail_location - self.assertIsNotNone(content) - - # - # cdodge: temporarily comment out assertion on thumbnails because many environments - # will not have the jpeg converter installed and this test will fail - # - # self.assertIsNotNone(thumbnail_location) - - # go through the website to do the delete, since the soft-delete logic is in the view - course = course_items[0] - url = reverse_course_url( - 'assets_handler', - course.id, - kwargs={'asset_key_string': unicode(course.id.make_asset_key('asset', 'sample_static.txt'))} - ) - resp = self.client.delete(url) - self.assertEqual(resp.status_code, 204) - - return content_store, trash_store, thumbnail_location, location - def test_course_info_updates_import_export(self): """ Test that course info updates are imported and exported with all content fields ('data', 'items') """ content_store = contentstore() data_dir = "common/test/data/" - import_from_xml(self.store, self.user.id, data_dir, ['course_info_updates'], - static_content_store=content_store, verbose=True) - - course_id = SlashSeparatedCourseKey('edX', 'course_info_updates', '2014_T1') - course = self.store.get_course(course_id) + courses = import_from_xml( + self.store, self.user.id, data_dir, ['course_info_updates'], + static_content_store=content_store, verbose=True, + ) + course = courses[0] self.assertIsNotNone(course) - course_updates = self.store.get_item(course_id.make_usage_key('course_info', 'updates')) - + course_updates = self.store.get_item(course.id.make_usage_key('course_info', 'updates')) self.assertIsNotNone(course_updates) # check that course which is imported has files 'updates.html' and 'updates.items.json' @@ -516,7 +185,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): # with same content as in course 'info' directory root_dir = path(mkdtemp_clean()) print 'Exporting to tempdir = {0}'.format(root_dir) - export_to_xml(self.store, content_store, course_id, root_dir, 'test_export') + export_to_xml(self.store, content_store, course.id, root_dir, 'test_export') # check that exported course has files 'updates.html' and 'updates.items.json' filesystem = OSFS(root_dir / 'test_export/info') @@ -532,61 +201,6 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course_updates.items) - def test_empty_trashcan(self): - ''' - This test will exercise the emptying of the asset trashcan - ''' - __, trash_store, __, _location = self._delete_asset_in_course() - - # make sure there's something in the trashcan - course_id = SlashSeparatedCourseKey('edX', 'toy', '6.002_Spring_2012') - all_assets, __ = trash_store.get_all_content_for_course(course_id) - self.assertGreater(len(all_assets), 0) - - # make sure we have some thumbnails in our trashcan - _all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_id) - # - # cdodge: temporarily comment out assertion on thumbnails because many environments - # will not have the jpeg converter installed and this test will fail - # - # self.assertGreater(len(all_thumbnails), 0) - - # empty the trashcan - empty_asset_trashcan([course_id]) - - # make sure trashcan is empty - all_assets, count = trash_store.get_all_content_for_course(course_id) - self.assertEqual(len(all_assets), 0) - self.assertEqual(count, 0) - - all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_id) - self.assertEqual(len(all_thumbnails), 0) - - def test_illegal_draft_crud_ops(self): - draft_store = self.store - - course = CourseFactory.create() - - location = course.id.make_usage_key('chapter', 'neuvo') - # Ensure draft mongo store does not create drafts for things that shouldn't be draft - newobject = draft_store.create_item(self.user.id, location.course_key, location.block_type, location.block_id) - self.assertFalse(getattr(newobject, 'is_draft', False)) - with self.assertRaises(InvalidVersionError): - draft_store.convert_to_draft(location, self.user.id) - chapter = draft_store.get_item(location) - chapter.data = 'chapter data' - - draft_store.update_item(chapter, self.user.id) - newobject = draft_store.get_item(chapter.location) - self.assertFalse(getattr(newobject, 'is_draft', False)) - - with self.assertRaises(InvalidVersionError): - draft_store.unpublish(location, self.user.id) - - def test_bad_contentstore_request(self): - resp = self.client.get_html('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') - self.assertEqual(resp.status_code, 400) - def test_rewrite_nonportable_links_on_import(self): content_store = contentstore() @@ -603,35 +217,6 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): html_module = self.store.get_item(html_module_location) self.assertIn('/jump_to_id/nonportable_link', html_module.data) - def test_delete_course(self): - """ - This test will import a course, make a draft item, and delete it. This will also assert that the - draft content is also deleted - """ - content_store = contentstore() - - course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy'], static_content_store=content_store) - - course_id = course_items[0].id - - # get a vertical (and components in it) to put into DRAFT - vertical = self.store.get_item(course_id.make_usage_key('vertical', 'vertical_test'), depth=1) - - self.store.convert_to_draft(vertical.location, self.user.id) - - # delete the course - self.store.delete_course(course_id, self.user.id) - - # assert that there's absolutely no non-draft modules in the course - # this should also include all draft items - items = self.store.get_items(course_id) - self.assertEqual(len(items), 0) - - # assert that all content in the asset library is also deleted - assets, count = content_store.get_all_content_for_course(course_id) - self.assertEqual(len(assets), 0) - self.assertEqual(count, 0) - def verify_content_existence(self, store, root_dir, course_id, dirname, category_name, filename_suffix=''): filesystem = OSFS(root_dir / 'test_export') self.assertTrue(filesystem.exists(dirname)) @@ -835,51 +420,6 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): html_module = self.store.get_item(course_id.make_usage_key('html', 'just_img')) self.assertIn('', html_module.data) - def test_course_handouts_rewrites(self): - # import a test course - course_items = import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) - course_id = course_items[0].id - - handouts_location = course_id.make_usage_key('course_info', 'handouts') - - # get module info (json) - resp = self.client.get(get_url('xblock_handler', handouts_location)) - - # make sure we got a successful response - self.assertEqual(resp.status_code, 200) - # check that /static/ has been converted to the full path - # note, we know the link it should be because that's what in the 'toy' course in the test data - self.assertContains(resp, '/c4x/edX/toy/asset/handouts_sample_handout.txt') - - def test_prefetch_children(self): - mongo_store = self.store._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) - import_from_xml(self.store, self.user.id, 'common/test/data/', ['toy']) - course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') - - # make sure we haven't done too many round trips to DB - # note we say 4 round trips here for: - # 1) to get the run id - # 2) the course, - # 3 & 4) for the chapters and sequentials - # Because we're querying from the top of the tree, we cache information needed for inheritance, - # so we don't need to make an extra query to compute it. - # set the branch to 'publish' in order to prevent extra lookups of draft versions - with mongo_store.branch_setting(ModuleStoreEnum.Branch.published_only): - with check_mongo_calls(4, 0): - course = mongo_store.get_course(course_id, depth=2) - - # make sure we pre-fetched a known sequential which should be at depth=2 - self.assertTrue(course_id.make_usage_key('sequential', 'vertical_sequential') in course.system.module_data) - - # make sure we don't have a specific vertical which should be at depth=3 - self.assertFalse(course_id.make_usage_key('vertical', 'vertical_test') in course.system.module_data) - - # Now, test with the branch set to draft. No extra round trips b/c it doesn't go deep enough to get - # beyond direct only categories - with mongo_store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): - with check_mongo_calls(4, 0): - mongo_store.get_course(course_id, depth=2) - def test_export_course_without_content_store(self): # Create toy course @@ -912,12 +452,427 @@ class ContentStoreToyCourseTest(ContentStoreTestCase): ) self.assertEqual(len(items), 1) - def _check_verticals(self, items): + +class MiscCourseTests(ContentStoreTestCase): + """ + Tests that rely on the toy courses. + """ + def setUp(self): + super(MiscCourseTests, self).setUp() + # save locs not items b/c the items won't have the subsequently created children in them until refetched + self.chapter_loc = self.store.create_child( + self.user.id, self.course.location, 'chapter', 'test_chapter' + ).location + self.seq_loc = self.store.create_child( + self.user.id, self.chapter_loc, 'sequential', 'test_seq' + ).location + self.vert_loc = self.store.create_child(self.user.id, self.seq_loc, 'vertical', 'test_vert').location + # now create some things quasi like the toy course had + self.problem = self.store.create_child( + self.user.id, self.vert_loc, 'problem', 'test_problem', fields={ + "data": "Test" + } + ) + self.store.create_child( + self.user.id, self.vert_loc, 'video', fields={ + "youtube_id_0_75": "JMD_ifUUfsU", + "youtube_id_1_0": "OEoXaMPEzfM", + "youtube_id_1_25": "AKqURZnYqpk", + "youtube_id_1_5": "DYpADpL7jAY", + "name": "sample_video", + } + ) + self.store.create_child( + self.user.id, self.vert_loc, 'video', fields={ + "youtube_id_0_75": "JMD_ifUUfsU", + "youtube_id_1_0": "OEoXaMPEzfM", + "youtube_id_1_25": "AKqURZnYqpk", + "youtube_id_1_5": "DYpADpL7jAY", + "name": "truncated_video", + "end_time": 10.0, + } + ) + self.store.create_child( + self.user.id, self.vert_loc, 'poll_question', fields={ + "name": "T1_changemind_poll_foo_2", + "display_name": "Change your answer", + "reset": False, + "question": "Have you changed your mind?", + "answers": [{"id": "yes", "text": "Yes"}, {"id": "no", "text": "No"}], + } + ) + self.course = self.store.publish(self.course.location, self.user.id) + + def check_components_on_page(self, component_types, expected_types): + """ + Ensure that the right types end up on the page. + + component_types is the list of advanced components. + + expected_types is the list of elements that should appear on the page. + + expected_types and component_types should be similar, but not + exactly the same -- for example, 'video' in + component_types should cause 'Video' to be present. + """ + self.course.advanced_modules = component_types + self.store.update_item(self.course, self.user.id) + + # just pick one vertical + resp = self.client.get_html(get_url('container_handler', self.vert_loc)) + self.assertEqual(resp.status_code, 200) + + for expected in expected_types: + self.assertIn(expected, resp.content) + + def test_advanced_components_in_edit_unit(self): + # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page + # response HTML + self.check_components_on_page( + ADVANCED_COMPONENT_TYPES, + ['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation', 'Image Annotation', + 'Open Response Assessment', 'Peer Grading Interface', 'split_test'], + ) + + def test_advanced_components_require_two_clicks(self): + self.check_components_on_page(['word_cloud'], ['Word cloud']) + + def test_malformed_edit_unit_request(self): + # just pick one vertical + usage_key = self.course.id.make_usage_key('vertical', None) + + resp = self.client.get_html(get_url('container_handler', usage_key)) + self.assertEqual(resp.status_code, 400) + + def test_edit_unit(self): + """Verifies rendering the editor in all the verticals in the given test course""" + self._check_verticals([self.vert_loc]) + + def _get_draft_counts(self, item): + cnt = 1 if getattr(item, 'is_draft', False) else 0 + for child in item.get_children(): + cnt = cnt + self._get_draft_counts(child) + + return cnt + + def test_get_items(self): + ''' + This verifies a bug we had where the None setting in get_items() meant 'wildcard' + Unfortunately, None = published for the revision field, so get_items() would return + both draft and non-draft copies. + ''' + self.store.convert_to_draft(self.problem.location, self.user.id) + + # Query get_items() and find the html item. This should just return back a single item (not 2). + direct_store_items = self.store.get_items( + self.course.id, revision=ModuleStoreEnum.RevisionOption.published_only + ) + items_from_direct_store = [item for item in direct_store_items if (item.location == self.problem.location)] + self.assertEqual(len(items_from_direct_store), 1) + self.assertFalse(getattr(items_from_direct_store[0], 'is_draft', False)) + + # Fetch from the draft store. + draft_store_items = self.store.get_items( + self.course.id, revision=ModuleStoreEnum.RevisionOption.draft_only + ) + items_from_draft_store = [item for item in draft_store_items if (item.location == self.problem.location)] + self.assertEqual(len(items_from_draft_store), 1) + # TODO the below won't work for split mongo + self.assertTrue(getattr(items_from_draft_store[0], 'is_draft', False)) + + def test_draft_metadata(self): + ''' + This verifies a bug we had where inherited metadata was getting written to the + module as 'own-metadata' when publishing. Also verifies the metadata inheritance is + properly computed + ''' + # refetch course so it has all the children correct + course = self.store.update_item(self.course, self.user.id) + course.graceperiod = timedelta(days=1, hours=5, minutes=59, seconds=59) + course = self.store.update_item(course, self.user.id) + problem = self.store.get_item(self.problem.location) + + self.assertEqual(problem.graceperiod, course.graceperiod) + self.assertNotIn('graceperiod', own_metadata(problem)) + + self.store.convert_to_draft(problem.location, self.user.id) + + # refetch to check metadata + problem = self.store.get_item(problem.location) + + self.assertEqual(problem.graceperiod, course.graceperiod) + self.assertNotIn('graceperiod', own_metadata(problem)) + + # publish module + self.store.publish(problem.location, self.user.id) + + # refetch to check metadata + problem = self.store.get_item(problem.location) + + self.assertEqual(problem.graceperiod, course.graceperiod) + self.assertNotIn('graceperiod', own_metadata(problem)) + + # put back in draft and change metadata and see if it's now marked as 'own_metadata' + self.store.convert_to_draft(problem.location, self.user.id) + problem = self.store.get_item(problem.location) + + new_graceperiod = timedelta(hours=1) + + self.assertNotIn('graceperiod', own_metadata(problem)) + problem.graceperiod = new_graceperiod + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + problem.save() + self.assertIn('graceperiod', own_metadata(problem)) + self.assertEqual(problem.graceperiod, new_graceperiod) + + self.store.update_item(problem, self.user.id) + + # read back to make sure it reads as 'own-metadata' + problem = self.store.get_item(problem.location) + + self.assertIn('graceperiod', own_metadata(problem)) + self.assertEqual(problem.graceperiod, new_graceperiod) + + # republish + self.store.publish(problem.location, self.user.id) + + # and re-read and verify 'own-metadata' + self.store.convert_to_draft(problem.location, self.user.id) + problem = self.store.get_item(problem.location) + + self.assertIn('graceperiod', own_metadata(problem)) + self.assertEqual(problem.graceperiod, new_graceperiod) + + def test_get_depth_with_drafts(self): + # make sure no draft items have been returned + num_drafts = self._get_draft_counts(self.course) + self.assertEqual(num_drafts, 0) + + # put into draft + self.store.convert_to_draft(self.problem.location, self.user.id) + + # make sure we can query that item and verify that it is a draft + draft_problem = self.store.get_item(self.problem.location) + self.assertTrue(getattr(draft_problem, 'is_draft', False)) + + # now requery with depth + course = self.store.get_course(self.course.id, depth=None) + + # make sure just one draft item have been returned + num_drafts = self._get_draft_counts(course) + self.assertEqual(num_drafts, 1) + + @mock.patch('xmodule.course_module.requests.get') + def test_import_textbook_as_content_element(self, mock_get): + mock_get.return_value.text = dedent(""" + + + + """).strip() + self.course.textbooks = [Textbook("Textbook", "https://s3.amazonaws.com/edx-textbooks/guttag_computation_v3/")] + course = self.store.update_item(self.course, self.user.id) + self.assertGreater(len(course.textbooks), 0) + + def test_import_polls(self): + items = self.store.get_items(self.course.id, qualifiers={'category': 'poll_question'}) + self.assertTrue(len(items) > 0) + # check that there's actually content in the 'question' field + self.assertGreater(len(items[0].question), 0) + + def test_module_preview_in_whitelist(self): + """ + Tests the ajax callback to render an XModule + """ + with override_settings(COURSES_WITH_UNSAFE_CODE=[unicode(self.course.id)]): + # also try a custom response which will trigger the 'is this course in whitelist' logic + resp = self.client.get_json( + get_url('xblock_view_handler', self.vert_loc, kwargs={'view_name': 'container_preview'}) + ) + self.assertEqual(resp.status_code, 200) + + vertical = self.store.get_item(self.vert_loc) + for child in vertical.children: + self.assertContains(resp, unicode(child)) + + def test_delete(self): + # make sure the parent points to the child object which is to be deleted + # need to refetch chapter b/c at the time it was assigned it had no children + chapter = self.store.get_item(self.chapter_loc) + self.assertIn(self.seq_loc, chapter.children) + + self.client.delete(get_url('xblock_handler', self.seq_loc)) + + with self.assertRaises(ItemNotFoundError): + self.store.get_item(self.seq_loc) + + chapter = self.store.get_item(self.chapter_loc) + + # make sure the parent no longer points to the child object which was deleted + self.assertNotIn(self.seq_loc, chapter.children) + + def test_asset_delete_and_restore(self): + ''' + This test will exercise the soft delete/restore functionality of the assets + ''' + asset_key = self._delete_asset_in_course() + + # now try to find it in store, but they should not be there any longer + content = contentstore().find(asset_key, throw_on_not_found=False) + self.assertIsNone(content) + + # now try to find it and the thumbnail in trashcan - should be in there + content = contentstore('trashcan').find(asset_key, throw_on_not_found=False) + self.assertIsNotNone(content) + + # let's restore the asset + restore_asset_from_trashcan(unicode(asset_key)) + + # now try to find it in courseware store, and they should be back after restore + content = contentstore('trashcan').find(asset_key, throw_on_not_found=False) + self.assertIsNotNone(content) + + def _delete_asset_in_course(self): + """ + Helper method for: + 1) importing course from xml + 2) finding asset in course (verifying non-empty) + 3) computing thumbnail location of asset + 4) deleting the asset from the course + """ + asset_key = self.course.id.make_asset_key('asset', 'sample_static.txt') + content = StaticContent( + asset_key, "Fake asset", "application/text", "test", + ) + contentstore().save(content) + + # go through the website to do the delete, since the soft-delete logic is in the view + url = reverse_course_url( + 'assets_handler', + self.course.id, + kwargs={'asset_key_string': unicode(asset_key)} + ) + resp = self.client.delete(url) + self.assertEqual(resp.status_code, 204) + + return asset_key + + def test_empty_trashcan(self): + ''' + This test will exercise the emptying of the asset trashcan + ''' + self._delete_asset_in_course() + + # make sure there's something in the trashcan + all_assets, __ = contentstore('trashcan').get_all_content_for_course(self.course.id) + self.assertGreater(len(all_assets), 0) + + # empty the trashcan + empty_asset_trashcan([self.course.id]) + + # make sure trashcan is empty + all_assets, count = contentstore('trashcan').get_all_content_for_course(self.course.id) + self.assertEqual(len(all_assets), 0) + self.assertEqual(count, 0) + + def test_illegal_draft_crud_ops(self): + # this test presumes old mongo and split_draft not full split + with self.assertRaises(InvalidVersionError): + self.store.convert_to_draft(self.chapter_loc, self.user.id) + + chapter = self.store.get_item(self.chapter_loc) + chapter.data = 'chapter data' + self.store.update_item(chapter, self.user.id) + newobject = self.store.get_item(self.chapter_loc) + self.assertFalse(getattr(newobject, 'is_draft', False)) + + with self.assertRaises(InvalidVersionError): + self.store.unpublish(self.chapter_loc, self.user.id) + + def test_bad_contentstore_request(self): + resp = self.client.get_html('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') + self.assertEqual(resp.status_code, 400) + + def test_delete_course(self): + """ + This test creates a course, makes a draft item, and deletes the course. This will also assert that the + draft content is also deleted + """ + # add an asset + asset_key = self.course.id.make_asset_key('asset', 'sample_static.txt') + content = StaticContent( + asset_key, "Fake asset", "application/text", "test", + ) + contentstore().save(content) + assets, count = contentstore().get_all_content_for_course(self.course.id) + self.assertGreater(len(assets), 0) + self.assertGreater(count, 0) + + self.store.convert_to_draft(self.vert_loc, self.user.id) + + # delete the course + self.store.delete_course(self.course.id, self.user.id) + + # assert that there's absolutely no non-draft modules in the course + # this should also include all draft items + items = self.store.get_items(self.course.id) + self.assertEqual(len(items), 0) + + # assert that all content in the asset library is also deleted + assets, count = contentstore().get_all_content_for_course(self.course.id) + self.assertEqual(len(assets), 0) + self.assertEqual(count, 0) + + def test_course_handouts_rewrites(self): + """ + Test that the xblock_handler rewrites static handout links + """ + handouts = self.store.create_item( + self.user.id, self.course.id, 'course_info', 'handouts', fields={ + "data": "Sample", + } + ) + + # get module info (json) + resp = self.client.get(get_url('xblock_handler', handouts.location)) + + # make sure we got a successful response + self.assertEqual(resp.status_code, 200) + # check that /static/ has been converted to the full path + # note, we know the link it should be because that's what in the 'toy' course in the test data + asset_key = self.course.id.make_asset_key('asset', 'handouts_sample_handout.txt') + self.assertContains(resp, unicode(asset_key)) + + def test_prefetch_children(self): + # make sure we haven't done too many round trips to DB + # note we say 4 round trips here for: + # 1) the course, + # 2 & 3) for the chapters and sequentials + # Because we're querying from the top of the tree, we cache information needed for inheritance, + # so we don't need to make an extra query to compute it. + # set the branch to 'publish' in order to prevent extra lookups of draft versions + with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, self.course.id): + with check_mongo_calls(3, 0): + course = self.store.get_course(self.course.id, depth=2) + + # make sure we pre-fetched a known sequential which should be at depth=2 + self.assertIn(self.seq_loc, course.system.module_data) + + # make sure we don't have a specific vertical which should be at depth=3 + self.assertNotIn(self.vert_loc, course.system.module_data) + + # Now, test with the branch set to draft. No extra round trips b/c it doesn't go deep enough to get + # beyond direct only categories + with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id): + with check_mongo_calls(3, 0): + self.store.get_course(self.course.id, depth=2) + + def _check_verticals(self, locations): """ Test getting the editing HTML for each vertical. """ # Assert is here to make sure that the course being tested actually has verticals (units) to check. - self.assertGreater(len(items), 0) - for descriptor in items: - resp = self.client.get_html(get_url('container_handler', descriptor.location)) + self.assertGreater(len(locations), 0) + for loc in locations: + resp = self.client.get_html(get_url('container_handler', loc)) self.assertEqual(resp.status_code, 200) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_contentstore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_contentstore.py index 9856730643..f39daaed16 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_contentstore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_contentstore.py @@ -1,7 +1,6 @@ """ Test contentstore.mongo functionality """ -import os import logging from uuid import uuid4 import unittest diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py index 749b6a7aed..2fdb0803d2 100644 --- a/common/lib/xmodule/xmodule/poll_module.py +++ b/common/lib/xmodule/xmodule/poll_module.py @@ -30,7 +30,7 @@ class PollFields(object): voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.user_state, default=False) poll_answer = String(help="Student answer", scope=Scope.user_state, default='') - poll_answers = Dict(help="All possible answers for the poll fro other students", scope=Scope.user_state_summary) + poll_answers = Dict(help="Poll answers from all students", scope=Scope.user_state_summary) # List of answers, in the form {'id': 'some id', 'text': 'the answer text'} answers = List(help="Poll answers from xml", scope=Scope.content, default=[]) diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 2595ec79d3..6974be759c 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -230,12 +230,55 @@ class ConvertExportFormat(unittest.TestCase): # Expand all the test archives and store their paths. self.data_dir = path(__file__).realpath().parent / 'data' - self.version0_nodrafts = self._expand_archive('Version0_nodrafts.tar.gz') - self.version1_nodrafts = self._expand_archive('Version1_nodrafts.tar.gz') - self.version0_drafts = self._expand_archive('Version0_drafts.tar.gz') - self.version1_drafts = self._expand_archive('Version1_drafts.tar.gz') - self.version1_drafts_extra_branch = self._expand_archive('Version1_drafts_extra_branch.tar.gz') - self.no_version = self._expand_archive('NoVersionNumber.tar.gz') + + self._version0_nodrafts = None + self._version1_nodrafts = None + self._version0_drafts = None + self._version1_drafts = None + self._version1_drafts_extra_branch = None + self._no_version = None + + @property + def version0_nodrafts(self): + "lazily expand this" + if self._version0_nodrafts is None: + self._version0_nodrafts = self._expand_archive('Version0_nodrafts.tar.gz') + return self._version0_nodrafts + + @property + def version1_nodrafts(self): + "lazily expand this" + if self._version1_nodrafts is None: + self._version1_nodrafts = self._expand_archive('Version1_nodrafts.tar.gz') + return self._version1_nodrafts + + @property + def version0_drafts(self): + "lazily expand this" + if self._version0_drafts is None: + self._version0_drafts = self._expand_archive('Version0_drafts.tar.gz') + return self._version0_drafts + + @property + def version1_drafts(self): + "lazily expand this" + if self._version1_drafts is None: + self._version1_drafts = self._expand_archive('Version1_drafts.tar.gz') + return self._version1_drafts + + @property + def version1_drafts_extra_branch(self): + "lazily expand this" + if self._version1_drafts_extra_branch is None: + self._version1_drafts_extra_branch = self._expand_archive('Version1_drafts_extra_branch.tar.gz') + return self._version1_drafts_extra_branch + + @property + def no_version(self): + "lazily expand this" + if self._no_version is None: + self._no_version = self._expand_archive('NoVersionNumber.tar.gz') + return self._no_version def tearDown(self): """ Common cleanup. """