diff --git a/.gitignore b/.gitignore index b1a36e5f2e..4fd90cfe03 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ node_modules autodeploy.properties .ws_migrations_complete .vagrant/ +logs diff --git a/.tx/config b/.tx/config index 540c4732af..9288418924 100644 --- a/.tx/config +++ b/.tx/config @@ -1,25 +1,25 @@ [main] host = https://www.transifex.com -[edx-studio.django-partial] +[edx-platform.django-partial] file_filter = conf/locale//LC_MESSAGES/django-partial.po source_file = conf/locale/en/LC_MESSAGES/django-partial.po source_lang = en type = PO -[edx-studio.djangojs] +[edx-platform.djangojs] file_filter = conf/locale//LC_MESSAGES/djangojs.po source_file = conf/locale/en/LC_MESSAGES/djangojs.po source_lang = en type = PO -[edx-studio.mako] +[edx-platform.mako] file_filter = conf/locale//LC_MESSAGES/mako.po source_file = conf/locale/en/LC_MESSAGES/mako.po source_lang = en type = PO -[edx-studio.messages] +[edx-platform.messages] file_filter = conf/locale//LC_MESSAGES/messages.po source_file = conf/locale/en/LC_MESSAGES/messages.po source_lang = en diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4d117a9c73..04c8a5baae 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -43,6 +43,13 @@ history of background tasks for a given problem and student. Blades: Small UX fix on capa multiple-choice problems. Make labels only as wide as the text to reduce accidental choice selections. +Studio: +- use xblock field defaults to initialize all new instances' fields and +only use templates as override samples. +- create new instances via in memory create_xmodule and related methods rather +than cloning a db record. +- have an explicit method for making a draft copy as distinct from making a new module. + Studio: Remove XML from the video component editor. All settings are moved to be edited as metadata. diff --git a/README.md b/README.md index e533459c8b..2208fe1cad 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,6 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run: $ rake django-admin[syncdb] $ rake django-admin[migrate] - $ rake cms:update_templates If you are running these commands using the [`zsh`](http://www.zsh.org/) shell, zsh will assume that you are doing diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index ada3873992..7e1e6470ff 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -20,8 +20,8 @@ def get_course_updates(location): try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: - template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"]) - course_updates = modulestore('direct').clone_item(template, Location(location)) + modulestore('direct').create_and_save_xmodule(location) + course_updates = modulestore('direct').get_item(location) # current db rep: {"_id" : locationjson, "definition" : { "data" : "
    [
  1. date

    content
  2. ]
