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/AUTHORS b/AUTHORS index 70af9f318d..89fc2d959b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -81,3 +81,4 @@ Felix Sun Adam Palay Ian Hoover Mukul Goyal +Robert Marks diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4d117a9c73..468db0607c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,10 +5,13 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. + Common: Added *experimental* support for jsinput type. Common: Added setting to specify Celery Broker vhost +Common: Utilize new XBlock bulk save API in LMS and CMS. + Studio: Add table for tracking course creator permissions (not yet used). Update rake django-admin[syncdb] and rake django-admin[migrate] so they run for both LMS and CMS. @@ -21,6 +24,8 @@ Studio: Added support for uploading and managing PDF textbooks Common: Student information is now passed to the tracking log via POST instead of GET. +Blades: Added functionality and tests for new capa input type: choicetextresponse. + Common: Add tests for documentation generation to test suite Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems @@ -43,6 +48,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/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index 3767144c99..021589df99 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -10,6 +10,7 @@ Feature: Course checklists Then I can check and uncheck tasks in a checklist And They are correctly selected after I reload the page + @skip Scenario: A task can link to a location within Studio Given I have opened Checklists When I select a link to the course outline diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index cb24af47e0..7d52124310 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -208,8 +208,9 @@ 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', - '.xmodule_VideoModule' + 'video', + '.xmodule_VideoModule', + has_multiple_templates=False ) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 43164f62be..2b206e4466 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -7,10 +7,16 @@ from terrain.steps import reload_the_page @world.absorb -def create_component_instance(step, component_button_css, instance_id, expected_css): - click_new_component_button(step, component_button_css) - click_component_from_menu(instance_id, expected_css) +def create_component_instance(step, component_button_css, category, + expected_css, boilerplate=None, + has_multiple_templates=True): + click_new_component_button(step, component_button_css) + + if has_multiple_templates: + click_component_from_menu(category, boilerplate, expected_css) + + assert_equal(1, len(world.css_find(expected_css))) @world.absorb def click_new_component_button(step, component_button_css): @@ -19,7 +25,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,12 +33,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(1, len(world.css_find(expected_css))) + assert_equal(len(elements), 1) + world.css_click(elem_css) @world.absorb diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index ae3da3c458..13927a7d89 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -8,8 +8,9 @@ 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', - '.xmodule_DiscussionModule' + 'discussion', + '.xmodule_DiscussionModule', + has_multiple_templates=False ) @@ -17,14 +18,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..565a35f802 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') @@ -169,7 +170,8 @@ def edit_latex_source(step): @step('my change to the High Level Source is persisted') def high_level_source_persisted(step): def verify_text(driver): - return world.css_text('.problem') == 'hi' + css_sel = '.problem div>span' + return world.css_text(css_sel) == 'hi' world.wait_for(verify_text) @@ -203,7 +205,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/dump_course_structure.py b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py new file mode 100644 index 0000000000..d9b7c55cbd --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py @@ -0,0 +1,55 @@ +from django.core.management.base import BaseCommand, CommandError +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from json import dumps +from xmodule.modulestore.inheritance import own_metadata +from django.conf import settings + +filter_list = ['xml_attributes', 'checklists'] + + +class Command(BaseCommand): + help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized + in a JSON format. This can be used for analytics.''' + + def handle(self, *args, **options): + if len(args) < 2 or len(args) > 3: + raise CommandError("dump_course_structure requires two or more arguments: ||") + + course_id = args[0] + outfile = args[1] + + # use a user-specified database name, if present + # this is useful for doing dumps from databases restored from prod backups + if len(args) == 3: + settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2] + + loc = CourseDescriptor.id_to_location(course_id) + + store = modulestore() + + course = None + try: + course = store.get_item(loc, depth=4) + except: + print 'Could not find course at {0}'.format(course_id) + return + + info = {} + + def dump_into_dict(module, info): + filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems() + if key not in filter_list) + info[module.location.url()] = { + 'category': module.location.category, + 'children': module.children if hasattr(module, 'children') else [], + 'metadata': filtered_metadata + } + + for child in module.get_children(): + dump_into_dict(child, info) + + dump_into_dict(course, info) + + with open(outfile, 'w') as f: + f.write(dumps(info)) 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_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 99ffb8678d..02999f6567 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -46,6 +46,8 @@ class ChecklistTestCase(CourseTestCase): # Now delete the checklists from the course and verify they get repopulated (for courses # created before checklists were introduced). self.course.checklists = None + # Save the changed `checklists` to the underlying KeyValueStore before updating the modulestore + self.course.save() modulestore = get_modulestore(self.course.location) modulestore.update_metadata(self.course.location, own_metadata(self.course)) self.assertEqual(self.get_persisted_checklists(), None) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index be122fa1a4..ce7e886220 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 @@ -88,6 +87,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.user.is_active = True # Staff has access to view all courses self.user.is_staff = True + + # Save the data that we've just changed to the db. self.user.save() self.client = Client() @@ -118,6 +119,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course.advanced_modules = component_types + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course.save() + store.update_metadata(course.location, own_metadata(course)) # just pick one vertical @@ -135,7 +140,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha', 'Word cloud', 'Annotation', - 'Open Ended Response', + 'Open Response Assessment', 'Peer Grading Interface']) def test_advanced_components_require_two_clicks(self): @@ -183,7 +188,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 +220,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,13 +238,16 @@ 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) self.assertNotIn('graceperiod', own_metadata(html_module)) html_module.lms.graceperiod = new_graceperiod + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + html_module.save() self.assertIn('graceperiod', own_metadata(html_module)) self.assertEqual(html_module.lms.graceperiod, new_graceperiod) @@ -255,7 +263,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 +286,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 +317,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 +381,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 +584,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 +623,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 +661,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 +696,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 +810,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') @@ -846,6 +892,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # add a bool piece of unknown metadata so we can verify we don't throw an exception metadata['new_metadata'] = True + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course.save() module_store.update_metadata(location, metadata) print 'Exporting to tempdir = {0}'.format(root_dir) @@ -885,7 +934,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 +1077,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 +1102,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 +1242,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 +1265,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 +1285,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 +1302,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) @@ -1263,6 +1311,7 @@ class ContentStoreTest(ModuleStoreTestCase): # now let's define an override at the leaf node level # new_module.lms.graceperiod = timedelta(1) + new_module.save() module_store.update_metadata(new_module.location, own_metadata(new_module)) # flush the cache and refetch @@ -1271,29 +1320,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..0862eb462d 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") @@ -291,6 +290,71 @@ class CourseGradingTest(CourseTestCase): altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") + def test_update_cutoffs_from_json(self): + test_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + # Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json + # simply returns the cutoffs you send into it, rather than returning the db contents. + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update") + + test_grader.grade_cutoffs['D'] = 0.3 + CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D") + + test_grader.grade_cutoffs['Pass'] = 0.75 + CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'") + + def test_delete_grace_period(self): + test_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period) + # update_grace_period_from_json doesn't return anything, so query the db for its contents. + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update") + + test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30} + CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period) + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period") + + test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0} + # Now delete the grace period + CourseGradingModel.delete_grace_period(test_grader.course_location) + # update_grace_period_from_json doesn't return anything, so query the db for its contents. + altered_grader = CourseGradingModel.fetch(self.course.location) + # Once deleted, the grace period should simply be None + self.assertEqual(None, altered_grader.grace_period, "Delete grace period") + + def test_update_section_grader_type(self): + # Get the descriptor and the section_grader_type and assert they are the default values + descriptor = get_modulestore(self.course.location).get_item(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + + self.assertEqual('Not Graded', section_grader_type['graderType']) + self.assertEqual(None, descriptor.lms.format) + self.assertEqual(False, descriptor.lms.graded) + + # Change the default grader type to Homework, which should also mark the section as graded + CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'}) + descriptor = get_modulestore(self.course.location).get_item(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + + self.assertEqual('Homework', section_grader_type['graderType']) + self.assertEqual('Homework', descriptor.lms.format) + self.assertEqual(True, descriptor.lms.graded) + + # Change the grader type back to Not Graded, which should also unmark the section as graded + CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'}) + descriptor = get_modulestore(self.course.location).get_item(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + + self.assertEqual('Not Graded', section_grader_type['graderType']) + self.assertEqual(None, descriptor.lms.format) + self.assertEqual(False, descriptor.lms.graded) + class CourseMetadataEditingTest(CourseTestCase): """ @@ -352,7 +416,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_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index 4f92806871..30114496c8 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -36,8 +36,11 @@ class CourseUpdateTest(CourseTestCase): 'provided_id': payload['id']}) content += '
div

