diff --git a/cms/djangoapps/contentstore/git_export_utils.py b/cms/djangoapps/contentstore/git_export_utils.py index f2752737d271..734455fd1ff2 100644 --- a/cms/djangoapps/contentstore/git_export_utils.py +++ b/cms/djangoapps/contentstore/git_export_utils.py @@ -15,7 +15,7 @@ from xmodule.contentstore.django import contentstore from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_exporter import export_course_to_xml log = logging.getLogger(__name__) @@ -129,7 +129,7 @@ def export_to_git(course_id, repo, user='', rdir=None): root_dir = os.path.dirname(rdirp) course_dir = os.path.basename(rdirp).rsplit('.git', 1)[0] try: - export_to_xml(modulestore(), contentstore(), course_id, + export_course_to_xml(modulestore(), contentstore(), course_id, root_dir, course_dir) except (EnvironmentError, AttributeError): log.exception('Failed export to xml') diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index e91d3bb35553..54328436ccab 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -4,7 +4,7 @@ import os from django.core.management.base import BaseCommand, CommandError -from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_exporter import export_course_to_xml from xmodule.modulestore.django import modulestore from opaque_keys.edx.keys import CourseKey from xmodule.contentstore.django import contentstore @@ -35,4 +35,4 @@ def handle(self, *args, **options): root_dir = os.path.dirname(output_path) course_dir = os.path.splitext(os.path.basename(output_path))[0] - export_to_xml(modulestore(), contentstore(), course_key, root_dir, course_dir) + export_course_to_xml(modulestore(), contentstore(), course_key, root_dir, course_dir) diff --git a/cms/djangoapps/contentstore/management/commands/export_all_courses.py b/cms/djangoapps/contentstore/management/commands/export_all_courses.py index c6ad250baeef..205a9b4233d6 100644 --- a/cms/djangoapps/contentstore/management/commands/export_all_courses.py +++ b/cms/djangoapps/contentstore/management/commands/export_all_courses.py @@ -2,7 +2,7 @@ Script for exporting all courseware from Mongo to a directory and listing the courses which failed to export """ from django.core.management.base import BaseCommand, CommandError -from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_exporter import export_course_to_xml from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore @@ -49,7 +49,7 @@ def export_courses_to_output_path(output_path): print(u"Exporting course id = {0} to {1}".format(course_id, output_path)) try: course_dir = course_id.to_deprecated_string().replace('/', '...') - export_to_xml(module_store, content_store, course_id, root_dir, course_dir) + export_course_to_xml(module_store, content_store, course_id, root_dir, course_dir) except Exception as err: # pylint: disable=broad-except failed_export_courses.append(unicode(course_id)) print(u"=" * 30 + u"> Oops, failed to export {0}".format(course_id)) diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 08dae9f20df9..5fa4c92aef74 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand, CommandError, make_option from django_comment_common.utils import (seed_permissions_roles, are_permissions_roles_seeded) -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore @@ -40,11 +40,11 @@ def handle(self, *args, **options): dis=do_import_static)) mstore = modulestore() - course_items = import_from_xml( + course_items = import_course_from_xml( mstore, ModuleStoreEnum.UserID.mgmt_command, data_dir, course_dirs, load_error_modules=False, static_content_store=contentstore(), verbose=True, do_import_static=do_import_static, - create_course_if_not_present=True, + create_if_not_present=True, ) for course in course_items: diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py b/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py index 7bda45df3bb6..750513b8a18e 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py @@ -10,7 +10,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.mongo.base import location_to_query from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from django.conf import settings TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -30,7 +30,7 @@ def test_export_all_courses(self): This test validates that redundant Mac metadata files ('._example.txt', '.DS_Store') are cleaned up on import """ - import_from_xml( + import_course_from_xml( self.module_store, '**replace_user**', TEST_DATA_DIR, diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 27714230dc62..2eb2644afb15 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -33,8 +33,8 @@ from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation, CourseLocator from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls -from xmodule.modulestore.xml_exporter import export_to_xml -from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint +from xmodule.modulestore.xml_exporter import export_course_to_xml +from xmodule.modulestore.xml_importer import import_course_from_xml, perform_xlint from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor, Textbook @@ -92,7 +92,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): Tests which legitimately need to import a course """ def test_no_static_link_rewrites_on_import(self): - course_items = import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + course_items = import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course = course_items[0] handouts_usage_key = course.id.make_usage_key('course_info', 'handouts') @@ -113,7 +113,7 @@ def test_about_overrides(self): 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, TEST_DATA_DIR, ['toy']) + course_items = import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course_key = course_items[0].id effort = self.store.get_item(course_key.make_usage_key('about', 'effort')) self.assertEqual(effort.data, '6 hours') @@ -129,7 +129,7 @@ def test_asset_import(self): ''' content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store, verbose=True) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store, verbose=True) course = self.store.get_course(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')) @@ -157,7 +157,7 @@ def test_course_info_updates_import_export(self): """ content_store = contentstore() data_dir = TEST_DATA_DIR - courses = import_from_xml( + courses = import_course_from_xml( self.store, self.user.id, data_dir, ['course_info_updates'], static_content_store=content_store, verbose=True, ) @@ -188,7 +188,7 @@ def test_course_info_updates_import_export(self): # 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_course_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') @@ -207,7 +207,7 @@ def test_course_info_updates_import_export(self): def test_rewrite_nonportable_links_on_import(self): content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store) # first check a static asset link course_key = SlashSeparatedCourseKey('edX', 'toy', 'run') @@ -245,7 +245,7 @@ def test_export_course_roundtrip(self, mock_get): print 'Exporting to tempdir = {0}'.format(root_dir) # export out to a tempdir - export_to_xml(self.store, content_store, course_id, root_dir, 'test_export') + export_course_to_xml(self.store, content_store, course_id, root_dir, 'test_export') # check for static tabs self.verify_content_existence(self.store, root_dir, course_id, 'tabs', 'static_tab', '.html') @@ -298,7 +298,7 @@ def test_export_course_roundtrip(self, mock_get): def check_import(self, root_dir, content_store, course_id): """Imports the course in root_dir into the given course_id and verifies its content""" # reimport - import_from_xml( + import_course_from_xml( self.store, self.user.id, root_dir, @@ -328,7 +328,7 @@ def verify_export_attrs_removed(attributes): def test_export_course_with_metadata_only_video(self): content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') # create a new video module and add it as a child to a vertical @@ -347,7 +347,7 @@ def test_export_course_with_metadata_only_video(self): print 'Exporting to tempdir = {0}'.format(root_dir) # export out to a tempdir - export_to_xml(self.store, content_store, course_id, root_dir, 'test_export') + export_course_to_xml(self.store, content_store, course_id, root_dir, 'test_export') shutil.rmtree(root_dir) @@ -357,7 +357,7 @@ def test_export_course_with_metadata_only_word_cloud(self): """ content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['word_cloud']) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['word_cloud']) course_id = SlashSeparatedCourseKey('HarvardX', 'ER22x', '2013_Spring') verticals = self.store.get_items(course_id, qualifiers={'category': 'vertical'}) @@ -373,7 +373,7 @@ def test_export_course_with_metadata_only_word_cloud(self): print 'Exporting to tempdir = {0}'.format(root_dir) # export out to a tempdir - export_to_xml(self.store, content_store, course_id, root_dir, 'test_export') + export_course_to_xml(self.store, content_store, course_id, root_dir, 'test_export') shutil.rmtree(root_dir) @@ -384,7 +384,7 @@ def test_empty_data_roundtrip(self): """ content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') verticals = self.store.get_items(course_id, qualifiers={'category': 'vertical'}) @@ -400,10 +400,10 @@ def test_empty_data_roundtrip(self): # Export the course root_dir = path(mkdtemp_clean()) - export_to_xml(self.store, content_store, course_id, root_dir, 'test_roundtrip') + export_course_to_xml(self.store, content_store, course_id, root_dir, 'test_roundtrip') # Reimport and get the video back - import_from_xml(self.store, self.user.id, root_dir) + import_course_from_xml(self.store, self.user.id, root_dir) imported_word_cloud = self.store.get_item(course_id.make_usage_key('word_cloud', 'untitled')) # It should now contain empty data @@ -415,16 +415,16 @@ def test_html_export_roundtrip(self): """ content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') # Export the course root_dir = path(mkdtemp_clean()) - export_to_xml(self.store, content_store, course_id, root_dir, 'test_roundtrip') + export_course_to_xml(self.store, content_store, course_id, root_dir, 'test_roundtrip') # Reimport and get the video back - import_from_xml(self.store, self.user.id, root_dir) + import_course_from_xml(self.store, self.user.id, root_dir) # get the sample HTML with styling information html_module = self.store.get_item(course_id.make_usage_key('html', 'with_styling')) @@ -437,19 +437,19 @@ def test_html_export_roundtrip(self): def test_export_course_without_content_store(self): # Create toy course - course_items = import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + course_items = import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course_id = course_items[0].id root_dir = path(mkdtemp_clean()) print 'Exporting to tempdir = {0}'.format(root_dir) - export_to_xml(self.store, None, course_id, root_dir, 'test_export_no_content_store') + export_course_to_xml(self.store, None, course_id, root_dir, 'test_export_no_content_store') # Delete the course from module store and reimport it self.store.delete_course(course_id, self.user.id) - import_from_xml( + import_course_from_xml( self.store, self.user.id, root_dir, ['test_export_no_content_store'], static_content_store=None, target_course_id=course_id @@ -472,7 +472,7 @@ def test_export_course_no_xml_attributes(self): exported successfully """ content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') verticals = self.store.get_items(course_id, qualifiers={'category': 'vertical'}) vertical = verticals[0] @@ -493,7 +493,7 @@ def test_export_course_no_xml_attributes(self): # export should still complete successfully root_dir = path(mkdtemp_clean()) - export_to_xml( + export_course_to_xml( self.store, content_store, course_id, @@ -1261,7 +1261,7 @@ def test_get_html(handler): ) self.assertEqual(resp.status_code, 200) - course_items = import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['simple']) + course_items = import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['simple']) course_key = course_items[0].id resp = self._show_course_overview(course_key) @@ -1269,8 +1269,8 @@ def test_get_html(handler): self.assertContains(resp, 'Chapter 2') # go to various pages - test_get_html('import_handler') - test_get_html('export_handler') + test_get_html('course_import_handler') + test_get_html('course_export_handler') test_get_html('course_team_handler') test_get_html('course_info_handler') test_get_html('checklists_handler') @@ -1308,7 +1308,7 @@ def test_import_into_new_course_id(self): target_course_id = _get_course_id(self.course_data) _create_course(self, target_course_id, self.course_data) - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_course_id=target_course_id) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_course_id=target_course_id) modules = self.store.get_items(target_course_id) @@ -1343,7 +1343,7 @@ def test_import_into_new_course_id_wiki_slug_renamespacing(self): course_module.save() # Import a course with wiki_slug == location.course - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_course_id=target_course_id) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_course_id=target_course_id) course_module = self.store.get_course(target_course_id) self.assertEquals(course_module.wiki_slug, 'toy') @@ -1358,17 +1358,17 @@ def test_import_into_new_course_id_wiki_slug_renamespacing(self): _create_course(self, target_course_id, course_data) # Import a course with wiki_slug == location.course - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_course_id=target_course_id) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], target_course_id=target_course_id) course_module = self.store.get_course(target_course_id) self.assertEquals(course_module.wiki_slug, 'MITx.111.2013_Spring') # Now try importing a course with wiki_slug == '{0}.{1}.{2}'.format(location.org, location.course, location.run) - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['two_toys'], target_course_id=target_course_id) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['two_toys'], target_course_id=target_course_id) course_module = self.store.get_course(target_course_id) self.assertEquals(course_module.wiki_slug, 'MITx.111.2013_Spring') def test_import_metadata_with_attempts_empty_string(self): - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['simple']) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['simple']) did_load_item = False try: course_key = SlashSeparatedCourseKey('edX', 'simple', 'problem') @@ -1390,7 +1390,7 @@ def test_forum_id_generation(self): self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$') def test_metadata_inheritance(self): - course_items = import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) + course_items = import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy']) course = course_items[0] verticals = self.store.get_items(course.id, qualifiers={'category': 'vertical'}) @@ -1456,7 +1456,7 @@ def test_image_import(self): content_store = contentstore() # Use conditional_and_poll, as it's got an image already - courses = import_from_xml( + courses = import_course_from_xml( self.store, self.user.id, TEST_DATA_DIR, diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index 4297df6d2cdd..20739ed50777 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -2,7 +2,7 @@ # pylint: disable=no-member # pylint: disable=protected-access """ -Tests for import_from_xml using the mongo modulestore. +Tests for import_course_from_xml using the mongo modulestore. """ from django.test.client import Client @@ -17,7 +17,7 @@ from xmodule.contentstore.django import contentstore from xmodule.modulestore.tests.factories import check_exact_number_of_calls, check_number_of_calls from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.exceptions import NotFoundError from uuid import uuid4 @@ -40,14 +40,14 @@ def setUp(self): self.client = Client() self.client.login(username=self.user.username, password=password) - def load_test_import_course(self, target_course_id=None, create_new_course_if_not_present=False): + def load_test_import_course(self, target_course_id=None, create_if_not_present=False): ''' Load the standard course used to test imports (for do_import_static=False behavior). ''' content_store = contentstore() module_store = modulestore() - import_from_xml( + import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, @@ -56,7 +56,7 @@ def load_test_import_course(self, target_course_id=None, create_new_course_if_no do_import_static=False, verbose=True, target_course_id=target_course_id, - create_course_if_not_present=create_new_course_if_not_present, + create_if_not_present=create_if_not_present, ) course_id = module_store.make_course_key('edX', 'test_import_course', '2012_Fall') course = module_store.get_course(course_id) @@ -69,7 +69,7 @@ def test_import_course_into_similar_namespace(self): # edx/course can be imported into a namespace with an org/course # like edx/course_name module_store, __, course = self.load_test_import_course() - course_items = import_from_xml( + course_items = import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, @@ -85,7 +85,7 @@ def test_unicode_chars_in_course_name_import(self): """ module_store = modulestore() course_id = SlashSeparatedCourseKey(u'Юникода', u'unicode_course', u'échantillon') - import_from_xml( + import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, @@ -133,7 +133,7 @@ def test_asset_import_nostatic(self): content_store = contentstore() module_store = modulestore() - import_from_xml(module_store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store, do_import_static=False, verbose=True) + import_course_from_xml(module_store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store, do_import_static=False, verbose=True) course = module_store.get_course(SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')) @@ -144,7 +144,7 @@ def test_asset_import_nostatic(self): def test_no_static_link_rewrites_on_import(self): module_store = modulestore() - courses = import_from_xml(module_store, self.user.id, TEST_DATA_DIR, ['toy'], do_import_static=False, verbose=True) + courses = import_course_from_xml(module_store, self.user.id, TEST_DATA_DIR, ['toy'], do_import_static=False, verbose=True) course_key = courses[0].id handouts = module_store.get_item(course_key.make_usage_key('course_info', 'handouts')) @@ -176,13 +176,13 @@ def test_import_performance_mongo(self): @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_reimport(self, default_ms_type): with modulestore().default_store(default_ms_type): - __, __, course = self.load_test_import_course(create_new_course_if_not_present=True) + __, __, course = self.load_test_import_course(create_if_not_present=True) self.load_test_import_course(target_course_id=course.id) def test_rewrite_reference_list(self): module_store = modulestore() target_course_id = SlashSeparatedCourseKey('testX', 'conditional_copy', 'copy_run') - import_from_xml( + import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, @@ -212,7 +212,7 @@ def test_rewrite_reference_list(self): def test_rewrite_reference(self): module_store = modulestore() target_course_id = SlashSeparatedCourseKey('testX', 'peergrading_copy', 'copy_run') - import_from_xml( + import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, @@ -253,7 +253,7 @@ def test_rewrite_reference_value_dict_draft(self): def _verify_split_test_import(self, target_course_name, source_course_name, split_test_name, groups_to_verticals): module_store = modulestore() target_course_id = SlashSeparatedCourseKey('testX', target_course_name, 'copy_run') - import_from_xml( + import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, diff --git a/cms/djangoapps/contentstore/tests/test_import_draft_order.py b/cms/djangoapps/contentstore/tests/test_import_draft_order.py index 4f3e2c1d3a2e..5ea2a93eb8cc 100644 --- a/cms/djangoapps/contentstore/tests/test_import_draft_order.py +++ b/cms/djangoapps/contentstore/tests/test_import_draft_order.py @@ -1,4 +1,7 @@ -from xmodule.modulestore.xml_importer import import_from_xml +""" +Tests Draft import order. +""" +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.django import modulestore @@ -12,8 +15,11 @@ class DraftReorderTestCase(ModuleStoreTestCase): def test_order(self): + """ + Verify that drafts are imported in the correct order. + """ store = modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['import_draft_order']) + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['import_draft_order']) course_key = course_items[0].id sequential = store.get_item(course_key.make_usage_key('sequential', '0f4f7649b10141b0bdc9922dcf94515a')) verticals = sequential.children diff --git a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py index e2b5fd73cc49..b58970cb0d71 100644 --- a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py +++ b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py @@ -5,7 +5,7 @@ from xblock.core import XBlock from xblock.fields import String -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.mongo.draft import as_draft from django.conf import settings @@ -60,7 +60,7 @@ def _assert_import(self, course_dir, expected_field_val, has_draft=False): the expected field value set. """ - courses = import_from_xml( + courses = import_course_from_xml( self.store, self.user.id, TEST_DATA_DIR, [course_dir] ) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py new file mode 100644 index 000000000000..bc1b44c03754 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -0,0 +1,305 @@ +""" +Content library unit tests that require the CMS runtime. +""" +from contentstore.tests.utils import AjaxEnabledTestClient, parse_json +from contentstore.utils import reverse_usage_url +from contentstore.views.preview import _load_preview_module +from contentstore.views.tests.test_library import LIBRARY_REST_URL +import ddt +from xmodule.library_content_module import LibraryVersionReference +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 CourseFactory, ItemFactory +from xmodule.tests import get_test_system +from mock import Mock +from opaque_keys.edx.locator import CourseKey, LibraryLocator + + +@ddt.ddt +class TestLibraries(ModuleStoreTestCase): + """ + High-level tests for libraries + """ + def setUp(self): + user_password = super(TestLibraries, self).setUp() + + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=user_password) + + self.lib_key = self._create_library() + self.library = modulestore().get_library(self.lib_key) + + def _create_library(self, org="org", library="lib", display_name="Test Library"): + """ + Helper method used to create a library. Uses the REST API. + """ + response = self.client.ajax_post(LIBRARY_REST_URL, { + 'org': org, + 'library': library, + 'display_name': display_name, + }) + self.assertEqual(response.status_code, 200) + lib_info = parse_json(response) + lib_key = CourseKey.from_string(lib_info['library_key']) + self.assertIsInstance(lib_key, LibraryLocator) + return lib_key + + def _add_library_content_block(self, course, library_key, other_settings=None): + """ + Helper method to add a LibraryContent block to a course. + The block will be configured to select content from the library + specified by library_key. + other_settings can be a dict of Scope.settings fields to set on the block. + """ + return ItemFactory.create( + category='library_content', + parent_location=course.location, + user_id=self.user.id, + publish_item=False, + source_libraries=[LibraryVersionReference(library_key)], + **(other_settings or {}) + ) + + def _refresh_children(self, lib_content_block): + """ + Helper method: Uses the REST API to call the 'refresh_children' handler + of a LibraryContent block + """ + if 'user' not in lib_content_block.runtime._services: # pylint: disable=protected-access + lib_content_block.runtime._services['user'] = Mock(user_id=self.user.id) # pylint: disable=protected-access + handler_url = reverse_usage_url('component_handler', lib_content_block.location, kwargs={'handler': 'refresh_children'}) + response = self.client.ajax_post(handler_url) + self.assertEqual(response.status_code, 200) + return modulestore().get_item(lib_content_block.location) + + def _update_item(self, usage_key, metadata): + """ + Helper method: Uses the REST API to update the fields of an XBlock. + This will result in the XBlock's editor_saved() method being called. + """ + update_url = reverse_usage_url("xblock_handler", usage_key) + return self.client.ajax_post( + update_url, + data={ + 'metadata': metadata, + } + ) + + @ddt.data( + (2, 1, 1), + (2, 2, 2), + (2, 20, 2), + ) + @ddt.unpack + def test_max_items(self, num_to_create, num_to_select, num_expected): + """ + Test the 'max_count' property of LibraryContent blocks. + """ + for _ in range(0, num_to_create): + ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': num_to_select}) + self.assertEqual(len(lc_block.children), 0) + lc_block = self._refresh_children(lc_block) + + # Now, we want to make sure that .children has the total # of potential + # children, and that get_child_descriptors() returns the actual children + # chosen for a given student. + # In order to be able to call get_child_descriptors(), we must first + # call bind_for_student: + lc_block.bind_for_student(get_test_system(), lc_block._field_data) # pylint: disable=protected-access + self.assertEqual(len(lc_block.children), num_to_create) + self.assertEqual(len(lc_block.get_child_descriptors()), num_expected) + + def test_consistent_children(self): + """ + Test that the same student will always see the same selected child block + """ + session_data = {} + + def bind_module(descriptor): + """ + Helper to use the CMS's module system so we can access student-specific fields. + """ + request = Mock(user=self.user, session=session_data) + return _load_preview_module(request, descriptor) # pylint: disable=protected-access + + # Create many blocks in the library and add them to a course: + for num in range(0, 8): + ItemFactory.create( + data="This is #{}".format(num + 1), + category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False + ) + + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': 1}) + lc_block_key = lc_block.location + lc_block = self._refresh_children(lc_block) + + def get_child_of_lc_block(block): + """ + Fetch the child shown to the current user. + """ + children = block.get_child_descriptors() + self.assertEqual(len(children), 1) + return children[0] + + # Check which child a student will see: + bind_module(lc_block) + chosen_child = get_child_of_lc_block(lc_block) + chosen_child_defn_id = chosen_child.definition_locator.definition_id + lc_block.save() + + modulestore().update_item(lc_block, self.user.id) + + # Now re-load the block and try again: + def check(): + """ + Confirm that chosen_child is still the child seen by the test student + """ + for _ in range(0, 6): # Repeat many times b/c blocks are randomized + lc_block = modulestore().get_item(lc_block_key) # Reload block from the database + bind_module(lc_block) + current_child = get_child_of_lc_block(lc_block) + self.assertEqual(current_child.location, chosen_child.location) + self.assertEqual(current_child.data, chosen_child.data) + self.assertEqual(current_child.definition_locator.definition_id, chosen_child_defn_id) + + check() + # Refresh the children: + lc_block = self._refresh_children(lc_block) + # Now re-load the block and try yet again, in case refreshing the children changed anything: + check() + + def test_definition_shared_with_library(self): + """ + Test that the same block definition is used for the library and course[s] + """ + block1 = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + def_id1 = block1.definition_locator.definition_id + block2 = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + def_id2 = block2.definition_locator.definition_id + self.assertNotEqual(def_id1, def_id2) + + # Next, create a course: + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + # Add a LibraryContent block to the course: + lc_block = self._add_library_content_block(course, self.lib_key) + lc_block = self._refresh_children(lc_block) + for child_key in lc_block.children: + child = modulestore().get_item(child_key) + def_id = child.definition_locator.definition_id + self.assertIn(def_id, (def_id1, def_id2)) + + def test_fields(self): + """ + Test that blocks used from a library have the same field values as + defined by the library author. + """ + data_value = "A Scope.content value" + name_value = "A Scope.settings value" + lib_block = ItemFactory.create( + category="html", + parent_location=self.library.location, + user_id=self.user.id, + publish_item=False, + display_name=name_value, + data=data_value, + ) + self.assertEqual(lib_block.data, data_value) + self.assertEqual(lib_block.display_name, name_value) + + # Next, create a course: + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + # Add a LibraryContent block to the course: + lc_block = self._add_library_content_block(course, self.lib_key) + lc_block = self._refresh_children(lc_block) + course_block = modulestore().get_item(lc_block.children[0]) + + self.assertEqual(course_block.data, data_value) + self.assertEqual(course_block.display_name, name_value) + + def test_block_with_children(self): + """ + Test that blocks used from a library can have children. + """ + data_value = "A Scope.content value" + name_value = "A Scope.settings value" + # In the library, create a vertical block with a child: + vert_block = ItemFactory.create( + category="vertical", + parent_location=self.library.location, + user_id=self.user.id, + publish_item=False, + ) + child_block = ItemFactory.create( + category="html", + parent_location=vert_block.location, + user_id=self.user.id, + publish_item=False, + display_name=name_value, + data=data_value, + ) + self.assertEqual(child_block.data, data_value) + self.assertEqual(child_block.display_name, name_value) + + # Next, create a course: + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + # Add a LibraryContent block to the course: + lc_block = self._add_library_content_block(course, self.lib_key) + lc_block = self._refresh_children(lc_block) + self.assertEqual(len(lc_block.children), 1) + course_vert_block = modulestore().get_item(lc_block.children[0]) + self.assertEqual(len(course_vert_block.children), 1) + course_child_block = modulestore().get_item(course_vert_block.children[0]) + + self.assertEqual(course_child_block.data, data_value) + self.assertEqual(course_child_block.display_name, name_value) + + def test_change_after_first_sync(self): + """ + Check that nothing goes wrong if we (A) Set up a LibraryContent block + and use it successfully, then (B) Give it an invalid configuration. + No children should be deleted until the configuration is fixed. + """ + # Add a block to the library: + data_value = "Hello world!" + ItemFactory.create( + category="html", + parent_location=self.library.location, + user_id=self.user.id, + publish_item=False, + display_name="HTML BLock", + data=data_value, + ) + # Create a course: + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + # Add a LibraryContent block to the course: + lc_block = self._add_library_content_block(course, self.lib_key) + lc_block = self._refresh_children(lc_block) + self.assertEqual(len(lc_block.children), 1) + + # Now, change the block settings to have an invalid library key: + resp = self._update_item( + lc_block.location, + {"source_libraries": [["library-v1:NOT+FOUND", None]]}, + ) + self.assertEqual(resp.status_code, 200) + lc_block = modulestore().get_item(lc_block.location) + self.assertEqual(len(lc_block.children), 1) # Children should not be deleted due to a bad setting. + html_block = modulestore().get_item(lc_block.children[0]) + self.assertEqual(html_block.data, data_value) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 9eb26acf7d9f..4deb39d4e4de 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -19,7 +19,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -148,7 +148,7 @@ def import_and_populate_course(self): Imports the test toy course and populates it with additional test data """ content_store = contentstore() - import_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store) + import_course_from_xml(self.store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store) course_id = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') # create an Orphan diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 914ad1ec65d4..e6c6d458cc7a 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -296,6 +296,13 @@ def reverse_course_url(handler_name, course_key, kwargs=None): return reverse_url(handler_name, 'course_key_string', course_key, kwargs) +def reverse_library_url(handler_name, library_key, kwargs=None): + """ + Creates the URL for handlers that use library_keys as URL parameters. + """ + return reverse_url(handler_name, 'library_key_string', library_key, kwargs) + + def reverse_usage_url(handler_name, usage_key, kwargs=None): """ Creates the URL for handlers that use usage_keys as URL parameters. diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index 48ff107f117c..ae78c9953239 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -12,6 +12,7 @@ from .helpers import * from .item import * from .import_export import * +from .library import * from .preview import * from .public import * from .export_git import * diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 70a470f9dc76..90f1dde26723 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -56,6 +56,15 @@ ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES +CONTAINER_TEMPATES = [ + "basic-modal", "modal-button", "edit-xblock-modal", + "editor-mode-button", "upload-dialog", "image-modal", + "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", + "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", + "unit-outline", "container-message" +] + + def _advanced_component_types(): """ Return advanced component types which can be created. @@ -202,14 +211,15 @@ def container_handler(request, usage_key_string): 'xblock_info': xblock_info, 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, + 'templates': CONTAINER_TEMPATES }) else: return HttpResponseBadRequest("Only supports HTML requests") -def get_component_templates(course): +def get_component_templates(courselike, library=False): """ - Returns the applicable component templates that can be used by the specified course. + Returns the applicable component templates that can be used by the specified course or library. """ def create_template_dict(name, cat, boilerplate_name=None, is_common=False): """ @@ -240,7 +250,13 @@ def create_template_dict(name, cat, boilerplate_name=None, is_common=False): categories = set() # The component_templates array is in the order of "advanced" (if present), followed # by the components in the order listed in COMPONENT_TYPES. - for category in COMPONENT_TYPES: + component_types = COMPONENT_TYPES[:] + + # Libraries do not support discussions + if library: + component_types = [component for component in component_types if component != 'discussion'] + + for category in component_types: templates_for_category = [] component_class = _load_mixed_class(category) # add the default template with localized display name @@ -254,7 +270,7 @@ def create_template_dict(name, cat, boilerplate_name=None, is_common=False): if hasattr(component_class, 'templates'): for template in component_class.templates(): filter_templates = getattr(component_class, 'filter_templates', None) - if not filter_templates or filter_templates(template, course): + if not filter_templates or filter_templates(template, courselike): templates_for_category.append( create_template_dict( _(template['metadata'].get('display_name')), @@ -279,11 +295,15 @@ def create_template_dict(name, cat, boilerplate_name=None, is_common=False): "display_name": component_display_names[category] }) + # Libraries do not support advanced components at this time. + if library: + return component_templates + # Check if there are any advanced modules specified in the course policy. # These modules should be specified as a list of strings, where the strings # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be # enabled for the course. - course_advanced_keys = course.advanced_modules + course_advanced_keys = courselike.advanced_modules advanced_component_templates = {"type": "advanced", "templates": [], "display_name": _("Advanced")} advanced_component_types = _advanced_component_types() # Set component types according to course policy file diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 6548607eb197..d6b2b67aad97 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -38,6 +38,7 @@ add_extra_panel_tab, remove_extra_panel_tab, reverse_course_url, + reverse_library_url, reverse_usage_url, reverse_url, remove_all_instructors, @@ -56,6 +57,7 @@ ADVANCED_COMPONENT_TYPES, ) from contentstore.tasks import rerun_course +from .library import LIBRARIES_ENABLED from .item import create_xblock_info from course_creators.views import get_course_creator_status, add_user_with_status_unrequested from contentstore import utils @@ -341,6 +343,14 @@ def _accessible_courses_list_from_groups(request): return courses_list.values(), in_process_course_actions +def _accessible_libraries_list(user): + """ + List all libraries available to the logged in user by iterating through all libraries + """ + # No need to worry about ErrorDescriptors - split's get_libraries() never returns them. + return [lib for lib in modulestore().get_libraries() if has_course_author_access(user, lib.location)] + + @login_required @ensure_csrf_cookie def course_listing(request): @@ -360,6 +370,8 @@ def course_listing(request): # so fallback to iterating through all courses courses, in_process_course_actions = _accessible_courses_list(request) + libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else [] + def format_course_for_view(course): """ Return a dict of the data which the view requires for each course @@ -396,6 +408,18 @@ def format_in_process_course_view(uca): ) if uca.state == CourseRerunUIStateManager.State.FAILED else '' } + def format_library_for_view(library): + """ + Return a dict of the data which the view requires for each library + """ + return { + 'display_name': library.display_name, + 'library_key': unicode(library.location.library_key), + 'url': reverse_library_url('library_handler', unicode(library.location.library_key)), + 'org': library.display_org_with_default, + 'number': library.display_number_with_default, + } + # remove any courses in courses that are also in the in_process_course_actions list in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions] courses = [ @@ -409,6 +433,8 @@ def format_in_process_course_view(uca): return render_to_response('index.html', { 'courses': courses, 'in_process_course_actions': in_process_course_actions, + 'libraries_enabled': LIBRARIES_ENABLED, + 'libraries': [format_library_for_view(lib) for lib in libraries], 'user': request.user, 'request_course_creator_url': reverse('contentstore.views.request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index 34ef869f170f..3769c81978fd 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -13,7 +13,7 @@ from edxmako.shortcuts import render_to_string, render_to_response from xblock.core import XBlock from xmodule.modulestore.django import modulestore -from contentstore.utils import reverse_course_url, reverse_usage_url +from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url __all__ = ['edge', 'event', 'landing'] @@ -106,6 +106,9 @@ def xblock_studio_url(xblock, parent_xblock=None): url=reverse_course_url('course_handler', xblock.location.course_key), usage_key=urllib.quote(unicode(xblock.location)) ) + elif category == 'library': + library_key = xblock.location.course_key + return reverse_library_url('library_handler', library_key) else: return reverse_usage_url('container_handler', xblock.location) diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index f2729e01c6d7..c38cbe6637f9 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -25,8 +25,8 @@ from xmodule.exceptions import SerializationError from xmodule.modulestore.django import modulestore from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_importer import import_course_from_xml, import_library_from_xml +from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml from student.auth import has_course_author_access @@ -34,10 +34,13 @@ from util.json_request import JsonResponse from util.views import ensure_valid_course_key -from contentstore.utils import reverse_course_url, reverse_usage_url +from contentstore.utils import reverse_course_url, reverse_usage_url, reverse_library_url -__all__ = ['import_handler', 'import_status_handler', 'export_handler'] +__all__ = [ + 'course_import_handler', 'library_import_handler', 'import_status_handler', 'library_import_status_handler', + 'course_export_handler', 'library_export_handler' +] log = logging.getLogger(__name__) @@ -52,7 +55,7 @@ @ensure_csrf_cookie @require_http_methods(("GET", "POST", "PUT")) @ensure_valid_course_key -def import_handler(request, course_key_string): +def course_import_handler(request, course_key_string, library=False): """ The restful handler for importing a course. @@ -73,7 +76,10 @@ def import_handler(request, course_key_string): # Do everything in a try-except block to make sure everything is properly cleaned up. try: data_root = path(settings.GITHUB_REPO_ROOT) - course_subdir = "{0}-{1}-{2}".format(course_key.org, course_key.course, course_key.run) + if library: + course_subdir = "library-v1_{0}-{1}".format(course_key.org, course_key.run) + else: + course_subdir = "{0}-{1}-{2}".format(course_key.org, course_key.course, course_key.run) course_dir = data_root / course_subdir filename = request.FILES['course-data'].name @@ -152,7 +158,7 @@ def import_handler(request, course_key_string): }] }) # Send errors to client with stage at which error occurred. - except Exception as exception: # pylint: disable=broad-except + except Exception as exception: # pylint: disable=broad-except _save_request_status(request, key, -1) if course_dir.isdir(): shutil.rmtree(course_dir) @@ -215,34 +221,46 @@ def get_dir_for_fname(directory, filename): return dirpath return None - fname = "course.xml" + if library: + fname = 'library.xml' + else: + fname = "course.xml" dirpath = get_dir_for_fname(course_dir, fname) if not dirpath: _save_request_status(request, key, -2) return JsonResponse( { - 'ErrMsg': _('Could not find the course.xml file in the package.'), + 'ErrMsg': _('Could not find the {0} file in the package.'.format(fname)), 'Stage': -2 }, status=415 ) dirpath = os.path.relpath(dirpath, data_root) - logging.debug('found course.xml at {0}'.format(dirpath)) + logging.debug('found {0} at {0}'.format(fname, dirpath)) log.info("Course import {0}: Extracted file verified".format(course_key)) _save_request_status(request, key, 3) - course_items = import_from_xml( - modulestore(), - request.user.id, - settings.GITHUB_REPO_ROOT, - [dirpath], - load_error_modules=False, - static_content_store=contentstore(), - target_course_id=course_key, - ) + if library: + course_items = import_library_from_xml( + modulestore(), request.user.id, + settings.GITHUB_REPO_ROOT, [dirpath], + load_error_modules=False, + static_content_store=contentstore(), + target_library_id=course_key + ) + else: + course_items = import_course_from_xml( + modulestore(), + request.user.id, + settings.GITHUB_REPO_ROOT, + [dirpath], + load_error_modules=False, + static_content_store=contentstore(), + target_course_id=course_key, + ) new_location = course_items[0].location logging.debug('new course at {0}'.format(new_location)) @@ -273,16 +291,39 @@ def get_dir_for_fname(directory, filename): return JsonResponse({'Status': 'OK'}) elif request.method == 'GET': # assume html - course_module = modulestore().get_course(course_key) - return render_to_response('import.html', { - 'context_course': course_module, - 'successful_import_redirect_url': reverse_course_url('course_handler', course_key), - 'import_status_url': reverse_course_url("import_status_handler", course_key, kwargs={'filename': "fillerName"}), + if library: + template = 'import_library.html' + successful_url = reverse_library_url('library_handler', course_key) + status_url = reverse_library_url("library_import_status_handler", course_key, kwargs={'filename': "fillerName"}) + context_name = 'context_library' + course_module = modulestore().get_library(course_key) + else: + template = 'import_course.html' + successful_url = reverse_course_url('course_handler', course_key) + status_url = reverse_course_url("import_status_handler", course_key, kwargs={'filename': "fillerName"}) + context_name = 'context_course' + course_module = modulestore().get_course(course_key) + return render_to_response(template, { + context_name: course_module, + 'successful_import_redirect_url': successful_url, + 'import_status_url': status_url, }) else: return HttpResponseNotFound() +# pylint: disable=unused-argument +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) +@ensure_valid_course_key +def library_import_handler(request, library_key_string): + """ + Helper method to call the import handler in Library-compatible form. + """ + return course_import_handler(request, library_key_string, library=True) + + def _save_request_status(request, key, status): """ Save import status for a course in request session @@ -325,12 +366,89 @@ def import_status_handler(request, course_key_string, filename=None): return JsonResponse({"ImportStatus": status}) +def library_import_status_handler(request, library_key_string, filename=None): + """ + Shim to make the status handler work with libraries. + """ + return import_status_handler(request, library_key_string, filename) + + +def create_export_tarball(course_module, course_key, context, library=False): + """ + Generates the export tarball, or returns None if there was an error. + + Updates the context with any error information if applicable. + """ + name = course_module.url_name + export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") + root_dir = path(mkdtemp()) + + try: + if library: + export_library_to_xml(modulestore(), contentstore(), course_key, root_dir, name) + else: + export_course_to_xml(modulestore(), contentstore(), course_module.id, root_dir, name) + + logging.debug(u'tar file being generated at {0}'.format(export_file.name)) + with tarfile.open(name=export_file.name, mode='w:gz') as tar_file: + tar_file.add(root_dir / name, arcname=name) + + except SerializationError as exc: + log.exception(u'There was an error exporting {0}'.format(course_key)) + unit = None + failed_item = None + parent = None + try: + failed_item = modulestore().get_item(exc.location) + parent_loc = modulestore().get_parent_location(failed_item.location) + + if parent_loc is not None: + parent = modulestore().get_item(parent_loc) + if parent.location.category == 'vertical': + unit = parent + except: # pylint: disable=bare-except + # if we have a nested exception, then we'll show the more generic error message + pass + + context.update({ + 'in_err': True, + 'raw_err_msg': str(exc), + 'failed_module': failed_item, + 'unit': unit, + 'edit_unit_url': reverse_usage_url("container_handler", parent.location) if parent else "", + 'course_home_url': reverse_course_url("course_handler", course_key), + }) + raise + except Exception as exc: + log.exception('There was an error exporting {0}'.format(course_key)) + context.update({ + 'in_err': True, + 'unit': None, + 'raw_err_msg': str(exc)}) + raise SerializationError + finally: + shutil.rmtree(root_dir / name) + + return export_file + + +def send_tarball(tarball): + """ + Renders a tarball to response, for use when sending a tar.gz file to the user. + """ + wrapper = FileWrapper(tarball) + response = HttpResponse(wrapper, content_type='application/x-tgz') + response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(tarball.name.encode('utf-8')) + response['Content-Length'] = os.path.getsize(tarball.name) + return response + + # pylint: disable=unused-argument @ensure_csrf_cookie @login_required @require_http_methods(("GET",)) @ensure_valid_course_key -def export_handler(request, course_key_string): +def course_export_handler(request, course_key_string, library=False): """ The restful handler for exporting a course. @@ -349,75 +467,50 @@ def export_handler(request, course_key_string): if not has_course_author_access(request.user, course_key): raise PermissionDenied() - course_module = modulestore().get_course(course_key) + if library: + course_module = modulestore().get_library(course_key) + export_url = reverse_library_url('library_export_handler', course_key) + template = 'export_library.html' + context = { + 'context_library': course_module, + 'course_home_url': reverse_library_url("library_handler", course_key) + } + else: + course_module = modulestore().get_course(course_key) + export_url = reverse_course_url('course_export_handler', course_key) + template = 'export_course.html' + context = { + 'context_course': course_module, + 'course_home_url': reverse_course_url("course_handler", course_key), + } + + context['export_url'] = export_url + '?_accept=application/x-tgz' # an _accept URL parameter will be preferred over HTTP_ACCEPT in the header. requested_format = request.REQUEST.get('_accept', request.META.get('HTTP_ACCEPT', 'text/html')) - export_url = reverse_course_url('export_handler', course_key) + '?_accept=application/x-tgz' if 'application/x-tgz' in requested_format: - name = course_module.url_name - export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") - root_dir = path(mkdtemp()) - try: - export_to_xml(modulestore(), contentstore(), course_module.id, root_dir, name) - - logging.debug(u'tar file being generated at {0}'.format(export_file.name)) - with tarfile.open(name=export_file.name, mode='w:gz') as tar_file: - tar_file.add(root_dir / name, arcname=name) - except SerializationError as exc: - log.exception(u'There was an error exporting course %s', course_module.id) - unit = None - failed_item = None - parent = None - try: - failed_item = modulestore().get_item(exc.location) - parent_loc = modulestore().get_parent_location(failed_item.location) - - if parent_loc is not None: - parent = modulestore().get_item(parent_loc) - if parent.location.category == 'vertical': - unit = parent - except: # pylint: disable=bare-except - # if we have a nested exception, then we'll show the more generic error message - pass - - return render_to_response('export.html', { - 'context_course': course_module, - 'in_err': True, - 'raw_err_msg': str(exc), - 'failed_module': failed_item, - 'unit': unit, - 'edit_unit_url': reverse_usage_url("container_handler", parent.location) if parent else "", - 'course_home_url': reverse_course_url("course_handler", course_key), - 'export_url': export_url - }) - except Exception as exc: - log.exception('There was an error exporting course %s', course_module.id) - return render_to_response('export.html', { - 'context_course': course_module, - 'in_err': True, - 'unit': None, - 'raw_err_msg': str(exc), - 'course_home_url': reverse_course_url("course_handler", course_key), - 'export_url': export_url - }) - finally: - shutil.rmtree(root_dir / name) - - wrapper = FileWrapper(export_file) - response = HttpResponse(wrapper, content_type='application/x-tgz') - response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name.encode('utf-8')) - response['Content-Length'] = os.path.getsize(export_file.name) - return response + tarball = create_export_tarball(course_module, course_key, context, library=library) + except SerializationError: + return render_to_response(template, context) + return send_tarball(tarball) elif 'text/html' in requested_format: - return render_to_response('export.html', { - 'context_course': course_module, - 'export_url': export_url - }) + return render_to_response(template, context) else: # Only HTML or x-tgz request formats are supported (no JSON). return HttpResponse(status=406) + + +# pylint: disable=unused-argument +@ensure_csrf_cookie +@login_required +@require_http_methods(("GET",)) +def library_export_handler(request, library_key_string): + """ + Library export has enough separate concerns that its functionality is not + easily piggy-backed onto the course export function. + """ + return course_export_handler(request, library_key_string, library=True) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index ffbcd68d18ad..7b893486eedd 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -47,6 +47,7 @@ from models.settings.course_grading import CourseGradingModel from cms.lib.xblock.runtime import handler_url, local_resource_url, get_asides from opaque_keys.edx.keys import UsageKey, CourseKey +from opaque_keys.edx.locator import LibraryUsageLocator __all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler'] @@ -205,7 +206,7 @@ def xblock_view_handler(request, usage_key_string, view_name): if 'application/json' in accept_header: store = modulestore() xblock = store.get_item(usage_key) - container_views = ['container_preview', 'reorderable_container_child_preview'] + container_views = ['container_preview', 'reorderable_container_child_preview', 'container_child_preview'] # wrap the generated fragment in the xmodule_editor div so that the javascript # can bind to it correctly @@ -237,12 +238,32 @@ def xblock_view_handler(request, usage_key_string, view_name): if view_name == 'reorderable_container_child_preview': reorderable_items.add(xblock.location) + paging = None + try: + if request.REQUEST.get('enable_paging', 'false') == 'true': + paging = { + 'page_number': int(request.REQUEST.get('page_number', 0)), + 'page_size': int(request.REQUEST.get('page_size', 0)), + } + except ValueError: + return HttpResponse( + content="Couldn't parse paging parameters: enable_paging: " + "%s, page_number: %s, page_size: %s".format( + request.REQUEST.get('enable_paging', 'false'), + request.REQUEST.get('page_number', 0), + request.REQUEST.get('page_size', 0) + ), + status=400, + content_type="text/plain", + ) + # Set up the context to be passed to each XBlock's render method. context = { 'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks 'is_unit_page': is_unit(xblock), 'root_xblock': xblock if (view_name == 'container_preview') else None, - 'reorderable_items': reorderable_items + 'reorderable_items': reorderable_items, + 'paging': paging, } fragment = get_preview_fragment(request, xblock, context) @@ -406,8 +427,9 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, else: try: value = field.from_json(value) - except ValueError: - return JsonResponse({"error": "Invalid data"}, 400) + except ValueError as verr: + reason = _("Invalid data ({details})").format(details=verr.message) if verr.message else _("Invalid data") + return JsonResponse({"error": reason}, 400) field.write_to(xblock, value) # update the xblock and call any xblock callbacks @@ -460,6 +482,13 @@ def _create_item(request): if not has_course_author_access(request.user, usage_key.course_key): raise PermissionDenied() + if isinstance(usage_key, LibraryUsageLocator): + # Only these categories are supported at this time. + if category not in ['html', 'problem', 'video']: + return HttpResponseBadRequest( + "Category '%s' not supported for Libraries" % category, content_type='text/plain' + ) + store = modulestore() with store.bulk_operations(usage_key.course_key): parent = store.get_item(usage_key) @@ -650,7 +679,9 @@ def _get_module_info(xblock, rewrite_static_links=True): ) # Pre-cache has changes for the entire course because we'll need it for the ancestor info - modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None)) + # Except library blocks which don't [yet] use draft/publish + if not isinstance(xblock.location, LibraryUsageLocator): + modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None)) # Note that children aren't being returned until we have a use case. return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True) @@ -691,12 +722,16 @@ def safe_get_username(user_id): return None + is_library_block = isinstance(xblock.location, LibraryUsageLocator) is_xblock_unit = is_unit(xblock, parent_xblock) - # this should not be calculated for Sections and Subsections on Unit page - has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None + # this should not be calculated for Sections and Subsections on Unit page or for library blocks + has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) and not is_library_block else None if graders is None: - graders = CourseGradingModel.fetch(xblock.location.course_key).graders + if not is_library_block: + graders = CourseGradingModel.fetch(xblock.location.course_key).graders + else: + graders = [] # Compute the child info first so it can be included in aggregate information for the parent should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline) @@ -716,7 +751,7 @@ def safe_get_username(user_id): visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes) else: visibility_state = None - published = modulestore().has_published_version(xblock) + published = modulestore().has_published_version(xblock) if not is_library_block else None xblock_info = { "id": unicode(xblock.location), @@ -724,7 +759,7 @@ def safe_get_username(user_id): "category": xblock.category, "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, "published": published, - "published_on": get_default_time_display(xblock.published_on) if xblock.published_on else None, + "published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None, "studio_url": xblock_studio_url(xblock, parent_xblock), "released_to_students": datetime.now(UTC) > xblock.start, "release_date": release_date, diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py new file mode 100644 index 000000000000..1fdc8381a8f4 --- /dev/null +++ b/cms/djangoapps/contentstore/views/library.py @@ -0,0 +1,185 @@ +""" +Views related to content libraries. +A content library is a structure containing XBlocks which can be re-used in the +multiple courses. +""" +from __future__ import absolute_import + +import json +import logging + +from contentstore.views.item import create_xblock_info +from contentstore.utils import reverse_library_url +from django.http import HttpResponseNotAllowed, Http404 +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.conf import settings +from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_http_methods +from django_future.csrf import ensure_csrf_cookie +from edxmako.shortcuts import render_to_response +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator +from xmodule.modulestore.exceptions import DuplicateCourseError +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore + +from .component import get_component_templates, CONTAINER_TEMPATES +from student.auth import has_course_author_access +from student.roles import CourseCreatorRole +from student import auth +from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest + +__all__ = ['library_handler'] + +log = logging.getLogger(__name__) + +LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(('GET', 'POST')) +def library_handler(request, library_key_string=None): + """ + RESTful interface to most content library related functionality. + """ + if not LIBRARIES_ENABLED: + log.exception("Attempted to use the content library API when the libraries feature is disabled.") + raise Http404 # Should never happen because we test the feature in urls.py also + + if library_key_string is not None and request.method == 'POST': + return HttpResponseNotAllowed(("POST",)) + + if request.method == 'POST': + return _create_library(request) + + # request method is get, since only GET and POST are allowed by @require_http_methods(('GET', 'POST')) + if library_key_string: + return _display_library(library_key_string, request) + + return _list_libraries(request) + + +def _display_library(library_key_string, request): + """ + Displays single library + """ + library_key = CourseKey.from_string(library_key_string) + if not isinstance(library_key, LibraryLocator): + log.exception("Non-library key passed to content libraries API.") # Should never happen due to url regex + raise Http404 # This is not a library + if not has_course_author_access(request.user, library_key): + log.exception(u"User %s tried to access library %s without permission", request.user.username, unicode(library_key)) + raise PermissionDenied() + + library = modulestore().get_library(library_key) + if library is None: + log.exception(u"Library %s not found", unicode(library_key)) + raise Http404 + + response_format = 'html' + if request.REQUEST.get('format', 'html') == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'text/html'): + response_format = 'json' + + return library_blocks_view(library, response_format) + + +def _list_libraries(request): + """ + List all accessible libraries + """ + lib_info = [ + { + "display_name": lib.display_name, + "library_key": unicode(lib.location.library_key), + } + for lib in modulestore().get_libraries() + if has_course_author_access(request.user, lib.location.library_key) + ] + return JsonResponse(lib_info) + + +@expect_json +def _create_library(request): + """ + Helper method for creating a new library. + """ + if not auth.has_access(request.user, CourseCreatorRole()): + log.exception(u"User %s tried to create a library without permission", request.user.username) + raise PermissionDenied() + display_name = None + try: + display_name = request.json['display_name'] + org = request.json['org'] + library = request.json.get('number', None) + if library is None: + library = request.json['library'] + store = modulestore() + with store.default_store(ModuleStoreEnum.Type.split): + new_lib = store.create_library( + org=org, + library=library, + user_id=request.user.id, + fields={"display_name": display_name}, + ) + except KeyError as error: + log.exception("Unable to create library - missing required JSON key.") + return JsonResponseBadRequest({ + "ErrMsg": _("Unable to create library - missing required field '{field}'".format(field=error.message)) + }) + except InvalidKeyError as error: + log.exception("Unable to create library - invalid key.") + return JsonResponseBadRequest({ + "ErrMsg": _("Unable to create library '{name}'.\n\n{err}").format(name=display_name, err=error.message)} + ) + except DuplicateCourseError: + log.exception("Unable to create library - one already exists with the same key.") + return JsonResponseBadRequest({ + 'ErrMsg': _( + 'There is already a library defined with the same ' + 'organization and library code. Please ' + 'change either organization or library code to be unique.' + ) + }) + + lib_key_str = unicode(new_lib.location.library_key) + return JsonResponse({ + 'url': reverse_library_url('library_handler', lib_key_str), + 'library_key': lib_key_str, + }) + + +def library_blocks_view(library, response_format): + """ + The main view of a course's content library. + Shows all the XBlocks in the library, and allows adding/editing/deleting + them. + Can be called with response_format="json" to get a JSON-formatted list of + the XBlocks in the library along with library metadata. + """ + assert isinstance(library.location.library_key, LibraryLocator) + assert isinstance(library.location, LibraryUsageLocator) + + children = library.children + if response_format == "json": + # The JSON response for this request is short and sweet: + prev_version = library.runtime.course_entry.structure['previous_version'] + return JsonResponse({ + "display_name": library.display_name, + "library_id": unicode(library.course_id), + "version": unicode(library.runtime.course_entry.course_key.version), + "previous_version": unicode(prev_version) if prev_version else None, + "blocks": [unicode(x) for x in children], + }) + + xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[]) + component_templates = get_component_templates(library, library=True) + + return render_to_response('library.html', { + 'context_library': library, + 'component_templates': json.dumps(component_templates), + 'xblock_info': xblock_info, + 'templates': CONTAINER_TEMPATES + }) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index a67b6019b6cf..f0a610ff1270 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -14,6 +14,7 @@ from xmodule.contentstore.django import contentstore from xmodule.error_module import ErrorDescriptor from xmodule.exceptions import NotFoundError, ProcessingError +from xmodule.library_tools import LibraryToolsService from xmodule.modulestore.django import modulestore, ModuleI18nService from opaque_keys.edx.keys import UsageKey from xmodule.x_module import ModuleSystem @@ -168,6 +169,7 @@ def _preview_module_system(request, descriptor, field_data): services={ "i18n": ModuleI18nService(), "field-data": field_data, + "library_tools": LibraryToolsService(modulestore()), }, ) diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index a340a4d24f99..95e34fd883fa 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -14,7 +14,7 @@ from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from django.test.utils import override_settings from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation import mock @@ -65,7 +65,7 @@ def test_static_url_generation(self): def test_pdf_asset(self): module_store = modulestore() - course_items = import_from_xml( + course_items = import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, @@ -282,7 +282,7 @@ def post_asset_update(lock, course): # Load the toy course. module_store = modulestore() - course_items = import_from_xml( + course_items = import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index b0ea997b4d80..965879e15428 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -6,7 +6,7 @@ import datetime from contentstore.tests.utils import CourseTestCase -from contentstore.utils import reverse_course_url, add_instructor +from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor from student.auth import has_course_author_access from contentstore.views.course import course_outline_initial_state from contentstore.views.item import create_xblock_info, VisibilityState @@ -14,7 +14,7 @@ from util.date_utils import get_default_time_display from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory from opaque_keys.edx.locator import CourseLocator from student.tests.factories import UserFactory from course_action_state.managers import CourseRerunUIStateManager @@ -61,6 +61,27 @@ def check_index_and_outline(self, authed_client): course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0] self.assertEqual(course_menu_link.find("a").get("href"), link.get("href")) + def test_libraries_on_course_index(self): + """ + Test getting the list of libraries from the course listing page + """ + # Add a library: + lib1 = LibraryFactory.create() + + index_url = '/course/' + index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html') + parsed_html = lxml.html.fromstring(index_response.content) + library_link_elements = parsed_html.find_class('library-link') + self.assertEqual(len(library_link_elements), 1) + link = library_link_elements[0] + self.assertEqual( + link.get("href"), + reverse_library_url('library_handler', lib1.location.library_key), + ) + # now test that url + outline_response = self.client.get(link.get("href"), {}, HTTP_ACCEPT='text/html') + self.assertEqual(outline_response.status_code, 200) + def test_is_staff_access(self): """ Test that people with is_staff see the courses and can navigate into them diff --git a/cms/djangoapps/contentstore/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py index 034a9002fb76..576ea388081d 100644 --- a/cms/djangoapps/contentstore/views/tests/test_helpers.py +++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py @@ -4,7 +4,7 @@ from contentstore.tests.utils import CourseTestCase from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name -from xmodule.modulestore.tests.factories import ItemFactory +from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory from django.utils import http @@ -50,6 +50,11 @@ def test_xblock_studio_url(self): display_name="My Video") self.assertIsNone(xblock_studio_url(video)) + # Verify library URL + library = LibraryFactory.create() + expected_url = u'/library/{}'.format(unicode(library.location.library_key)) + self.assertEqual(xblock_studio_url(library), expected_url) + def test_xblock_type_display_name(self): # Verify chapter type display name diff --git a/cms/djangoapps/contentstore/views/tests/test_import_export.py b/cms/djangoapps/contentstore/views/tests/test_import_export.py index 8f42d8eaa656..b6c8d37190c3 100644 --- a/cms/djangoapps/contentstore/views/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/views/tests/test_import_export.py @@ -34,7 +34,7 @@ class ImportTestCase(CourseTestCase): """ def setUp(self): super(ImportTestCase, self).setUp() - self.url = reverse_course_url('import_handler', self.course.id) + self.url = reverse_course_url('course_import_handler', self.course.id) self.content_dir = path(tempfile.mkdtemp()) def touch(name): @@ -245,14 +245,14 @@ def try_tar(tarpath): @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ExportTestCase(CourseTestCase): """ - Tests for export_handler. + Tests for course_export_handler. """ def setUp(self): """ Sets up the test course. """ super(ExportTestCase, self).setUp() - self.url = reverse_course_url('export_handler', self.course.id) + self.url = reverse_course_url('course_export_handler', self.course.id) def test_export_html(self): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index b496a7ffc4f9..6b595ae00755 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -24,7 +24,8 @@ from xmodule.capa_module import CapaDescriptor from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.factories import ItemFactory, check_mongo_calls +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory, check_mongo_calls from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW from xblock.exceptions import NoSuchHandlerError from opaque_keys.edx.keys import UsageKey, CourseKey @@ -893,6 +894,29 @@ def test_publish_states_of_nested_xblocks(self): self._verify_published_with_draft(unit_usage_key) self._verify_published_with_draft(html_usage_key) + def test_field_value_errors(self): + """ + Test that if the user's input causes a ValueError on an XBlock field, + we provide a friendly error message back to the user. + """ + response = self.create_xblock(parent_usage_key=self.seq_usage_key, category='video') + video_usage_key = self.response_usage_key(response) + update_url = reverse_usage_url('xblock_handler', video_usage_key) + + response = self.client.ajax_post( + update_url, + data={ + 'id': unicode(video_usage_key), + 'metadata': { + 'saved_video_position': "Not a valid relative time", + }, + } + ) + self.assertEqual(response.status_code, 400) + parsed = json.loads(response.content) + self.assertIn("error", parsed) + self.assertIn("Incorrect RelativeTime value", parsed["error"]) # See xmodule/fields.py + class TestEditSplitModule(ItemTest): """ @@ -1420,6 +1444,89 @@ def validate_xblock_info_consistency(self, xblock_info, has_ancestor_info=False, self.assertIsNone(xblock_info.get('edited_by', None)) +class TestLibraryXBlockInfo(ModuleStoreTestCase): + """ + Unit tests for XBlock Info for XBlocks in a content library + """ + def setUp(self): + super(TestLibraryXBlockInfo, self).setUp() + user_id = self.user.id + self.library = LibraryFactory.create() + self.top_level_html = ItemFactory.create( + parent_location=self.library.location, category='html', user_id=user_id, publish_item=False + ) + self.vertical = ItemFactory.create( + parent_location=self.library.location, category='vertical', user_id=user_id, publish_item=False + ) + self.child_html = ItemFactory.create( + parent_location=self.vertical.location, category='html', display_name='Test HTML Child Block', user_id=user_id, publish_item=False + ) + + def test_lib_xblock_info(self): + html_block = modulestore().get_item(self.top_level_html.location) + xblock_info = create_xblock_info(html_block) + self.validate_component_xblock_info(xblock_info, html_block) + self.assertIsNone(xblock_info.get('child_info', None)) + + def test_lib_child_xblock_info(self): + html_block = modulestore().get_item(self.child_html.location) + xblock_info = create_xblock_info(html_block, include_ancestor_info=True, include_child_info=True) + self.validate_component_xblock_info(xblock_info, html_block) + self.assertIsNone(xblock_info.get('child_info', None)) + ancestors = xblock_info['ancestor_info']['ancestors'] + self.assertEqual(len(ancestors), 2) + self.assertEqual(ancestors[0]['category'], 'vertical') + self.assertEqual(ancestors[0]['id'], unicode(self.vertical.location)) + self.assertEqual(ancestors[1]['category'], 'library') + + def validate_component_xblock_info(self, xblock_info, original_block): + """ + Validate that the xblock info is correct for the test component. + """ + self.assertEqual(xblock_info['category'], original_block.category) + self.assertEqual(xblock_info['id'], unicode(original_block.location)) + self.assertEqual(xblock_info['display_name'], original_block.display_name) + self.assertIsNone(xblock_info.get('has_changes', None)) + self.assertIsNone(xblock_info.get('published', None)) + self.assertIsNone(xblock_info.get('published_on', None)) + self.assertIsNone(xblock_info.get('graders', None)) + + +class TestLibraryXBlockCreation(ItemTest): + """ + Tests the adding of XBlocks to Library + """ + def test_add_xblock(self): + """ + Verify we can add an XBlock to a Library. + """ + lib = LibraryFactory.create() + self.create_xblock(parent_usage_key=lib.location, display_name='Test', category="html") + lib = self.store.get_library(lib.location.library_key) + self.assertTrue(lib.children) + xblock_locator = lib.children[0] + self.assertEqual(self.store.get_item(xblock_locator).display_name, 'Test') + + def test_no_add_discussion(self): + """ + Verify we cannot add a discussion module to a Library. + """ + lib = LibraryFactory.create() + response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='discussion') + self.assertEqual(response.status_code, 400) + lib = self.store.get_library(lib.location.library_key) + self.assertFalse(lib.children) + + def test_no_add_advanced(self): + lib = LibraryFactory.create() + lib.advanced_modules = ['lti'] + lib.save() + response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='lti') + self.assertEqual(response.status_code, 400) + lib = self.store.get_library(lib.location.library_key) + self.assertFalse(lib.children) + + class TestXBlockPublishingInfo(ItemTest): """ Unit tests for XBlock's outline handling. diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py new file mode 100644 index 000000000000..acfc1bd8c4bc --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -0,0 +1,318 @@ +""" +Unit tests for contentstore.views.library + +More important high-level tests are in contentstore/tests/test_libraries.py +""" +import shutil +import tarfile + +import ddt +import lxml.etree +from django.conf import settings +from paver.path import path +from tempfile import mkdtemp + +from contentstore.tests.utils import AjaxEnabledTestClient, parse_json +from contentstore.views.component import get_component_templates +from extract_tar import safetar_extractall +from xmodule.contentstore.django import contentstore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory +from xmodule.modulestore.xml_exporter import export_library_to_xml +from xmodule.modulestore.xml_importer import import_library_from_xml +from mock import patch +from opaque_keys.edx.locator import CourseKey, LibraryLocator + +LIBRARY_REST_URL = '/library/' # URL for GET/POST requests involving libraries + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT + + +def make_url_for_lib(key): + """ Get the RESTful/studio URL for testing the given library """ + if isinstance(key, LibraryLocator): + key = unicode(key) + return LIBRARY_REST_URL + key + + +@ddt.ddt +class UnitTestLibraries(ModuleStoreTestCase): + """ + Unit tests for library views + """ + + def setUp(self): + user_password = super(UnitTestLibraries, self).setUp() + + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=user_password) + + ###################################################### + # Tests for /library/ - list and create libraries: + + @patch("contentstore.views.library.LIBRARIES_ENABLED", False) + def test_with_libraries_disabled(self): + """ + The library URLs should return 404 if libraries are disabled. + """ + response = self.client.get_json(LIBRARY_REST_URL) + self.assertEqual(response.status_code, 404) + + def test_list_libraries(self): + """ + Test that we can GET /library/ to list all libraries visible to the current user. + """ + # Create some more libraries + libraries = [LibraryFactory.create() for _ in range(0, 3)] + lib_dict = dict([(lib.location.library_key, lib) for lib in libraries]) + + response = self.client.get_json(LIBRARY_REST_URL) + self.assertEqual(response.status_code, 200) + lib_list = parse_json(response) + self.assertEqual(len(lib_list), len(libraries)) + for entry in lib_list: + self.assertIn("library_key", entry) + self.assertIn("display_name", entry) + key = CourseKey.from_string(entry["library_key"]) + self.assertIn(key, lib_dict) + self.assertEqual(entry["display_name"], lib_dict[key].display_name) + del lib_dict[key] # To ensure no duplicates are matched + + @ddt.data("delete", "put") + def test_bad_http_verb(self, verb): + """ + We should get an error if we do weird requests to /library/ + """ + response = getattr(self.client, verb)(LIBRARY_REST_URL) + self.assertEqual(response.status_code, 405) + + def test_create_library(self): + """ Create a library. """ + response = self.client.ajax_post(LIBRARY_REST_URL, { + 'org': 'org', + 'library': 'lib', + 'display_name': "New Library", + }) + self.assertEqual(response.status_code, 200) + # That's all we check. More detailed tests are in contentstore.tests.test_libraries... + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}) + def test_lib_create_permission(self): + """ + Users who aren't given course creator roles shouldn't be able to create + libraries either. + """ + self.client.logout() + ns_user, password = self.create_non_staff_user() + self.client.login(username=ns_user.username, password=password) + + response = self.client.ajax_post(LIBRARY_REST_URL, { + 'org': 'org', 'library': 'lib', 'display_name': "New Library", + }) + self.assertEqual(response.status_code, 403) + + @ddt.data( + {}, + {'org': 'org'}, + {'library': 'lib'}, + {'org': 'C++', 'library': 'lib', 'display_name': 'Lib with invalid characters in key'}, + {'org': 'Org', 'library': 'Wh@t?', 'display_name': 'Lib with invalid characters in key'}, + ) + def test_create_library_invalid(self, data): + """ + Make sure we are prevented from creating libraries with invalid keys/data + """ + response = self.client.ajax_post(LIBRARY_REST_URL, data) + self.assertEqual(response.status_code, 400) + + def test_no_duplicate_libraries(self): + """ + We should not be able to create multiple libraries with the same key + """ + lib = LibraryFactory.create() + lib_key = lib.location.library_key + response = self.client.ajax_post(LIBRARY_REST_URL, { + 'org': lib_key.org, + 'library': lib_key.library, + 'display_name': "A Duplicate key, same as 'lib'", + }) + self.assertIn('already a library defined', parse_json(response)['ErrMsg']) + self.assertEqual(response.status_code, 400) + + ###################################################### + # Tests for /library/:lib_key/ - get a specific library as JSON or HTML editing view + + def test_get_lib_info(self): + """ + Test that we can get data about a library (in JSON format) using /library/:key/ + """ + # Create a library + lib_key = LibraryFactory.create().location.library_key + # Re-load the library from the modulestore, explicitly including version information: + lib = self.store.get_library(lib_key, remove_version=False, remove_branch=False) + version = lib.location.library_key.version_guid + self.assertNotEqual(version, None) + + response = self.client.get_json(make_url_for_lib(lib_key)) + self.assertEqual(response.status_code, 200) + info = parse_json(response) + self.assertEqual(info['display_name'], lib.display_name) + self.assertEqual(info['library_id'], unicode(lib_key)) + self.assertEqual(info['previous_version'], None) + self.assertNotEqual(info['version'], None) + self.assertNotEqual(info['version'], '') + self.assertEqual(info['version'], unicode(version)) + + def test_get_lib_edit_html(self): + """ + Test that we can get the studio view for editing a library using /library/:key/ + """ + lib = LibraryFactory.create() + + response = self.client.get(make_url_for_lib(lib.location.library_key)) + self.assertEqual(response.status_code, 200) + self.assertIn("' + errorMessage + '

'); $('.new-course-save').addClass('is-disabled'); }); }; - var cancelNewCourse = function (e) { - e.preventDefault(); - $('.new-course-button').removeClass('is-disabled'); - $('.wrapper-create-course').removeClass('is-shown'); - // Clear out existing fields and errors - _.each( - ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], - function (field) { - $(field).val(''); - } - ); - $('#course_creation_error').html(''); - $('.wrap-error').removeClass('is-shown'); - $('.new-course-save').off('click'); + var makeCancelHandler = function (addType) { + return function(e) { + e.preventDefault(); + $('.new-'+addType+'-button').removeClass('is-disabled'); + $('.wrapper-create-'+addType).removeClass('is-shown'); + // Clear out existing fields and errors + $('#create-'+addType+'-form input[type=text]').val(''); + $('#'+addType+'_creation_error').html(''); + $('.create-'+addType+' .wrap-error').removeClass('is-shown'); + $('.new-'+addType+'-save').off('click'); + }; }; var addNewCourse = function (e) { @@ -73,18 +89,70 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie var $courseName = $('.new-course-name'); $courseName.focus().select(); $('.new-course-save').on('click', saveNewCourse); - $cancelButton.bind('click', cancelNewCourse); + $cancelButton.bind('click', makeCancelHandler('course')); CancelOnEscape($cancelButton); CreateCourseUtils.configureHandlers(); }; + var saveNewLibrary = function (e) { + e.preventDefault(); + + if (CreateLibraryUtils.hasInvalidRequiredFields()) { + return; + } + + var $newLibraryForm = $(this).closest('#create-library-form'); + var display_name = $newLibraryForm.find('.new-library-name').val(); + var org = $newLibraryForm.find('.new-library-org').val(); + var number = $newLibraryForm.find('.new-library-number').val(); + + var lib_info = { + org: org, + number: number, + display_name: display_name, + }; + + analytics.track('Created a Library', lib_info); + CreateLibraryUtils.createLibrary(lib_info, function (errorMessage) { + $('.create-library .wrap-error').addClass('is-shown'); + $('#library_creation_error').html('

' + errorMessage + '

'); + $('.new-library-save').addClass('is-disabled'); + }); + }; + + var addNewLibrary = function (e) { + e.preventDefault(); + $('.new-library-button').addClass('is-disabled'); + $('.new-library-save').addClass('is-disabled'); + var $newLibrary = $('.wrapper-create-library').addClass('is-shown'); + var $cancelButton = $newLibrary.find('.new-library-cancel'); + var $libraryName = $('.new-library-name'); + $libraryName.focus().select(); + $('.new-library-save').on('click', saveNewLibrary); + $cancelButton.bind('click', makeCancelHandler('library')); + CancelOnEscape($cancelButton); + + CreateLibraryUtils.configureHandlers(); + }; + + var showTab = function(tab) { + return function(e) { + e.preventDefault(); + $('.courses-tab').toggleClass('active', tab === 'courses'); + $('.libraries-tab').toggleClass('active', tab === 'libraries'); + }; + }; + var onReady = function () { $('.new-course-button').bind('click', addNewCourse); + $('.new-library-button').bind('click', addNewLibrary); $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { ViewUtils.reload(); })); $('.action-reload').bind('click', ViewUtils.reload); + $('#course-index-tabs .courses-tab').bind('click', showTab('courses')); + $('#course-index-tabs .libraries-tab').bind('click', showTab('libraries')); }; domReady(onReady); diff --git a/cms/static/js/spec/views/paged_container_spec.js b/cms/static/js/spec/views/paged_container_spec.js new file mode 100644 index 000000000000..524f88e552f7 --- /dev/null +++ b/cms/static/js/spec/views/paged_container_spec.js @@ -0,0 +1,489 @@ +define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "URI", "js/models/xblock_info", + "js/views/paged_container", "js/views/paging_header", "js/views/paging_footer"], + function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter) { + + var htmlResponseTpl = _.template('' + + '
' + ); + + function getResponseHtml(options){ + return '
' + + '
' + + htmlResponseTpl(options) + + '' + + '
' + } + + var PAGE_SIZE = 3; + + var mockFirstPage = { + resources: [], + html: getResponseHtml({ + start: 0, + displayed: PAGE_SIZE, + total: PAGE_SIZE + 1 + }) + }; + + var mockSecondPage = { + resources: [], + html: getResponseHtml({ + start: PAGE_SIZE, + displayed: 1, + total: PAGE_SIZE + 1 + }) + }; + + var mockEmptyPage = { + resources: [], + html: getResponseHtml({ + start: 0, + displayed: 0, + total: 0 + }) + }; + + var respondWithMockPage = function(requests) { + var requestIndex = requests.length - 1; + var request = requests[requestIndex]; + var url = new URI(request.url); + var queryParameters = url.query(true); // Returns an object with each query parameter stored as a value + var page = queryParameters.page_number; + var response = page === "0" ? mockFirstPage : mockSecondPage; + AjaxHelpers.respondWithJson(requests, response, requestIndex); + }; + + var MockPagingView = PagedContainer.extend({ + view: 'container_preview', + el: $("
"), + model: new XBlockInfo({}, {parse: true}) + }); + + describe("Paging Container", function() { + var pagingContainer; + + beforeEach(function () { + var feedbackTpl = readFixtures('system-feedback.underscore'); + setFixtures($(" + + +
+ +
+
+
+
+ +
+
    +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
    +
  • + +
  • +
+
+
+
+ +
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+ +
+ diff --git a/cms/templates/js/mock/mock-index-page.underscore b/cms/templates/js/mock/mock-index-page.underscore index f63f12e571a4..83f1bc509e26 100644 --- a/cms/templates/js/mock/mock-index-page.underscore +++ b/cms/templates/js/mock/mock-index-page.underscore @@ -8,6 +8,10 @@ New Course + @@ -78,6 +82,53 @@
+
+
+
+ +
+ +
+

Create a New Library

+ +
+ Required Information to Create a New Library + +
    +
  1. + + + The public display name for your library. + +
  2. +
  3. + + + The name of the organization sponsoring the library. Note: This is part of your library URL, so no spaces or special characters are allowed. This cannot be changed. + +
  4. + +
  5. + + + The unique code that identifies this library. Note: This is part of your library URL, so no spaces or special characters are allowed and it cannot be changed. + +
  6. +
+ +
+
+ +
+ + + +
+
+
+

Courses Being Processed

@@ -163,6 +214,15 @@
+ + + +
+
+
diff --git a/cms/templates/js/mock/mock-xblock-paged.underscore b/cms/templates/js/mock/mock-xblock-paged.underscore new file mode 100644 index 000000000000..c6c2c881d8d2 --- /dev/null +++ b/cms/templates/js/mock/mock-xblock-paged.underscore @@ -0,0 +1,21 @@ +
+
+
+ Mock XBlock +
+ +
+
+
+

Mock XBlock

+
+
+
diff --git a/cms/templates/library.html b/cms/templates/library.html new file mode 100644 index 000000000000..5d06317f04cb --- /dev/null +++ b/cms/templates/library.html @@ -0,0 +1,79 @@ +<%inherit file="base.html" /> +<%def name="online_help_token()"><% return "content_libraries" %> +<%! +import json + +from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name +from django.utils.translation import ugettext as _ +%> +<%block name="title">${context_library.display_name_with_default} ${xblock_type_display_name(context_library)} +<%block name="bodyclass">is-signedin course container view-container view-library + +<%namespace name='static' file='static_content.html'/> + +<%block name="header_extras"> +% for template_name in templates: + +% endfor + + +<%block name="requirejs"> + require(["js/factories/library"], function(LibraryFactory) { + LibraryFactory( + ${component_templates | n}, ${json.dumps(xblock_info) | n}, + { + isUnitPage: false, + page_size: 10 + } + ); + }); + + +<%block name="content"> + + +
+
+ +
+
+ +
+
+
+ +
+
+ +
+

${_("Loading")}

+
+
+ +
+
+
+ diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 6ff87f6d4b39..0142f87952d6 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -22,9 +22,9 @@

${_("Tools")} + ${_("Current Library:")} + + ${context_library.display_org_with_default | h}${context_library.display_number_with_default | h} + ${context_library.display_name_with_default} + +

+ + % endif
diff --git a/cms/urls.py b/cms/urls.py index 7e06f5033909..ad52eca61c6f 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -79,9 +79,9 @@ url(r'^checklists/{}/(?P\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'checklists_handler'), url(r'^orphan/{}$'.format(settings.COURSE_KEY_PATTERN), 'orphan_handler'), url(r'^assets/{}/{}?$'.format(settings.COURSE_KEY_PATTERN, settings.ASSET_KEY_PATTERN), 'assets_handler'), - url(r'^import/{}$'.format(settings.COURSE_KEY_PATTERN), 'import_handler'), + url(r'^import/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_import_handler'), url(r'^import_status/{}/(?P.+)$'.format(settings.COURSE_KEY_PATTERN), 'import_status_handler'), - url(r'^export/{}$'.format(settings.COURSE_KEY_PATTERN), 'export_handler'), + url(r'^export/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_export_handler'), url(r'^xblock/outline/{}$'.format(settings.USAGE_KEY_PATTERN), 'xblock_outline_handler'), url(r'^xblock/{}/(?P[^/]+)$'.format(settings.USAGE_KEY_PATTERN), 'xblock_view_handler'), url(r'^xblock/{}?$'.format(settings.USAGE_KEY_PATTERN), 'xblock_handler'), @@ -110,6 +110,18 @@ url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict), ) +if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'): + LIBRARY_KEY_PATTERN = r'(?Plibrary-v1:[^/+]+\+[^/+]+)' + urlpatterns += ( + url(r'^library/{}?$'.format(LIBRARY_KEY_PATTERN), + 'contentstore.views.library_handler', name='library_handler'), + url(r'^library/export/{}$'.format(LIBRARY_KEY_PATTERN), 'contentstore.views.library_export_handler', + name='library_export_handler'), + url(r'^library/import/{}$'.format(LIBRARY_KEY_PATTERN), 'contentstore.views.library_import_handler', + name='library_import_handler'), + url(r'^library/import_status/{}/(?P.+)$'.format(LIBRARY_KEY_PATTERN), + 'contentstore.views.library_import_status_handler', name='library_import_status_handler'), + ) if settings.FEATURES.get('ENABLE_EXPORT_GIT'): urlpatterns += (url( diff --git a/common/djangoapps/contentserver/tests/test.py b/common/djangoapps/contentserver/tests/test.py index 9aa52049201a..825b383e67f7 100644 --- a/common/djangoapps/contentserver/tests/test.py +++ b/common/djangoapps/contentserver/tests/test.py @@ -15,7 +15,7 @@ from xmodule.modulestore.django import modulestore from opaque_keys.edx.locations import SlashSeparatedCourseKey from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from contentserver.middleware import parse_range_header from student.models import CourseEnrollment @@ -48,7 +48,7 @@ def setUp(self): self.course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') - import_from_xml( + import_course_from_xml( modulestore(), self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=self.contentstore, verbose=True ) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index f0721e91a47f..f2b548efe9c0 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -11,6 +11,7 @@ "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", "html = xmodule.html_module:HtmlDescriptor", "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "library_content = xmodule.library_content_module:LibraryContentDescriptor", "error = xmodule.error_module:ErrorDescriptor", "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", "poll_question = xmodule.poll_module:PollDescriptor", diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py new file mode 100644 index 000000000000..8571540f7589 --- /dev/null +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +""" +LibraryContent: The XBlock used to include blocks from a library in a course. +""" +from bson.objectid import ObjectId, InvalidId +from collections import namedtuple +from copy import copy +from .mako_module import MakoModuleDescriptor +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryLocator +import random +from webob import Response +from xblock.core import XBlock +from xblock.fields import Scope, String, List, Integer, Boolean +from xblock.fragment import Fragment +from xmodule.validation import StudioValidationMessage, StudioValidation +from xmodule.x_module import XModule, STUDENT_VIEW +from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor +from .xml_module import XmlDescriptor +from pkg_resources import resource_string + +# Make '_' a no-op so we can scrape strings +_ = lambda text: text + + +def enum(**enums): + """ enum helper in lieu of enum34 """ + return type('Enum', (), enums) + + +class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")): + """ + A reference to a specific library, with an optional version. + The version is used to find out when the LibraryContentXBlock was last + updated with the latest content from the library. + + library_id is a LibraryLocator + version is an ObjectId or None + """ + def __new__(cls, library_id, version=None): + # pylint: disable=super-on-old-class + if not isinstance(library_id, LibraryLocator): + library_id = LibraryLocator.from_string(library_id) + if library_id.version_guid: + assert (version is None) or (version == library_id.version_guid) + if not version: + version = library_id.version_guid + library_id = library_id.for_version(None) + if version and not isinstance(version, ObjectId): + try: + version = ObjectId(version) + except InvalidId: + raise ValueError(version) + return super(LibraryVersionReference, cls).__new__(cls, library_id, version) + + @staticmethod + def from_json(value): + """ + Implement from_json to convert from JSON + """ + return LibraryVersionReference(*value) + + def to_json(self): + """ + Implement to_json to convert value to JSON + """ + # TODO: Is there anyway for an xblock to *store* an ObjectId as + # part of the List() field value? + return [unicode(self.library_id), unicode(self.version) if self.version else None] # pylint: disable=no-member + + +class LibraryList(List): + """ + Special List class for listing references to content libraries. + Is simply a list of LibraryVersionReference tuples. + """ + def from_json(self, values): + """ + Implement from_json to convert from JSON. + + values might be a list of lists, or a list of strings + Normally the runtime gives us: + [[u'library-v1:ProblemX+PR0B', '5436ffec56c02c13806a4c1b'], ...] + But the studio editor gives us: + [u'library-v1:ProblemX+PR0B,5436ffec56c02c13806a4c1b', ...] + """ + def parse(val): + """ Convert this list entry from its JSON representation """ + if isinstance(val, basestring): + val = val.strip(' []') + parts = val.rsplit(',', 1) + val = [parts[0], parts[1] if len(parts) > 1 else None] + try: + return LibraryVersionReference.from_json(val) + except InvalidKeyError: + try: + friendly_val = val[0] # Just get the library key part, not the version + except IndexError: + friendly_val = unicode(val) + raise ValueError(_('"{value}" is not a valid library ID.').format(value=friendly_val)) + return [parse(v) for v in values] + + def to_json(self, values): + """ + Implement to_json to convert value to JSON + """ + return [lvr.to_json() for lvr in values] + + +class LibraryContentFields(object): + """ + Fields for the LibraryContentModule. + + Separated out for now because they need to be added to the module and the + descriptor. + """ + # Please note the display_name of each field below is used in + # common/test/acceptance/pages/studio/overview.py:StudioLibraryContentXBlockEditModal + # to locate input elements - keep synchronized + display_name = String( + display_name=_("Display Name"), + help=_("Display name for this module"), + default="Library Content", + scope=Scope.settings, + ) + source_libraries = LibraryList( + display_name=_("Libraries"), + help=_("Enter a library ID for each library from which you want to draw content."), + default=[], + scope=Scope.settings, + ) + mode = String( + display_name=_("Mode"), + help=_("Determines how content is drawn from the library"), + default="random", + values=[ + {"display_name": _("Choose n at random"), "value": "random"} + # Future addition: Choose a new random set of n every time the student refreshes the block, for self tests + # Future addition: manually selected blocks + ], + scope=Scope.settings, + ) + max_count = Integer( + display_name=_("Count"), + help=_("Enter the number of components to display to each student."), + default=1, + scope=Scope.settings, + ) + filters = String(default="") # TBD + has_score = Boolean( + display_name=_("Scored"), + help=_("Set this value to True if this module is either a graded assignment or a practice problem."), + default=False, + scope=Scope.settings, + ) + selected = List( + # This is a list of (block_type, block_id) tuples used to record which random/first set of matching blocks was selected per user + default=[], + scope=Scope.user_state, + ) + has_children = True + + +#pylint: disable=abstract-method +@XBlock.wants('library_tools') # Only needed in studio +class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): + """ + An XBlock whose children are chosen dynamically from a content library. + Can be used to create randomized assessments among other things. + + Note: technically, all matching blocks from the content library are added + as children of this block, but only a subset of those children are shown to + any particular student. + """ + def selected_children(self): + """ + Returns a set() of block_ids indicating which of the possible children + have been selected to display to the current user. + + This reads and updates the "selected" field, which has user_state scope. + + Note: self.selected and the return value contain block_ids. To get + actual BlockUsageLocators, it is necessary to use self.children, + because the block_ids alone do not specify the block type. + """ + if hasattr(self, "_selected_set"): + # Already done: + return self._selected_set # pylint: disable=access-member-before-definition + # Determine which of our children we will show: + selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples + valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member + # Remove any selected blocks that are no longer valid: + selected -= (selected - valid_block_keys) + # If max_count has been decreased, we may have to drop some previously selected blocks: + while len(selected) > self.max_count: + selected.pop() + # Do we have enough blocks now? + num_to_add = self.max_count - len(selected) + if num_to_add > 0: + # We need to select [more] blocks to display to this user: + if self.mode == "random": + pool = valid_block_keys - selected + num_to_add = min(len(pool), num_to_add) + selected |= set(random.sample(pool, num_to_add)) + # We now have the correct n random children to show for this user. + else: + raise NotImplementedError("Unsupported mode.") + # Save our selections to the user state, to ensure consistency: + self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. + # Cache the results + self._selected_set = selected # pylint: disable=attribute-defined-outside-init + return selected + + def _get_selected_child_blocks(self): + """ + Generator returning XBlock instances of the children selected for the + current user. + """ + for block_type, block_id in self.selected_children(): + yield self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id)) + + def student_view(self, context): + fragment = Fragment() + contents = [] + child_context = {} if not context else copy(context) + + for child in self._get_selected_child_blocks(): + for displayable in child.displayable_items(): + rendered_child = displayable.render(STUDENT_VIEW, child_context) + fragment.add_frag_resources(rendered_child) + contents.append({ + 'id': displayable.location.to_deprecated_string(), + 'content': rendered_child.content, + }) + + fragment.add_content(self.system.render_template('vert_module.html', { + 'items': contents, + 'xblock_context': context, + })) + return fragment + + def validate(self): + """ + Validates the state of this Library Content Module Instance. + """ + return self.descriptor.validate() + + def author_view(self, context): + """ + Renders the Studio views. + Normal studio view: If block is properly configured, displays library status summary + Studio container view: displays a preview of all possible children. + """ + fragment = Fragment() + root_xblock = context.get('root_xblock') + is_root = root_xblock and root_xblock.location == self.location + + if is_root: + # User has clicked the "View" link. Show a preview of all possible children: + if self.children: # pylint: disable=no-member + fragment.add_content(self.system.render_template("library-block-author-preview-header.html", { + 'max_count': self.max_count, + 'display_name': self.display_name or self.url_name, + })) + self.render_children(context, fragment, can_reorder=False, can_add=False) + # else: When shown on a unit page, don't show any sort of preview - just the status of this block in the validation area. + + # The following JS is used to make the "Update now" button work on the unit page and the container view: + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js')) + fragment.initialize_js('LibraryContentAuthorView') + return fragment + + def get_child_descriptors(self): + """ + Return only the subset of our children relevant to the current student. + """ + return list(self._get_selected_child_blocks()) + + +@XBlock.wants('user') +@XBlock.wants('library_tools') # Only needed in studio +class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor): + """ + Descriptor class for LibraryContentModule XBlock. + """ + module_class = LibraryContentModule + mako_template = 'widgets/metadata-edit.html' + js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} + js_module_name = "VerticalDescriptor" + + @XBlock.handler + def refresh_children(self, request, suffix, update_db=True): # pylint: disable=unused-argument + """ + Refresh children: + This method is to be used when any of the libraries that this block + references have been updated. It will re-fetch all matching blocks from + the libraries, and copy them as children of this block. The children + will be given new block_ids, but the definition ID used should be the + exact same definition ID used in the library. + + This method will update this block's 'source_libraries' field to store + the version number of the libraries used, so we easily determine if + this block is up to date or not. + + If update_db is True (default), this will explicitly persist the changes + to the modulestore by calling update_item() + """ + lib_tools = self.runtime.service(self, 'library_tools') + user_service = self.runtime.service(self, 'user') + user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures + lib_tools.update_children(self, user_id, update_db) + return Response() + + def validate(self): + """ + Validates the state of this Library Content Module Instance. This + is the override of the general XBlock method, and it will also ask + its superclass to validate. + """ + validation = super(LibraryContentDescriptor, self).validate() + if not isinstance(validation, StudioValidation): + validation = StudioValidation.copy(validation) + if not self.source_libraries: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.NOT_CONFIGURED, + _(u"A library has not yet been selected."), + action_class='edit-button', + action_label=_(u"Select a Library") + ) + ) + return validation + lib_tools = self.runtime.service(self, 'library_tools') + for library_key, version in self.source_libraries: + latest_version = lib_tools.get_library_version(library_key) + if latest_version is not None: + if version is None or version != latest_version: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'This component is out of date. The library has new content.'), + action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. + action_label=_(u"↻ Update now") + ) + ) + break + else: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.ERROR, + _(u'Library is invalid, corrupt, or has been deleted.'), + action_class='edit-button', + action_label=_(u"Edit Library List") + ) + ) + break + + return validation + + def editor_saved(self, user, old_metadata, old_content): + """ + If source_libraries has been edited, refresh_children automatically. + """ + old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) + if set(old_source_libraries) != set(self.source_libraries): + try: + self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways + except ValueError: + pass # The validation area will display an error message, no need to do anything now. + + def has_dynamic_children(self): + """ + Inform the runtime that our children vary per-user. + See get_child_descriptors() above + """ + return True + + def get_content_titles(self): + """ + Returns list of friendly titles for our selected children only; without + thi, all possible children's titles would be seen in the sequence bar in + the LMS. + + This overwrites the get_content_titles method included in x_module by default. + """ + titles = [] + for child in self._xmodule.get_child_descriptors(): + titles.extend(child.get_content_titles()) + return titles + + @classmethod + def definition_from_xml(cls, xml_object, system): + """ XML support not yet implemented. """ + raise NotImplementedError + + def definition_to_xml(self, resource_fs): + """ XML support not yet implemented. """ + raise NotImplementedError + + @classmethod + def from_xml(cls, xml_data, system, id_generator): + """ XML support not yet implemented. """ + raise NotImplementedError + + def export_to_xml(self, resource_fs): + """ XML support not yet implemented. """ + raise NotImplementedError diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py index dc00aaa97fa9..b59f8e751c7b 100644 --- a/common/lib/xmodule/xmodule/library_root_xblock.py +++ b/common/lib/xmodule/xmodule/library_root_xblock.py @@ -3,10 +3,10 @@ """ import logging -from .studio_editable import StudioEditableModule from xblock.core import XBlock from xblock.fields import Scope, String, List from xblock.fragment import Fragment +from xmodule.studio_editable import StudioEditableModule log = logging.getLogger(__name__) @@ -42,29 +42,53 @@ def __str__(self): def author_view(self, context): """ - Renders the Studio preview view, which supports drag and drop. + Renders the Studio preview view. """ fragment = Fragment() + self.render_children(context, fragment, can_reorder=False, can_add=True) + return fragment + + def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument + """ + Renders the children of the module with HTML appropriate for Studio. Reordering is not supported. + """ contents = [] - for child_key in self.children: # pylint: disable=E1101 - context['reorderable_items'].add(child_key) + paging = context.get('paging', None) + + children_count = len(self.children) # pylint: disable=no-member + item_start, item_end = 0, children_count + + # TODO sort children + if paging: + page_number = paging.get('page_number', 0) + raw_page_size = paging.get('page_size', None) + page_size = raw_page_size if raw_page_size is not None else children_count + item_start, item_end = page_size * page_number, page_size * (page_number + 1) + + children_to_show = self.children[item_start:item_end] # pylint: disable=no-member + + for child_key in children_to_show: # pylint: disable=E1101 child = self.runtime.get_block(child_key) - rendered_child = self.runtime.render_child(child, StudioEditableModule.get_preview_view_name(child), context) + child_view_name = StudioEditableModule.get_preview_view_name(child) + rendered_child = self.runtime.render_child(child, child_view_name, context) fragment.add_frag_resources(rendered_child) contents.append({ - 'id': unicode(child_key), + 'id': unicode(child.location), 'content': rendered_child.content, }) - fragment.add_content(self.runtime.render_template("studio_render_children_view.html", { - 'items': contents, - 'xblock_context': context, - 'can_add': True, - 'can_reorder': True, - })) - return fragment + fragment.add_content( + self.runtime.render_template("studio_render_paged_children_view.html", { + 'items': contents, + 'xblock_context': context, + 'can_add': can_add, + 'first_displayed': item_start, + 'total_children': children_count, + 'displayed_children': len(children_to_show) + }) + ) @property def display_org_with_default(self): @@ -81,12 +105,3 @@ def display_number_with_default(self): Always returns the raw 'library' field from the key. """ return self.scope_ids.usage_id.course_key.library - - @classmethod - def parse_xml(cls, xml_data, system, id_generator, **kwargs): - """ XML support not yet implemented. """ - raise NotImplementedError - - def add_xml_to_node(self, resource_fs): - """ XML support not yet implemented. """ - raise NotImplementedError diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py new file mode 100644 index 000000000000..0ff50d81a911 --- /dev/null +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -0,0 +1,138 @@ +""" +XBlock runtime services for LibraryContentModule +""" +import hashlib +from opaque_keys.edx.locator import LibraryLocator +from xblock.fields import Scope +from xmodule.library_content_module import LibraryVersionReference +from xmodule.modulestore.exceptions import ItemNotFoundError + + +class LibraryToolsService(object): + """ + Service that allows LibraryContentModule to interact with libraries in the + modulestore. + """ + def __init__(self, modulestore): + self.store = modulestore + + def _get_library(self, library_key): + """ + Given a library key like "library-v1:ProblemX+PR0B", return the + 'library' XBlock with meta-information about the library. + + Returns None on error. + """ + if not isinstance(library_key, LibraryLocator): + library_key = LibraryLocator.from_string(library_key) + assert library_key.version_guid is None + + try: + return self.store.get_library(library_key, remove_version=False) + except ItemNotFoundError: + return None + + def get_library_version(self, lib_key): + """ + Get the version (an ObjectID) of the given library. + Returns None if the library does not exist. + """ + library = self._get_library(lib_key) + if library: + # We need to know the library's version so ensure it's set in library.location.library_key.version_guid + assert library.location.library_key.version_guid is not None + return library.location.library_key.version_guid + return None + + def get_library_display_name(self, lib_key): + """ + Get the display_name of the given library. + Returns None if the library does not exist. + """ + library = self._get_library(lib_key) + if library: + return library.display_name + return None + + def update_children(self, dest_block, user_id, update_db=True): + """ + This method is to be used when any of the libraries that a LibraryContentModule + references have been updated. It will re-fetch all matching blocks from + the libraries, and copy them as children of dest_block. The children + will be given new block_ids, but the definition ID used should be the + exact same definition ID used in the library. + + This method will update dest_block's 'source_libraries' field to store + the version number of the libraries used, so we easily determine if + dest_block is up to date or not. + + If update_db is True (default), this will explicitly persist the changes + to the modulestore by calling update_item(). Only set update_db False if + you know for sure that dest_block is about to be saved to the modulestore + anyways. Otherwise, orphaned blocks may be created. + """ + root_children = [] + + with self.store.bulk_operations(dest_block.location.course_key): + # Currently, ALL children are essentially deleted and then re-added + # in a way that preserves their block_ids (and thus should preserve + # student data, grades, analytics, etc.) + # Once course-level field overrides are implemented, this will + # change to a more conservative implementation. + + # First, load and validate the source_libraries: + libraries = [] + for library_key, old_version in dest_block.source_libraries: # pylint: disable=unused-variable + library = self._get_library(library_key) + if library is None: + raise ValueError("Required library not found.") + libraries.append((library_key, library)) + + # Next, delete all our existing children to avoid block_id conflicts when we add them: + for child in dest_block.children: + self.store.delete_item(child, user_id) + + # Now add all matching children, and record the library version we use: + new_libraries = [] + for library_key, library in libraries: + + def copy_children_recursively(from_block): + """ + Internal method to copy blocks from the library recursively + """ + new_children = [] + for child_key in from_block.children: + child = self.store.get_item(child_key, depth=9) + # We compute a block_id for each matching child block found in the library. + # block_ids are unique within any branch, but are not unique per-course or globally. + # We need our block_ids to be consistent when content in the library is updated, so + # we compute block_id as a hash of three pieces of data: + unique_data = "{}:{}:{}".format( + dest_block.location.block_id, # Must not clash with other usages of the same library in this course + unicode(library_key.for_version(None)).encode("utf-8"), # The block ID below is only unique within a library, so we need this too + child_key.block_id, # Child block ID. Should not change even if the block is edited. + ) + child_block_id = hashlib.sha1(unique_data).hexdigest()[:20] + fields = {} + for field in child.fields.itervalues(): + if field.scope == Scope.settings and field.is_set_on(child): + fields[field.name] = field.read_from(child) + if child.has_children: + fields['children'] = copy_children_recursively(from_block=child) + new_child_info = self.store.create_item( + user_id, + dest_block.location.course_key, + child_key.block_type, + block_id=child_block_id, + definition_locator=child.definition_locator, + runtime=dest_block.system, + fields=fields, + ) + new_children.append(new_child_info.location) + return new_children + root_children.extend(copy_children_recursively(from_block=library)) + new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) + dest_block.source_libraries = new_libraries + dest_block.children = root_children + if update_db: + self.store.update_item(dest_block, user_id) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py index 205bf1eaf5a3..3c357c87517e 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -6,6 +6,7 @@ from xblock.runtime import KvsFieldData from xblock.fields import ScopeIds from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator +from xmodule.library_tools import LibraryToolsService from xmodule.mako_module import MakoDescriptorSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str @@ -71,6 +72,7 @@ def __init__(self, modulestore, course_entry, default_class, module_data, lazy, self.module_data = module_data self.default_class = default_class self.local_modules = {} + self._services['library_tools'] = LibraryToolsService(modulestore) @lazy @contract(returns="dict(BlockKey: BlockKey)") diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index bb59da2a7d76..12c2f4c84b0f 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -5,7 +5,7 @@ from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL from xmodule.exceptions import InvalidVersionError from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.exceptions import InsufficientSpecificationError +from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError from xmodule.modulestore.draft_and_published import ( ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError ) @@ -409,7 +409,11 @@ def convert_to_draft(self, location, user_id): pass def _get_head(self, xblock, branch): - course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure + try: + course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure + except ItemNotFoundError: + # There is no published version xblock container, e.g. Library + return None return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location)) def _get_version(self, block): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py b/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py index dd684c682679..c09b38280c3a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py @@ -26,8 +26,8 @@ from xmodule.modulestore.mongo.draft import DraftModuleStore from xmodule.modulestore.mixed import MixedModuleStore from xmodule.contentstore.mongo import MongoContentStore -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_importer import import_course_from_xml +from xmodule.modulestore.xml_exporter import export_course_to_xml from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.inheritance import InheritanceMixin @@ -324,18 +324,18 @@ def test_round_trip(self, source_builder, dest_builder, source_content_builder, source_course_key = source_store.make_course_key('a', 'course', 'course') dest_course_key = dest_store.make_course_key('a', 'course', 'course') - import_from_xml( + import_course_from_xml( source_store, 'test_user', TEST_DATA_DIR, course_dirs=[course_data_name], static_content_store=source_content, target_course_id=source_course_key, - create_course_if_not_present=True, raise_on_failure=True, + create_if_not_present=True, ) - export_to_xml( + export_course_to_xml( source_store, source_content, source_course_key, @@ -343,19 +343,19 @@ def test_round_trip(self, source_builder, dest_builder, source_content_builder, 'exported_source_course', ) - import_from_xml( + import_course_from_xml( dest_store, 'test_user', self.export_dir, course_dirs=['exported_source_course'], static_content_store=dest_content, target_course_id=dest_course_key, - create_course_if_not_present=True, raise_on_failure=True, + create_if_not_present=True, ) # NOT CURRENTLY USED -# export_to_xml( +# export_course_to_xml( # dest_store, # dest_content, # dest_course_key, diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index 1447b0238414..2eaab575958e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -4,10 +4,12 @@ Higher-level tests are in `cms/djangoapps/contentstore`. """ -from bson.objectid import ObjectId + import ddt +from bson.objectid import ObjectId from mock import patch from opaque_keys.edx.locator import LibraryLocator + from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime from xmodule.modulestore.exceptions import DuplicateCourseError @@ -206,3 +208,14 @@ def test_library_author_view(self): with patch('xmodule.x_module.descriptor_global_get_asides', lambda block: []): result = library.render(AUTHOR_VIEW, context) self.assertIn(message, result.content) + + def test_xblock_in_lib_have_published_version_returns_false(self): + library = LibraryFactory.create(modulestore=self.store) + block = ItemFactory.create( + category="html", + parent_location=library.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + ) + self.assertFalse(self.store.has_published_version(block)) 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 0bdef107b500..c010b55e24b2 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -23,7 +23,7 @@ from xmodule.modulestore.tests.test_cross_modulestore_import_export import MongoContentstoreBuilder from xmodule.contentstore.content import StaticContent from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from nose import SkipTest if not settings.configured: @@ -1928,11 +1928,11 @@ def test_import_delete_import(self, default): self.addCleanup(self.store.close_all_connections) with self.store.default_store(default): dest_course_key = self.store.make_course_key('a', 'course', 'course') - courses = import_from_xml( + courses = import_course_from_xml( self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False, static_content_store=contentstore, target_course_id=dest_course_key, - create_course_if_not_present=True, + create_if_not_present=True, ) course_id = courses[0].id # no need to verify course content here as test_cross_modulestore_import_export does that @@ -1946,7 +1946,7 @@ def test_import_delete_import(self, default): self.assertTrue(self.store.has_item(vertical_loc)) # now re-import - import_from_xml( + import_course_from_xml( self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False, static_content_store=contentstore, target_course_id=dest_course_key, @@ -1976,11 +1976,11 @@ def test_import_edit_import(self, default): self.addCleanup(self.store.close_all_connections) with self.store.default_store(default): dest_course_key = self.store.make_course_key('a', 'course', 'course') - courses = import_from_xml( + courses = import_course_from_xml( self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False, static_content_store=contentstore, target_course_id=dest_course_key, - create_course_if_not_present=True, + create_if_not_present=True, ) course_id = courses[0].id # no need to verify course content here as test_cross_modulestore_import_export does that @@ -1992,7 +1992,7 @@ def test_import_edit_import(self, default): self.store.update_item(vertical, self.user_id) # now re-import - import_from_xml( + import_course_from_xml( self.store, self.user_id, DATA_DIR, ['toy'], load_error_modules=False, static_content_store=contentstore, target_course_id=dest_course_key, diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index aa1f80443b72..5e153d964c3f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -31,8 +31,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation from opaque_keys.edx.locator import LibraryLocator from opaque_keys.edx.keys import UsageKey -from xmodule.modulestore.xml_exporter import export_to_xml -from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint +from xmodule.modulestore.xml_exporter import export_course_to_xml +from xmodule.modulestore.xml_importer import import_course_from_xml, perform_xlint from xmodule.contentstore.mongo import MongoContentStore from nose.tools import assert_in @@ -125,7 +125,7 @@ def initdb(cls): xblock_mixins=(EditInfoMixin,) ) - import_from_xml( + import_course_from_xml( draft_store, 999, DATA_DIR, @@ -134,7 +134,7 @@ def initdb(cls): ) # also test a course with no importing of static content - import_from_xml( + import_course_from_xml( draft_store, 999, DATA_DIR, @@ -512,7 +512,7 @@ def test_export_course_image(self): root_dir = path(mkdtemp()) try: - export_to_xml(self.draft_store, self.content_store, course_key, root_dir, 'test_export') + export_course_to_xml(self.draft_store, self.content_store, course_key, root_dir, 'test_export') assert_true(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) assert_true(path(root_dir / 'test_export/static/images_course_image.jpg').isfile()) finally: @@ -528,7 +528,7 @@ def test_export_course_image_nondefault(self): root_dir = path(mkdtemp()) try: - export_to_xml(self.draft_store, self.content_store, course.id, root_dir, 'test_export') + export_course_to_xml(self.draft_store, self.content_store, course.id, root_dir, 'test_export') assert_true(path(root_dir / 'test_export/static/just_a_test.jpg').isfile()) assert_false(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) finally: @@ -542,7 +542,7 @@ def test_course_without_image(self): course = self.draft_store.get_course(SlashSeparatedCourseKey('edX', 'simple_with_draft', '2012_Fall')) root_dir = path(mkdtemp()) try: - export_to_xml(self.draft_store, self.content_store, course.id, root_dir, 'test_export') + export_course_to_xml(self.draft_store, self.content_store, course.id, root_dir, 'test_export') assert_false(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) assert_false(path(root_dir / 'test_export/static/images_course_image.jpg').isfile()) finally: @@ -668,9 +668,9 @@ def test_export_course_with_peer_component(self): root_dir = path(mkdtemp()) - # export_to_xml should work. + # export_course_to_xml should work. try: - export_to_xml(self.draft_store, self.content_store, interface_location.course_key, root_dir, 'test_export') + export_course_to_xml(self.draft_store, self.content_store, interface_location.course_key, root_dir, 'test_export') finally: shutil.rmtree(root_dir) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 0007d57574e5..8d1d44ffb92a 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -24,7 +24,7 @@ from xmodule.tabs import CourseTabList from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location -from opaque_keys.edx.locator import CourseLocator +from opaque_keys.edx.locator import CourseLocator, LibraryLocator from xblock.field_data import DictFieldData from xblock.runtime import DictKeyValueStore @@ -385,7 +385,8 @@ class XMLModuleStore(ModuleStoreReadBase): """ def __init__( self, data_dir, default_class=None, course_dirs=None, course_ids=None, - load_error_modules=True, i18n_service=None, fs_service=None, **kwargs + load_error_modules=True, i18n_service=None, fs_service=None, library=False, + **kwargs ): """ Initialize an XMLModuleStore from data_dir @@ -405,6 +406,7 @@ class to use if none is specified in entry_points self.modules = defaultdict(dict) # course_id -> dict(location -> XBlock) self.courses = {} # course_dir -> XBlock for the course self.errored_courses = {} # course_dir -> errorlog, for dirs that failed to load + self.library = library if course_ids is not None: course_ids = [SlashSeparatedCourseKey.from_deprecated_string(course_id) for course_id in course_ids] @@ -431,9 +433,15 @@ class to use if none is specified in entry_points # that have a course.xml. We sort the dirs in alpha order so we always # read things in the same order (OS differences in load order have # bitten us in the past.) + + if self.library: + self.parent_xml = 'library.xml' + else: + self.parent_xml = 'course.xml' + if course_dirs is None: course_dirs = sorted([d for d in os.listdir(self.data_dir) if - os.path.exists(self.data_dir / d / "course.xml")]) + os.path.exists(self.data_dir / d / self.parent_xml)]) for course_dir in course_dirs: self.try_load_course(course_dir, course_ids) @@ -449,7 +457,7 @@ def try_load_course(self, course_dir, course_ids=None): errorlog = make_error_tracker() course_descriptor = None try: - course_descriptor = self.load_course(course_dir, course_ids, errorlog.tracker) + course_descriptor = self.load_course(course_dir, course_ids, errorlog.tracker, library=self.library) except Exception as exc: # pylint: disable=broad-except msg = "ERROR: Failed to load course '{0}': {1}".format( course_dir.encode("utf-8"), unicode(exc) @@ -465,8 +473,12 @@ def try_load_course(self, course_dir, course_ids=None): self.errored_courses[course_dir] = errorlog else: self.courses[course_dir] = course_descriptor - self._course_errors[course_descriptor.id] = errorlog - self.parent_trackers[course_descriptor.id].make_known(course_descriptor.scope_ids.usage_id) + if self.library: + course_id = course_descriptor.location + else: + course_id = course_descriptor.id + self._course_errors[course_id] = errorlog + self.parent_trackers[course_id].make_known(course_descriptor.scope_ids.usage_id) def __unicode__(self): ''' @@ -494,7 +506,7 @@ def load_policy(self, policy_path, tracker): log.warning(msg + " " + str(err)) return {} - def load_course(self, course_dir, course_ids, tracker): + def load_course(self, course_dir, course_ids, tracker, library=False): """ Load a course into this module store course_path: Course directory name @@ -503,7 +515,7 @@ def load_course(self, course_dir, course_ids, tracker): """ log.debug('========> Starting course import from {0}'.format(course_dir)) - with open(self.data_dir / course_dir / "course.xml") as course_file: + with open(self.data_dir / course_dir / self.parent_xml) as course_file: # VS[compat] # TODO (cpennington): Remove this once all fall 2012 courses have @@ -521,11 +533,17 @@ def load_course(self, course_dir, course_ids, tracker): tracker(msg) org = 'edx' - course = course_data.get('course') + if library: + course_label = 'library' + else: + course_label = 'course' + + course = course_data.get(course_label) if course is None: - msg = ("No 'course' attribute set for course in {dir}." - " Using default '{default}'".format(dir=course_dir, + msg = ("No '{course_label}' attribute set for course in {dir}." + " Using default '{default}'".format(couse_label=course_label, + dir=course_dir, default=course_dir ) ) @@ -553,10 +571,14 @@ def load_course(self, course_dir, course_ids, tracker): tracker("'name' is deprecated for module xml. Please use " "display_name and url_name.") else: - raise ValueError("Can't load a course without a 'url_name' " - "(or 'name') set. Set url_name.") + if not library: + raise ValueError("Can't load a course without a 'url_name' " + "(or 'name') set. Set url_name.") - course_id = SlashSeparatedCourseKey(org, course, url_name) + if library: + course_id = LibraryLocator(org=org, library=course) + else: + course_id = SlashSeparatedCourseKey(org, course, url_name) if course_ids is not None and course_id not in course_ids: return None @@ -602,15 +624,16 @@ def get_policy(usage_id): # now import all pieces of course_info which is expected to be stored # in /info or /info/ - self.load_extra_content(system, course_descriptor, 'course_info', self.data_dir / course_dir / 'info', course_dir, url_name) + if not library: + self.load_extra_content(system, course_descriptor, 'course_info', self.data_dir / course_dir / 'info', course_dir, url_name) - # now import all static tabs which are expected to be stored in - # in /tabs or /tabs/ - self.load_extra_content(system, course_descriptor, 'static_tab', self.data_dir / course_dir / 'tabs', course_dir, url_name) + # now import all static tabs which are expected to be stored in + # in /tabs or /tabs/ + self.load_extra_content(system, course_descriptor, 'static_tab', self.data_dir / course_dir / 'tabs', course_dir, url_name) - self.load_extra_content(system, course_descriptor, 'custom_tag_template', self.data_dir / course_dir / 'custom_tags', course_dir, url_name) + self.load_extra_content(system, course_descriptor, 'custom_tag_template', self.data_dir / course_dir / 'custom_tags', course_dir, url_name) - self.load_extra_content(system, course_descriptor, 'about', self.data_dir / course_dir / 'about', course_dir, url_name) + self.load_extra_content(system, course_descriptor, 'about', self.data_dir / course_dir / 'about', course_dir, url_name) log.debug('========> Done with course import from {0}'.format(course_dir)) return course_descriptor diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index a0a97a47ca80..cdc16672e918 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -18,7 +18,7 @@ from path import path import shutil from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES -from opaque_keys.edx.locator import CourseLocator +from opaque_keys.edx.locator import CourseLocator, LibraryLocator DRAFT_DIR = "drafts" PUBLISHED_DIR = "published" @@ -28,7 +28,76 @@ DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] -def export_to_xml(modulestore, contentstore, course_key, root_dir, course_dir): +def _export_drafts(modulestore, course_key, export_fs, xml_centric_course_key): + """ + Exports course drafts. + """ + # NOTE: we need to explicitly implement the logic for setting the vertical's parent + # and index here since the XML modulestore cannot load draft modules + with modulestore.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): + draft_modules = modulestore.get_items( + course_key, + qualifiers={'category': {'$nin': DIRECT_ONLY_CATEGORIES}}, + revision=ModuleStoreEnum.RevisionOption.draft_only + ) + + if draft_modules: + draft_course_dir = export_fs.makeopendir(DRAFT_DIR) + + # accumulate tuples of draft_modules and their parents in + # this list: + draft_node_list = [] + + for draft_module in draft_modules: + parent_loc = modulestore.get_parent_location( + draft_module.location, + revision=ModuleStoreEnum.RevisionOption.draft_preferred + ) + + # if module has no parent, set its parent_url to `None` + parent_url = None + if parent_loc is not None: + parent_url = parent_loc.to_deprecated_string() + + draft_node = draft_node_constructor( + draft_module, + location=draft_module.location, + url=draft_module.location.to_deprecated_string(), + parent_location=parent_loc, + parent_url=parent_url, + ) + + draft_node_list.append(draft_node) + + for draft_node in get_draft_subtree_roots(draft_node_list): + # only export the roots of the draft subtrees + # since export_from_xml (called by `add_xml_to_node`) + # exports a whole tree + + # ensure module has "xml_attributes" attr + if not hasattr(draft_node.module, 'xml_attributes'): + draft_node.module.xml_attributes = {} + + # Don't try to export orphaned items + # and their descendents + if draft_node.parent_location is None: + continue + + logging.debug('parent_loc = {0}'.format(draft_node.parent_location)) + + draft_node.module.xml_attributes['parent_url'] = draft_node.parent_url + parent = modulestore.get_item(draft_node.parent_location) + index = parent.children.index(draft_node.module.location) + draft_node.module.xml_attributes['index_in_children_list'] = str(index) + + draft_node.module.runtime.export_fs = draft_course_dir + adapt_references(draft_node.module, xml_centric_course_key, draft_course_dir) + node = lxml.etree.Element('unknown') + + draft_node.module.add_xml_to_node(node) + + +def export_course_to_xml(modulestore, contentstore, course_key, root_dir, course_dir): """ Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`. @@ -122,72 +191,52 @@ def export_to_xml(modulestore, contentstore, course_key, root_dir, course_dir): policy = {'course/' + course.location.name: own_metadata(course)} course_policy.write(dumps(policy, cls=EdxJSONEncoder, sort_keys=True, indent=4)) - #### DRAFTS #### # xml backed courses don't support drafts! if course.runtime.modulestore.get_modulestore_type() != ModuleStoreEnum.Type.xml: - # NOTE: we need to explicitly implement the logic for setting the vertical's parent - # and index here since the XML modulestore cannot load draft modules - with modulestore.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): - draft_modules = modulestore.get_items( - course_key, - qualifiers={'category': {'$nin': DIRECT_ONLY_CATEGORIES}}, - revision=ModuleStoreEnum.RevisionOption.draft_only - ) - - if draft_modules: - draft_course_dir = export_fs.makeopendir(DRAFT_DIR) + _export_drafts(modulestore, course_key, export_fs, xml_centric_course_key) - # accumulate tuples of draft_modules and their parents in - # this list: - draft_node_list = [] - for draft_module in draft_modules: - parent_loc = modulestore.get_parent_location( - draft_module.location, - revision=ModuleStoreEnum.RevisionOption.draft_preferred - ) - - # if module has no parent, set its parent_url to `None` - parent_url = None - if parent_loc is not None: - parent_url = parent_loc.to_deprecated_string() - - draft_node = draft_node_constructor( - draft_module, - location=draft_module.location, - url=draft_module.location.to_deprecated_string(), - parent_location=parent_loc, - parent_url=parent_url, - ) - - draft_node_list.append(draft_node) +def export_library_to_xml(modulestore, contentstore, library_key, root_dir, library_dir): + """ + Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`. - for draft_node in get_draft_subtree_roots(draft_node_list): - # only export the roots of the draft subtrees - # since export_from_xml (called by `add_xml_to_node`) - # exports a whole tree + `modulestore`: A `ModuleStore` object that is the source of the modules to export + `contentstore`: A `ContentStore` object that is the source of the content to export, can be None + `library_key`: The `LibraryKey` of the `LibraryDescriptor` to export + `root_dir`: The directory to write the exported xml to + `library_dir`: The name of the directory inside `root_dir` to write the course content to + """ + with modulestore.bulk_operations(library_key): + library = modulestore.get_library(library_key) + fsm = OSFS(root_dir) + export_fs = library.runtime.export_fs = fsm.makeopendir(library_dir) - # ensure module has "xml_attributes" attr - if not hasattr(draft_node.module, 'xml_attributes'): - draft_node.module.xml_attributes = {} + root = lxml.etree.Element('unknown') - # Don't try to export orphaned items - # and their descendents - if draft_node.parent_location is None: - continue + # export only the published content + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, library_key): + # change all of the references inside the course to use the xml expected key type w/o version & branch + xml_centric_course_key = LibraryLocator(library_key.org, library_key.run) + adapt_references(library, xml_centric_course_key, export_fs) - logging.debug('parent_loc = {0}'.format(draft_node.parent_location)) + library.add_xml_to_node(root) + root.set('org', library_key.org) + root.set('library', library_key.library) - draft_node.module.xml_attributes['parent_url'] = draft_node.parent_url - parent = modulestore.get_item(draft_node.parent_location) - index = parent.children.index(draft_node.module.location) - draft_node.module.xml_attributes['index_in_children_list'] = str(index) + # export the static assets + export_fs.makeopendir('policies') - draft_node.module.runtime.export_fs = draft_course_dir - adapt_references(draft_node.module, xml_centric_course_key, draft_course_dir) - node = lxml.etree.Element('unknown') + if contentstore: + contentstore.export_all_for_course( + library_key, + root_dir + '/' + library_dir + '/static/', + root_dir + '/' + library_dir + '/policies/assets.json', + ) - draft_node.module.add_xml_to_node(node) + # Create the Library.xml file, which acts as the index of all library contents. + xml_file = export_fs.open('library.xml', 'w') + xml_file.write(lxml.etree.tostring(root, pretty_print=True, encoding='utf-8')) + xml_file.close() def adapt_references(subtree, destination_course_key, export_fs): diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 39a61bd342cb..bde48a89c2bc 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -28,7 +28,7 @@ import re from lxml import etree -from .xml import XMLModuleStore, ImportSystem, ParentTracker +from xmodule.modulestore.xml import XMLModuleStore, ImportSystem, ParentTracker, edx_xml_parser from xblock.runtime import KvsFieldData, DictKeyValueStore from xmodule.x_module import XModuleDescriptor from opaque_keys.edx.keys import UsageKey @@ -41,7 +41,7 @@ from xmodule.tabs import CourseTabList from xmodule.assetstore import AssetMetadata from xmodule.modulestore.django import ASSET_IGNORE_REGEX -from xmodule.modulestore.exceptions import DuplicateCourseError +from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError from xmodule.modulestore.mongo.base import MongoRevisionKey from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots @@ -136,13 +136,14 @@ def import_static_content( return remap_dict -def import_from_xml( +def import_course_from_xml( store, user_id, data_dir, course_dirs=None, default_class='xmodule.raw_module.RawDescriptor', load_error_modules=True, static_content_store=None, target_course_id=None, verbose=False, - do_import_static=True, create_course_if_not_present=False, - raise_on_failure=False): + do_import_static=True, create_if_not_present=False, + raise_on_failure=False, +): """ Import xml-based courses from data_dir into modulestore. @@ -170,7 +171,7 @@ def import_from_xml( time the course is loaded. Static content for some courses may also be served directly by nginx, instead of going through django. - create_course_if_not_present: If True, then a new course is created if it doesn't already exist. + create_if_not_present: If True, then a new course is created if it doesn't already exist. Otherwise, it throws an InvalidLocationError if the course does not exist. default_class, load_error_modules: are arguments for constructing the XMLModuleStore (see its doc) @@ -199,7 +200,8 @@ def import_from_xml( runtime = None # Creates a new course if it doesn't already exist - if create_course_if_not_present and not store.has_course(dest_course_id, ignore_case=True): + + if create_if_not_present and not store.has_course(dest_course_id, ignore_case=True): try: new_course = store.create_course(dest_course_id.org, dest_course_id.course, dest_course_id.run, user_id) runtime = new_course.runtime @@ -338,6 +340,115 @@ def make_asset_id(course_id, asset_xml): store.save_asset_metadata_list(all_assets, all_assets[0].edited_by, import_only=True) +def import_library_from_xml( + store, + user_id, data_dir, library_dirs, + default_class='xmodule.raw_module.RawDescriptor', + load_error_modules=True, static_content_store=None, + target_library_id=None, display_name='', + do_import_static=True, create_if_not_present=False +): + """ + Import xml-based libraries from data_dir into modulestore. + + Returns: + list of new libraries (should only contain one item, currently) + + Args: + store: a modulestore implementing ModuleStoreWriteBase in which to store the imported libraries. + + data_dir: the root directory from which to find the xml courses. + + library_dirs: If specified, the list of data_dir subdirectories to load. Otherwise, load + all library dirs + + target_library_id: is the LibraryKey that all modules should be remapped to + after import off disk. + + static_content_store: the static asset store + + do_import_static: if True, then import the library's static files into static_content_store + This can be employed for libraries which have substantial + unchanging static content, which is too inefficient to import every + time the course is loaded. Static content for some courses may also be + served directly by nginx, instead of going through django. + + create_if_not_present: If True, then a new library is created if it doesn't already exist. + Otherwise, it throws an InvalidLocationError if the library does not exist. + + default_class, load_error_modules: are arguments for constructing the XMLModuleStore (see its doc) + """ + + # We only actually support one library import at a time right now. + if len(library_dirs) > 1: + raise NotImplementedError( + "Only one library may be imported at a time from this function.") + lib_dir = path(data_dir) / library_dirs[0] + lib_file = open(lib_dir / 'library.xml') + + xml_module_store = XMLModuleStore( + data_dir, + default_class=default_class, + course_dirs=library_dirs, + load_error_modules=load_error_modules, + xblock_mixins=store.xblock_mixins, + xblock_select=store.xblock_select, + library=True, + ) + + try: + library = store.get_library(target_library_id) + except (ItemNotFoundError, AssertionError): + library = None + if library is None and create_if_not_present: + library = store.create_library( + org=target_library_id.org, + library=target_library_id.library, + user_id=user_id, + fields={"display_name": display_name} + ) + + logger, errors = make_error_tracker() # pylint: disable=unused-variable + parent_tracker = ParentTracker() + system = ImportSystem( + xml_module_store, library.location.library_key, lib_dir, + logger, parent_tracker, + load_error_modules=load_error_modules, + mixins=xml_module_store.xblock_mixins, + field_data=KvsFieldData(kvs=DictKeyValueStore())) + + lib_data = etree.parse(lib_file, parser=edx_xml_parser).getroot() + + temp_library = system.process_xml(etree.tostring(lib_data, encoding='unicode')) + # Return a list to make return signature similar to course import. Also to permit multi import in + # the future if it's needed. + + # Everything that has happened so far has only placed the data in a temporary position. We now move it + # over to its destination library. First, delete the old items. + for child in library.children: + store.delete_item(child, user_id) + + temp_key = temp_library.location.library_key + library_key = library.location.library_key + # Now stick all the new items into the library. + for module in temp_library.children: + _import_module_and_update_references( + xml_module_store.get_item(module), store, user_id, + temp_key, + library_key, + do_import_static=do_import_static, + runtime=library.runtime, + library=True + ) + + if do_import_static: + import_static_content( + lib_dir, static_content_store, + library_key, subpath='static', verbose=False) + + return [library] + + def _import_course_module( store, runtime, user_id, data_dir, course_key, dest_course_id, source_course, do_import_static, verbose, @@ -450,7 +561,7 @@ def _import_static_content_wrapper(static_content_store, do_import_static, cours def _import_module_and_update_references( module, store, user_id, source_course_id, dest_course_id, - do_import_static=True, runtime=None): + do_import_static=True, runtime=None, library=False): logging.debug(u'processing import of module {}...'.format(module.location.to_deprecated_string())) @@ -480,6 +591,8 @@ def _convert_reference_fields_to_new_namespace(reference): fields = {} for field_name, field in module.fields.iteritems(): if field.is_set_on(module): + if field.scope == Scope.parent: + continue if isinstance(field, Reference): fields[field_name] = _convert_reference_fields_to_new_namespace(field.read_from(module)) elif isinstance(field, ReferenceList): @@ -507,7 +620,18 @@ def _convert_reference_fields_to_new_namespace(reference): else: fields[field_name] = field.read_from(module) - return store.import_xblock(user_id, dest_course_id, module.location.category, module.location.block_id, fields, runtime) + if library: + lib = store.get_library(dest_course_id).location + return store.create_child( + user_id, lib, module.location.category, + module.location.block_id, fields, + runtime=runtime + ) + + return store.import_xblock( + user_id, dest_course_id, module.location.category, + module.location.block_id, fields, runtime + ) def _import_course_draft( diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js new file mode 100644 index 000000000000..89011789b99b --- /dev/null +++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js @@ -0,0 +1,36 @@ +/* JavaScript for special editing operations that can be done on LibraryContentXBlock */ +window.LibraryContentAuthorView = function (runtime, element) { + "use strict"; + var $element = $(element); + var usage_id = $element.data('usage-id'); + // The "Update Now" button is not a child of 'element', as it is in the validation message area + // But it is still inside this xblock's wrapper element, which we can easily find: + var $wrapper = $element.parents('*[data-locator="'+usage_id+'"]'); + + // We can't bind to the button itself because in the bok choy test environment, + // it may not yet exist at this point in time... not sure why. + $wrapper.on('click', '.library-update-btn', function(e) { + e.preventDefault(); + // Update the XBlock with the latest matching content from the library: + runtime.notify('save', { + state: 'start', + element: element, + message: gettext('Updating with latest library content') + }); + $.post(runtime.handlerUrl(element, 'refresh_children')).done(function() { + runtime.notify('save', { + state: 'end', + element: element + }); + if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) { + // We are on a course unit page. The notify('save') should refresh this block, + // but that is only working on the container page view of this block. + // Why? On the unit page, this XBlock's runtime has no reference to the + // XBlockContainerPage - only the top-level XBlock (a vertical) runtime does. + // But unfortunately there is no way to get a reference to our parent block's + // JS 'runtime' object. So instead we must refresh the whole page: + location.reload(); + } + }); + }); +}; diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py new file mode 100644 index 000000000000..2b52386e3740 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" +Basic unit tests for LibraryContentModule + +Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. +""" +import ddt +from xmodule.library_content_module import LibraryVersionReference +from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory +from xmodule.modulestore.tests.utils import MixedSplitTestCase +from xmodule.tests import get_test_system +from xmodule.validation import StudioValidationMessage + + +@ddt.ddt +class TestLibraries(MixedSplitTestCase): + """ + Basic unit tests for LibraryContentModule (library_content_module.py) + """ + def setUp(self): + super(TestLibraries, self).setUp() + + self.library = LibraryFactory.create(modulestore=self.store) + self.lib_blocks = [ + ItemFactory.create( + category="html", + parent_location=self.library.location, + user_id=self.user_id, + publish_item=False, + metadata={"data": "Hello world from block {}".format(i), }, + modulestore=self.store, + ) + for i in range(1, 5) + ] + self.course = CourseFactory.create(modulestore=self.store) + self.chapter = ItemFactory.create( + category="chapter", + parent_location=self.course.location, + user_id=self.user_id, + modulestore=self.store, + ) + self.sequential = ItemFactory.create( + category="sequential", + parent_location=self.chapter.location, + user_id=self.user_id, + modulestore=self.store, + ) + self.vertical = ItemFactory.create( + category="vertical", + parent_location=self.sequential.location, + user_id=self.user_id, + modulestore=self.store, + ) + self.lc_block = ItemFactory.create( + category="library_content", + parent_location=self.vertical.location, + user_id=self.user_id, + modulestore=self.store, + metadata={ + 'max_count': 1, + 'source_libraries': [LibraryVersionReference(self.library.location.library_key)] + } + ) + + def _bind_course_module(self, module): + """ + Bind a module (part of self.course) so we can access student-specific data. + """ + module_system = get_test_system(course_id=self.course.location.course_key) + module_system.descriptor_runtime = module.runtime + + def get_module(descriptor): + """Mocks module_system get_module function""" + sub_module_system = get_test_system(course_id=self.course.location.course_key) + sub_module_system.get_module = get_module + sub_module_system.descriptor_runtime = descriptor.runtime + descriptor.bind_for_student(sub_module_system, descriptor._field_data) # pylint: disable=protected-access + return descriptor + + module_system.get_module = get_module + module.xmodule_runtime = module_system + + def test_lib_content_block(self): + """ + Test that blocks from a library are copied and added as children + """ + # Check that the LibraryContent block has no children initially + # Normally the children get added when the "source_libraries" setting + # is updated, but the way we do it through a factory doesn't do that. + self.assertEqual(len(self.lc_block.children), 0) + # Update the LibraryContent module: + self.lc_block.refresh_children(None, None) + # Check that all blocks from the library are now children of the block: + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) + + def test_children_seen_by_a_user(self): + """ + Test that each student sees only one block as a child of the LibraryContent block. + """ + self.lc_block.refresh_children(None, None) + self.lc_block = self.store.get_item(self.lc_block.location) + self._bind_course_module(self.lc_block) + # Make sure the runtime knows that the block's children vary per-user: + self.assertTrue(self.lc_block.has_dynamic_children()) + + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) + + # Check how many children each user will see: + self.assertEqual(len(self.lc_block.get_child_descriptors()), 1) + # Check that get_content_titles() doesn't return titles for hidden/unused children + self.assertEqual(len(self.lc_block.get_content_titles()), 1) + + def test_validation(self): + """ + Test that the validation method of LibraryContent blocks is working. + """ + # When source_libraries is blank, the validation summary should say this block needs to be configured: + self.lc_block.source_libraries = [] + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.NOT_CONFIGURED, result.summary.type) + + # When source_libraries references a non-existent library, we should get an error: + self.lc_block.source_libraries = [LibraryVersionReference("library-v1:BAD+WOLF")] + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.ERROR, result.summary.type) + self.assertIn("invalid", result.summary.text) + + # When source_libraries is set but the block needs to be updated, the summary should say so: + self.lc_block.source_libraries = [LibraryVersionReference(self.library.location.library_key)] + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.WARNING, result.summary.type) + self.assertIn("out of date", result.summary.text) + + # Now if we update the block, all validation should pass: + self.lc_block.refresh_children(None, None) + self.assertTrue(self.lc_block.validate()) diff --git a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py index 647a92f27c33..82cd49129df0 100644 --- a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py +++ b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py @@ -376,7 +376,7 @@ def test_xmodule_handler_return_value(self): class TestXmlExport(XBlockWrapperTestMixin, TestCase): """ - This tests that XModuleDescriptor.export_to_xml and add_xml_to_node produce the same results. + This tests that XModuleDescriptor.export_course_to_xml and add_xml_to_node produce the same results. """ def skip_if_invalid(self, descriptor_cls): if descriptor_cls.add_xml_to_node != XModuleDescriptor.add_xml_to_node: diff --git a/common/static/js/xblock/core.js b/common/static/js/xblock/core.js index ffef2b27627b..99b2ae0489b0 100644 --- a/common/static/js/xblock/core.js +++ b/common/static/js/xblock/core.js @@ -23,10 +23,10 @@ if (runtime && version && initFnName) { return new window[runtime]['v' + version]; } else { - if (!runtime || !version || !initFnName) { + if (runtime || version || initFnName) { var elementTag = $('
').append($element.clone()).html(); console.log('Block ' + elementTag + ' is missing data-runtime, data-runtime-version or data-init, and can\'t be initialized'); - } + } // else this XBlock doesn't have a JS init function. return null; } } diff --git a/common/test/acceptance/fixtures/base.py b/common/test/acceptance/fixtures/base.py new file mode 100644 index 000000000000..0f2e2723839e --- /dev/null +++ b/common/test/acceptance/fixtures/base.py @@ -0,0 +1,196 @@ +""" +Common code shared by course and library fixtures. +""" +import re +import requests +import json +from lazy import lazy + +from . import STUDIO_BASE_URL + + +class StudioApiLoginError(Exception): + """ + Error occurred while logging in to the Studio API. + """ + pass + + +class StudioApiFixture(object): + """ + Base class for fixtures that use the Studio restful API. + """ + def __init__(self): + # Info about the auto-auth user used to create the course/library. + self.user = {} + + @lazy + def session(self): + """ + Log in as a staff user, then return a `requests` `session` object for the logged in user. + Raises a `StudioApiLoginError` if the login fails. + """ + # Use auto-auth to retrieve the session for a logged in user + session = requests.Session() + response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true") + + # Return the session from the request + if response.ok: + # auto_auth returns information about the newly created user + # capture this so it can be used by by the testcases. + user_pattern = re.compile(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format( + r'(?P\S+)', r'(?P[^\)]+)', r'(?P\S+)', r'(?P\d+)')) + user_matches = re.match(user_pattern, response.text) + if user_matches: + self.user = user_matches.groupdict() + + return session + + else: + msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code) + raise StudioApiLoginError(msg) + + @lazy + def session_cookies(self): + """ + Log in as a staff user, then return the cookies for the session (as a dict) + Raises a `StudioApiLoginError` if the login fails. + """ + return {key: val for key, val in self.session.cookies.items()} + + @lazy + def headers(self): + """ + Default HTTP headers dict. + """ + return { + 'Content-type': 'application/json', + 'Accept': 'application/json', + 'X-CSRFToken': self.session_cookies.get('csrftoken', '') + } + + +class FixtureError(Exception): + """ + Error occurred while installing a course or library fixture. + """ + pass + + +class XBlockContainerFixture(StudioApiFixture): + """ + Base class for course and library fixtures. + """ + + def __init__(self): + self.children = [] + super(XBlockContainerFixture, self).__init__() + + def add_children(self, *args): + """ + Add children XBlock to the container. + Each item in `args` is an `XBlockFixtureDesc` object. + + Returns the fixture to allow chaining. + """ + self.children.extend(args) + return self + + def _create_xblock_children(self, parent_loc, xblock_descriptions): + """ + Recursively create XBlock children. + """ + for desc in xblock_descriptions: + loc = self.create_xblock(parent_loc, desc) + self._create_xblock_children(loc, desc.children) + + def create_xblock(self, parent_loc, xblock_desc): + """ + Create an XBlock with `parent_loc` (the location of the parent block) + and `xblock_desc` (an `XBlockFixtureDesc` instance). + """ + create_payload = { + 'category': xblock_desc.category, + 'display_name': xblock_desc.display_name, + } + + if parent_loc is not None: + create_payload['parent_locator'] = parent_loc + + # Create the new XBlock + response = self.session.post( + STUDIO_BASE_URL + '/xblock/', + data=json.dumps(create_payload), + headers=self.headers, + ) + + if not response.ok: + msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code) + raise FixtureError(msg) + + try: + loc = response.json().get('locator') + xblock_desc.locator = loc + except ValueError: + raise FixtureError("Could not decode JSON from '{0}'".format(response.content)) + + # Configure the XBlock + response = self.session.post( + STUDIO_BASE_URL + '/xblock/' + loc, + data=xblock_desc.serialize(), + headers=self.headers, + ) + + if response.ok: + return loc + else: + raise FixtureError("Could not update {0}. Status code: {1}".format(xblock_desc, response.status_code)) + + def _update_xblock(self, locator, data): + """ + Update the xblock at `locator`. + """ + # Create the new XBlock + response = self.session.put( + "{}/xblock/{}".format(STUDIO_BASE_URL, locator), + data=json.dumps(data), + headers=self.headers, + ) + + if not response.ok: + msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code) + raise FixtureError(msg) + + def _encode_post_dict(self, post_dict): + """ + Encode `post_dict` (a dictionary) as UTF-8 encoded JSON. + """ + return json.dumps({ + k: v.encode('utf-8') if isinstance(v, basestring) else v + for k, v in post_dict.items() + }) + + def get_nested_xblocks(self, category=None): + """ + Return a list of nested XBlocks for the container that can be filtered by + category. + """ + xblocks = self._get_nested_xblocks(self) + if category: + xblocks = [x for x in xblocks if x.category == category] + return xblocks + + def _get_nested_xblocks(self, xblock_descriptor): + """ + Return a list of nested XBlocks for the container. + """ + xblocks = list(xblock_descriptor.children) + for child in xblock_descriptor.children: + xblocks.extend(self._get_nested_xblocks(child)) + return xblocks + + def _publish_xblock(self, locator): + """ + Publish the xblock at `locator`. + """ + self._update_xblock(locator, {'publish': 'make_public'}) diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index 69836fbee048..656a12a9658a 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -4,77 +4,17 @@ import mimetypes import json -import re + import datetime -import requests + from textwrap import dedent from collections import namedtuple from path import path -from lazy import lazy + from opaque_keys.edx.keys import CourseKey from . import STUDIO_BASE_URL - - -class StudioApiLoginError(Exception): - """ - Error occurred while logging in to the Studio API. - """ - pass - - -class StudioApiFixture(object): - """ - Base class for fixtures that use the Studio restful API. - """ - def __init__(self): - # Info about the auto-auth user used to create the course. - self.user = {} - - @lazy - def session(self): - """ - Log in as a staff user, then return a `requests` `session` object for the logged in user. - Raises a `StudioApiLoginError` if the login fails. - """ - # Use auto-auth to retrieve the session for a logged in user - session = requests.Session() - response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true") - - # Return the session from the request - if response.ok: - # auto_auth returns information about the newly created user - # capture this so it can be used by by the testcases. - user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format( - '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)')) - user_matches = re.match(user_pattern, response.text) - if user_matches: - self.user = user_matches.groupdict() - - return session - - else: - msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code) - raise StudioApiLoginError(msg) - - @lazy - def session_cookies(self): - """ - Log in as a staff user, then return the cookies for the session (as a dict) - Raises a `StudioApiLoginError` if the login fails. - """ - return {key: val for key, val in self.session.cookies.items()} - - @lazy - def headers(self): - """ - Default HTTP headers dict. - """ - return { - 'Content-type': 'application/json', - 'Accept': 'application/json', - 'X-CSRFToken': self.session_cookies.get('csrftoken', '') - } +from .base import XBlockContainerFixture, FixtureError class XBlockFixtureDesc(object): @@ -105,7 +45,7 @@ def __init__(self, category, display_name, data=None, metadata=None, grader_type def add_children(self, *args): """ Add child XBlocks to this XBlock. - Each item in `args` is an `XBlockFixtureDescriptor` object. + Each item in `args` is an `XBlockFixtureDesc` object. Returns the `xblock_desc` instance to allow chaining. """ @@ -154,14 +94,7 @@ def __str__(self): CourseUpdateDesc = namedtuple("CourseUpdateDesc", ['date', 'content']) -class CourseFixtureError(Exception): - """ - Error occurred while installing a course fixture. - """ - pass - - -class CourseFixture(StudioApiFixture): +class CourseFixture(XBlockContainerFixture): """ Fixture for ensuring that a course exists. @@ -181,6 +114,7 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non These have the same meaning as in the Studio restful API /course end-point. """ + super(CourseFixture, self).__init__() self._course_dict = { 'org': org, 'number': number, @@ -202,7 +136,6 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non self._updates = [] self._handouts = [] - self.children = [] self._assets = [] self._advanced_settings = {} self._course_key = None @@ -213,16 +146,6 @@ def __str__(self): """ return "".format(**self._course_dict) - def add_children(self, *args): - """ - Add children XBlock to the course. - Each item in `args` is an `XBlockFixtureDescriptor` object. - - Returns the course fixture to allow chaining. - """ - self.children.extend(args) - return self - def add_update(self, update): """ Add an update to the course. `update` should be a `CourseUpdateDesc`. @@ -252,7 +175,7 @@ def install(self): """ Create the course and XBlocks within the course. This is NOT an idempotent method; if the course already exists, this will - raise a `CourseFixtureError`. You should use unique course identifiers to avoid + raise a `FixtureError`. You should use unique course identifiers to avoid conflicts between tests. """ self._create_course() @@ -308,18 +231,18 @@ def _create_course(self): err = response.json().get('ErrMsg') except ValueError: - raise CourseFixtureError( + raise FixtureError( "Could not parse response from course request as JSON: '{0}'".format( response.content)) # This will occur if the course identifier is not unique if err is not None: - raise CourseFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err)) + raise FixtureError("Could not create course {0}. Error message: '{1}'".format(self, err)) if response.ok: self._course_key = response.json()['course_key'] else: - raise CourseFixtureError( + raise FixtureError( "Could not create course {0}. Status was {1}".format( self._course_dict, response.status_code)) @@ -333,14 +256,14 @@ def _configure_course(self): response = self.session.get(url, headers=self.headers) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not retrieve course details. Status was {0}".format( response.status_code)) try: details = response.json() except ValueError: - raise CourseFixtureError( + raise FixtureError( "Could not decode course details as JSON: '{0}'".format(details) ) @@ -354,7 +277,7 @@ def _configure_course(self): ) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not update course details to '{0}' with {1}: Status was {2}.".format( self._course_details, url, response.status_code)) @@ -382,7 +305,7 @@ def _install_course_handouts(self): response = self.session.post(url, data=payload, headers=self.headers) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not update course handouts with {0}. Status was {1}".format(url, response.status_code)) def _install_course_updates(self): @@ -399,14 +322,14 @@ def _install_course_updates(self): response = self.session.post(url, headers=self.headers, data=payload) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not add update to course: {0} with {1}. Status was {2}".format( update, url, response.status_code)) def _upload_assets(self): """ Upload assets - :raise CourseFixtureError: + :raise FixtureError: """ url = STUDIO_BASE_URL + self._assets_url @@ -426,7 +349,7 @@ def _upload_assets(self): upload_response = self.session.post(url, files=files, headers=headers) if not upload_response.ok: - raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format( + raise FixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format( asset_name=asset_name, url=url, code=upload_response.status_code)) def _add_advanced_settings(self): @@ -442,7 +365,7 @@ def _add_advanced_settings(self): ) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not update advanced details to '{0}' with {1}: Status was {2}.".format( self._advanced_settings, url, response.status_code)) @@ -450,101 +373,5 @@ def _create_xblock_children(self, parent_loc, xblock_descriptions): """ Recursively create XBlock children. """ - for desc in xblock_descriptions: - loc = self.create_xblock(parent_loc, desc) - self._create_xblock_children(loc, desc.children) - + super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions) self._publish_xblock(parent_loc) - - def get_nested_xblocks(self, category=None): - """ - Return a list of nested XBlocks for the course that can be filtered by - category. - """ - xblocks = self._get_nested_xblocks(self) - if category: - xblocks = filter(lambda x: x.category == category, xblocks) - return xblocks - - def _get_nested_xblocks(self, xblock_descriptor): - """ - Return a list of nested XBlocks for the course. - """ - xblocks = list(xblock_descriptor.children) - for child in xblock_descriptor.children: - xblocks.extend(self._get_nested_xblocks(child)) - return xblocks - - def create_xblock(self, parent_loc, xblock_desc): - """ - Create an XBlock with `parent_loc` (the location of the parent block) - and `xblock_desc` (an `XBlockFixtureDesc` instance). - """ - create_payload = { - 'category': xblock_desc.category, - 'display_name': xblock_desc.display_name, - } - - if parent_loc is not None: - create_payload['parent_locator'] = parent_loc - - # Create the new XBlock - response = self.session.post( - STUDIO_BASE_URL + '/xblock/', - data=json.dumps(create_payload), - headers=self.headers, - ) - - if not response.ok: - msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code) - raise CourseFixtureError(msg) - - try: - loc = response.json().get('locator') - xblock_desc.locator = loc - except ValueError: - raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content)) - - # Configure the XBlock - response = self.session.post( - STUDIO_BASE_URL + '/xblock/' + loc, - data=xblock_desc.serialize(), - headers=self.headers, - ) - - if response.ok: - return loc - else: - raise CourseFixtureError( - "Could not update {0}. Status code: {1}".format( - xblock_desc, response.status_code)) - - def _publish_xblock(self, locator): - """ - Publish the xblock at `locator`. - """ - self._update_xblock(locator, {'publish': 'make_public'}) - - def _update_xblock(self, locator, data): - """ - Update the xblock at `locator`. - """ - # Create the new XBlock - response = self.session.put( - "{}/xblock/{}".format(STUDIO_BASE_URL, locator), - data=json.dumps(data), - headers=self.headers, - ) - - if not response.ok: - msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code) - raise CourseFixtureError(msg) - - def _encode_post_dict(self, post_dict): - """ - Encode `post_dict` (a dictionary) as UTF-8 encoded JSON. - """ - return json.dumps({ - k: v.encode('utf-8') if isinstance(v, basestring) else v - for k, v in post_dict.items() - }) diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py new file mode 100644 index 000000000000..5692c078dbd5 --- /dev/null +++ b/common/test/acceptance/fixtures/library.py @@ -0,0 +1,93 @@ +""" +Fixture to create a Content Library +""" + +from opaque_keys.edx.keys import CourseKey + +from . import STUDIO_BASE_URL +from .base import XBlockContainerFixture, FixtureError + + +class LibraryFixture(XBlockContainerFixture): + """ + Fixture for ensuring that a library exists. + + WARNING: This fixture is NOT idempotent. To avoid conflicts + between tests, you should use unique library identifiers for each fixture. + """ + + def __init__(self, org, number, display_name): + """ + Configure the library fixture to create a library with + """ + super(LibraryFixture, self).__init__() + self.library_info = { + 'org': org, + 'number': number, + 'display_name': display_name + } + + self.display_name = display_name + self._library_key = None + super(LibraryFixture, self).__init__() + + def __str__(self): + """ + String representation of the library fixture, useful for debugging. + """ + return "".format(**self.library_info) + + def install(self): + """ + Create the library and XBlocks within the library. + This is NOT an idempotent method; if the library already exists, this will + raise a `FixtureError`. You should use unique library identifiers to avoid + conflicts between tests. + """ + self._create_library() + self._create_xblock_children(self.library_location, self.children) + + return self + + @property + def library_key(self): + """ + Get the LibraryLocator for this library, as a string. + """ + return self._library_key + + @property + def library_location(self): + """ + Return the locator string for the LibraryRoot XBlock that is the root of the library hierarchy. + """ + lib_key = CourseKey.from_string(self._library_key) + return unicode(lib_key.make_usage_key('library', 'library')) + + def _create_library(self): + """ + Create the library described in the fixture. + Will fail if the library already exists. + """ + response = self.session.post( + STUDIO_BASE_URL + '/library/', + data=self._encode_post_dict(self.library_info), + headers=self.headers + ) + + if response.ok: + self._library_key = response.json()['library_key'] + else: + try: + err_msg = response.json().get('ErrMsg') + except ValueError: + err_msg = "Unknown Error" + raise FixtureError( + "Could not create library {}. Status was {}, error was: {}".format(self.library_info, response.status_code, err_msg) + ) + + def create_xblock(self, parent_loc, xblock_desc): + # Disable publishing for library XBlocks: + xblock_desc.publish = "not-applicable" + + return super(LibraryFixture, self).create_xblock(parent_loc, xblock_desc) diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py new file mode 100644 index 000000000000..8655fae79f55 --- /dev/null +++ b/common/test/acceptance/pages/lms/library.py @@ -0,0 +1,37 @@ +""" +Library Content XBlock Wrapper +""" +from bok_choy.page_object import PageObject + + +class LibraryContentXBlockWrapper(PageObject): + """ + A PageObject representing a wrapper around a LibraryContent block seen in the LMS + """ + url = None + BODY_SELECTOR = '.xblock-student_view div' + + def __init__(self, browser, locator): + super(LibraryContentXBlockWrapper, self).__init__(browser) + self.locator = locator + + def is_browser_on_page(self): + return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present + + def _bounded_selector(self, selector): + """ + Return `selector`, but limited to this particular block's context + """ + return '{}[data-id="{}"] {}'.format( + self.BODY_SELECTOR, + self.locator, + selector + ) + + @property + def children_contents(self): + """ + Gets contents of all child XBlocks as list of strings + """ + child_blocks = self.q(css=self._bounded_selector("div[data-id]")) + return frozenset(child.text for child in child_blocks) diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index e65d55146f0c..d8a760cac972 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -6,7 +6,7 @@ from bok_choy.promise import Promise, EmptyPromise from . import BASE_URL -from utils import click_css, confirm_prompt +from .utils import click_css, confirm_prompt, type_in_codemirror class ContainerPage(PageObject): @@ -309,6 +309,14 @@ def student_content(self): """ return self.q(css=self._bounded_selector('.xblock-student_view'))[0].text + @property + def author_content(self): + """ + Returns the text content of the xblock as displayed on the container page. + (For blocks which implement a distinct author_view). + """ + return self.q(css=self._bounded_selector('.xblock-author_view'))[0].text + @property def name(self): titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text @@ -333,6 +341,45 @@ def children(self): grand_locators = [grandkid.locator for grandkid in grandkids] return [descendant for descendant in descendants if descendant.locator not in grand_locators] + @property + def has_validation_message(self): + """ Is a validation warning/error/message shown? """ + return self.q(css=self._bounded_selector('.xblock-message.validation')).present + + def _validation_paragraph(self, css_class): + """ Helper method to return the

element of a validation warning """ + return self.q(css=self._bounded_selector('.xblock-message.validation p.{}'.format(css_class))) + + @property + def has_validation_warning(self): + """ Is a validation warning shown? """ + return self._validation_paragraph('warning').present + + @property + def has_validation_error(self): + """ Is a validation error shown? """ + return self._validation_paragraph('error').present + + @property + def has_validation_not_configured_warning(self): + """ Is a validation "not configured" message shown? """ + return self._validation_paragraph('not-configured').present + + @property + def validation_warning_text(self): + """ Get the text of the validation warning. """ + return self._validation_paragraph('warning').text[0] + + @property + def validation_error_text(self): + """ Get the text of the validation error. """ + return self._validation_paragraph('error').text[0] + + @property + def validation_not_configured_warning_text(self): + """ Get the text of the validation "not configured" message. """ + return self._validation_paragraph('not-configured').text[0] + @property def preview_selector(self): return self._bounded_selector('.xblock-student_view,.xblock-author_view') @@ -362,6 +409,12 @@ def open_basic_tab(self): """ self._click_button('basic_tab') + def set_codemirror_text(self, text, index=0): + """ + Set the text of a CodeMirror editor that is part of this xblock's settings. + """ + type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector)) + def save_settings(self): """ Click on settings Save button. diff --git a/common/test/acceptance/pages/studio/index.py b/common/test/acceptance/pages/studio/index.py index af163eca6852..aed9a5faae23 100644 --- a/common/test/acceptance/pages/studio/index.py +++ b/common/test/acceptance/pages/studio/index.py @@ -28,6 +28,13 @@ def course_runs(self): def has_processing_courses(self): return self.q(css='.courses-processing').present + @property + def page_subheader(self): + """ + Get the text of the introductory copy seen below the Welcome header. ("Here are all of...") + """ + return self.q(css='.content-primary .introduction .copy p').first.text[0] + def create_rerun(self, display_name): """ Clicks the create rerun link of the course specified by display_name. @@ -40,3 +47,68 @@ def click_course_run(self, run): Clicks on the course with run given by run. """ self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click() + + def has_new_library_button(self): + """ + (bool) is the "New Library" button present? + """ + return self.q(css='.new-library-button').present + + def click_new_library(self): + """ + Click on the "New Library" button + """ + self.q(css='.new-library-button').click() + + def is_new_library_form_visible(self): + """ + Is the new library form visisble? + """ + return self.q(css='.wrapper-create-library').visible + + def fill_new_library_form(self, display_name, org, number): + """ + Fill out the form to create a new library. + Must have called click_new_library() first. + """ + field = lambda fn: self.q(css='.wrapper-create-library #new-library-{}'.format(fn)) + field('name').fill(display_name) + field('org').fill(org) + field('number').fill(number) + + def is_new_library_form_valid(self): + """ + IS the new library form ready to submit? + """ + return ( + self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and + not self.q(css='.wrapper-create-library .wrap-error.is-shown').present + ) + + def submit_new_library_form(self): + """ + Submit the new library form. + """ + self.q(css='.wrapper-create-library .new-library-save').click() + + def list_libraries(self): + """ + List all the libraries found on the page's list of libraries. + """ + self.q(css='#course-index-tabs .libraries-tab a').click() # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements + div2info = lambda element: { + 'name': element.find_element_by_css_selector('.course-title').text, + 'org': element.find_element_by_css_selector('.course-org .value').text, + 'number': element.find_element_by_css_selector('.course-num .value').text, + 'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'), + } + return self.q(css='.libraries li.course-item').map(div2info).results + + def has_library(self, **kwargs): + """ + Does the page's list of libraries include a library matching kwargs? + """ + for lib in self.list_libraries(): + if all([lib[key] == kwargs[key] for key in kwargs]): + return True + return False diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py new file mode 100644 index 000000000000..ea7f2299f961 --- /dev/null +++ b/common/test/acceptance/pages/studio/library.py @@ -0,0 +1,262 @@ +""" +Library edit page in Studio +""" + +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.select import Select +from .overview import CourseOutlineModal +from .container import XBlockWrapper +from ...pages.studio.pagination import PaginatedMixin +from ...tests.helpers import disable_animations +from .utils import confirm_prompt, wait_for_notification +from . import BASE_URL + + +class LibraryPage(PageObject, PaginatedMixin): + """ + Library page in Studio + """ + + def __init__(self, browser, locator): + super(LibraryPage, self).__init__(browser) + self.locator = locator + + @property + def url(self): + """ + URL to the library edit page for the given library. + """ + return "{}/library/{}".format(BASE_URL, unicode(self.locator)) + + def is_browser_on_page(self): + """ + Returns True iff the browser has loaded the library edit page. + """ + return self.q(css='body.view-library').present + + def get_header_title(self): + """ + The text of the main heading (H1) visible on the page. + """ + return self.q(css='h1.page-header-title').text + + def wait_until_ready(self): + """ + When the page first loads, there is a loading indicator and most + functionality is not yet available. This waits for that loading to + finish. + + Always call this before using the page. It also disables animations + for improved test reliability. + """ + self.wait_for_ajax() + self.wait_for_element_invisibility( + '.ui-loading', + 'Wait for the page to complete its initial loading of XBlocks via AJAX' + ) + disable_animations(self) + + @property + def xblocks(self): + """ + Return a list of xblocks loaded on the container page. + """ + return self._get_xblocks() + + def click_duplicate_button(self, xblock_id): + """ + Click on the duplicate button for the given XBlock + """ + self._action_btn_for_xblock_id(xblock_id, "duplicate").click() + wait_for_notification(self) + self.wait_for_ajax() + + def click_delete_button(self, xblock_id, confirm=True): + """ + Click on the delete button for the given XBlock + """ + self._action_btn_for_xblock_id(xblock_id, "delete").click() + if confirm: + confirm_prompt(self) # this will also wait_for_notification() + self.wait_for_ajax() + + def _get_xblocks(self): + """ + Create an XBlockWrapper for each XBlock div found on the page. + """ + prefix = '.wrapper-xblock.level-page ' + return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map( + lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator')) + ).results + + def _div_for_xblock_id(self, xblock_id): + """ + Given an XBlock's usage locator as a string, return the WebElement for + that block's wrapper div. + """ + return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter( + lambda el: el.get_attribute('data-locator') == xblock_id + ) + + def _action_btn_for_xblock_id(self, xblock_id, action): + """ + Given an XBlock's usage locator as a string, return one of its action + buttons. + action is 'edit', 'duplicate', or 'delete' + """ + return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector( + '.header-actions .{action}-button.action-button'.format(action=action) + ) + + +class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject): + """ + Library Content XBlock Modal edit window + """ + url = None + MODAL_SELECTOR = ".wrapper-modal-window-edit-xblock" + + # Labels used to identify the fields on the edit modal: + LIBRARY_LABEL = "Libraries" + COUNT_LABEL = "Count" + SCORED_LABEL = "Scored" + + def is_browser_on_page(self): + """ + Check that we are on the right page in the browser. + """ + return self.is_shown() + + @property + def library_key(self): + """ + Gets value of first library key input + """ + library_key_input = self.get_metadata_input(self.LIBRARY_LABEL) + if library_key_input is not None: + return library_key_input.get_attribute('value').strip(',') + return None + + @library_key.setter + def library_key(self, library_key): + """ + Sets value of first library key input, creating it if necessary + """ + library_key_input = self.get_metadata_input(self.LIBRARY_LABEL) + if library_key_input is None: + library_key_input = self._add_library_key() + if library_key is not None: + # can't use lib_text.clear() here as input get deleted by client side script + library_key_input.send_keys(Keys.HOME) + library_key_input.send_keys(Keys.SHIFT, Keys.END) + library_key_input.send_keys(library_key) + else: + library_key_input.clear() + EmptyPromise(lambda: self.library_key == library_key, "library_key is updated in modal.").fulfill() + + @property + def count(self): + """ + Gets value of children count input + """ + return int(self.get_metadata_input(self.COUNT_LABEL).get_attribute('value')) + + @count.setter + def count(self, count): + """ + Sets value of children count input + """ + count_text = self.get_metadata_input(self.COUNT_LABEL) + count_text.clear() + count_text.send_keys(count) + EmptyPromise(lambda: self.count == count, "count is updated in modal.").fulfill() + + @property + def scored(self): + """ + Gets value of scored select + """ + value = self.get_metadata_input(self.SCORED_LABEL).get_attribute('value') + if value == 'True': + return True + elif value == 'False': + return False + raise ValueError("Unknown value {value} set for {label}".format(value=value, label=self.SCORED_LABEL)) + + @scored.setter + def scored(self, scored): + """ + Sets value of scored select + """ + select_element = self.get_metadata_input(self.SCORED_LABEL) + select_element.click() + scored_select = Select(select_element) + scored_select.select_by_value(str(scored)) + EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill() + + def _add_library_key(self): + """ + Adds library key input + """ + wrapper = self._get_metadata_element(self.LIBRARY_LABEL) + add_button = wrapper.find_element_by_xpath(".//a[contains(@class, 'create-action')]") + add_button.click() + return self._get_list_inputs(wrapper)[0] + + def _get_list_inputs(self, list_wrapper): + """ + Finds nested input elements (useful for List and Dict fields) + """ + return list_wrapper.find_elements_by_xpath(".//input[@type='text']") + + def _get_metadata_element(self, metadata_key): + """ + Gets metadata input element (a wrapper div for List and Dict fields) + """ + metadata_inputs = self.find_css(".metadata_entry .wrapper-comp-setting label.setting-label") + target_label = [elem for elem in metadata_inputs if elem.text == metadata_key][0] + label_for = target_label.get_attribute('for') + return self.find_css("#" + label_for)[0] + + def get_metadata_input(self, metadata_key): + """ + Gets input/select element for given field + """ + element = self._get_metadata_element(metadata_key) + if element.tag_name == 'div': + # List or Dict field - return first input + # TODO support multiple values + inputs = self._get_list_inputs(element) + element = inputs[0] if inputs else None + return element + + +class StudioLibraryContainerXBlockWrapper(XBlockWrapper): + """ + Wraps :class:`.container.XBlockWrapper` for use with LibraryContent blocks + """ + url = None + + @classmethod + def from_xblock_wrapper(cls, xblock_wrapper): + """ + Factory method: creates :class:`.StudioLibraryContainerXBlockWrapper` from :class:`.container.XBlockWrapper` + """ + return cls(xblock_wrapper.browser, xblock_wrapper.locator) + + def get_body_paragraphs(self): + """ + Gets library content body paragraphs + """ + return self.q(css=self._bounded_selector(".xblock-message-area p")) + + def refresh_children(self): + """ + Click "Update now..." button + """ + btn_selector = self._bounded_selector(".library-update-btn") + refresh_button = self.q(css=btn_selector) + refresh_button.click() + self.wait_for_element_absence(btn_selector, 'Wait for the XBlock to reload') diff --git a/common/test/acceptance/pages/studio/pagination.py b/common/test/acceptance/pages/studio/pagination.py new file mode 100644 index 000000000000..a976149c37dd --- /dev/null +++ b/common/test/acceptance/pages/studio/pagination.py @@ -0,0 +1,62 @@ +""" +Mixin to include for Paginated container pages +""" +from selenium.webdriver.common.keys import Keys + + +class PaginatedMixin(object): + """ + Mixin class used for paginated page tests. + """ + def nav_disabled(self, position, arrows=('next', 'previous')): + """ + Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'. + + `top` is the header, `bottom` is the footer. + + To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'. + """ + return all([ + self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow)) + for arrow in arrows + ]) + + def move_back(self, position): + """ + Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. + """ + self.q(css='nav.%s * a.previous-page-link' % position)[0].click() + self.wait_until_ready() + + def move_forward(self, position): + """ + Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. + """ + self.q(css='nav.%s * a.next-page-link' % position)[0].click() + self.wait_until_ready() + + def go_to_page(self, number): + """ + Enter a number into the page number input field, and then try to navigate to it. + """ + page_input = self.q(css="#page-number-input")[0] + page_input.click() + page_input.send_keys(str(number)) + page_input.send_keys(Keys.RETURN) + self.wait_until_ready() + + def get_page_number(self): + """ + Returns the page number as the page represents it, in string form. + """ + return self.q(css="span.current-page")[0].get_attribute('innerHTML') + + def check_page_unchanged(self, first_block_name): + """ + Used to make sure that a page has not transitioned after a bogus number is given. + """ + if not self.xblocks[0].name == first_block_name: + return False + if not self.q(css='#page-number-input')[0].get_attribute('value') == '': + return False + return True diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py index a94f50ba6fa1..dd8ec091a347 100644 --- a/common/test/acceptance/pages/studio/utils.py +++ b/common/test/acceptance/pages/studio/utils.py @@ -103,6 +103,30 @@ def add_advanced_component(page, menu_index, name): click_css(page, component_css, 0) +def add_component(page, item_type, specific_type): + """ + Click one of the "Add New Component" buttons. + + item_type should be "advanced", "html", "problem", or "video" + + specific_type is required for some types and should be something like + "Blank Common Problem". + """ + btn = page.q(css='.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type)) + multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present + btn.click() + if multiple_templates: + sub_template_menu_div_selector = '.new-component-{}'.format(item_type) + page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear') + page.wait_for_element_invisibility('.add-xblock-component .new-component', 'Wait for the add component menu to disappear') + + all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type)) + chosen_option = all_options.filter(lambda el: el.text == specific_type).first + chosen_option.click() + wait_for_notification(page) + page.wait_for_ajax() + + @js_defined('window.jQuery') def type_in_codemirror(page, index, text, find_prefix="$"): script = """ diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py new file mode 100644 index 000000000000..f83e6b94e9d5 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_library.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for LibraryContent block in LMS +""" +import ddt + +from ..helpers import UniqueCourseTest +from ...pages.studio.auto_auth import AutoAuthPage +from ...pages.studio.overview import CourseOutlinePage +from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.library import LibraryContentXBlockWrapper +from ...pages.common.logout import LogoutPage +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...fixtures.library import LibraryFixture + +SECTION_NAME = 'Test Section' +SUBSECTION_NAME = 'Test Subsection' +UNIT_NAME = 'Test Unit' + + +@ddt.ddt +class LibraryContentTest(UniqueCourseTest): + """ + Test courseware. + """ + USERNAME = "STUDENT_TESTER" + EMAIL = "student101@example.com" + + STAFF_USERNAME = "STAFF_TESTER" + STAFF_EMAIL = "staff101@example.com" + + def setUp(self): + """ + Set up library, course and library content XBlock + """ + super(LibraryContentTest, self).setUp() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + + self.course_outline = CourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + self.library_fixture = LibraryFixture('test_org', self.unique_id, 'Test Library {}'.format(self.unique_id)) + self.library_fixture.add_children( + XBlockFixtureDesc("html", "Html1", data='html1'), + XBlockFixtureDesc("html", "Html2", data='html2'), + XBlockFixtureDesc("html", "Html3", data='html3'), + ) + + self.library_fixture.install() + self.library_info = self.library_fixture.library_info + self.library_key = self.library_fixture.library_key + + # Install a course with library content xblock + self.course_fixture = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + library_content_metadata = { + 'source_libraries': [self.library_key], + 'mode': 'random', + 'max_count': 1, + 'has_score': False + } + + self.lib_block = XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata) + + self.course_fixture.add_children( + XBlockFixtureDesc('chapter', SECTION_NAME).add_children( + XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children( + XBlockFixtureDesc('vertical', UNIT_NAME).add_children( + self.lib_block + ) + ) + ) + ) + + self.course_fixture.install() + + def _refresh_library_content_children(self, count=1): + """ + Performs library block refresh in Studio, configuring it to show {count} children + """ + unit_page = self._go_to_unit_page(True) + library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[0]) + modal = StudioLibraryContentXBlockEditModal(library_container_block.edit()) + modal.count = count + library_container_block.save_settings() + self._go_to_unit_page(change_login=False) + unit_page.wait_for_page() + unit_page.publish_action.click() + unit_page.wait_for_ajax() + self.assertIn("Published and Live", unit_page.publish_title) + + @property + def library_xblocks_texts(self): + """ + Gets texts of all xblocks in library + """ + return frozenset(child.data for child in self.library_fixture.children) + + def _go_to_unit_page(self, change_login=True): + """ + Open unit page in Studio + """ + if change_login: + LogoutPage(self.browser).visit() + self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True) + self.course_outline.visit() + subsection = self.course_outline.section(SECTION_NAME).subsection(SUBSECTION_NAME) + return subsection.toggle_expand().unit(UNIT_NAME).go_to() + + def _goto_library_block_page(self, block_id=None): + """ + Open library page in LMS + """ + self.courseware_page.visit() + block_id = block_id if block_id is not None else self.lib_block.locator + #pylint: disable=attribute-defined-outside-init + self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id) + + def _auto_auth(self, username, email, staff): + """ + Logout and login with given credentials. + """ + AutoAuthPage(self.browser, username=username, email=email, + course_id=self.course_id, staff=staff).visit() + + @ddt.data(1, 2, 3) + def test_shows_random_xblocks_from_configured(self, count): + """ + Scenario: Ensures that library content shows {count} random xblocks from library in LMS + Given I have a library, a course and a LibraryContent block in that course + When I go to studio unit page for library content xblock as staff + And I set library content xblock to display {count} random children + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see {count} random xblocks from the library + """ + self._refresh_library_content_children(count=count) + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_contents = self.library_content_page.children_contents + self.assertEqual(len(children_contents), count) + self.assertLessEqual(children_contents, self.library_xblocks_texts) + + def test_shows_all_if_max_set_to_greater_value(self): + """ + Scenario: Ensures that library content shows {count} random xblocks from library in LMS + Given I have a library, a course and a LibraryContent block in that course + When I go to studio unit page for library content xblock as staff + And I set library content xblock to display more children than library have + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see all xblocks from the library + """ + self._refresh_library_content_children(count=10) + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_contents = self.library_content_page.children_contents + self.assertEqual(len(children_contents), 3) + self.assertEqual(children_contents, self.library_xblocks_texts) diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py index fa07533fba86..02fdcbe99849 100644 --- a/common/test/acceptance/tests/studio/base_studio_test.py +++ b/common/test/acceptance/tests/studio/base_studio_test.py @@ -1,5 +1,10 @@ +""" +Base classes used by studio tests. +""" +from bok_choy.web_app_test import WebAppTest from ...pages.studio.auto_auth import AutoAuthPage from ...fixtures.course import CourseFixture +from ...fixtures.library import LibraryFixture from ..helpers import UniqueCourseTest from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.utils import verify_ordering @@ -98,3 +103,48 @@ def do_action_and_verify(self, action, expected_ordering): # Reload the page to see that the change was persisted. container = self.go_to_nested_container_page() verify_ordering(self, container, expected_ordering) + + +class StudioLibraryTest(WebAppTest): + """ + Base class for all Studio library tests. + """ + as_staff = True + + def setUp(self): # pylint: disable=arguments-differ + """ + Install a library with no content using a fixture. + """ + super(StudioLibraryTest, self).setUp() + fixture = LibraryFixture( + 'test_org', + self.unique_id, + 'Test Library {}'.format(self.unique_id), + ) + self.populate_library_fixture(fixture) + fixture.install() + self.library_fixture = fixture + self.library_info = fixture.library_info + self.library_key = fixture.library_key + self.user = fixture.user + self.log_in(self.user, self.as_staff) + + def populate_library_fixture(self, library_fixture): + """ + Populate the children of the test course fixture. + """ + pass + + def log_in(self, user, is_staff=False): + """ + Log in as the user that created the library. + By default the user will not have staff access unless is_staff is passed as True. + """ + auth_page = AutoAuthPage( + self.browser, + staff=is_staff, + username=user.get('username'), + email=user.get('email'), + password=user.get('password') + ) + auth_page.visit() diff --git a/common/test/acceptance/tests/studio/test_studio_home.py b/common/test/acceptance/tests/studio/test_studio_home.py new file mode 100644 index 000000000000..9dc9b0249716 --- /dev/null +++ b/common/test/acceptance/tests/studio/test_studio_home.py @@ -0,0 +1,67 @@ +""" +Acceptance tests for Home Page (My Courses / My Libraries). +""" +from bok_choy.web_app_test import WebAppTest +from opaque_keys.edx.locator import LibraryLocator + +from ...pages.studio.auto_auth import AutoAuthPage +from ...pages.studio.library import LibraryPage +from ...pages.studio.index import DashboardPage + + +class CreateLibraryTest(WebAppTest): + """ + Test that we can create a new content library on the studio home page. + """ + + def setUp(self): + """ + Load the helper for the home page (dashboard page) + """ + super(CreateLibraryTest, self).setUp() + + self.auth_page = AutoAuthPage(self.browser, staff=True) + self.dashboard_page = DashboardPage(self.browser) + + def test_subheader(self): + """ + From the home page: + Verify that subheader is correct + """ + self.auth_page.visit() + self.dashboard_page.visit() + + self.assertIn("courses and libraries", self.dashboard_page.page_subheader) + + def test_create_library(self): + """ + From the home page: + Click "New Library" + Fill out the form + Submit the form + We should be redirected to the edit view for the library + Return to the home page + The newly created library should now appear in the list of libraries + """ + name = "New Library Name" + org = "TestOrgX" + number = "TESTLIB" + + self.auth_page.visit() + self.dashboard_page.visit() + self.assertFalse(self.dashboard_page.has_library(name=name, org=org, number=number)) + self.assertTrue(self.dashboard_page.has_new_library_button()) + + self.dashboard_page.click_new_library() + self.assertTrue(self.dashboard_page.is_new_library_form_visible()) + self.dashboard_page.fill_new_library_form(name, org, number) + self.assertTrue(self.dashboard_page.is_new_library_form_valid()) + self.dashboard_page.submit_new_library_form() + + # The next page is the library edit view; make sure it loads: + lib_page = LibraryPage(self.browser, LibraryLocator(org, number)) + lib_page.wait_for_page() + + # Then go back to the home page and make sure the new library is listed there: + self.dashboard_page.visit() + self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number)) diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py new file mode 100644 index 000000000000..b0d6cffb1aed --- /dev/null +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -0,0 +1,308 @@ +""" +Acceptance tests for Content Libraries in Studio +""" +from ddt import ddt, data + +from .base_studio_test import StudioLibraryTest +from ...fixtures.course import XBlockFixtureDesc +from ...pages.studio.utils import add_component +from ...pages.studio.library import LibraryPage + + +@ddt +class LibraryEditPageTest(StudioLibraryTest): + """ + Test the functionality of the library edit page. + """ + def setUp(self): # pylint: disable=arguments-differ + """ + Ensure a library exists and navigate to the library edit page. + """ + super(LibraryEditPageTest, self).setUp() + self.lib_page = LibraryPage(self.browser, self.library_key) + self.lib_page.visit() + self.lib_page.wait_until_ready() + + def test_page_header(self): + """ + Scenario: Ensure that the library's name is displayed in the header and title. + Given I have a library in Studio + And I navigate to Library Page in Studio + Then I can see library name in page header title + And I can see library name in browser page title + """ + self.assertIn(self.library_info['display_name'], self.lib_page.get_header_title()) + self.assertIn(self.library_info['display_name'], self.browser.title) + + def test_add_duplicate_delete_actions(self): + """ + Scenario: Ensure that we can add an HTML block, duplicate it, then delete the original. + Given I have a library in Studio with no XBlocks + And I navigate to Library Page in Studio + Then there are no XBlocks displayed + When I add Text XBlock + Then one XBlock is displayed + When I duplicate first XBlock + Then two XBlocks are displayed + And those XBlocks locators' are different + When I delete first XBlock + Then one XBlock is displayed + And displayed XBlock are second one + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + + # Create a new block: + add_component(self.lib_page, "html", "Text") + self.assertEqual(len(self.lib_page.xblocks), 1) + first_block_id = self.lib_page.xblocks[0].locator + + # Duplicate the block: + self.lib_page.click_duplicate_button(first_block_id) + self.assertEqual(len(self.lib_page.xblocks), 2) + second_block_id = self.lib_page.xblocks[1].locator + self.assertNotEqual(first_block_id, second_block_id) + + # Delete the first block: + self.lib_page.click_delete_button(first_block_id, confirm=True) + self.assertEqual(len(self.lib_page.xblocks), 1) + self.assertEqual(self.lib_page.xblocks[0].locator, second_block_id) + + def test_add_edit_xblock(self): + """ + Scenario: Ensure that we can add an XBlock, edit it, then see the resulting changes. + Given I have a library in Studio with no XBlocks + And I navigate to Library Page in Studio + Then there are no XBlocks displayed + When I add Multiple Choice XBlock + Then one XBlock is displayed + When I edit first XBlock + And I go to basic tab + And set it's text to a fairly trivial question about Battlestar Galactica + And save XBlock + Then one XBlock is displayed + And first XBlock student content contains at least part of text I set + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + # Create a new problem block: + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 1) + problem_block = self.lib_page.xblocks[0] + # Edit it: + problem_block.edit() + problem_block.open_basic_tab() + problem_block.set_codemirror_text( + """ + >>Who is "Starbuck"?<< + (x) Kara Thrace + ( ) William Adama + ( ) Laura Roslin + ( ) Lee Adama + ( ) Gaius Baltar + """ + ) + problem_block.save_settings() + # Check that the save worked: + self.assertEqual(len(self.lib_page.xblocks), 1) + problem_block = self.lib_page.xblocks[0] + self.assertIn("Laura Roslin", problem_block.student_content) + + def test_no_discussion_button(self): + """ + Ensure the UI is not loaded for adding discussions. + """ + self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon')) + + def test_library_pagination(self): + """ + Scenario: Ensure that adding several XBlocks to a library results in pagination. + Given that I have a library in Studio with no XBlocks + And I create 10 Multiple Choice XBlocks + Then 10 are displayed. + When I add one more Multiple Choice XBlock + Then 1 XBlock will be displayed + When I delete that XBlock + Then 10 are displayed. + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + for _ in range(0, 10): + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 10) + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 1) + self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator) + self.assertEqual(len(self.lib_page.xblocks), 10) + + @data('top', 'bottom') + def test_nav_present_but_disabled(self, position): + """ + Scenario: Ensure that the navigation buttons aren't active when there aren't enough XBlocks. + Given that I have a library in Studio with no XBlocks + The Navigation buttons should be disabled. + When I add a multiple choice problem + The Navigation buttons should be disabled. + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + self.assertTrue(self.lib_page.nav_disabled(position)) + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertTrue(self.lib_page.nav_disabled(position)) + + +@ddt +class LibraryNavigationTest(StudioLibraryTest): + """ + Test common Navigation actions + """ + def setUp(self): # pylint: disable=arguments-differ + """ + Ensure a library exists and navigate to the library edit page. + """ + super(LibraryNavigationTest, self).setUp() + self.lib_page = LibraryPage(self.browser, self.library_key) + self.lib_page.visit() + self.lib_page.wait_until_ready() + + def populate_library_fixture(self, library_fixture): + """ + Create four pages worth of XBlocks, and offset by one so each is named + after the number they should be in line by the user's perception. + """ + # pylint: disable=attribute-defined-outside-init + self.blocks = [XBlockFixtureDesc('html', str(i)) for i in xrange(1, 41)] + library_fixture.add_children(*self.blocks) + + def test_arbitrary_page_selection(self): + """ + Scenario: I can pick a specific page number of a Library at will. + Given that I have a library in Studio with 40 XBlocks + When I go to the 3rd page + The first XBlock should be the 21st XBlock + When I go to the 4th Page + The first XBlock should be the 31st XBlock + When I go to the 1st page + The first XBlock should be the 1st XBlock + When I go to the 2nd page + The first XBlock should be the 11th XBlock + """ + self.lib_page.go_to_page(3) + self.assertEqual(self.lib_page.xblocks[0].name, '21') + self.lib_page.go_to_page(4) + self.assertEqual(self.lib_page.xblocks[0].name, '31') + self.lib_page.go_to_page(1) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.lib_page.go_to_page(2) + self.assertEqual(self.lib_page.xblocks[0].name, '11') + + def test_bogus_page_selection(self): + """ + Scenario: I can't pick a nonsense page number of a Library + Given that I have a library in Studio with 40 XBlocks + When I attempt to go to the 'a'th page + The input field will be cleared and no change of XBlocks will be made + When I attempt to visit the 5th page + The input field will be cleared and no change of XBlocks will be made + When I attempt to visit the -1st page + The input field will be cleared and no change of XBlocks will be made + When I attempt to visit the 0th page + The input field will be cleared and no change of XBlocks will be made + """ + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.lib_page.go_to_page('a') + self.assertTrue(self.lib_page.check_page_unchanged('1')) + self.lib_page.go_to_page(-1) + self.assertTrue(self.lib_page.check_page_unchanged('1')) + self.lib_page.go_to_page(5) + self.assertTrue(self.lib_page.check_page_unchanged('1')) + self.lib_page.go_to_page(0) + self.assertTrue(self.lib_page.check_page_unchanged('1')) + + @data('top', 'bottom') + def test_nav_buttons(self, position): + """ + Scenario: Ensure that the navigation buttons work. + Given that I have a library in Studio with 40 XBlocks + The previous button should be disabled. + The first XBlock should be the 1st XBlock + Then if I hit the next button + The first XBlock should be the 11th XBlock + Then if I hit the next button + The first XBlock should be the 21st XBlock + Then if I hit the next button + The first XBlock should be the 31st XBlock + And the next button should be disabled + Then if I hit the previous button + The first XBlock should be the 21st XBlock + Then if I hit the previous button + The first XBlock should be the 11th XBlock + Then if I hit the previous button + The first XBlock should be the 1st XBlock + And the previous button should be disabled + """ + # Check forward navigation + self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, '11') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, '21') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, '31') + self.lib_page.nav_disabled(position, ['next']) + + # Check backward navigation + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, '21') + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, '11') + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + + def test_library_pagination(self): + """ + Scenario: Ensure that adding several XBlocks to a library results in pagination. + Given that I have a library in Studio with 40 XBlocks + Then 10 are displayed + And the first XBlock will be the 1st one + And I'm on the 1st page + When I add 1 Multiple Choice XBlock + Then 1 XBlock will be displayed + And I'm on the 5th page + The first XBlock will be the newest one + When I delete that XBlock + Then 10 are displayed + And I'm on the 4th page + And the first XBlock is the 31st one + And the last XBlock is the 40th one. + """ + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.get_page_number(), '1') + self.assertEqual(self.lib_page.xblocks[0].name, '1') + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 1) + self.assertEqual(self.lib_page.get_page_number(), '5') + self.assertEqual(self.lib_page.xblocks[0].name, "Multiple Choice") + self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator) + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.get_page_number(), '4') + self.assertEqual(self.lib_page.xblocks[0].name, '31') + self.assertEqual(self.lib_page.xblocks[-1].name, '40') + + def test_delete_shifts_blocks(self): + """ + Scenario: Ensure that removing an XBlock shifts other blocks back. + Given that I have a library in Studio with 40 XBlocks + Then 10 are displayed + And I will be on the first page + When I delete the third XBlock + There will be 10 displayed + And the first XBlock will be the first one + And the last XBlock will be the 11th one + And I will be on the first page + """ + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.get_page_number(), '1') + self.lib_page.click_delete_button(self.lib_page.xblocks[2].locator, confirm=True) + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.assertEqual(self.lib_page.xblocks[-1].name, '11') + self.assertEqual(self.lib_page.get_page_number(), '1') diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py new file mode 100644 index 000000000000..d7a592c79fce --- /dev/null +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -0,0 +1,164 @@ +""" +Acceptance tests for Library Content in LMS +""" +import ddt +from .base_studio_test import StudioLibraryTest, ContainerBase +from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper +from ...fixtures.course import XBlockFixtureDesc + +SECTION_NAME = 'Test Section' +SUBSECTION_NAME = 'Test Subsection' +UNIT_NAME = 'Test Unit' + + +@ddt.ddt +class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest): + """ + Test Library Content block in LMS + """ + def setUp(self): + """ + Install library with some content and a course using fixtures + """ + super(StudioLibraryContainerTest, self).setUp() + self.outline.visit() + subsection = self.outline.section(SECTION_NAME).subsection(SUBSECTION_NAME) + self.unit_page = subsection.toggle_expand().unit(UNIT_NAME).go_to() + + def populate_library_fixture(self, library_fixture): + """ + Populate the children of the test course fixture. + """ + library_fixture.add_children( + XBlockFixtureDesc("html", "Html1"), + XBlockFixtureDesc("html", "Html2"), + XBlockFixtureDesc("html", "Html3"), + ) + + def populate_course_fixture(self, course_fixture): + """ Install a course with sections/problems, tabs, updates, and handouts """ + library_content_metadata = { + 'source_libraries': [self.library_key], + 'mode': 'random', + 'max_count': 1, + 'has_score': False + } + + course_fixture.add_children( + XBlockFixtureDesc('chapter', SECTION_NAME).add_children( + XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children( + XBlockFixtureDesc('vertical', UNIT_NAME).add_children( + XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata) + ) + ) + ) + ) + + def _get_library_xblock_wrapper(self, xblock): + """ + Wraps xblock into :class:`...pages.studio.library.StudioLibraryContainerXBlockWrapper` + """ + return StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(xblock) + + @ddt.data( + ('library-v1:111+111', 1, True), + ('library-v1:edX+L104', 2, False), + ('library-v1:OtherX+IDDQD', 3, True), + ) + @ddt.unpack + def test_can_edit_metadata(self, library_key, max_count, scored): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I edit library content metadata and save it + Then I can ensure that data is persisted + """ + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.library_key = library_key + edit_modal.count = max_count + edit_modal.scored = scored + + library_container.save_settings() # saving settings + + # open edit window again to verify changes are persistent + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + self.assertEqual(edit_modal.library_key, library_key) + self.assertEqual(edit_modal.count, max_count) + self.assertEqual(edit_modal.scored, scored) + + def test_no_library_shows_library_not_configured(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I edit set library key to none + Then I can see that library content block is misconfigured + """ + expected_text = 'A library has not yet been selected.' + expected_action = 'Select a Library' + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - the library block should be configured before we remove the library setting + self.assertFalse(library_container.has_validation_not_configured_warning) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.library_key = None + library_container.save_settings() + + self.assertTrue(library_container.has_validation_not_configured_warning) + self.assertIn(expected_text, library_container.validation_not_configured_warning_text) + self.assertIn(expected_action, library_container.validation_not_configured_warning_text) + + def test_set_missing_library_shows_correct_label(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I edit set library key to non-existent library + Then I can see that library content block is misconfigured + """ + nonexistent_lib_key = 'library-v1:111+111' + expected_text = "Library is invalid, corrupt, or has been deleted." + + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert library is configured before we remove it + self.assertFalse(library_container.has_validation_error) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.library_key = nonexistent_lib_key + + library_container.save_settings() + + self.assertTrue(library_container.has_validation_error) + self.assertIn(expected_text, library_container.validation_error_text) + + def test_out_of_date_message(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + Then I update the library being used + Then I refresh the page + Then I can see that library content block needs to be updated + When I click on the update link + Then I can see that the content no longer needs to be updated + """ + expected_text = "This component is out of date. The library has new content." + library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + self.assertFalse(library_block.has_validation_warning) + #self.assertIn("3 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192) + + self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4")) + + self.unit_page.visit() # Reload the page + + self.assertTrue(library_block.has_validation_warning) + self.assertIn(expected_text, library_block.validation_warning_text) + + library_block.refresh_children() + + self.unit_page.wait_for_page() # Wait for the page to reload + library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + self.assertFalse(library_block.has_validation_message) + #self.assertIn("4 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192) diff --git a/common/test/data/library_import/library.HhJfPD.tar.gz b/common/test/data/library_import/library.HhJfPD.tar.gz new file mode 100644 index 000000000000..f0f26f337be7 Binary files /dev/null and b/common/test/data/library_import/library.HhJfPD.tar.gz differ diff --git a/lms/djangoapps/courseware/management/commands/export_course.py b/lms/djangoapps/courseware/management/commands/export_course.py index 836c4e0f6fc2..2239adbc4c89 100644 --- a/lms/djangoapps/courseware/management/commands/export_course.py +++ b/lms/djangoapps/courseware/management/commands/export_course.py @@ -17,7 +17,7 @@ from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_exporter import export_course_to_xml from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -89,7 +89,7 @@ def export_course_to_directory(course_key, root_dir): course_dir = replacement_char.join([course.id.org, course.id.course, course.id.run]) course_dir = re.sub(r'[^\w\.\-]', replacement_char, course_dir) - export_to_xml(store, None, course.id, root_dir, course_dir) + export_course_to_xml(store, None, course.id, root_dir, course_dir) export_dir = path(root_dir) / course_dir return export_dir 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 a32c9a7a0860..41c23323eca0 100644 --- a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py +++ b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py @@ -9,18 +9,18 @@ import tarfile from tempfile import mkdtemp -from django.conf import settings from django.core.management import call_command from django.test.utils import override_settings from django.test.testcases import TestCase -from opaque_keys.edx.locations import SlashSeparatedCourseKey from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config from xmodule.modulestore.tests.django_utils import TEST_DATA_MONGO_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from django.conf import settings DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_COURSE_ID = 'edX/simple/2012_Fall' @@ -63,7 +63,7 @@ def load_courses(self): courses = store.get_courses() # NOTE: if xml store owns these, it won't import them into mongo if SlashSeparatedCourseKey.from_deprecated_string(TEST_COURSE_ID) not in [c.id for c in courses]: - import_from_xml(store, ModuleStoreEnum.UserID.mgmt_command, DATA_DIR, XML_COURSE_DIRS) + import_course_from_xml(store, ModuleStoreEnum.UserID.mgmt_command, DATA_DIR, XML_COURSE_DIRS) return [course.id for course in store.get_courses()] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 56818d4e2eea..d1f1f45b89ed 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -32,6 +32,10 @@ class StudentModule(models.Model): MODULE_TYPES = (('problem', 'problem'), ('video', 'video'), ('html', 'html'), + ('course', 'course'), + ('chapter', 'Section'), + ('sequential', 'Subsection'), + ('library_content', 'Library Content'), ) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 465ab9824180..24537dd63508 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -15,7 +15,7 @@ from student.tests.factories import UserFactory import xmodule.modulestore.django as store_django from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.xml_importer import import_from_xml +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_MOCK_MODULESTORE, TEST_DATA_MIXED_TOY_MODULESTORE @@ -161,7 +161,7 @@ def setUp(self): super(CoursesRenderTest, self).setUp() store = store_django.modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['toy']) + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['toy']) course_key = course_items[0].id self.course = get_course_by_id(course_key) self.request = get_request_for_user(UserFactory.create()) diff --git a/lms/djangoapps/instructor/management/tests/test_openended_commands.py b/lms/djangoapps/instructor/management/tests/test_openended_commands.py index b1d0efcaa50b..1b1c8e2e6275 100644 --- a/lms/djangoapps/instructor/management/tests/test_openended_commands.py +++ b/lms/djangoapps/instructor/management/tests/test_openended_commands.py @@ -15,7 +15,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild from xmodule.tests.test_util_open_ended import ( STATE_INITIAL, STATE_ACCESSING, STATE_POST_ASSESSMENT @@ -36,7 +36,7 @@ class OpenEndedPostTest(ModuleStoreTestCase): def setUp(self): self.user = UserFactory() store = modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member self.course = course_items[0] self.course_id = self.course.id @@ -137,7 +137,7 @@ class OpenEndedStatsTest(ModuleStoreTestCase): def setUp(self): self.user = UserFactory() store = modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member self.course = course_items[0] self.course_id = self.course.id diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/course_info/tests.py index 68d4f11d870d..774ed140d4d1 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/course_info/tests.py @@ -12,7 +12,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml class TestCourseInfo(APITestCase): @@ -90,7 +90,7 @@ def setUp(self): super(TestHandoutInfo, self).setUp() self.user = UserFactory.create() self.client.login(username=self.user.username, password='test') - course_items = import_from_xml(self.store, self.user.id, settings.COMMON_TEST_DATA_ROOT, ['toy']) + course_items = import_course_from_xml(self.store, self.user.id, settings.COMMON_TEST_DATA_ROOT, ['toy']) self.course = course_items[0] def test_no_handouts(self): diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index 5f97ec2afa8d..34e09a26db5c 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -31,7 +31,7 @@ from xmodule.modulestore.tests.django_utils import ( TEST_DATA_MOCK_MODULESTORE, TEST_DATA_MIXED_TOY_MODULESTORE ) -from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.open_ended_grading_classes import peer_grading_service, controller_query_service from xmodule.tests import test_util_open_ended @@ -452,7 +452,7 @@ class TestPanel(ModuleStoreTestCase): def setUp(self): self.user = factories.UserFactory() store = modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member self.course = course_items[0] self.course_key = self.course.id @@ -496,7 +496,7 @@ class TestPeerGradingFound(ModuleStoreTestCase): def setUp(self): self.user = factories.UserFactory() store = modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended_nopath']) # pylint: disable=maybe-no-member + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended_nopath']) # pylint: disable=maybe-no-member self.course = course_items[0] self.course_key = self.course.id @@ -519,7 +519,7 @@ def setUp(self): # Load an open ended course with several problems. self.user = factories.UserFactory() store = modulestore() - course_items = import_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member + course_items = import_course_from_xml(store, self.user.id, TEST_DATA_DIR, ['open_ended']) # pylint: disable=maybe-no-member self.course = course_items[0] self.course_key = self.course.id diff --git a/lms/templates/library-block-author-preview-header.html b/lms/templates/library-block-author-preview-header.html new file mode 100644 index 000000000000..b4de62d8a08b --- /dev/null +++ b/lms/templates/library-block-author-preview-header.html @@ -0,0 +1,14 @@ +<%! from django.utils.translation import ungettext %> +

+
+

+ + ${ungettext( + 'Showing all matching content eligible to be added into {display_name}. Each student will be assigned {max_count} component drawn randomly from this list.', + 'Showing all matching content eligible to be added into {display_name}. Each student will be assigned {max_count} components drawn randomly from this list.', + max_count + ).format(max_count=max_count, display_name=display_name)} + +

+
+
diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 75d2789d7c6e..f486bfc6f65f 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -4,7 +4,7 @@ ## The JS for this is defined in xqa_interface.html ${block_content} -%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool']: +%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool', 'library_content']: % if edit_link:
Edit diff --git a/lms/templates/studio_render_paged_children_view.html b/lms/templates/studio_render_paged_children_view.html new file mode 100644 index 000000000000..fe5b5403e1ab --- /dev/null +++ b/lms/templates/studio_render_paged_children_view.html @@ -0,0 +1,23 @@ +<%! from django.utils.translation import ugettext as _ %> + +<%namespace name='static' file='static_content.html'/> + +% for template_name in ["paging-header", "paging-footer"]: + +% endfor + +
+ +
+ +% for item in items: + ${item['content']} +% endfor + +% if can_add: +
+% endif + +