"} "metadata" : ignored} location_base = course_updates.location.url() diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index cb24af47e0..756adad7c4 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -208,7 +208,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): def i_created_a_video_component(step): world.create_component_instance( step, '.large-video-icon', - 'i4x://edx/templates/video/default', + 'video', '.xmodule_VideoModule' ) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 43164f62be..2f1788c6a4 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -7,9 +7,9 @@ from terrain.steps import reload_the_page @world.absorb -def create_component_instance(step, component_button_css, instance_id, expected_css): +def create_component_instance(step, component_button_css, category, expected_css, boilerplate=None): click_new_component_button(step, component_button_css) - click_component_from_menu(instance_id, expected_css) + click_component_from_menu(category, boilerplate, expected_css) @world.absorb @@ -19,7 +19,7 @@ def click_new_component_button(step, component_button_css): @world.absorb -def click_component_from_menu(instance_id, expected_css): +def click_component_from_menu(category, boilerplate, expected_css): """ Creates a component from `instance_id`. For components with more than one template, clicks on `elem_css` to create the new @@ -27,11 +27,13 @@ def click_component_from_menu(instance_id, expected_css): as the user clicks the appropriate button, so we assert that the expected component is present. """ - elem_css = "a[data-location='%s']" % instance_id + if boilerplate: + elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate) + else: + elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category) elements = world.css_find(elem_css) - assert(len(elements) == 1) - if elements[0]['id'] == instance_id: # If this is a component with multiple templates - world.css_click(elem_css) + assert_equal(len(elements), 1) + world.css_click(elem_css) assert_equal(1, len(world.css_find(expected_css))) diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index ae3da3c458..8e4becb62e 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -8,7 +8,7 @@ from lettuce import world, step def i_created_discussion_tag(step): world.create_component_instance( step, '.large-discussion-icon', - 'i4x://edx/templates/discussion/Discussion_Tag', + 'discussion', '.xmodule_DiscussionModule' ) @@ -17,14 +17,14 @@ def i_created_discussion_tag(step): def i_see_only_the_settings_and_values(step): world.verify_all_setting_entries( [ - ['Category', "Week 1", True], - ['Display Name', "Discussion Tag", True], - ['Subcategory', "Topic-Level Student-Visible Label", True] + ['Category', "Week 1", False], + ['Display Name', "Discussion Tag", False], + ['Subcategory', "Topic-Level Student-Visible Label", False] ]) @step('creating a discussion takes a single click') def discussion_takes_a_single_click(step): assert(not world.is_css_present('.xmodule_DiscussionModule')) - world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']") + world.css_click("a[data-category='discussion']") assert(world.is_css_present('.xmodule_DiscussionModule')) diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index 054c0ea642..b03388c89a 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -7,11 +7,11 @@ from lettuce import world, step @step('I have created a Blank HTML Page$') def i_created_blank_html_page(step): world.create_component_instance( - step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page', + step, '.large-html-icon', 'html', '.xmodule_HtmlModule' ) @step('I see only the HTML display name setting$') def i_see_only_the_html_display_name(step): - world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]]) + world.verify_all_setting_entries([['Display Name', "Blank HTML Page", False]]) diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 5d12b23d90..64b2ec9b5c 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -18,8 +18,9 @@ def i_created_blank_common_problem(step): world.create_component_instance( step, '.large-problem-icon', - 'i4x://edx/templates/problem/Blank_Common_Problem', - '.xmodule_CapaModule' + 'problem', + '.xmodule_CapaModule', + 'blank_common.yaml' ) @@ -35,8 +36,8 @@ def i_see_five_settings_with_values(step): [DISPLAY_NAME, "Blank Common Problem", True], [MAXIMUM_ATTEMPTS, "", False], [PROBLEM_WEIGHT, "", False], - [RANDOMIZATION, "Never", True], - [SHOW_ANSWER, "Finished", True] + [RANDOMIZATION, "Never", False], + [SHOW_ANSWER, "Finished", False] ]) @@ -94,7 +95,7 @@ def my_change_to_randomization_is_persisted(step): def i_can_revert_to_default_for_randomization(step): world.revert_setting_entry(RANDOMIZATION) world.save_component_and_reopen(step) - world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False) + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False) @step('I can set the weight to "(.*)"?') @@ -156,7 +157,7 @@ def create_latex_problem(step): world.click_new_component_button(step, '.large-problem-icon') # Go to advanced tab. world.css_click('#ui-id-2') - world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') + world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule') @step('I edit and compile the High Level Source') @@ -203,7 +204,7 @@ def verify_modified_display_name_with_special_chars(): def verify_unset_display_name(): - world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False) + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False) def set_weight(weight): diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 9ab17fbdac..41e39513ea 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -22,7 +22,7 @@ def have_a_course_with_1_section(step): section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( parent_location=section.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection One',) @@ -33,18 +33,18 @@ def have_a_course_with_two_sections(step): section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( parent_location=section.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection One',) section2 = world.ItemFactory.create( parent_location=course.location, display_name='Section Two',) subsection2 = world.ItemFactory.create( parent_location=section2.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection Alpha',) subsection3 = world.ItemFactory.create( parent_location=section2.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection Beta',) diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index a6865fdd6d..e0f76b30ad 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -7,7 +7,7 @@ from lettuce import world, step @step('I see the correct settings and default values$') def i_see_the_correct_settings_and_values(step): world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False], - ['Display Name', 'default', True], + ['Display Name', 'Video Title', False], ['Download Track', '', False], ['Download Video', '', False], ['Show Captions', 'True', False], diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index cb59193f17..a6a362befc 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -14,7 +14,7 @@ def does_not_autoplay(_step): @step('creating a video takes a single click') def video_takes_a_single_click(_step): assert(not world.is_css_present('.xmodule_VideoModule')) - world.css_click("a[data-location='i4x://edx/templates/video/default']") + world.css_click("a[data-category='video']") assert(world.is_css_present('.xmodule_VideoModule')) diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index eb7800d46c..90db8750d9 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -14,11 +14,11 @@ unnamed_modules = 0 class Command(BaseCommand): - help = 'Import the specified data directory into the default ModuleStore' + help = 'Export the specified data directory into the default ModuleStore' def handle(self, *args, **options): if len(args) != 2: - raise CommandError("import requires two arguments: ") + raise CommandError("export requires two arguments: ") course_id = args[0] output_path = args[1] @@ -30,4 +30,4 @@ class Command(BaseCommand): root_dir = os.path.dirname(output_path) course_dir = os.path.splitext(os.path.basename(output_path))[0] - export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir) + export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir, modulestore()) diff --git a/cms/djangoapps/contentstore/management/commands/export_all_courses.py b/cms/djangoapps/contentstore/management/commands/export_all_courses.py new file mode 100644 index 0000000000..69cfb298fb --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/export_all_courses.py @@ -0,0 +1,47 @@ +### +### Script for exporting all courseware from Mongo to a directory +### +import os + +from django.core.management.base import BaseCommand, CommandError +from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.django import contentstore +from xmodule.course_module import CourseDescriptor + + +unnamed_modules = 0 + + +class Command(BaseCommand): + help = 'Export all courses from mongo to the specified data directory' + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("export requires one argument: ") + + output_path = args[0] + + cs = contentstore() + ms = modulestore('direct') + root_dir = output_path + courses = ms.get_courses() + + print "%d courses to export:" % len(courses) + cids = [x.id for x in courses] + print cids + + for course_id in cids: + + print "-"*77 + print "Exporting course id = {0} to {1}".format(course_id, output_path) + + if 1: + try: + location = CourseDescriptor.id_to_location(course_id) + course_dir = course_id.replace('/', '...') + export_to_xml(ms, cs, location, root_dir, course_dir, modulestore()) + except Exception as err: + print "="*30 + "> Oops, failed to export %s" % course_id + print "Error:" + print err diff --git a/cms/djangoapps/contentstore/management/commands/update_templates.py b/cms/djangoapps/contentstore/management/commands/update_templates.py deleted file mode 100644 index 36348314b9..0000000000 --- a/cms/djangoapps/contentstore/management/commands/update_templates.py +++ /dev/null @@ -1,10 +0,0 @@ -from xmodule.templates import update_templates -from xmodule.modulestore.django import modulestore -from django.core.management.base import BaseCommand - - -class Command(BaseCommand): - help = 'Imports and updates the Studio component templates from the code pack and put in the DB' - - def handle(self, *args, **options): - update_templates(modulestore('direct')) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 726d4bb0ce..bce4b0326c 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -3,13 +3,13 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location -def get_module_info(store, location, parent_location=None, rewrite_static_links=False): +def get_module_info(store, location, rewrite_static_links=False): try: module = store.get_item(location) except ItemNotFoundError: # create a new one - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) + store.create_and_save_xmodule(location) + module = store.get_item(location) data = module.data if rewrite_static_links: @@ -29,7 +29,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= 'id': module.location.url(), 'data': data, # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata - 'metadata': module._model_data._kvs._metadata + # what's the intent here? all metadata incl inherited & namespaced? + 'metadata': module.xblock_kvs._metadata } @@ -37,14 +38,11 @@ def set_module_info(store, location, post_data): module = None try: module = store.get_item(location) - except: - pass - - if module is None: - # new module at this location - # presume that we have an 'Empty' template - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) + except ItemNotFoundError: + # new module at this location: almost always used for the course about pages; thus, no parent. (there + # are quite a handful of about page types available for a course and only the overview is pre-created) + store.create_and_save_xmodule(location) + module = store.get_item(location) if post_data.get('data') is not None: data = post_data['data'] @@ -79,4 +77,4 @@ def set_module_info(store, location, post_data): # commit to datastore # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata - store.update_metadata(location, module._model_data._kvs._metadata) + store.update_metadata(location, module.xblock_kvs._metadata) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index be122fa1a4..500db414f4 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -24,12 +24,11 @@ from auth.authz import add_user_to_creator_group from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore import Location +from xmodule.modulestore import Location, mongo from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.store_utilities import delete_course from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore, _CONTENTSTORE -from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.inheritance import own_metadata @@ -135,7 +134,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha', 'Word cloud', 'Annotation', - 'Open Ended Response', + 'Open Ended Grading', 'Peer Grading Interface']) def test_advanced_components_require_two_clicks(self): @@ -183,7 +182,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) # now query get_items() to get this location with revision=None, this should just # return back a single item (not 2) @@ -215,7 +214,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod) self.assertNotIn('graceperiod', own_metadata(html_module)) - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) # refetch to check metadata html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) @@ -233,7 +232,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertNotIn('graceperiod', own_metadata(html_module)) # put back in draft and change metadata and see if it's now marked as 'own_metadata' - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) new_graceperiod = timedelta(hours=1) @@ -255,7 +254,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): draft_store.publish(html_module.location, 0) # and re-read and verify 'own-metadata' - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) self.assertIn('graceperiod', own_metadata(html_module)) @@ -278,7 +277,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) # put into draft - modulestore('draft').clone_item(problem.location, problem.location) + modulestore('draft').convert_to_draft(problem.location) # make sure we can query that item and verify that it is a draft draft_problem = modulestore('draft').get_item( @@ -309,12 +308,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) - ItemFactory.create(parent_location=course_location, - template="i4x://edx/templates/static_tab/Empty", - display_name="Static_1") - ItemFactory.create(parent_location=course_location, - template="i4x://edx/templates/static_tab/Empty", - display_name="Static_2") + ItemFactory.create( + parent_location=course_location, + category="static_tab", + display_name="Static_1") + ItemFactory.create( + parent_location=course_location, + category="static_tab", + display_name="Static_2") course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])) @@ -371,7 +372,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) chapterloc = ItemFactory.create(parent_location=course_location, display_name="Chapter").location - ItemFactory.create(parent_location=chapterloc, template='i4x://edx/templates/sequential/Empty', display_name="Sequential") + ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential") sequential = direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None])) chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None])) @@ -574,7 +575,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_clone_course(self): course_data = { - 'template': 'i4x://edx/templates/course/Empty', 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', @@ -614,10 +614,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') location = Location('i4x://MITx/999/chapter/neuvo') - self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', - location) - direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) - self.assertRaises(InvalidVersionError, draft_store.clone_item, location, location) + # Ensure draft mongo store does not allow us to create chapters either directly or via convert to draft + self.assertRaises(InvalidVersionError, draft_store.create_and_save_xmodule, location) + direct_store.create_and_save_xmodule(location) + self.assertRaises(InvalidVersionError, draft_store.convert_to_draft, location) self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data') @@ -652,9 +652,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): vertical = module_store.get_item(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]), depth=1) - draft_store.clone_item(vertical.location, vertical.location) + draft_store.convert_to_draft(vertical.location) for child in vertical.get_children(): - draft_store.clone_item(child.location, child.location) + draft_store.convert_to_draft(child.location) # delete the course delete_course(module_store, content_store, location, commit=True) @@ -687,26 +687,35 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): import_from_xml(module_store, 'common/test/data/', ['toy']) location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') - # get a vertical (and components in it) to put into 'draft' - vertical = module_store.get_item(Location(['i4x', 'edX', 'toy', - 'vertical', 'vertical_test', None]), depth=1) - - draft_store.clone_item(vertical.location, vertical.location) - + # get a vertical (and components in it) to copy into an orphan sub dag + vertical = module_store.get_item( + Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]), + depth=1 + ) # We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case. - draft_store.clone_item(vertical.location, Location(['i4x', 'edX', 'toy', - 'vertical', 'no_references', 'draft'])) + vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references')) + draft_store.save_xmodule(vertical) + orphan_vertical = draft_store.get_item(vertical.location) + self.assertEqual(orphan_vertical.location.name, 'no_references') + # get the original vertical (and components in it) to put into 'draft' + vertical = module_store.get_item( + Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]), + depth=1) + self.assertEqual(len(orphan_vertical.children), len(vertical.children)) + draft_store.convert_to_draft(vertical.location) for child in vertical.get_children(): - draft_store.clone_item(child.location, child.location) + draft_store.convert_to_draft(child.location) root_dir = path(mkdtemp_clean()) - # now create a private vertical - private_vertical = draft_store.clone_item(vertical.location, - Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None])) + # now create a new/different private (draft only) vertical + vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None])) + draft_store.save_xmodule(vertical) + private_vertical = draft_store.get_item(vertical.location) + vertical = None # blank out b/c i destructively manipulated its location 2 lines above - # add private to list of children + # add the new private to list of children sequential = module_store.get_item(Location(['i4x', 'edX', 'toy', 'sequential', 'vertical_sequential', None])) private_location_no_draft = private_vertical.location.replace(revision=None) @@ -792,6 +801,34 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): shutil.rmtree(root_dir) + def test_export_course_with_metadata_only_video(self): + module_store = modulestore('direct') + draft_store = modulestore('draft') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') + + # create a new video module and add it as a child to a vertical + # this re-creates a bug whereby since the video template doesn't have + # anything in 'data' field, the export was blowing up + verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None]) + + self.assertGreater(len(verticals), 0) + + parent = verticals[0] + + ItemFactory.create(parent_location=parent.location, category="video", display_name="untitled") + + root_dir = path(mkdtemp_clean()) + + print 'Exporting to tempdir = {0}'.format(root_dir) + + # export out to a tempdir + export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store) + + shutil.rmtree(root_dir) + def test_course_handouts_rewrites(self): module_store = modulestore('direct') @@ -885,7 +922,6 @@ class ContentStoreTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) self.course_data = { - 'template': 'i4x://edx/templates/course/Empty', 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', @@ -1029,17 +1065,17 @@ class ContentStoreTest(ModuleStoreTestCase): html=True ) - def test_clone_item(self): + def test_create_item(self): """Test cloning an item. E.g. creating a new section""" CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') section_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', - 'template': 'i4x://edx/templates/chapter/Empty', + 'category': 'chapter', 'display_name': 'Section One', } - resp = self.client.post(reverse('clone_item'), section_data) + resp = self.client.post(reverse('create_item'), section_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) @@ -1054,14 +1090,14 @@ class ContentStoreTest(ModuleStoreTestCase): problem_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', - 'template': 'i4x://edx/templates/problem/Blank_Common_Problem' + 'category': 'problem' } - resp = self.client.post(reverse('clone_item'), problem_data) + resp = self.client.post(reverse('create_item'), problem_data) self.assertEqual(resp.status_code, 200) payload = parse_json(resp) - problem_loc = payload['id'] + problem_loc = Location(payload['id']) problem = get_modulestore(problem_loc).get_item(problem_loc) # should be a CapaDescriptor self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") @@ -1194,10 +1230,9 @@ class ContentStoreTest(ModuleStoreTestCase): CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') new_component_location = Location('i4x', 'edX', '999', 'discussion', 'new_component') - source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag') # crate a new module and add it as a child to a vertical - module_store.clone_item(source_template_location, new_component_location) + module_store.create_and_save_xmodule(new_component_location) new_discussion_item = module_store.get_item(new_component_location) @@ -1218,10 +1253,9 @@ class ContentStoreTest(ModuleStoreTestCase): module_store.modulestore_update_signal.connect(_signal_hander) new_component_location = Location('i4x', 'edX', '999', 'html', 'new_component') - source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') # crate a new module - module_store.clone_item(source_template_location, new_component_location) + module_store.create_and_save_xmodule(new_component_location) finally: module_store.modulestore_update_signal = None @@ -1239,14 +1273,14 @@ class ContentStoreTest(ModuleStoreTestCase): # let's assert on the metadata_inheritance on an existing vertical for vertical in verticals: self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key) + self.assertEqual(course.start, vertical.lms.start) self.assertGreater(len(verticals), 0) new_component_location = Location('i4x', 'edX', 'toy', 'html', 'new_component') - source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') # crate a new module and add it as a child to a vertical - module_store.clone_item(source_template_location, new_component_location) + module_store.create_and_save_xmodule(new_component_location) parent = verticals[0] module_store.update_children(parent.location, parent.children + [new_component_location.url()]) @@ -1256,6 +1290,8 @@ class ContentStoreTest(ModuleStoreTestCase): # check for grace period definition which should be defined at the course level self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod) + self.assertEqual(parent.lms.start, new_module.lms.start) + self.assertEqual(course.start, new_module.lms.start) self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key) @@ -1271,29 +1307,25 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(timedelta(1), new_module.lms.graceperiod) + def test_default_metadata_inheritance(self): + course = CourseFactory.create() + vertical = ItemFactory.create(parent_location=course.location) + course.children.append(vertical) + # in memory + self.assertIsNotNone(course.start) + self.assertEqual(course.start, vertical.lms.start) + self.assertEqual(course.textbooks, []) + self.assertIn('GRADER', course.grading_policy) + self.assertIn('GRADE_CUTOFFS', course.grading_policy) + self.assertGreaterEqual(len(course.checklists), 4) -class TemplateTestCase(ModuleStoreTestCase): - - def test_template_cleanup(self): + # by fetching module_store = modulestore('direct') - - # insert a bogus template in the store - bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus') - source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') - - module_store.clone_item(source_template_location, bogus_template_location) - - verify_create = module_store.get_item(bogus_template_location) - self.assertIsNotNone(verify_create) - - # now run cleanup - update_templates(modulestore('direct')) - - # now try to find dangling template, it should not be in DB any longer - asserted = False - try: - verify_create = module_store.get_item(bogus_template_location) - except ItemNotFoundError: - asserted = True - - self.assertTrue(asserted) + fetched_course = module_store.get_item(course.location) + fetched_item = module_store.get_item(vertical.location) + self.assertIsNotNone(fetched_course.start) + self.assertEqual(course.start, fetched_course.start) + self.assertEqual(fetched_course.start, fetched_item.lms.start) + self.assertEqual(course.textbooks, fetched_course.textbooks) + # is this test too strict? i.e., it requires the dicts to be == + self.assertEqual(course.checklists, fetched_course.checklists) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 21d7d69d41..44eb16436d 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -19,6 +19,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.django import modulestore from xmodule.fields import Date from .utils import CourseTestCase @@ -36,7 +37,6 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end)) self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus)) - self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview) self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video)) self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) @@ -49,7 +49,6 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized") - self.assertEqual(jsondetails['overview'], "", "overview somehow initialized") self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") self.assertIsNone(jsondetails['effort'], "effort somehow initialized") @@ -352,7 +351,7 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') # check for deletion effectiveness - self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in') + self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in') self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index a292b7316e..88df19ec2d 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -35,7 +35,6 @@ class InternationalizationTest(ModuleStoreTestCase): self.user.save() self.course_data = { - 'template': 'i4x://edx/templates/course/Empty', 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 4e6c951d9b..cd203e6af7 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -1,6 +1,9 @@ from contentstore.tests.test_course_settings import CourseTestCase from xmodule.modulestore.tests.factories import CourseFactory from django.core.urlresolvers import reverse +from xmodule.capa_module import CapaDescriptor +import json +from xmodule.modulestore.django import modulestore class DeleteItem(CourseTestCase): @@ -11,14 +14,199 @@ class DeleteItem(CourseTestCase): def testDeleteStaticPage(self): # Add static tab - data = { + data = json.dumps({ 'parent_location': 'i4x://mitX/333/course/Dummy_Course', - 'template': 'i4x://edx/templates/static_tab/Empty' - } + 'category': 'static_tab' + }) - resp = self.client.post(reverse('clone_item'), data) + resp = self.client.post(reverse('create_item'), data, + content_type="application/json") self.assertEqual(resp.status_code, 200) # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). resp = self.client.post(reverse('delete_item'), resp.content, "application/json") self.assertEqual(resp.status_code, 200) + + +class TestCreateItem(CourseTestCase): + """ + Test the create_item handler thoroughly + """ + def response_id(self, response): + """ + Get the id from the response payload + :param response: + """ + parsed = json.loads(response.content) + return parsed['id'] + + def test_create_nicely(self): + """ + Try the straightforward use cases + """ + # create a chapter + display_name = 'Nicely created' + resp = self.client.post( + reverse('create_item'), + json.dumps({ + 'parent_location': self.course.location.url(), + 'display_name': display_name, + 'category': 'chapter' + }), + content_type="application/json" + ) + self.assertEqual(resp.status_code, 200) + + # get the new item and check its category and display_name + chap_location = self.response_id(resp) + new_obj = modulestore().get_item(chap_location) + self.assertEqual(new_obj.category, 'chapter') + self.assertEqual(new_obj.display_name, display_name) + self.assertEqual(new_obj.location.org, self.course.location.org) + self.assertEqual(new_obj.location.course, self.course.location.course) + + # get the course and ensure it now points to this one + course = modulestore().get_item(self.course.location) + self.assertIn(chap_location, course.children) + + # use default display name + resp = self.client.post( + reverse('create_item'), + json.dumps({ + 'parent_location': chap_location, + 'category': 'vertical' + }), + content_type="application/json" + ) + self.assertEqual(resp.status_code, 200) + + vert_location = self.response_id(resp) + + # create problem w/ boilerplate + template_id = 'multiplechoice.yaml' + resp = self.client.post( + reverse('create_item'), + json.dumps({ + 'parent_location': vert_location, + 'category': 'problem', + 'boilerplate': template_id + }), + content_type="application/json" + ) + self.assertEqual(resp.status_code, 200) + prob_location = self.response_id(resp) + problem = modulestore('draft').get_item(prob_location) + # ensure it's draft + self.assertTrue(problem.is_draft) + # check against the template + template = CapaDescriptor.get_template(template_id) + self.assertEqual(problem.data, template['data']) + self.assertEqual(problem.display_name, template['metadata']['display_name']) + self.assertEqual(problem.markdown, template['metadata']['markdown']) + + def test_create_item_negative(self): + """ + Negative tests for create_item + """ + # non-existent boilerplate: creates a default + resp = self.client.post( + reverse('create_item'), + json.dumps( + {'parent_location': self.course.location.url(), + 'category': 'problem', + 'boilerplate': 'nosuchboilerplate.yaml' + }), + content_type="application/json" + ) + self.assertEqual(resp.status_code, 200) + +class TestEditItem(CourseTestCase): + """ + Test contentstore.views.item.save_item + """ + def response_id(self, response): + """ + Get the id from the response payload + :param response: + """ + parsed = json.loads(response.content) + return parsed['id'] + + def setUp(self): + """ Creates the test course structure and a couple problems to 'edit'. """ + super(TestEditItem, self).setUp() + # create a chapter + display_name = 'chapter created' + resp = self.client.post( + reverse('create_item'), + json.dumps( + {'parent_location': self.course.location.url(), + 'display_name': display_name, + 'category': 'chapter' + }), + content_type="application/json" + ) + chap_location = self.response_id(resp) + resp = self.client.post( + reverse('create_item'), + json.dumps( + {'parent_location': chap_location, + 'category': 'vertical' + }), + content_type="application/json" + ) + vert_location = self.response_id(resp) + # create problem w/ boilerplate + template_id = 'multiplechoice.yaml' + resp = self.client.post( + reverse('create_item'), + json.dumps({'parent_location': vert_location, + 'category': 'problem', + 'boilerplate': template_id + }), + content_type="application/json" + ) + self.problems = [self.response_id(resp)] + + def test_delete_field(self): + """ + Sending null in for a field 'deletes' it + """ + self.client.post( + reverse('save_item'), + json.dumps({ + 'id': self.problems[0], + 'metadata': {'rerandomize': 'onreset'} + }), + content_type="application/json" + ) + problem = modulestore('draft').get_item(self.problems[0]) + self.assertEqual(problem.rerandomize, 'onreset') + self.client.post( + reverse('save_item'), + json.dumps({ + 'id': self.problems[0], + 'metadata': {'rerandomize': None} + }), + content_type="application/json" + ) + problem = modulestore('draft').get_item(self.problems[0]) + self.assertEqual(problem.rerandomize, 'never') + + + def test_null_field(self): + """ + Sending null in for a field 'deletes' it + """ + problem = modulestore('draft').get_item(self.problems[0]) + self.assertIsNotNone(problem.markdown) + self.client.post( + reverse('save_item'), + json.dumps({ + 'id': self.problems[0], + 'nullout': ['markdown'] + }), + content_type="application/json" + ) + problem = modulestore('draft').get_item(self.problems[0]) + self.assertIsNone(problem.markdown) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index bc9e9e8bae..a3f211a703 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -54,7 +54,6 @@ class CourseTestCase(ModuleStoreTestCase): self.client.login(username=uname, password=password) self.course = CourseFactory.create( - template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course', diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 452806fe64..4973bddaca 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -9,23 +9,24 @@ import copy import logging import re from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES +from django.utils.translation import ugettext as _ log = logging.getLogger(__name__) # In order to instantiate an open ended tab automatically, need to have this data -OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"} -NOTES_PANEL = {"name": "My Notes", "type": "notes"} +OPEN_ENDED_PANEL = {"name": _("Open Ended Panel"), "type": "open_ended"} +NOTES_PANEL = {"name": _("My Notes"), "type": "notes"} EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]]) -def get_modulestore(location): +def get_modulestore(category_or_location): """ Returns the correct modulestore to use for modifying the specified location """ - if not isinstance(location, Location): - location = Location(location) + if isinstance(category_or_location, Location): + category_or_location = category_or_location.category - if location.category in DIRECT_ONLY_CATEGORIES: + if category_or_location in DIRECT_ONLY_CATEGORIES: return modulestore('direct') else: return modulestore() diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index fa0a7b7b62..fdb5857ba7 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -7,11 +7,11 @@ from django.views.decorators.http import require_http_methods from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response -from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata from ..utils import get_modulestore, get_url_reverse from .access import get_location_and_verify_access +from xmodule.course_module import CourseDescriptor __all__ = ['get_checklists', 'update_checklist'] @@ -28,13 +28,11 @@ def get_checklists(request, org, course, name): modulestore = get_modulestore(location) course_module = modulestore.get_item(location) - new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty') - template_module = modulestore.get_item(new_course_template) # If course was created before checklists were introduced, copy them over from the template. copied = False if not course_module.checklists: - course_module.checklists = template_module.checklists + course_module.checklists = CourseDescriptor.checklists.default copied = True checklists, modified = expand_checklist_action_urls(course_module) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 30958d5866..505a93903a 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -26,6 +26,8 @@ from models.settings.course_grading import CourseGradingModel from .requests import _xmodule_recurse from .access import has_access +from xmodule.x_module import XModuleDescriptor +from xblock.plugin import PluginMissingError __all__ = ['OPEN_ENDED_COMPONENT_TYPES', 'ADVANCED_COMPONENT_POLICY_KEY', @@ -101,7 +103,7 @@ def edit_subsection(request, location): return render_to_response('edit_subsection.html', {'subsection': item, 'context_course': course, - 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), + 'new_unit_category': 'vertical', 'lms_link': lms_link, 'preview_link': preview_link, 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), @@ -134,10 +136,26 @@ def edit_unit(request, location): item = modulestore().get_item(location, depth=1) except ItemNotFoundError: return HttpResponseBadRequest() - lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id) component_templates = defaultdict(list) + for category in COMPONENT_TYPES: + component_class = XModuleDescriptor.load_class(category) + # add the default template + component_templates[category].append(( + component_class.display_name.default or 'Blank', + category, + False, # No defaults have markdown (hardcoded current default) + None # no boilerplate for overrides + )) + # add boilerplates + for template in component_class.templates(): + component_templates[category].append(( + template['metadata'].get('display_name'), + category, + template['metadata'].get('markdown') is not None, + template.get('template_id') + )) # 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 @@ -145,29 +163,29 @@ def edit_unit(request, location): course_advanced_keys = course.advanced_modules # Set component types according to course policy file - component_types = list(COMPONENT_TYPES) if isinstance(course_advanced_keys, list): - course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES] - if len(course_advanced_keys) > 0: - component_types.append(ADVANCED_COMPONENT_CATEGORY) + for category in course_advanced_keys: + if category in ADVANCED_COMPONENT_TYPES: + # Do I need to allow for boilerplates or just defaults on the class? i.e., can an advanced + # have more than one entry in the menu? one for default and others for prefilled boilerplates? + try: + component_class = XModuleDescriptor.load_class(category) + + component_templates['advanced'].append(( + component_class.display_name.default or category, + category, + False, + None # don't override default data + )) + except PluginMissingError: + # dhm: I got this once but it can happen any time the course author configures + # an advanced component which does not exist on the server. This code here merely + # prevents any authors from trying to instantiate the non-existent component type + # by not showing it in the menu + pass else: log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys)) - templates = modulestore().get_items(Location('i4x', 'edx', 'templates')) - for template in templates: - category = template.location.category - - if category in course_advanced_keys: - category = ADVANCED_COMPONENT_CATEGORY - - if category in component_types: - # This is a hack to create categories for different xmodules - component_templates[category].append(( - template.display_name_with_default, - template.location.url(), - hasattr(template, 'markdown') and template.markdown is not None - )) - components = [ component.location.url() for component @@ -219,7 +237,7 @@ def edit_unit(request, location): 'subsection': containing_subsection, 'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None, 'section': containing_section, - 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), + 'new_unit_category': 'vertical', 'unit_state': unit_state, 'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None }) @@ -253,7 +271,7 @@ def create_draft(request): # This clones the existing item location to a draft location (the draft is implicit, # because modulestore is a Draft modulestore) - modulestore().clone_item(location, location) + modulestore().convert_to_draft(location) return HttpResponse() diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index f8de053d95..0e16624c42 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -45,6 +45,7 @@ from .component import ( from django_comment_common.utils import seed_permissions_roles import datetime from django.utils.timezone import UTC +from xmodule.html_module import AboutDescriptor __all__ = ['course_index', 'create_new_course', 'course_info', 'course_info_updates', 'get_course_settings', 'course_config_graders_page', @@ -82,10 +83,11 @@ def course_index(request, org, course, name): 'sections': sections, 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, - 'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'), - 'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point... + 'new_section_category': 'chapter', + 'new_subsection_category': 'sequential', 'upload_asset_callback_url': upload_asset_callback_url, - 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty') + 'new_unit_category': 'vertical', + 'category': 'vertical' }) @@ -98,12 +100,6 @@ def create_new_course(request): if not is_user_in_creator_group(request.user): raise PermissionDenied() - # This logic is repeated in xmodule/modulestore/tests/factories.py - # so if you change anything here, you need to also change it there. - # TODO: write a test that creates two courses, one with the factory and - # the other with this method, then compare them to make sure they are - # equivalent. - template = Location(request.POST['template']) org = request.POST.get('org') number = request.POST.get('number') display_name = request.POST.get('display_name') @@ -121,29 +117,31 @@ def create_new_course(request): existing_course = modulestore('direct').get_item(dest_location) except ItemNotFoundError: pass - if existing_course is not None: return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'}) course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None] courses = modulestore().get_items(course_search_location) - if len(courses) > 0: return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'}) - new_course = modulestore('direct').clone_item(template, dest_location) + # instantiate the CourseDescriptor and then persist it + # note: no system to pass + if display_name is None: + metadata = {} + else: + metadata = {'display_name': display_name} + modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata) + new_course = modulestore('direct').get_item(dest_location) - # clone a default 'about' module as well - - about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview']) - dest_about_location = dest_location._replace(category='about', name='overview') - modulestore('direct').clone_item(about_template_location, dest_about_location) - - if display_name is not None: - new_course.display_name = display_name - - # set a default start date to now - new_course.start = datetime.datetime.now(UTC()) + # clone a default 'about' overview module as well + dest_about_location = dest_location.replace(category='about', name='overview') + overview_template = AboutDescriptor.get_template('overview.yaml') + modulestore('direct').create_and_save_xmodule( + dest_about_location, + system=new_course.system, + definition_data=overview_template.get('data') + ) initialize_course_tabs(new_course) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index abc5f48564..90dae10c23 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -13,16 +13,26 @@ from util.json_request import expect_json from ..utils import get_modulestore from .access import has_access from .requests import _xmodule_recurse +from xmodule.x_module import XModuleDescriptor -__all__ = ['save_item', 'clone_item', 'delete_item'] +__all__ = ['save_item', 'create_item', 'delete_item'] # cdodge: these are categories which should not be parented, they are detached from the hierarchy DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] - @login_required @expect_json def save_item(request): + """ + Will carry a json payload with these possible fields + :id (required): the id + :data (optional): the new value for the data + :metadata (optional): new values for the metadata fields. + Any whose values are None will be deleted not set to None! Absent ones will be left alone + :nullout (optional): which metadata fields to set to None + """ + # The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a + # little smarter and able to pass something more akin to {unset: [field, field]} item_location = request.POST['id'] # check permissions for this user within this course @@ -42,30 +52,25 @@ def save_item(request): children = request.POST['children'] store.update_children(item_location, children) - # cdodge: also commit any metadata which might have been passed along in the - # POST from the client, if it is there - # NOTE, that the postback is not the complete metadata, as there's system metadata which is - # not presented to the end-user for editing. So let's fetch the original and - # 'apply' the submitted metadata, so we don't end up deleting system metadata - if request.POST.get('metadata') is not None: - posted_metadata = request.POST['metadata'] - # fetch original + # cdodge: also commit any metadata which might have been passed along + if request.POST.get('nullout') is not None or request.POST.get('metadata') is not None: + # the postback is not the complete metadata, as there's system metadata which is + # not presented to the end-user for editing. So let's fetch the original and + # 'apply' the submitted metadata, so we don't end up deleting system metadata existing_item = modulestore().get_item(item_location) + for metadata_key in request.POST.get('nullout', []): + setattr(existing_item, metadata_key, None) # update existing metadata with submitted metadata (which can be partial) - # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key, value in posted_metadata.items(): + # IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If + # the intent is to make it None, use the nullout field + for metadata_key, value in request.POST.get('metadata', {}).items(): - if posted_metadata[metadata_key] is None: - # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in existing_item._model_data: - del existing_item._model_data[metadata_key] - del posted_metadata[metadata_key] + if value is None: + delattr(existing_item, metadata_key) else: - existing_item._model_data[metadata_key] = value - + setattr(existing_item, metadata_key, value) # commit to datastore - # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata store.update_metadata(item_location, own_metadata(existing_item)) return HttpResponse() @@ -73,28 +78,38 @@ def save_item(request): @login_required @expect_json -def clone_item(request): +def create_item(request): parent_location = Location(request.POST['parent_location']) - template = Location(request.POST['template']) + category = request.POST['category'] display_name = request.POST.get('display_name') if not has_access(request.user, parent_location): raise PermissionDenied() - parent = get_modulestore(template).get_item(parent_location) - dest_location = parent_location._replace(category=template.category, name=uuid4().hex) + parent = get_modulestore(category).get_item(parent_location) + dest_location = parent_location.replace(category=category, name=uuid4().hex) - new_item = get_modulestore(template).clone_item(template, dest_location) + # get the metadata, display_name, and definition from the request + metadata = {} + data = None + template_id = request.POST.get('boilerplate') + if template_id is not None: + clz = XModuleDescriptor.load_class(category) + if clz is not None: + template = clz.get_template(template_id) + if template is not None: + metadata = template.get('metadata', {}) + data = template.get('data') - # replace the display name with an optional parameter passed in from the caller if display_name is not None: - new_item.display_name = display_name + metadata['display_name'] = display_name - get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item)) + get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data, + metadata=metadata, system=parent.system) - if new_item.location.category not in DETACHED_CATEGORIES: - get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()]) + if category not in DETACHED_CATEGORIES: + get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()]) return HttpResponse(json.dumps({'id': dest_location.url()})) diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index a7b232e92a..154f9fb55d 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -13,7 +13,7 @@ from xmodule.modulestore.django import modulestore from ..utils import get_course_for_item, get_modulestore from .access import get_location_and_verify_access -__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages', 'edit_static'] +__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages'] def initialize_course_tabs(course): @@ -127,7 +127,3 @@ def static_pages(request, org, course, coursename): return render_to_response('static-pages.html', { 'context_course': course, }) - - -def edit_static(request, org, course, coursename): - return render_to_response('edit-static-page.html', {}) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index dae0d246a5..ee6b0bf84d 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required +from django.utils.translation import ugettext as _ from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response @@ -26,6 +27,7 @@ def index(request): # filter out courses that we don't have access too def course_filter(course): return (has_access(request.user, course.location) + # TODO remove this condition when templates purged from db and course.location.course != 'templates' and course.location.org != '' and course.location.course != '' @@ -33,7 +35,6 @@ def index(request): courses = filter(course_filter, courses) return render_to_response('index.html', { - 'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'), 'courses': [(course.display_name, get_url_reverse('CourseOutline', course), get_lms_link_for_item(course.location, course_id=course.location.course_id)) @@ -78,7 +79,7 @@ def add_user(request, location): if not email: msg = { 'Status': 'Failed', - 'ErrMsg': 'Please specify an email address.', + 'ErrMsg': _('Please specify an email address.'), } return JsonResponse(msg, 400) @@ -92,7 +93,7 @@ def add_user(request, location): if user is None: msg = { 'Status': 'Failed', - 'ErrMsg': "Could not find user by email address '{0}'.".format(email), + 'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email), } return JsonResponse(msg, 404) @@ -100,7 +101,7 @@ def add_user(request, location): if not user.is_active: msg = { 'Status': 'Failed', - 'ErrMsg': 'User {0} has registered but has not yet activated his/her account.'.format(email), + 'ErrMsg': _('User {email} has registered but has not yet activated his/her account.').format(email=email), } return JsonResponse(msg, 400) @@ -129,7 +130,7 @@ def remove_user(request, location): if user is None: msg = { 'Status': 'Failed', - 'ErrMsg': "Could not find user by email address '{0}'.".format(email), + 'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email), } return JsonResponse(msg, 404) diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 4ea9f2f5db..e529a284c6 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -9,7 +9,7 @@ class CourseGradingModel(object): """ def __init__(self, course_descriptor): self.course_location = course_descriptor.location - self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100] + self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100] self.grade_cutoffs = course_descriptor.grade_cutoffs self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor) @@ -81,7 +81,7 @@ class CourseGradingModel(object): Decode the json into CourseGradingModel and save any changes. Returns the modified model. Probably not the usual path for updates as it's too coarse grained. """ - course_location = jsondict['course_location'] + course_location = Location(jsondict['course_location']) descriptor = get_modulestore(course_location).get_item(course_location) graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']] @@ -89,7 +89,7 @@ class CourseGradingModel(object): descriptor.raw_grader = graders_parsed descriptor.grade_cutoffs = jsondict['grade_cutoffs'] - get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) + get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data) CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period']) return CourseGradingModel.fetch(course_location) @@ -209,7 +209,7 @@ class CourseGradingModel(object): descriptor = get_modulestore(location).get_item(location) return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded', "location": location, - "id": 99 # just an arbitrary value to + "id": 99 # just an arbitrary value to } @staticmethod @@ -232,7 +232,7 @@ class CourseGradingModel(object): # 5 hours 59 minutes 59 seconds => converted to iso format rawgrace = descriptor.lms.graceperiod if rawgrace: - hours_from_days = rawgrace.days*24 + hours_from_days = rawgrace.days * 24 seconds = rawgrace.seconds hours_from_seconds = int(seconds / 3600) hours = hours_from_days + hours_from_seconds diff --git a/cms/static/client_templates/checklist.html b/cms/static/client_templates/checklist.html index e985ab9509..5d36264d07 100644 --- a/cms/static/client_templates/checklist.html +++ b/cms/static/client_templates/checklist.html @@ -6,15 +6,15 @@ class="course-checklist" <% } %> id="<%= 'course-checklist' + checklistIndex %>"> - <% var widthPercentage = 'width:' + percentChecked + '%;'; %> - - <%= percentChecked %>% of checklist completed + + <%= _.template(gettext("{number}% of checklists completed"), {number: '' + percentChecked + ''}, {interpolate: /\{(.+?)\}/g}) %> +