p

' payload['content'] = content + # POST requests were coming in w/ these header values causing an error; so, repro error here resp = self.client.post(first_update_url, json.dumps(payload), - "application/json") + "application/json", + HTTP_X_HTTP_METHOD_OVERRIDE="PUT", + REQUEST_METHOD="POST") self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div") diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py new file mode 100644 index 0000000000..84643f7787 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -0,0 +1,186 @@ +''' +Created on May 7, 2013 + +@author: dmitchell +''' +import unittest +from xmodule import templates +from xmodule.modulestore.tests import persistent_factories +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.seq_module import SequenceDescriptor +from xmodule.x_module import XModuleDescriptor +from xmodule.capa_module import CapaDescriptor +from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.html_module import HtmlDescriptor + + +class TemplateTests(unittest.TestCase): + """ + Test finding and using the templates (boilerplates) for xblocks. + """ + + def test_get_templates(self): + found = templates.all_templates() + self.assertIsNotNone(found.get('course')) + self.assertIsNotNone(found.get('about')) + self.assertIsNotNone(found.get('html')) + self.assertIsNotNone(found.get('problem')) + self.assertEqual(len(found.get('course')), 0) + self.assertEqual(len(found.get('about')), 1) + self.assertGreaterEqual(len(found.get('html')), 2) + self.assertGreaterEqual(len(found.get('problem')), 10) + dropdown = None + for template in found['problem']: + self.assertIn('metadata', template) + self.assertIn('display_name', template['metadata']) + if template['metadata']['display_name'] == 'Dropdown': + dropdown = template + break + self.assertIsNotNone(dropdown) + self.assertIn('markdown', dropdown['metadata']) + self.assertIn('data', dropdown) + self.assertRegexpMatches(dropdown['metadata']['markdown'], r'^Dropdown.*') + self.assertRegexpMatches(dropdown['data'], r'\s*

Dropdown.*') + + def test_get_some_templates(self): + self.assertEqual(len(SequenceDescriptor.templates()), 0) + self.assertGreater(len(HtmlDescriptor.templates()), 0) + self.assertIsNone(SequenceDescriptor.get_template('doesntexist.yaml')) + self.assertIsNone(HtmlDescriptor.get_template('doesntexist.yaml')) + self.assertIsNotNone(HtmlDescriptor.get_template('announcement.yaml')) + + def test_factories(self): + test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse', + display_name='fun test course', user_id='testbot') + self.assertIsInstance(test_course, CourseDescriptor) + self.assertEqual(test_course.display_name, 'fun test course') + index_info = modulestore('split').get_course_index_info(test_course.location) + self.assertEqual(index_info['org'], 'testx') + self.assertEqual(index_info['prettyid'], 'tempcourse') + + test_chapter = persistent_factories.ItemFactory.create(display_name='chapter 1', + parent_location=test_course.location) + self.assertIsInstance(test_chapter, SequenceDescriptor) + # refetch parent which should now point to child + test_course = modulestore('split').get_course(test_chapter.location) + self.assertIn(test_chapter.location.usage_id, test_course.children) + + def test_temporary_xblocks(self): + """ + Test using load_from_json to create non persisted xblocks + """ + test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse', + display_name='fun test course', user_id='testbot') + + test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter', + 'metadata': {'display_name': 'chapter n'}}, + test_course.system, parent_xblock=test_course) + self.assertIsInstance(test_chapter, SequenceDescriptor) + self.assertEqual(test_chapter.display_name, 'chapter n') + self.assertIn(test_chapter, test_course.get_children()) + + # test w/ a definition (e.g., a problem) + test_def_content = 'boo' + test_problem = XModuleDescriptor.load_from_json({'category': 'problem', + 'definition': {'data': test_def_content}}, + test_course.system, parent_xblock=test_chapter) + self.assertIsInstance(test_problem, CapaDescriptor) + self.assertEqual(test_problem.data, test_def_content) + self.assertIn(test_problem, test_chapter.get_children()) + test_problem.display_name = 'test problem' + self.assertEqual(test_problem.display_name, 'test problem') + + def test_persist_dag(self): + """ + try saving temporary xblocks + """ + test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse', + display_name='fun test course', user_id='testbot') + test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter', + 'metadata': {'display_name': 'chapter n'}}, + test_course.system, parent_xblock=test_course) + test_def_content = 'boo' + test_problem = XModuleDescriptor.load_from_json({'category': 'problem', + 'definition': {'data': test_def_content}}, + test_course.system, parent_xblock=test_chapter) + # better to pass in persisted parent over the subdag so + # subdag gets the parent pointer (otherwise 2 ops, persist dag, update parent children, + # persist parent + persisted_course = modulestore('split').persist_xblock_dag(test_course, 'testbot') + self.assertEqual(len(persisted_course.children), 1) + persisted_chapter = persisted_course.get_children()[0] + self.assertEqual(persisted_chapter.category, 'chapter') + self.assertEqual(persisted_chapter.display_name, 'chapter n') + self.assertEqual(len(persisted_chapter.children), 1) + persisted_problem = persisted_chapter.get_children()[0] + self.assertEqual(persisted_problem.category, 'problem') + self.assertEqual(persisted_problem.data, test_def_content) + + def test_delete_course(self): + test_course = persistent_factories.PersistentCourseFactory.create( + org='testx', + prettyid='edu.harvard.history.doomed', + display_name='doomed test course', + user_id='testbot') + persistent_factories.ItemFactory.create(display_name='chapter 1', + parent_location=test_course.location) + + id_locator = CourseLocator(course_id=test_course.location.course_id, revision='draft') + guid_locator = CourseLocator(version_guid=test_course.location.version_guid) + # verify it can be retireved by id + self.assertIsInstance(modulestore('split').get_course(id_locator), CourseDescriptor) + # and by guid + self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor) + modulestore('split').delete_course(id_locator.course_id) + # test can no longer retrieve by id + self.assertRaises(ItemNotFoundError, modulestore('split').get_course, id_locator) + # but can by guid + self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor) + + def test_block_generations(self): + """ + Test get_block_generations + """ + test_course = persistent_factories.PersistentCourseFactory.create( + org='testx', + prettyid='edu.harvard.history.hist101', + display_name='history test course', + user_id='testbot') + chapter = persistent_factories.ItemFactory.create(display_name='chapter 1', + parent_location=test_course.location, user_id='testbot') + sub = persistent_factories.ItemFactory.create(display_name='subsection 1', + parent_location=chapter.location, user_id='testbot', category='vertical') + first_problem = persistent_factories.ItemFactory.create(display_name='problem 1', + parent_location=sub.location, user_id='testbot', category='problem', data="") + first_problem.max_attempts = 3 + updated_problem = modulestore('split').update_item(first_problem, 'testbot') + updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot') + + second_problem = persistent_factories.ItemFactory.create(display_name='problem 2', + parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id), + user_id='testbot', category='problem', data="") + + # course root only updated 2x + version_history = modulestore('split').get_block_generations(test_course.location) + self.assertEqual(version_history.locator.version_guid, test_course.location.version_guid) + self.assertEqual(len(version_history.children), 1) + self.assertEqual(version_history.children[0].children, []) + self.assertEqual(version_history.children[0].locator.version_guid, chapter.location.version_guid) + + # sub changed on add, add problem, delete problem, add problem in strict linear seq + version_history = modulestore('split').get_block_generations(sub.location) + self.assertEqual(len(version_history.children), 1) + self.assertEqual(len(version_history.children[0].children), 1) + self.assertEqual(len(version_history.children[0].children[0].children), 1) + self.assertEqual(len(version_history.children[0].children[0].children[0].children), 0) + + # first and second problem may show as same usage_id; so, need to ensure their histories are right + version_history = modulestore('split').get_block_generations(updated_problem.location) + self.assertEqual(version_history.locator.version_guid, first_problem.location.version_guid) + self.assertEqual(len(version_history.children), 1) # updated max_attempts + self.assertEqual(len(version_history.children[0].children), 0) + + version_history = modulestore('split').get_block_generations(second_problem.location) + self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid) 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..578b82b3cf 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -1,6 +1,11 @@ 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 +import datetime +from pytz import UTC class DeleteItem(CourseTestCase): @@ -11,14 +16,228 @@ 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': 'sequential' + }), + content_type="application/json" + ) + self.seq_location = self.response_id(resp) + # create problem w/ boilerplate + template_id = 'multiplechoice.yaml' + resp = self.client.post( + reverse('create_item'), + json.dumps({'parent_location': self.seq_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) + + def test_date_fields(self): + """ + Test setting due & start dates on sequential + """ + sequential = modulestore().get_item(self.seq_location) + self.assertIsNone(sequential.lms.due) + self.client.post( + reverse('save_item'), + json.dumps({ + 'id': self.seq_location, + 'metadata': {'due': '2010-11-22T04:00Z'} + }), + content_type="application/json" + ) + sequential = modulestore().get_item(self.seq_location) + self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) + self.client.post( + reverse('save_item'), + json.dumps({ + 'id': self.seq_location, + 'metadata': {'start': '2010-09-12T14:00Z'} + }), + content_type="application/json" + ) + sequential = modulestore().get_item(self.seq_location) + self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) + self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) + diff --git a/cms/djangoapps/contentstore/tests/test_textbooks.py b/cms/djangoapps/contentstore/tests/test_textbooks.py index 02c64e9413..a21a1b1023 100644 --- a/cms/djangoapps/contentstore/tests/test_textbooks.py +++ b/cms/djangoapps/contentstore/tests/test_textbooks.py @@ -62,6 +62,9 @@ class TextbookIndexTestCase(CourseTestCase): } ] self.course.pdf_textbooks = content + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + self.course.save() store = get_modulestore(self.course.location) store.update_metadata(self.course.location, own_metadata(self.course)) @@ -220,6 +223,9 @@ class TextbookByIdTestCase(CourseTestCase): 'tid': 2, }) self.course.pdf_textbooks = [self.textbook1, self.textbook2] + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + self.course.save() self.store = get_modulestore(self.course.location) self.store.update_metadata(self.course.location, own_metadata(self.course)) self.url_nonexist = reverse('textbook_by_id', kwargs={ 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/assets.py b/cms/djangoapps/contentstore/views/assets.py index d0b202da19..0bb9551ac9 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -13,7 +13,7 @@ from django_future.csrf import ensure_csrf_cookie from django.core.urlresolvers import reverse from django.core.servers.basehttp import FileWrapper from django.core.files.temp import NamedTemporaryFile -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_http_methods from mitxmako.shortcuts import render_to_response from cache_toolbox.core import del_cached_content @@ -249,6 +249,7 @@ def remove_asset(request, org, course, name): @ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) @login_required def import_course(request, org, course, name): """ @@ -256,7 +257,7 @@ def import_course(request, org, course, name): """ location = get_location_and_verify_access(request, org, course, name) - if request.method == 'POST': + if request.method in ('POST', 'PUT'): filename = request.FILES['course-data'].name if not filename.endswith('.tar.gz'): 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..1be6ac2822 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 }) @@ -227,6 +245,7 @@ def edit_unit(request, location): @expect_json @login_required +@require_http_methods(("GET", "POST", "PUT")) @ensure_csrf_cookie def assignment_type_update(request, org, course, category, name): ''' @@ -238,7 +257,7 @@ def assignment_type_update(request, org, course, category, name): if request.method == 'GET': return JsonResponse(CourseGradingModel.get_section_grader_type(location)) - elif request.method == 'POST': # post or put, doesn't matter. + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST)) @@ -253,7 +272,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..02eb4c65b8 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1,10 +1,9 @@ """ Views related to operations on course objects """ -#pylint: disable=W0402 import json import random -import string +import string # pylint: disable=W0402 from django.contrib.auth.decorators import login_required from django_future.csrf import ensure_csrf_cookie @@ -43,8 +42,8 @@ from .component import ( ADVANCED_COMPONENT_POLICY_KEY) 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 +81,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 +98,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 +115,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) @@ -179,6 +175,7 @@ def course_info(request, org, course, name, provided_id=None): @expect_json +@require_http_methods(("GET", "POST", "PUT", "DELETE")) @login_required @ensure_csrf_cookie def course_info_updates(request, org, course, provided_id=None): @@ -209,7 +206,7 @@ def course_info_updates(request, org, course, provided_id=None): except: return HttpResponseBadRequest("Failed to delete", content_type="text/plain") - elif request.method == 'POST': + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: return JsonResponse(update_course_updates(location, request.POST, provided_id)) except: @@ -303,7 +300,7 @@ def course_settings_updates(request, org, course, name, section): if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder) - elif request.method == 'POST': # post or put, doesn't matter. + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder) @@ -482,7 +479,7 @@ def textbook_index(request, org, course, name): if request.is_ajax(): if request.method == 'GET': return JsonResponse(course_module.pdf_textbooks) - elif request.method == 'POST': + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: textbooks = validate_textbooks_json(request.body) except TextbookValidationError as err: @@ -498,6 +495,9 @@ def textbook_index(request, org, course, name): if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs): course_module.tabs.append({"type": "pdf_textbooks"}) course_module.pdf_textbooks = textbooks + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) return JsonResponse(course_module.pdf_textbooks) else: @@ -544,6 +544,9 @@ def create_textbook(request, org, course, name): tabs = course_module.tabs tabs.append({"type": "pdf_textbooks"}) course_module.tabs = tabs + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) resp = JsonResponse(textbook, status=201) resp["Location"] = reverse("textbook_by_id", kwargs={ @@ -577,7 +580,7 @@ def textbook_by_id(request, org, course, name, tid): if not textbook: return JsonResponse(status=404) return JsonResponse(textbook) - elif request.method in ('POST', 'PUT'): + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other try: new_textbook = validate_textbook_json(request.body) except TextbookValidationError as err: @@ -587,10 +590,13 @@ def textbook_by_id(request, org, course, name, tid): i = course_module.pdf_textbooks.index(textbook) new_textbooks = course_module.pdf_textbooks[0:i] new_textbooks.append(new_textbook) - new_textbooks.extend(course_module.pdf_textbooks[i+1:]) + new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) course_module.pdf_textbooks = new_textbooks else: course_module.pdf_textbooks.append(new_textbook) + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) return JsonResponse(new_textbook, status=201) elif request.method == 'DELETE': @@ -598,7 +604,8 @@ def textbook_by_id(request, org, course, name, tid): return JsonResponse(status=404) i = course_module.pdf_textbooks.index(textbook) new_textbooks = course_module.pdf_textbooks[0:i] - new_textbooks.extend(course_module.pdf_textbooks[i+1:]) + new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) course_module.pdf_textbooks = new_textbooks + course_module.save() store.update_metadata(course_module.location, own_metadata(course_module)) return JsonResponse() diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index abc5f48564..efebded9b9 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,59 +52,98 @@ 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', []): + # [dhm] see comment on _get_xblock_field + _get_xblock_field(existing_item, metadata_key).write_to(existing_item, 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(): + # [dhm] see comment on _get_xblock_field + field = _get_xblock_field(existing_item, metadata_key) - 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: + field.delete_from(existing_item) else: - existing_item._model_data[metadata_key] = value - + value = field.from_json(value) + field.write_to(existing_item, value) + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + existing_item.save() # 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() +# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level +# fields in xblocks rather than requiring dereference through namespace but we'll need to consider whether there are +# plausible use cases for distinct fields w/ same name in different namespaces on the same blocks. +# The idea is that consumers of the xblock, and particularly the web client, shouldn't know about our internal +# representation (namespaces as means of decorating all modules). +# Given top-level access, the calls can simply be setattr(existing_item, field, value) ... +# Really, this method should be elsewhere (e.g., xblock). We also need methods for has_value (v is_default)... +def _get_xblock_field(xblock, field_name): + """ + A temporary function to get the xblock field either from the xblock or one of its namespaces by name. + :param xblock: + :param field_name: + """ + def find_field(fields): + for field in fields: + if field.name == field_name: + return field + + found = find_field(xblock.fields) + if found: + return found + for namespace in xblock.namespaces: + found = find_field(getattr(xblock, namespace).fields) + if found: + return found + + @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/preview.py b/cms/djangoapps/contentstore/views/preview.py index ba393e72f4..35af3e9ac3 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response -from xmodule_modifiers import replace_static_urls, wrap_xmodule +from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module # pylint: disable=F0401 from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str from xmodule.exceptions import NotFoundError, ProcessingError @@ -47,6 +47,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None): # Let the module handle the AJAX try: ajax_return = instance.handle_ajax(dispatch, request.POST) + # Save any module data that has changed to the underlying KeyValueStore + instance.save() except NotFoundError: log.exception("Module indicating to user that request doesn't exist") @@ -166,6 +168,11 @@ def load_preview_module(request, preview_id, descriptor): course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None]) ) + module.get_html = save_module( + module.get_html, + module + ) + return module diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index a7b232e92a..d55932e33d 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): @@ -76,6 +76,9 @@ def reorder_static_tabs(request): # OK, re-assemble the static tabs in the new order course.tabs = reordered_tabs + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course.save() modulestore('direct').update_metadata(course.location, own_metadata(course)) return HttpResponse() @@ -127,7 +130,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 f8e341f2cd..c73afc9d68 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -2,12 +2,12 @@ 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.views.decorators.http import require_POST from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response from django.core.context_processors import csrf -from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from contentstore.utils import get_url_reverse, get_lms_link_for_item from util.json_request import expect_json, JsonResponse @@ -29,6 +29,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 != '' @@ -36,7 +37,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)) @@ -49,7 +49,6 @@ def index(request): @require_POST -@ensure_csrf_cookie @login_required def request_course_creator(request): """ @@ -94,7 +93,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) @@ -108,7 +107,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) @@ -116,7 +115,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) @@ -145,7 +144,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_details.py b/cms/djangoapps/models/settings/course_details.py index 8ce8c2db34..7c3b883283 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -122,6 +122,10 @@ class CourseDetails(object): descriptor.enrollment_end = converted if dirty: + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() + get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 4ea9f2f5db..0746fc7a90 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -7,9 +7,12 @@ class CourseGradingModel(object): """ Basically a DAO and Model combo for CRUD operations pertaining to grading policy. """ + # Within this class, allow access to protected members of client classes. + # This comes up when accessing kvs data and caches during kvs saves and modulestore writes. + # pylint: disable=W0212 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,15 +84,18 @@ 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']] descriptor.raw_grader = graders_parsed descriptor.grade_cutoffs = jsondict['grade_cutoffs'] - get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() + 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) @@ -116,6 +122,9 @@ class CourseGradingModel(object): else: descriptor.raw_grader.append(grader) + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) @@ -131,6 +140,10 @@ class CourseGradingModel(object): descriptor = get_modulestore(course_location).get_item(course_location) descriptor.grade_cutoffs = cutoffs + + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return cutoffs @@ -156,6 +169,10 @@ class CourseGradingModel(object): descriptor = get_modulestore(course_location).get_item(course_location) descriptor.lms.graceperiod = grace_timedelta + + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata) @staticmethod @@ -172,23 +189,12 @@ class CourseGradingModel(object): del descriptor.raw_grader[index] # force propagation to definition descriptor.raw_grader = descriptor.raw_grader + + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) - # NOTE cannot delete cutoffs. May be useful to reset - @staticmethod - def delete_cutoffs(course_location, cutoffs): - """ - Resets the cutoffs to the defaults - """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) - descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS'] - get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) - - return descriptor.grade_cutoffs - @staticmethod def delete_grace_period(course_location): """ @@ -199,6 +205,10 @@ class CourseGradingModel(object): descriptor = get_modulestore(course_location).get_item(course_location) del descriptor.lms.graceperiod + + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata) @staticmethod @@ -209,7 +219,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 @@ -225,6 +235,9 @@ class CourseGradingModel(object): del descriptor.lms.format del descriptor.lms.graded + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata) @staticmethod @@ -232,7 +245,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/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 5fb07fe806..8d9a292867 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -76,6 +76,9 @@ class CourseMetadata(object): setattr(descriptor.lms, key, value) if dirty: + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) @@ -97,6 +100,10 @@ class CourseMetadata(object): elif hasattr(descriptor.lms, key): delattr(descriptor.lms, key) + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + descriptor.save() + get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 20217deaff..339425fee5 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -92,6 +92,7 @@ LOG_DIR = ENV_TOKENS['LOG_DIR'] CACHES = ENV_TOKENS['CACHES'] SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') +SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) # allow for environments to specify what cookie name our login subsystem should use # this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can @@ -122,6 +123,10 @@ LOGGING = get_logger_config(LOG_DIR, debug=False, service_variant=SERVICE_VARIANT) +#theming start: +PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX') + + ################ SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 4f8174ac2b..0b0a62f05d 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -33,6 +33,10 @@ MODULESTORE = { 'direct': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', 'OPTIONS': modulestore_options + }, + 'split': { + 'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore', + 'OPTIONS': modulestore_options } } diff --git a/cms/envs/test.py b/cms/envs/test.py index 431a2c4184..efc7c5a7ef 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -63,6 +63,10 @@ MODULESTORE = { 'draft': { 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS + }, + 'split': { + 'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore', + 'OPTIONS': MODULESTORE_OPTIONS } } 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 b15e906fd5..c651458864 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); @@ -251,17 +253,13 @@ function syncReleaseDate(e) { } function getEdxTimeFromDateTimeVals(date_val, time_val) { - var edxTimeStr = null; - if (date_val != '') { if (time_val == '') time_val = '00:00'; - // Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing - var date = Date.parse(date_val + " " + time_val); - edxTimeStr = date.toString('yyyy-MM-ddTHH:mm'); + return new Date(date_val + " " + time_val + "Z"); } - return edxTimeStr; + else return null; } function getEdxTimeFromDateTimeInputs(date_id, time_id) { @@ -326,7 +324,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 +334,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 +342,9 @@ function createNewUnit(e) { }); - $.post('/clone_item', { + $.post('/create_item', { 'parent_location': parent, - 'template': template, + 'category': category, 'display_name': 'New Unit' }, @@ -372,7 +370,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 +547,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 +555,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 +591,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 +607,6 @@ function saveNewCourse(e) { }); $.post('/create_new_course', { - 'template': template, 'org': org, 'number': number, 'display_name': display_name @@ -644,7 +640,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 +653,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 +662,9 @@ function saveNewSubsection(e) { }); - $.post('/clone_item', { + $.post('/create_item', { 'parent_location': parent, - 'template': template, + 'category': category, 'display_name': display_name }, @@ -730,18 +726,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/assets.js b/cms/static/js/views/assets.js index 224ec928fb..282aeab69c 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -9,7 +9,7 @@ function removeAsset(e){ e.preventDefault(); var that = this; - var msg = new CMS.Views.Prompt.Confirmation({ + var msg = new CMS.Views.Prompt.Warning({ title: gettext("Delete File Confirmation"), message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), actions: { 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 e9b01509fe..48a3e25782 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -558,7 +558,7 @@ p, ul, ol, dl { // misc hr.divide { - @extend .text-sr; + @extend .cont-text-sr; } .item-details { @@ -806,7 +806,7 @@ hr.divide { // basic utility .sr { - @extend .text-sr; + @extend .cont-text-sr; } .fake-link { @@ -859,7 +859,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 4fac3de01f..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,14 +25,14 @@ // blue primary button .btn-primary-blue { - @extend .btn-primary; - background: $blue-u1; - border-color: $blue-u1; + @extend .ui-btn-primary; + background: $blue; + border-color: $blue-s1; color: $white; &:hover, &:active { - background: $blue-s1; - border-color: $blue-s1; + background: $blue-s2; + border-color: $blue-s2; } &.current, &.active { @@ -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 90de604aa8..43028111dc 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -186,8 +186,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; @@ -284,7 +284,7 @@ // notifications .wrapper-notification { - @extend .depth5; + @extend .ui-depth5; @include clearfix(); box-shadow: 0 -1px 3px $shadow, inset 0 3px 1px $blue; position: fixed; @@ -486,7 +486,7 @@ } .copy p { - @extend .text-sr; + @extend .cont-text-sr; } } } @@ -495,7 +495,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; @@ -641,7 +641,7 @@ text-align: center; .label { - @extend .text-sr; + @extend .cont-text-sr; } [class^="icon"] { @@ -738,7 +738,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 51c01f1155..f2232f6f40 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"> diff --git a/cms/templates/activation_complete.html b/cms/templates/activation_complete.html index d6bdab36dc..27efbdc34f 100644 --- a/cms/templates/activation_complete.html +++ b/cms/templates/activation_complete.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%block name="content"> @@ -29,5 +30,3 @@
    - - diff --git a/cms/templates/activation_invalid.html b/cms/templates/activation_invalid.html index 0c663aa307..7f3fbed5a9 100644 --- a/cms/templates/activation_invalid.html +++ b/cms/templates/activation_invalid.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> <%block name="content"> @@ -30,6 +31,3 @@ - - - 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")}

    -