<%= checklistShortDescription %>

- Tasks Completed: <%= itemsChecked %>/<%= items.length %> + <%= gettext("Tasks Completed:") %> <%= itemsChecked %>/<%= items.length %>
@@ -47,7 +47,7 @@
  • - rel="external" title="This link will open in a new browser window/tab" + rel="external" title="<%= gettext("This link will open in a new browser window/tab") %>" <% } %> ><%= item['action_text'] %>
  • diff --git a/cms/static/client_templates/course_info_handouts.html b/cms/static/client_templates/course_info_handouts.html index 958a1c77d6..7fbbe9bc33 100644 --- a/cms/static/client_templates/course_info_handouts.html +++ b/cms/static/client_templates/course_info_handouts.html @@ -1,12 +1,12 @@ Edit -

    Course Handouts

    +

    Course Handouts

    <%if (model.get('data') != null) { %>
    <%= model.get('data') %>
    <% } else {%> -

    You have no handouts defined

    +

    ${_("You have no handouts defined")}

    <% } %>
    diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index 3c27629f69..3964bee455 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -1,5 +1,6 @@ { "static_files": [ + "../jsi18n/", "js/vendor/RequireJS.js", "js/vendor/jquery.min.js", "js/vendor/jquery-ui.min.js", diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index 5154591d6f..62083fa26d 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -56,14 +56,15 @@ class CMS.Views.ModuleEdit extends Backbone.View changedMetadata: -> return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata()) - cloneTemplate: (parent, template) -> - $.post("/clone_item", { - parent_location: parent - template: template - }, (data) => - @model.set(id: data.id) - @$el.data('id', data.id) - @render() + createItem: (parent, payload) -> + payload.parent_location = parent + $.post( + "/create_item" + payload + (data) => + @model.set(id: data.id) + @$el.data('id', data.id) + @render() ) render: -> diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 1034fc988e..58f52f27a3 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -55,9 +55,9 @@ class CMS.Views.TabsEdit extends Backbone.View editor.$el.removeClass('new') , 500) - editor.cloneTemplate( + editor.createItem( @model.get('id'), - 'i4x://edx/templates/static_tab/Empty' + {category: 'static_tab'} ) analytics.track "Added Static Page", diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 058bcf0ce1..774ef04f6d 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -89,9 +89,9 @@ class CMS.Views.UnitEdit extends Backbone.View @$newComponentItem.before(editor.$el) - editor.cloneTemplate( + editor.createItem( @$el.data('id'), - $(event.currentTarget).data('location') + $(event.currentTarget).data() ) analytics.track "Added a Component", diff --git a/cms/static/js/base.js b/cms/static/js/base.js index d597c2af27..3d8cd7684e 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -79,10 +79,10 @@ $(document).ready(function() { }); // general link management - new window/tab - $('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').bind('click', linkNewWindow); + $('a[rel="external"]').attr('title', gettext('This link will open in a new browser window/tab')).bind('click', linkNewWindow); // general link management - lean modal window - $('a[rel="modal"]').attr('title', 'This link will open in a modal window').leanModal({ + $('a[rel="modal"]').attr('title', gettext('This link will open in a modal window')).leanModal({ overlay: 0.50, closeButton: '.action-modal-close' }); @@ -199,8 +199,10 @@ function toggleSections(e) { $section = $('.courseware-section'); sectionCount = $section.length; $button = $(this); - $labelCollapsed = $(' Collapse All Sections'); - $labelExpanded = $(' Expand All Sections'); + $labelCollapsed = $(' ' + + gettext('Collapse All Sections') + ''); + $labelExpanded = $(' ' + + gettext('Expand All Sections') + ''); var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded; $button.toggleClass('is-activated').html(buttonLabel); @@ -326,7 +328,7 @@ function saveSubsection() { $changedInput = null; }, error: function() { - showToastMessage('There has been an error while saving your changes.'); + showToastMessage(gettext('There has been an error while saving your changes.')); } }); } @@ -336,7 +338,7 @@ function createNewUnit(e) { e.preventDefault(); var parent = $(this).data('parent'); - var template = $(this).data('template'); + var category = $(this).data('category'); analytics.track('Created a Unit', { 'course': course_location_analytics, @@ -344,9 +346,9 @@ function createNewUnit(e) { }); - $.post('/clone_item', { + $.post('/create_item', { 'parent_location': parent, - 'template': template, + 'category': category, 'display_name': 'New Unit' }, @@ -372,7 +374,7 @@ function deleteSection(e) { } function _deleteItem($el) { - if (!confirm('Are you sure you wish to delete this item. It cannot be reversed!')) return; + if (!confirm(gettext('Are you sure you wish to delete this item. It cannot be reversed!'))) return; var id = $el.data('id'); @@ -549,7 +551,7 @@ function saveNewSection(e) { var $saveButton = $(this).find('.new-section-name-save'); var parent = $saveButton.data('parent'); - var template = $saveButton.data('template'); + var category = $saveButton.data('category'); var display_name = $(this).find('.new-section-name').val(); analytics.track('Created a Section', { @@ -557,9 +559,9 @@ function saveNewSection(e) { 'display_name': display_name }); - $.post('/clone_item', { + $.post('/create_item', { 'parent_location': parent, - 'template': template, + 'category': category, 'display_name': display_name, }, @@ -593,13 +595,12 @@ function saveNewCourse(e) { e.preventDefault(); var $newCourse = $(this).closest('.new-course'); - var template = $(this).find('.new-course-save').data('template'); var org = $newCourse.find('.new-course-org').val(); var number = $newCourse.find('.new-course-number').val(); var display_name = $newCourse.find('.new-course-name').val(); if (org == '' || number == '' || display_name == '') { - alert('You must specify all fields in order to create a new course.'); + alert(gettext('You must specify all fields in order to create a new course.')); return; } @@ -610,7 +611,6 @@ function saveNewCourse(e) { }); $.post('/create_new_course', { - 'template': template, 'org': org, 'number': number, 'display_name': display_name @@ -644,7 +644,7 @@ function addNewSubsection(e) { var parent = $(this).parents("section.branch").data("id"); $saveButton.data('parent', parent); - $saveButton.data('template', $(this).data('template')); + $saveButton.data('category', $(this).data('category')); $newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection); $cancelButton.bind('click', cancelNewSubsection); @@ -657,7 +657,7 @@ function saveNewSubsection(e) { e.preventDefault(); var parent = $(this).find('.new-subsection-name-save').data('parent'); - var template = $(this).find('.new-subsection-name-save').data('template'); + var category = $(this).find('.new-subsection-name-save').data('category'); var display_name = $(this).find('.new-subsection-name-input').val(); analytics.track('Created a Subsection', { @@ -666,9 +666,9 @@ function saveNewSubsection(e) { }); - $.post('/clone_item', { + $.post('/create_item', { 'parent_location': parent, - 'template': template, + 'category': category, 'display_name': display_name }, @@ -730,18 +730,16 @@ function saveSetSectionScheduleDate(e) { }) }).success(function() { var $thisSection = $('.courseware-section[data-id="' + id + '"]'); - var format = gettext('Will Release: %(date)s at %(time)s UTC'); - var willReleaseAt = interpolate(format, { - 'date': input_date, - 'time': input_time - }, - true); - $thisSection.find('.section-published-date').html( - '' + willReleaseAt + '' + - '' + gettext('Edit') + ''); + var html = _.template( + '' + + '' + gettext("Will Release: ") + '' + + gettext("<%= date %> at <%= time %> UTC") + + '' + + '' + + gettext("Edit") + + '', + {date: input_date, time: input_time, id: id}); + $thisSection.find('.section-published-date').html(html); hideModal(); saving.hide(); }); diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 993832f830..d7e11d5689 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -38,23 +38,23 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ // A bit funny in that the video key validation is asynchronous; so, it won't stop the validation. var errors = {}; if (newattrs.start_date === null) { - errors.start_date = "The course must have an assigned start date."; + errors.start_date = gettext("The course must have an assigned start date."); } if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) { - errors.end_date = "The course end date cannot be before the course start date."; + errors.end_date = gettext("The course end date cannot be before the course start date."); } if (newattrs.start_date && newattrs.enrollment_start && newattrs.start_date < newattrs.enrollment_start) { - errors.enrollment_start = "The course start date cannot be before the enrollment start date."; + errors.enrollment_start = gettext("The course start date cannot be before the enrollment start date."); } if (newattrs.enrollment_start && newattrs.enrollment_end && newattrs.enrollment_start >= newattrs.enrollment_end) { - errors.enrollment_end = "The enrollment start date cannot be after the enrollment end date."; + errors.enrollment_end = gettext("The enrollment start date cannot be after the enrollment end date."); } if (newattrs.end_date && newattrs.enrollment_end && newattrs.end_date < newattrs.enrollment_end) { - errors.enrollment_end = "The enrollment end date cannot be after the course end date."; + errors.enrollment_end = gettext("The enrollment end date cannot be after the course end date."); } if (newattrs.intro_video && newattrs.intro_video !== this.get('intro_video')) { if (this._videokey_illegal_chars.exec(newattrs.intro_video)) { - errors.intro_video = "Key should only contain letters, numbers, _, or -"; + errors.intro_video = gettext("Key should only contain letters, numbers, _, or -"); } // TODO check if key points to a real video using google's youtube api } diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js index 3014b39e82..04ae3f4c32 100644 --- a/cms/static/js/models/settings/course_grading_policy.js +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -79,14 +79,14 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ // FIXME somehow this.collection is unbound sometimes. I can't track down when var existing = this.collection && this.collection.some(function(other) { return (other.cid != this.cid) && (other.get('type') == attrs['type']);}, this); if (existing) { - errors.type = "There's already another assignment type with this name."; + errors.type = gettext("There's already another assignment type with this name."); } } } if (_.has(attrs, 'weight')) { var intWeight = parseInt(attrs.weight); // see if this ensures value saved is int if (!isFinite(intWeight) || /\D+/.test(attrs.weight) || intWeight < 0 || intWeight > 100) { - errors.weight = "Please enter an integer between 0 and 100."; + errors.weight = gettext("Please enter an integer between 0 and 100."); } else { attrs.weight = intWeight; @@ -100,18 +100,20 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ }} if (_.has(attrs, 'min_count')) { if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) { - errors.min_count = "Please enter an integer."; + errors.min_count = gettext("Please enter an integer."); } else attrs.min_count = parseInt(attrs.min_count); } if (_.has(attrs, 'drop_count')) { if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) { - errors.drop_count = "Please enter an integer."; + errors.drop_count = gettext("Please enter an integer."); } else attrs.drop_count = parseInt(attrs.drop_count); } if (_.has(attrs, 'min_count') && _.has(attrs, 'drop_count') && attrs.drop_count > attrs.min_count) { - errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned."; + errors.drop_count = _.template( + gettext("Cannot drop more <% attrs.types %> than will assigned."), + attrs, {variable: 'attrs'}); } if (!_.isEmpty(errors)) return errors; } diff --git a/cms/static/js/views/textbook.js b/cms/static/js/views/textbook.js index fe12082c7a..74eaae8601 100644 --- a/cms/static/js/views/textbook.js +++ b/cms/static/js/views/textbook.js @@ -26,8 +26,8 @@ CMS.Views.ShowTextbook = Backbone.View.extend({ if(e && e.preventDefault) { e.preventDefault(); } var textbook = this.model, collection = this.model.collection; var msg = new CMS.Views.Prompt.Warning({ - title: _.str.sprintf(gettext("Delete “%s”?"), - textbook.escape('name')), + title: _.template(gettext("Delete “<%= name %>”?"), + {name: textbook.escape('name')}), message: gettext("Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed."), actions: { primary: { @@ -241,8 +241,8 @@ CMS.Views.EditChapter = Backbone.View.extend({ asset_path: this.$("input.chapter-asset-path").val() }); var msg = new CMS.Models.FileUpload({ - title: _.str.sprintf(gettext("Upload a new asset to %s"), - section.escape('name')), + title: _.template(gettext("Upload a new asset to “<%= name %>”"), + {name: section.escape('name')}), message: "Files must be in PDF format." }); var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model}); @@ -260,7 +260,7 @@ CMS.Views.UploadDialog = Backbone.View.extend({ this.listenTo(this.model, "change", this.render); }, render: function() { - var isValid = this.model.isValid() + var isValid = this.model.isValid(); var selectedFile = this.model.get('selectedFile'); var oldInput = this.$("input[type=file]").get(0); this.$el.html(this.template({ diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 4a20a98eb3..054a401e4b 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -576,7 +576,7 @@ p, ul, ol, dl { // misc hr.divide { - @extend .text-sr; + @extend .cont-text-sr; } .item-details { @@ -824,7 +824,7 @@ hr.divide { // basic utility .sr { - @extend .text-sr; + @extend .cont-text-sr; } .fake-link { @@ -877,7 +877,7 @@ body.js { text-align: center; .label { - @extend .text-sr; + @extend .cont-text-sr; } [class^="icon-"] { diff --git a/cms/static/sass/_mixins-inherited.scss b/cms/static/sass/_mixins-inherited.scss deleted file mode 120000 index f64a720561..0000000000 --- a/cms/static/sass/_mixins-inherited.scss +++ /dev/null @@ -1 +0,0 @@ -../../../common/static/sass/_mixins-inherited.scss \ No newline at end of file diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index 6859560361..226603b81d 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -3,7 +3,7 @@ // gray primary button .btn-primary-gray { - @extend .btn-primary; + @extend .ui-btn-primary; background: $gray-l1; border-color: $gray-l2; color: $white; @@ -25,7 +25,7 @@ // blue primary button .btn-primary-blue { - @extend .btn-primary; + @extend .ui-btn-primary; background: $blue; border-color: $blue-s1; color: $white; @@ -48,7 +48,7 @@ // green primary button .btn-primary-green { - @extend .btn-primary; + @extend .ui-btn-primary; background: $green; border-color: $green; color: $white; @@ -71,7 +71,7 @@ // gray secondary button .btn-secondary-gray { - @extend .btn-secondary; + @extend .ui-btn-secondary; border-color: $gray-l3; color: $gray-l1; @@ -92,7 +92,7 @@ // blue secondary button .btn-secondary-blue { - @extend .btn-secondary; + @extend .ui-btn-secondary; border-color: $blue-l3; color: $blue; @@ -114,7 +114,7 @@ // green secondary button .btn-secondary-green { - @extend .btn-secondary; + @extend .ui-btn-secondary; border-color: $green-l4; color: $green-l2; @@ -148,9 +148,9 @@ // ==================== // simple dropdown button styling - should we move this elsewhere? -.btn-dd { - @extend .btn; - @extend .btn-pill; +.ui-btn-dd { + @extend .ui-btn; + @extend .ui-btn-pill; padding:($baseline/4) ($baseline/2); border-width: 1px; border-style: solid; @@ -158,7 +158,7 @@ text-align: center; &:hover, &:active { - @extend .fake-link; + @extend .ui-fake-link; border-color: $gray-l3; } @@ -169,8 +169,8 @@ } // layout-based buttons - nav dd -.btn-dd-nav-primary { - @extend .btn-dd; +.ui-btn-dd-nav-primary { + @extend .ui-btn-dd; background: $white; border-color: $white; color: $gray-d1; diff --git a/cms/static/sass/elements/_header.scss b/cms/static/sass/elements/_header.scss index fc430ed6a1..56b2a0f061 100644 --- a/cms/static/sass/elements/_header.scss +++ b/cms/static/sass/elements/_header.scss @@ -2,10 +2,10 @@ // ==================== .wrapper-header { - @extend .depth3; - box-shadow: 0 1px 2px 0 $shadow-l1; + @extend .ui-depth3; position: relative; width: 100%; + box-shadow: 0 1px 2px 0 $shadow-l1; margin: 0; padding: 0 $baseline; background: $white; @@ -22,7 +22,6 @@ // ==================== // basic layout - .wrapper-l, .wrapper-r { background: $white; } @@ -76,7 +75,7 @@ .title { @extend .t-action2; - @extend .btn-dd-nav-primary; + @extend .ui-btn-dd-nav-primary; @include transition(all $tmg-f2 ease-in-out 0s); .label, .icon-caret-down { diff --git a/cms/static/sass/elements/_modal.scss b/cms/static/sass/elements/_modal.scss index fd60e690e7..72c2d93734 100644 --- a/cms/static/sass/elements/_modal.scss +++ b/cms/static/sass/elements/_modal.scss @@ -2,7 +2,7 @@ // ==================== .modal-cover { - @extend .depth3; + @extend .ui-depth3; display: none; position: fixed; top: 0; @@ -13,7 +13,7 @@ } .modal { - @extend .depth4; + @extend .ui-depth4; display: none; position: fixed; top: 60px; @@ -61,7 +61,7 @@ // lean modal alternative #lean_overlay { - @extend .depth4; + @extend .ui-depth4; position: fixed; top: 0px; left: 0px; diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index e118d231ef..d05965d83c 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -5,7 +5,7 @@ nav { ol, ul { - @extend .no-list; + @extend .cont-no-list; } .nav-item { diff --git a/cms/static/sass/elements/_sock.scss b/cms/static/sass/elements/_sock.scss index 99829f7106..ff79cb10ef 100644 --- a/cms/static/sass/elements/_sock.scss +++ b/cms/static/sass/elements/_sock.scss @@ -10,7 +10,7 @@ .wrapper-inner { @include linear-gradient($gray-d3 0%, $gray-d3 98%, $black 100%); - @extend .depth0; + @extend .ui-depth0; display: none; width: 100% !important; border-bottom: 1px solid $white; @@ -19,7 +19,7 @@ // sock - actions .list-cta { - @extend .depth1; + @extend .ui-depth1; position: absolute; top: -($baseline*0.75); width: 100%; @@ -27,7 +27,7 @@ text-align: center; .cta-show-sock { - @extend .btn-pill; + @extend .ui-btn-pill; @extend .t-action4; background: $gray-l5; padding: ($baseline/2) $baseline; diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index 5022a9f677..be1b41bf29 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -144,8 +144,8 @@ // prompts .wrapper-prompt { - @extend .depth5; - @include transition(all $tmg-f3 ease-in-out 0s); + @extend .ui-depth5; + @include transition(all $tmg-f3 ease-in-out 0s); position: fixed; top: 0; background: $black-t0; @@ -242,7 +242,7 @@ // notifications .wrapper-notification { - @extend .depth5; + @extend .ui-depth5; @include clearfix(); box-shadow: 0 -1px 3px $shadow, inset 0 3px 1px $blue; position: fixed; @@ -444,7 +444,7 @@ } .copy p { - @extend .text-sr; + @extend .cont-text-sr; } } } @@ -453,7 +453,7 @@ // alerts .wrapper-alert { - @extend .depth2; + @extend .ui-depth2; @include box-sizing(border-box); box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $blue; position: relative; @@ -599,7 +599,7 @@ text-align: center; .label { - @extend .text-sr; + @extend .cont-text-sr; } [class^="icon"] { @@ -696,7 +696,7 @@ body.uxdesign.alerts { } .content-primary { - @extend .window; + @extend .ui-window; width: flex-grid(12, 12); margin-right: flex-gutter(); padding: $baseline ($baseline*1.5); diff --git a/cms/static/sass/views/_checklists.scss b/cms/static/sass/views/_checklists.scss index 3f98765216..f2e134c32e 100644 --- a/cms/static/sass/views/_checklists.scss +++ b/cms/static/sass/views/_checklists.scss @@ -14,7 +14,7 @@ body.course.checklists { // checklists - general .course-checklist { - @extend .window; + @extend .ui-window; margin: 0 0 ($baseline*2) 0; &:last-child { @@ -23,7 +23,7 @@ body.course.checklists { // visual status .viz-checklist-status { - @extend .text-hide; + @extend .cont-text-hide; @include size(100%,($baseline/4)); position: relative; display: block; @@ -40,7 +40,7 @@ body.course.checklists { background: $green; .int { - @extend .text-sr; + @extend .cont-text-sr; } } } diff --git a/cms/static/sass/views/_export.scss b/cms/static/sass/views/_export.scss index 933bb50252..954fe7fc81 100644 --- a/cms/static/sass/views/_export.scss +++ b/cms/static/sass/views/_export.scss @@ -2,9 +2,9 @@ // ==================== body.course.export { - + .export-overview { - @extend .window; + @extend .ui-window; @include clearfix; padding: 30px 40px; } @@ -40,7 +40,7 @@ body.course.export { } .export-form-wrapper { - + .export-form { float: left; width: 35%; @@ -122,4 +122,4 @@ body.course.export { } } } -} \ No newline at end of file +} diff --git a/cms/static/sass/views/_import.scss b/cms/static/sass/views/_import.scss index e5fb955348..1f9c62d917 100644 --- a/cms/static/sass/views/_import.scss +++ b/cms/static/sass/views/_import.scss @@ -2,9 +2,9 @@ // ==================== body.course.import { - + .import-overview { - @extend .window; + @extend .ui-window; @include clearfix; padding: 30px 40px; } @@ -103,4 +103,4 @@ body.course.import { color: #fff; line-height: 48px; } -} \ No newline at end of file +} diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 2fc06f9df9..1430c41368 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -9,7 +9,7 @@ body.course.settings { } .content-primary { - @extend .window; + @extend .ui-window; width: flex-grid(9, 12); margin-right: flex-gutter(); padding: $baseline ($baseline*1.5); diff --git a/cms/static/sass/views/_static-pages.scss b/cms/static/sass/views/_static-pages.scss index c62979ebc8..2bcf13441e 100644 --- a/cms/static/sass/views/_static-pages.scss +++ b/cms/static/sass/views/_static-pages.scss @@ -171,7 +171,7 @@ body.course.static-pages { } .static-page-details { - @extend .window; + @extend .ui-window; padding: 32px 40px; .row { diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index ab2599581f..8d2b2d9489 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -115,7 +115,7 @@ body.course.textbooks { } .delete { - @extend .btn-non; + @extend .ui-btn-non; } } @@ -188,7 +188,7 @@ body.course.textbooks { .chapters-fields, .textbook-fields { - @extend .no-list; + @extend .cont-no-list; .field { margin: 0 0 ($baseline*0.75) 0; @@ -320,7 +320,7 @@ body.course.textbooks { } .action-upload { - @extend .btn-flat-outline; + @extend .ui-btn-flat-outline; position: absolute; top: 3px; right: 0; @@ -348,7 +348,7 @@ body.course.textbooks { .action-add-chapter { - @extend .btn-flat-outline; + @extend .ui-btn-flat-outline; @include font-size(16); display: block; width: 100%; @@ -365,7 +365,7 @@ body.course.textbooks { // dialog .wrapper-dialog { - @extend .depth5; + @extend .ui-depth5; @include transition(all 0.05s ease-in-out); position: fixed; top: 0; diff --git a/cms/static/sass/views/_updates.scss b/cms/static/sass/views/_updates.scss index 98adda2b5f..ba8b9bf6b0 100644 --- a/cms/static/sass/views/_updates.scss +++ b/cms/static/sass/views/_updates.scss @@ -2,12 +2,6 @@ // ==================== body.course.updates { - - h2 { - margin-bottom: 24px; - font-size: 22px; - font-weight: 300; - } .course-info-wrapper { display: table; @@ -180,9 +174,10 @@ body.course.updates { border-left: none; background: $lightGrey; - h2 { - font-size: 18px; - font-weight: 700; + .title { + margin-bottom: 24px; + font-size: 22px; + font-weight: 300; } .edit-button { @@ -220,4 +215,4 @@ body.course.updates { textarea { height: 300px; } -} \ No newline at end of file +} diff --git a/cms/templates/404.html b/cms/templates/404.html index a45a223bad..be7a66a31c 100644 --- a/cms/templates/404.html +++ b/cms/templates/404.html @@ -1,14 +1,19 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> -<%block name="title">Page Not Found +<%block name="title">${_("Page Not Found")} <%block name="content">
    -

    Page not found

    -

    The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at technical@edx.org.

    +

    ${_("Page not found")}

    +

    ${_('The page that you were looking for was not found.')} + ${_('Go back to the {homepage} or let us know about any pages that may have been moved at {email}.').format( + homepage='homepage', + email='technical@edx.org')} +

    - \ No newline at end of file + diff --git a/cms/templates/500.html b/cms/templates/500.html index 3d18d9dcc5..5d79dd7a16 100644 --- a/cms/templates/500.html +++ b/cms/templates/500.html @@ -1,18 +1,19 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> -<%block name="title">Studio Server Error +<%block name="title">${_("Studio Server Error")} <%block name="content">
    -

    The Studio servers encountered an error

    +

    ${_("The Studio servers encountered an error")}

    - An error occurred in Studio and the page could not be loaded. Please try again in a few moments. - We've logged the error and our staff is currently working to resolve this error as soon as possible. - If the problem persists, please email us at technical@edx.org. + ${_("An error occurred in Studio and the page could not be loaded. Please try again in a few moments.")} + ${_("We've logged the error and our staff is currently working to resolve this error as soon as possible.")} + ${_('If the problem persists, please email us at {email}.').format(email='technical@edx.org')}

    - \ No newline at end of file + diff --git a/cms/templates/activation_active.html b/cms/templates/activation_active.html index 712c73abf9..9a4ebd7e4e 100644 --- a/cms/templates/activation_active.html +++ b/cms/templates/activation_active.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%block name="content"> @@ -6,9 +7,9 @@
    -

    Account already active!

    -

    This account has already been activated. Log in here.

    +

    ${_("Account already active!")}

    +

    ${_('This account has already been activated.')}${_("Log in here.")}

    - \ No newline at end of file + diff --git a/cms/templates/activation_complete.html b/cms/templates/activation_complete.html index 1e195a632c..d845c5153b 100644 --- a/cms/templates/activation_complete.html +++ b/cms/templates/activation_complete.html @@ -1,12 +1,13 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%block name="content">
    -

    Activation Complete!

    -

    Thanks for activating your account. Log in here.

    +

    ${_("Activation Complete!")}

    +

    ${_('Thanks for activating your account.')}${_("Log in here.")}

    - \ No newline at end of file + diff --git a/cms/templates/activation_invalid.html b/cms/templates/activation_invalid.html index c4eb16875b..3ee4e8ec4e 100644 --- a/cms/templates/activation_invalid.html +++ b/cms/templates/activation_invalid.html @@ -1,16 +1,15 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%block name="content">
    -

    Activation Invalid

    +

    ${_("Activation Invalid")}

    -

    Something went wrong. Check to make sure the URL you went to was - correct -- e-mail programs will sometimes split it into two - lines. If you still have issues, e-mail us to let us know what happened - at bugs@mitx.mit.edu.

    +

    ${_('Something went wrong. Check to make sure the URL you went to was correct -- e-mail programs will sometimes split it into two lines. If you still have issues, e-mail us to let us know what happened at {email}.').format(email='bugs@mitx.mit.edu')}

    -

    Or you can go back to the home page.

    +

    ${_('Or you can go back to the {link_start}home page{link_end}.').format( + link_start='', link_end='')}

    - \ No newline at end of file + diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index bdad7b7b88..6c92994a6f 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -2,7 +2,7 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> <%block name="bodyclass">is-signedin course uploads -<%block name="title">Files & Uploads +<%block name="title">${_("Files & Uploads")} <%namespace name='static' file='static_content.html'/> @@ -48,7 +48,7 @@

    Page Actions

    diff --git a/cms/templates/base.html b/cms/templates/base.html index e58dcdfc60..44ebf59170 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -1,3 +1,4 @@ +## -*- coding: utf-8 -*- <%namespace name='static' file='static_content.html'/> @@ -17,6 +18,7 @@ + <%static:css group='base-style'/> @@ -35,7 +37,6 @@ ## javascript - diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html index 6f78e952c0..ad4f29aeb6 100644 --- a/cms/templates/checklists.html +++ b/cms/templates/checklists.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> <%block name="title">Course Checklists @@ -30,8 +31,8 @@

    - Tools - > Course Checklists + ${_("Tools")} + > ${_("Course Checklists")}

    @@ -40,18 +41,18 @@
    -

    Current Checklists

    +

    ${_("Current Checklists")}

    -children: [] diff --git a/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml b/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml index 73a94ed941..0d93cd3c5e 100644 --- a/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/problem_with_hint.yaml @@ -46,7 +46,7 @@ metadata: enter your answer in upper or lower case, with or without quotes. \edXabox{type="custom" cfn='test_str' expect='python' hintfn='hint_fn'} - + markdown: !!null data: | @@ -92,4 +92,3 @@ data: |

    -children: [] diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml index 64e3dc062f..cf95fe8331 100644 --- a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml @@ -1,18 +1,13 @@ --- metadata: display_name: Text Input - rerandomize: never - showanswer: finished - # Note, the extra newlines are needed to make the yaml parser add blank lines instead of folding - markdown: - "A text input problem accepts a line of text from the + markdown: | + A text input problem accepts a line of text from the student, and evaluates the input for correctness based on an expected answer. - The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear. - Which US state has Lansing as its capital? @@ -23,9 +18,8 @@ metadata: Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides. [explanation] - " data: | - +

    A text input problem accepts a line of text from the @@ -46,4 +40,3 @@ data: | -children: [] diff --git a/common/lib/xmodule/xmodule/templates/sequence/with_video.yaml b/common/lib/xmodule/xmodule/templates/sequence/with_video.yaml deleted file mode 100644 index a56d44ebff..0000000000 --- a/common/lib/xmodule/xmodule/templates/sequence/with_video.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -metadata: - display_name: Sequence with Video - data_dir: a_made_up_name -data: '' -children: - - 'i4x://edx/templates/video/default' diff --git a/common/lib/xmodule/xmodule/templates/statictab/empty.yaml b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml deleted file mode 100644 index 410e1496c2..0000000000 --- a/common/lib/xmodule/xmodule/templates/statictab/empty.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -metadata: - display_name: Empty -data: "

    This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.

    " -children: [] \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/templates/video/default.yaml b/common/lib/xmodule/xmodule/templates/video/default.yaml deleted file mode 100644 index 048e7396c7..0000000000 --- a/common/lib/xmodule/xmodule/templates/video/default.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -metadata: - display_name: default -data: "" -children: [] diff --git a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml deleted file mode 100644 index 1c25b272a3..0000000000 --- a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -metadata: - display_name: Video Alpha - version: 1 -data: | - - - - - -children: [] diff --git a/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml b/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml deleted file mode 100644 index 53e9eeaae4..0000000000 --- a/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -metadata: - display_name: Word cloud -data: {} -children: [] diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 1e84174291..0f3dfa5b85 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -636,10 +636,10 @@ class CapaModuleTest(unittest.TestCase): # Expect that the problem was reset module.new_lcp.assert_called_once_with(None) - module.choose_new_seed.assert_called_once_with() def test_reset_problem_closed(self): - module = CapaFactory.create() + # pre studio default + module = CapaFactory.create(rerandomize="always") # Simulate that the problem is closed with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: @@ -900,13 +900,13 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(done=False) self.assertFalse(module.should_show_reset_button()) - # Otherwise, DO show the reset button - module = CapaFactory.create(done=True) + # pre studio default value, DO show the reset button + module = CapaFactory.create(rerandomize="always", done=True) self.assertTrue(module.should_show_reset_button()) # If survey question for capa (max_attempts = 0), # DO show the reset button - module = CapaFactory.create(max_attempts=0, done=True) + module = CapaFactory.create(rerandomize="always", max_attempts=0, done=True) self.assertTrue(module.should_show_reset_button()) def test_should_show_save_button(self): @@ -940,8 +940,8 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(max_attempts=None, rerandomize="per_student", done=True) self.assertFalse(module.should_show_save_button()) - # Otherwise, DO show the save button - module = CapaFactory.create(done=False) + # pre-studio default, DO show the save button + module = CapaFactory.create(rerandomize="always", done=False) self.assertTrue(module.should_show_save_button()) # If we're not randomizing and we have limited attempts, then we can save diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 30c8939b5b..2fe9d70627 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -156,11 +156,7 @@ class ImportTestCase(BaseCourseTestCase): child = descriptor.get_children()[0] self.assertEqual(child.lms.due, ImportTestCase.date.from_json(v)) self.assertEqual(child._inheritable_metadata, child._inherited_metadata) - self.assertEqual(2, len(child._inherited_metadata)) - self.assertLessEqual( - ImportTestCase.date.from_json(child._inherited_metadata['start']), - datetime.datetime.now(UTC()) - ) + self.assertEqual(1, len(child._inherited_metadata)) self.assertEqual(v, child._inherited_metadata['due']) # Now export and check things @@ -218,10 +214,8 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(child.lms.due, None) # pylint: disable=W0212 self.assertEqual(child._inheritable_metadata, child._inherited_metadata) - self.assertEqual(1, len(child._inherited_metadata)) - # why do these tests look in the internal structure v just calling child.start? self.assertLessEqual( - ImportTestCase.date.from_json(child._inherited_metadata['start']), + child.lms.start, datetime.datetime.now(UTC()) ) @@ -249,12 +243,7 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(descriptor.lms.due, ImportTestCase.date.from_json(course_due)) self.assertEqual(child.lms.due, ImportTestCase.date.from_json(child_due)) # Test inherited metadata. Due does not appear here (because explicitly set on child). - self.assertEqual(1, len(child._inherited_metadata)) - self.assertLessEqual( - ImportTestCase.date.from_json(child._inherited_metadata['start']), - datetime.datetime.now(UTC())) - # Test inheritable metadata. This has the course inheritable value for due. - self.assertEqual(2, len(child._inheritable_metadata)) + self.assertEqual(1, len(child._inheritable_metadata)) self.assertEqual(course_due, child._inheritable_metadata['due']) def test_is_pointer_tag(self): diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py index 9be533885c..5fe7aa2832 100644 --- a/common/lib/xmodule/xmodule/tests/test_logic.py +++ b/common/lib/xmodule/xmodule/tests/test_logic.py @@ -28,7 +28,8 @@ class LogicTest(unittest.TestCase): def setUp(self): class EmptyClass: """Empty object.""" - pass + url_name = '' + category = 'test' self.system = get_test_system() self.descriptor = EmptyClass() diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py index 6581ce58f6..a277ff2900 100644 --- a/common/lib/xmodule/xmodule/tests/test_xml_module.py +++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py @@ -137,11 +137,11 @@ class EditableMetadataFieldsTest(unittest.TestCase): type='Float', options={'min': 0, 'step': .3} ) - # Start of helper methods def get_xml_editable_fields(self, model_data): system = get_test_system() system.render_template = Mock(return_value="
    Test Template HTML
    ") + model_data['category'] = 'test' return XmlDescriptor(runtime=system, model_data=model_data).editable_metadata_fields def get_descriptor(self, model_data): @@ -179,19 +179,19 @@ class TestSerialize(unittest.TestCase): def test_serialize(self): assert_equals('null', serialize_field(None)) assert_equals('-2', serialize_field(-2)) - assert_equals('"2"', serialize_field('2')) + assert_equals('2', serialize_field('2')) assert_equals('-3.41', serialize_field(-3.41)) - assert_equals('"2.589"', serialize_field('2.589')) + assert_equals('2.589', serialize_field('2.589')) assert_equals('false', serialize_field(False)) - assert_equals('"false"', serialize_field('false')) - assert_equals('"fAlse"', serialize_field('fAlse')) - assert_equals('"hat box"', serialize_field('hat box')) - assert_equals('{"bar": "hat", "frog": "green"}', serialize_field({'bar': 'hat', 'frog' : 'green'})) + assert_equals('false', serialize_field('false')) + assert_equals('fAlse', serialize_field('fAlse')) + assert_equals('hat box', serialize_field('hat box')) + assert_equals('{"bar": "hat", "frog": "green"}', serialize_field({'bar': 'hat', 'frog': 'green'})) assert_equals('[3.5, 5.6]', serialize_field([3.5, 5.6])) assert_equals('["foo", "bar"]', serialize_field(['foo', 'bar'])) - assert_equals('"2012-12-31T23:59:59Z"', serialize_field("2012-12-31T23:59:59Z")) - assert_equals('"1 day 12 hours 59 minutes 59 seconds"', - serialize_field("1 day 12 hours 59 minutes 59 seconds")) + assert_equals('2012-12-31T23:59:59Z', serialize_field("2012-12-31T23:59:59Z")) + assert_equals('1 day 12 hours 59 minutes 59 seconds', + serialize_field("1 day 12 hours 59 minutes 59 seconds")) class TestDeserialize(unittest.TestCase): @@ -201,7 +201,6 @@ class TestDeserialize(unittest.TestCase): """ assert_equals(expected, deserialize_field(self.test_field(), arg)) - def assertDeserializeNonString(self): """ Asserts input value is returned for None or something that is not a string. diff --git a/common/lib/xmodule/xmodule/util/date_utils.py b/common/lib/xmodule/xmodule/util/date_utils.py index 50b8a9cc0f..8baa59558b 100644 --- a/common/lib/xmodule/xmodule/util/date_utils.py +++ b/common/lib/xmodule/xmodule/util/date_utils.py @@ -1,8 +1,10 @@ """ Convenience methods for working with datetime objects """ +from datetime import timedelta +from django.utils.translation import ugettext as _ + -import datetime def get_default_time_display(dt, show_timezone=True): """ Converts a datetime to a string representation. This is the default @@ -14,20 +16,21 @@ def get_default_time_display(dt, show_timezone=True): The default value of show_timezone is True. """ if dt is None: - return "" - timezone = "" + return u"" + timezone = u"" if show_timezone: if dt.tzinfo is not None: try: - timezone = " " + dt.tzinfo.tzname(dt) + timezone = u" " + dt.tzinfo.tzname(dt) except NotImplementedError: timezone = dt.strftime('%z') else: - timezone = " UTC" - return dt.strftime("%b %d, %Y at %H:%M") + timezone + timezone = u" UTC" + return unicode(dt.strftime(u"%b %d, %Y {at} %H:%M{tz}")).format( + at=_(u"at"), tz=timezone).strip() -def almost_same_datetime(dt1, dt2, allowed_delta=datetime.timedelta(minutes=1)): +def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)): """ Returns true if these are w/in a minute of each other. (in case secs saved to db or timezone aren't same) diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 3c6203107d..5354297c2b 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -21,6 +21,17 @@ log = logging.getLogger(__name__) class VideoFields(object): """Fields for `VideoModule` and `VideoDescriptor`.""" + display_name = String( + display_name="Display Name", + help="This name appears in the horizontal navigation at the top of the page.", + scope=Scope.settings, + # it'd be nice to have a useful default but it screws up other things; so, + # use display_name_with_default for those + default="Video Title" + ) + data = String(help="XML data for the problem", + default='', + scope=Scope.content) position = Integer(help="Current position in the video", scope=Scope.user_state, default=0) show_captions = Boolean(help="This controls whether or not captions are shown by default.", display_name="Show Captions", scope=Scope.settings, default=True) youtube_id_1_0 = String(help="This is the Youtube ID reference for the normal speed video.", display_name="Default Speed", scope=Scope.settings, default="OEoXaMPEzfM") @@ -86,7 +97,6 @@ class VideoDescriptor(VideoFields, MetadataOnlyEditingDescriptor, RawDescriptor): module_class = VideoModule - template_dir_name = "video" def __init__(self, *args, **kwargs): super(VideoDescriptor, self).__init__(*args, **kwargs) @@ -118,6 +128,13 @@ class VideoDescriptor(VideoFields, _parse_video_xml(video, xml_data) return video + def definition_to_xml(self, resource_fs): + """ + Override the base implementation. We don't actually have anything in the 'data' field + (it's an empty string), so we just return a simple XML element + """ + return etree.Element('video') + def _parse_video_xml(video, xml_data): """ @@ -129,6 +146,10 @@ def _parse_video_xml(video, xml_data): display_name = xml.get('display_name') if display_name: video.display_name = display_name + elif video.url_name is not None: + # copies the logic of display_name_with_default in order that studio created videos will have an + # initial non guid name + video.display_name = video.url_name.replace('_', ' ') youtube = xml.get('youtube') if youtube: diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index 3b5b90e674..d8ed8949f1 100644 --- a/common/lib/xmodule/xmodule/videoalpha_module.py +++ b/common/lib/xmodule/xmodule/videoalpha_module.py @@ -28,15 +28,27 @@ from xblock.core import Integer, Scope, String import datetime import time +import textwrap log = logging.getLogger(__name__) class VideoAlphaFields(object): """Fields for `VideoAlphaModule` and `VideoAlphaDescriptor`.""" - data = String(help="XML data for the problem", scope=Scope.content) + data = String(help="XML data for the problem", + default=textwrap.dedent('''\ + + + + + '''), + scope=Scope.content) position = Integer(help="Current position in the video", scope=Scope.user_state, default=0) - display_name = String(help="Display name for this module", scope=Scope.settings) + display_name = String( + display_name="Display Name", help="Display name for this module", + default="Video Alpha", + scope=Scope.settings + ) class VideoAlphaModule(VideoAlphaFields, XModule): @@ -167,4 +179,3 @@ class VideoAlphaModule(VideoAlphaFields, XModule): class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor): """Descriptor for `VideoAlphaModule`.""" module_class = VideoAlphaModule - template_dir_name = "videoalpha" diff --git a/common/lib/xmodule/xmodule/word_cloud_module.py b/common/lib/xmodule/xmodule/word_cloud_module.py index a7f3f92795..004e6ed320 100644 --- a/common/lib/xmodule/xmodule/word_cloud_module.py +++ b/common/lib/xmodule/xmodule/word_cloud_module.py @@ -14,7 +14,7 @@ from xmodule.raw_module import RawDescriptor from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.x_module import XModule -from xblock.core import Scope, Dict, Boolean, List, Integer +from xblock.core import Scope, Dict, Boolean, List, Integer, String log = logging.getLogger(__name__) @@ -31,6 +31,12 @@ def pretty_bool(value): class WordCloudFields(object): """XFields for word cloud.""" + display_name = String( + display_name="Display Name", + help="Display name for this module", + scope=Scope.settings, + default="Word cloud" + ) num_inputs = Integer( display_name="Inputs", help="Number of text boxes available for students to input words/sentences.", @@ -234,7 +240,7 @@ class WordCloudModule(WordCloudFields, XModule): return self.content -class WordCloudDescriptor(MetadataOnlyEditingDescriptor, RawDescriptor, WordCloudFields): +class WordCloudDescriptor(WordCloudFields, MetadataOnlyEditingDescriptor, RawDescriptor): """Descriptor for WordCloud Xmodule.""" module_class = WordCloudModule template_dir_name = 'word_cloud' diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 0f5bbf4f2e..aee8e26171 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -7,8 +7,8 @@ from lxml import etree from collections import namedtuple from pkg_resources import resource_listdir, resource_string, resource_isdir -from xmodule.modulestore import Location -from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore import inheritance, Location +from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError from xblock.core import XBlock, Scope, String, Integer, Float, ModelType @@ -101,6 +101,8 @@ class XModuleFields(object): display_name="Display Name", help="This name appears in the horizontal navigation at the top of the page.", scope=Scope.settings, + # it'd be nice to have a useful default but it screws up other things; so, + # use display_name_with_default for those default=None ) @@ -113,6 +115,14 @@ class XModuleFields(object): scope=Scope.content, default=Location(None), ) + # Please note that in order to be compatible with XBlocks more generally, + # the LMS and CMS shouldn't be using this field. It's only for internal + # consumption by the XModules themselves + category = String( + display_name="xmodule category", + help="This is the category id for the XModule. It's for internal use only", + scope=Scope.content, + ) class XModule(XModuleFields, HTMLSnippet, XBlock): @@ -148,8 +158,16 @@ class XModule(XModuleFields, HTMLSnippet, XBlock): self._model_data = model_data self.system = runtime self.descriptor = descriptor - self.url_name = self.location.name - self.category = self.location.category + # LMS tests don't require descriptor but really it's required + if descriptor: + self.url_name = descriptor.url_name + # don't need to set category as it will automatically get from descriptor + elif isinstance(self.location, Location): + self.url_name = self.location.name + if not hasattr(self, 'category'): + self.category = self.location.category + else: + raise InsufficientSpecificationError() self._loaded_children = None @property @@ -290,36 +308,67 @@ Template = namedtuple("Template", "metadata data children") class ResourceTemplates(object): + """ + Gets the templates associated w/ a containing cls. The cls must have a 'template_dir_name' attribute. + It finds the templates as directly in this directory under 'templates'. + """ @classmethod def templates(cls): """ - Returns a list of Template objects that describe possible templates that can be used - to create a module of this type. - If no templates are provided, there will be no way to create a module of - this type + Returns a list of dictionary field: value objects that describe possible templates that can be used + to seed a module of this type. Expects a class attribute template_dir_name that defines the directory inside the 'templates' resource directory to pull templates from """ templates = [] - dirname = os.path.join('templates', cls.template_dir_name) - if not resource_isdir(__name__, dirname): - log.warning("No resource directory {dir} found when loading {cls_name} templates".format( - dir=dirname, - cls_name=cls.__name__, - )) - return [] - - for template_file in resource_listdir(__name__, dirname): - if not template_file.endswith('.yaml'): - log.warning("Skipping unknown template file %s" % template_file) - continue - template_content = resource_string(__name__, os.path.join(dirname, template_file)) - template = yaml.safe_load(template_content) - templates.append(Template(**template)) + dirname = cls.get_template_dir() + if dirname is not None: + for template_file in resource_listdir(__name__, dirname): + if not template_file.endswith('.yaml'): + log.warning("Skipping unknown template file %s", template_file) + continue + template_content = resource_string(__name__, os.path.join(dirname, template_file)) + template = yaml.safe_load(template_content) + template['template_id'] = template_file + templates.append(template) return templates + @classmethod + def get_template_dir(cls): + if getattr(cls, 'template_dir_name', None): + dirname = os.path.join('templates', getattr(cls, 'template_dir_name')) + if not resource_isdir(__name__, dirname): + log.warning("No resource directory {dir} found when loading {cls_name} templates".format( + dir=dirname, + cls_name=cls.__name__, + )) + return None + else: + return dirname + else: + return None + + @classmethod + def get_template(cls, template_id): + """ + Get a single template by the given id (which is the file name identifying it w/in the class's + template_dir_name) + + """ + dirname = cls.get_template_dir() + if dirname is not None: + try: + template_content = resource_string(__name__, os.path.join(dirname, template_id)) + except IOError: + return None + template = yaml.safe_load(template_content) + template['template_id'] = template_id + return template + else: + return None + class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): """ @@ -346,9 +395,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): # be equal equality_attributes = ('_model_data', 'location') - # Name of resource directory to load templates from - template_dir_name = "default" - # Class level variable # True if this descriptor always requires recalculation of grades, for @@ -386,8 +432,12 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): """ super(XModuleDescriptor, self).__init__(*args, **kwargs) self.system = self.runtime - self.url_name = self.location.name - self.category = self.location.category + if isinstance(self.location, Location): + self.url_name = self.location.name + if not hasattr(self, 'category'): + self.category = self.location.category + else: + raise InsufficientSpecificationError() self._child_instances = None @property @@ -419,11 +469,14 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): if self._child_instances is None: self._child_instances = [] for child_loc in self.children: - try: - child = self.system.load_item(child_loc) - except ItemNotFoundError: - log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) - continue + if isinstance(child_loc, XModuleDescriptor): + child = child_loc + else: + try: + child = self.system.load_item(child_loc) + except ItemNotFoundError: + log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) + continue self._child_instances.append(child) return self._child_instances @@ -591,6 +644,13 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): """ return [('{}', '{}')] + @property + def xblock_kvs(self): + """ + Use w/ caution. Really intended for use by the persistence layer. + """ + return self._model_data._kvs + # =============================== BUILTIN METHODS ========================== def __eq__(self, other): eq = (self.__class__ == other.__class__ and diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index c1340a9fc0..882e308c77 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -81,10 +81,14 @@ class AttrMap(_AttrMapBase): def serialize_field(value): """ - Return a string version of the value (where value is the JSON-formatted, internally stored value). + Return a string version of the value (where value is the JSON-formatted, internally stored value). + + If the value is a string, then we simply return what was passed in. + Otherwise, we return json.dumps on the input value. + """ + if isinstance(value, basestring): + return value - By default, this is the result of calling json.dumps on the input value. - """ return json.dumps(value, cls=EdxJSONEncoder) @@ -126,7 +130,7 @@ class XmlDescriptor(XModuleDescriptor): """ xml_attributes = Dict(help="Map of unhandled xml attributes, used only for storage between import and export", - default={}, scope=Scope.settings) + default={}, scope=Scope.settings) # Extension to append to filename paths filename_extension = 'xml' @@ -141,23 +145,23 @@ class XmlDescriptor(XModuleDescriptor): # understand? And if we do, is this the place? # Related: What's the right behavior for clean_metadata? metadata_attributes = ('format', 'graceperiod', 'showanswer', 'rerandomize', - 'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc', - 'ispublic', # if True, then course is listed for all users; see - 'xqa_key', # for xqaa server access - 'giturl', # url of git server for origin of file - # information about testcenter exams is a dict (of dicts), not a string, - # so it cannot be easily exportable as a course element's attribute. - 'testcenter_info', - # VS[compat] Remove once unused. - 'name', 'slug') + 'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc', + 'ispublic', # if True, then course is listed for all users; see + 'xqa_key', # for xqaa server access + 'giturl', # url of git server for origin of file + # information about testcenter exams is a dict (of dicts), not a string, + # so it cannot be easily exportable as a course element's attribute. + 'testcenter_info', + # VS[compat] Remove once unused. + 'name', 'slug') metadata_to_strip = ('data_dir', - 'tabs', 'grading_policy', 'published_by', 'published_date', - 'discussion_blackouts', 'testcenter_info', - # VS[compat] -- remove the below attrs once everything is in the CMS - 'course', 'org', 'url_name', 'filename', - # Used for storing xml attributes between import and export, for roundtrips - 'xml_attributes') + 'tabs', 'grading_policy', 'published_by', 'published_date', + 'discussion_blackouts', 'testcenter_info', + # VS[compat] -- remove the below attrs once everything is in the CMS + 'course', 'org', 'url_name', 'filename', + # Used for storing xml attributes between import and export, for roundtrips + 'xml_attributes') metadata_to_export_to_policy = ('discussion_topics') @@ -166,7 +170,7 @@ class XmlDescriptor(XModuleDescriptor): for field in set(cls.fields + cls.lms.fields): if field.name == attr: from_xml = lambda val: deserialize_field(field, val) - to_xml = lambda val : serialize_field(val) + to_xml = lambda val: serialize_field(val) return AttrMap(from_xml, to_xml) return AttrMap() @@ -254,7 +258,7 @@ class XmlDescriptor(XModuleDescriptor): definition, children = cls.definition_from_xml(definition_xml, system) if definition_metadata: definition['definition_metadata'] = definition_metadata - definition['filename'] = [ filepath, filename ] + definition['filename'] = [filepath, filename] return definition, children @@ -280,7 +284,6 @@ class XmlDescriptor(XModuleDescriptor): metadata[attr] = attr_map.from_xml(val) return metadata - @classmethod def apply_policy(cls, metadata, policy): """ @@ -353,6 +356,7 @@ class XmlDescriptor(XModuleDescriptor): if key not in set(f.name for f in cls.fields + cls.lms.fields): model_data['xml_attributes'][key] = value model_data['location'] = location + model_data['category'] = xml_object.tag return cls( system, @@ -374,7 +378,6 @@ class XmlDescriptor(XModuleDescriptor): """ return True - def export_to_xml(self, resource_fs): """ Returns an xml string representing this module, and all modules diff --git a/common/static/css/pdfviewer.css b/common/static/css/pdfviewer.css index 656bc47c29..8b0253261b 100644 --- a/common/static/css/pdfviewer.css +++ b/common/static/css/pdfviewer.css @@ -100,7 +100,7 @@ select { .toolbar { /* position: absolute; */ left: 0; - right: 0; + right: 0; height: 32px; z-index: 9999; cursor: default; @@ -185,6 +185,7 @@ select { margin: 0; } +.splitToolbarButton > .toolbarButton, /*added */ .splitToolbarButton:hover > .toolbarButton, .splitToolbarButton:focus > .toolbarButton, .splitToolbarButton.toggled > .toolbarButton, diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss index 64248734c3..d3ead53b25 100644 --- a/common/static/sass/_mixins.scss +++ b/common/static/sass/_mixins.scss @@ -26,7 +26,6 @@ @include size($size); } - // ==================== // mixins - placeholder styling @@ -44,78 +43,45 @@ // ==================== -// extends - layout - -// used for page/view-level wrappers (for centering/grids) -.wrapper { +// extends - UI - used for page/view-level wrappers (for centering/grids) +.ui-wrapper { @include clearfix(); @include box-sizing(border-box); width: 100%; } -// removes list styling/spacing when using uls, ols for navigation and less content-centric cases -.no-list { - list-style: none; - margin: 0; - padding: 0; - text-indent: 0; - - li { - margin: 0; - padding: 0; - } +// extends - UI - window +.ui-window { + @include clearfix(); + border-radius: 3px; + box-shadow: 0 1px 1px $shadow-l1; + margin-bottom: $baseline; + border: 1px solid $gray-l2; + background: $white; } -// extends - image-replacement hidden text -.text-hide { - text-indent: 100%; - white-space: nowrap; - overflow: hidden; -} - -// extends - hidden elems - screenreaders -.text-sr { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -// extends - wrapping -.text-wrap { - text-wrap: wrap; - white-space: pre-wrap; - white-space: -moz-pre-wrap; - word-wrap: break-word; -} - -// extends - visual link -.fake-link { +// extends - UI - visual link +.ui-fake-link { cursor: pointer; } -// extends - functional disable -.disabled { +// extends - UI - functional disable +.ui-disabled { pointer-events: none; outline: none; } -// extends - depth levels -.depth0 { z-index: 0; } -.depth1 { z-index: 10; } -.depth2 { z-index: 100; } -.depth3 { z-index: 1000; } -.depth4 { z-index: 10000; } -.depth5 { z-index: 100000; } +// extends - UI - depth levels +.ui-depth0 { z-index: 0; } +.ui-depth1 { z-index: 10; } +.ui-depth2 { z-index: 100; } +.ui-depth3 { z-index: 1000; } +.ui-depth4 { z-index: 10000; } +.ui-depth5 { z-index: 100000; } -// ==================== -// extends - buttons -.btn { +// extends - UI - buttons +.ui-btn { @include box-sizing(border-box); @include transition(color 0.25s ease-in-out 0s, border-color 0.25s ease-in-out 0s, background 0.25s ease-in-out 0s, box-shadow 0.25s ease-in-out 0s); display: inline-block; @@ -139,18 +105,18 @@ } // pill button -.btn-pill { +.ui-btn-pill { border-radius: ($baseline/5); } -.btn-rounded { +.ui-btn-rounded { border-radius: ($baseline/2); } // primary button -.btn-primary { - @extend .btn; - @extend .btn-pill; +.ui-btn-primary { + @extend .ui-btn; + @extend .ui-btn-pill; padding:($baseline/2) $baseline; border-width: 1px; border-style: solid; @@ -171,9 +137,9 @@ } // secondary button -.btn-secondary { - @extend .btn; - @extend .btn-pill; +.ui-btn-secondary { + @extend .ui-btn; + @extend .ui-btn-pill; border-width: 1px; border-style: solid; padding:($baseline/2) $baseline; @@ -190,7 +156,7 @@ } } -.btn-flat-outline { +.ui-btn-flat-outline { @extend .t-action4; @include transition(all .15s); font-weight: 600; @@ -217,7 +183,7 @@ } // button with no button shell until hover for understated actions -.btn-non { +.ui-btn-non { @include transition(all .15s); border: none; border-radius: ($baseline/4); @@ -232,12 +198,62 @@ } span { - @extend .text-sr; + @extend .cont-text-sr; } } -// UI archetypes - well +// extends - UI archetypes - well .ui-well { box-shadow: inset 0 1px 2px 1px $shadow; padding: ($baseline*0.75); } + +// ==================== + +// extends - content - removes list styling/spacing when using uls, ols for navigation and less content-centric cases +.cont-no-list { + list-style: none; + margin: 0; + padding: 0; + text-indent: 0; + + li { + margin: 0; + padding: 0; + } +} + +// extends - content - image-replacement hidden text +.cont-text-hide { + text-indent: 100%; + white-space: nowrap; + overflow: hidden; +} + +// extends - content - hidden elems - screenreaders +.cont-text-sr { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +// extends - content - wrapping +.cont-text-wrap { + text-wrap: wrap; + white-space: pre-wrap; + white-space: -moz-pre-wrap; + word-wrap: break-word; +} + +// extends - content - text overflow by ellipsis +.cont-truncated { + @include box-sizing(border-box); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/common/templates/mathjax_include.html b/common/templates/mathjax_include.html index 803f2145a4..0ddbd68eee 100644 --- a/common/templates/mathjax_include.html +++ b/common/templates/mathjax_include.html @@ -33,4 +33,4 @@ - + diff --git a/common/test/data/simple/course.xml b/common/test/data/simple/course.xml index c9bb5ec8b2..529528ca0a 100644 --- a/common/test/data/simple/course.xml +++ b/common/test/data/simple/course.xml @@ -1,13 +1,13 @@ -