diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index afb38c3f9e..96b840ae96 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -5,8 +5,6 @@ from lettuce import world, step from nose.tools import assert_true from nose.tools import assert_equal -from xmodule.modulestore.django import _MODULESTORES, modulestore -from xmodule.templates import update_templates from auth.authz import get_user_by_email from selenium.webdriver.common.keys import Keys @@ -50,31 +48,31 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') def i_have_opened_a_new_course(step): + open_new_course() + + +####### HELPER FUNCTIONS ############## +def open_new_course(): world.clear_courses() log_into_studio() create_a_course() -####### HELPER FUNCTIONS ############## def create_studio_user( uname='robot', email='robot+studio@edx.org', password='test', is_staff=False): - studio_user = world.UserFactory.build( + studio_user = world.UserFactory( username=uname, email=email, password=password, is_staff=is_staff) - studio_user.set_password(password) - studio_user.save() registration = world.RegistrationFactory(user=studio_user) registration.register(studio_user) registration.activate() - user_profile = world.UserProfileFactory(user=studio_user) - def fill_in_course_info( name='Robot Super Course', @@ -153,4 +151,13 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): world.css_fill(time_css, desired_time) e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) - time.sleep(float(1)) + time.sleep(float(1)) + + +@step('I have created a Video component$') +def i_created_a_video_component(step): + world.create_component_instance( + step, '.large-video-icon', + 'i4x://edx/templates/video/default', + '.xmodule_VideoModule' + ) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py new file mode 100644 index 0000000000..ee684e53dc --- /dev/null +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -0,0 +1,86 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world +from nose.tools import assert_equal +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) + + +@world.absorb +def click_new_component_button(step, component_button_css): + step.given('I have opened a new course section in Studio') + step.given('I have added a new subsection') + step.given('I expand the first section') + world.css_click('a.new-unit-item') + world.css_click(component_button_css) + + +@world.absorb +def click_component_from_menu(instance_id, expected_css): + elem_css = "a[data-location='%s']" % instance_id + assert_equal(1, len(world.css_find(elem_css))) + world.css_click(elem_css) + assert_equal(1, len(world.css_find(expected_css))) + + +@world.absorb +def edit_component_and_select_settings(): + world.css_click('a.edit-button') + world.css_click('#settings-mode') + + +@world.absorb +def verify_setting_entry(setting, display_name, value, explicitly_set): + assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) + assert_equal(value, setting.find_by_css('.setting-input')[0].value) + settingClearButton = setting.find_by_css('.setting-clear')[0] + assert_equal(explicitly_set, settingClearButton.has_class('active')) + assert_equal(not explicitly_set, settingClearButton.has_class('inactive')) + + +@world.absorb +def verify_all_setting_entries(expected_entries): + settings = world.browser.find_by_css('.wrapper-comp-setting') + assert_equal(len(expected_entries), len(settings)) + for (counter, setting) in enumerate(settings): + world.verify_setting_entry( + setting, expected_entries[counter][0], + expected_entries[counter][1], expected_entries[counter][2] + ) + + +@world.absorb +def save_component_and_reopen(step): + world.css_click("a.save-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save. + reload_the_page(step) + edit_component_and_select_settings() + + +@world.absorb +def cancel_component(step): + world.css_click("a.cancel-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes were not persisted. + reload_the_page(step) + + +@world.absorb +def revert_setting_entry(label): + get_setting_entry(label).find_by_css('.setting-clear')[0].click() + + +@world.absorb +def get_setting_entry(label): + settings = world.browser.find_by_css('.wrapper-comp-setting') + for setting in settings: + if setting.find_by_css('.setting-label')[0].value == label: + return setting + return None diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature new file mode 100644 index 0000000000..24683c3297 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -0,0 +1,13 @@ +Feature: Discussion Component Editor + As a course author, I want to be able to create discussion components. + + Scenario: User can view metadata + Given I have created a Discussion Tag + And I edit and select Settings + Then I see three alphabetized settings and their expected values + + Scenario: User can modify display name + Given I have created a Discussion Tag + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py new file mode 100644 index 0000000000..aced4c2c88 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -0,0 +1,23 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Discussion Tag$') +def i_created_discussion_tag(step): + world.create_component_instance( + step, '.large-discussion-icon', + 'i4x://edx/templates/discussion/Discussion_Tag', + '.xmodule_DiscussionModule' + ) + + +@step('I see three alphabetized settings and their expected values$') +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] + ]) diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature new file mode 100644 index 0000000000..6cd455d681 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -0,0 +1,13 @@ +Feature: HTML Editor + As a course author, I want to be able to create HTML blocks. + + Scenario: User can view metadata + Given I have created a Blank HTML Page + And I edit and select Settings + Then I see only the HTML display name setting + + Scenario: User can modify display name + Given I have created a Blank HTML Page + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py new file mode 100644 index 0000000000..054c0ea642 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -0,0 +1,17 @@ +# disable missing docstring +#pylint: disable=C0111 + +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', + '.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]]) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature new file mode 100644 index 0000000000..6ed8c1619b --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -0,0 +1,67 @@ +Feature: Problem Editor + As a course author, I want to be able to create problems and edit their settings. + + Scenario: User can view metadata + Given I have created a Blank Common Problem + And I edit and select Settings + Then I see five alphabetized settings and their expected values + And Edit High Level Source is not visible + + Scenario: User can modify String values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save + + Scenario: User can specify special characters in String values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can specify special characters in the display name + And my special characters and persisted on save + + Scenario: User can revert display name to unset + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can revert the display name to unset + And my display name is unset on save + + Scenario: User can select values in a Select + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can select Per Student for Randomization + And my change to randomization is persisted + And I can revert to the default value for randomization + + Scenario: User can modify float input values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can set the weight to "3.5" + And my change to weight is persisted + And I can revert to the default value of unset for weight + + Scenario: User cannot type letters in float number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the weight to "abc", it remains unset + + Scenario: User cannot type decimal values integer number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" + + Scenario: User cannot type out of range values in an integer number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "1" + + Scenario: Settings changes are not saved on Cancel + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can set the weight to "3.5" + And I can modify the display name + Then If I press Cancel my changes are not persisted + + Scenario: Edit High Level source is available for LaTeX problem + Given I have created a LaTeX Problem + And I edit and select Settings + Then Edit High Level Source is visible diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py new file mode 100644 index 0000000000..5dfcf55046 --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -0,0 +1,187 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step +from nose.tools import assert_equal + +DISPLAY_NAME = "Display Name" +MAXIMUM_ATTEMPTS = "Maximum Attempts" +PROBLEM_WEIGHT = "Problem Weight" +RANDOMIZATION = 'Randomization' +SHOW_ANSWER = "Show Answer" + + +############### ACTIONS #################### +@step('I have created a Blank Common Problem$') +def i_created_blank_common_problem(step): + world.create_component_instance( + step, + '.large-problem-icon', + 'i4x://edx/templates/problem/Blank_Common_Problem', + '.xmodule_CapaModule' + ) + + +@step('I edit and select Settings$') +def i_edit_and_select_settings(step): + world.edit_component_and_select_settings() + + +@step('I see five alphabetized settings and their expected values$') +def i_see_five_settings_with_values(step): + world.verify_all_setting_entries( + [ + [DISPLAY_NAME, "Blank Common Problem", True], + [MAXIMUM_ATTEMPTS, "", False], + [PROBLEM_WEIGHT, "", False], + [RANDOMIZATION, "Never", True], + [SHOW_ANSWER, "Finished", True] + ]) + + +@step('I can modify the display name') +def i_can_modify_the_display_name(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified') + verify_modified_display_name() + + +@step('my display name change is persisted on save') +def my_display_name_change_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name() + + +@step('I can specify special characters in the display name') +def i_can_modify_the_display_name_with_special_chars(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &") + verify_modified_display_name_with_special_chars() + + +@step('my special characters and persisted on save') +def special_chars_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name_with_special_chars() + + +@step('I can revert the display name to unset') +def can_revert_display_name_to_unset(step): + world.revert_setting_entry(DISPLAY_NAME) + verify_unset_display_name() + + +@step('my display name is unset on save') +def my_display_name_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_unset_display_name() + + +@step('I can select Per Student for Randomization') +def i_can_select_per_student_for_randomization(step): + world.browser.select(RANDOMIZATION, "Per Student") + verify_modified_randomization() + + +@step('my change to randomization is persisted') +def my_change_to_randomization_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_randomization() + + +@step('I can revert to the default value for randomization') +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) + + +@step('I can set the weight to "(.*)"?') +def i_can_set_weight(step, weight): + set_weight(weight) + verify_modified_weight() + + +@step('my change to weight is persisted') +def my_change_to_weight_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_weight() + + +@step('I can revert to the default value of unset for weight') +def i_can_revert_to_default_for_unset_weight(step): + world.revert_setting_entry(PROBLEM_WEIGHT) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the weight to "(.*)", it remains unset') +def set_the_weight_to_abc(step, bad_weight): + set_weight(bad_weight) + # We show the clear button immediately on type, hence the "True" here. + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True) + world.save_component_and_reopen(step) + # But no change was actually ever sent to the model, so on reopen, explicitly_set is False + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"') +def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted): + world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True) + + +@step('Edit High Level Source is not visible') +def edit_high_level_source_not_visible(step): + verify_high_level_source(step, False) + + +@step('Edit High Level Source is visible') +def edit_high_level_source_visible(step): + verify_high_level_source(step, True) + + +@step('If I press Cancel my changes are not persisted') +def cancel_does_not_save_changes(step): + world.cancel_component(step) + step.given("I edit and select Settings") + step.given("I see five alphabetized settings and their expected values") + + +@step('I have created a LaTeX Problem') +def create_latex_problem(step): + world.click_new_component_button(step, '.large-problem-icon') + # Go to advanced tab (waiting for the tab to be visible) + world.css_find('#ui-id-2') + world.css_click('#ui-id-2') + world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') + + +def verify_high_level_source(step, visible): + assert_equal(visible, world.is_css_present('.launch-latex-compiler')) + world.cancel_component(step) + assert_equal(visible, world.is_css_present('.upload-button')) + + +def verify_modified_weight(): + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True) + + +def verify_modified_randomization(): + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True) + + +def verify_modified_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True) + + +def verify_modified_display_name_with_special_chars(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True) + + +def verify_unset_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False) + + +def set_weight(weight): + world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index edc8b17168..1134e53280 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -10,9 +10,7 @@ from nose.tools import assert_equal @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): - world.clear_courses() - log_into_studio() - create_a_course() + open_new_course() add_section() diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature new file mode 100644 index 0000000000..4c2a460042 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -0,0 +1,13 @@ +Feature: Video Component Editor + As a course author, I want to be able to create video components. + + Scenario: User can view metadata + Given I have created a Video component + And I edit and select Settings + Then I see only the Video display name setting + + Scenario: User can modify display name + Given I have created a Video component + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py new file mode 100644 index 0000000000..27423575c3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -0,0 +1,9 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I see only the video display name setting$') +def i_see_only_the_video_display_name(step): + world.verify_all_setting_entries([['Display Name', "default", True]]) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 30005d4524..a06e55fe8f 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -149,8 +149,7 @@ def edit_unit(request, location): component_templates[category].append(( template.display_name_with_default, template.location.url(), - hasattr(template, 'markdown') and template.markdown is not None, - template.cms.empty, + hasattr(template, 'markdown') and template.markdown is not None )) components = [ diff --git a/cms/envs/common.py b/cms/envs/common.py index 90e15186d7..e60d337731 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -223,7 +223,8 @@ PIPELINE_JS = { rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') ) + ['js/hesitate.js', 'js/base.js', 'js/models/feedback.js', 'js/views/feedback.js', - 'js/models/section.js', 'js/views/section.js'], + 'js/models/section.js', 'js/views/section.js', + 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js'], 'output_filename': 'js/cms-application.js', 'test_order': 0 }, diff --git a/cms/static/coffee/fixtures/metadata-editor.underscore b/cms/static/coffee/fixtures/metadata-editor.underscore new file mode 120000 index 0000000000..9696774d0a --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-editor.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-editor.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-number-entry.underscore b/cms/static/coffee/fixtures/metadata-number-entry.underscore new file mode 120000 index 0000000000..99138aa9c1 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-number-entry.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-number-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-option-entry.underscore b/cms/static/coffee/fixtures/metadata-option-entry.underscore new file mode 120000 index 0000000000..c6cd499801 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-option-entry.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-option-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-string-entry.underscore b/cms/static/coffee/fixtures/metadata-string-entry.underscore new file mode 120000 index 0000000000..f713ab5387 --- /dev/null +++ b/cms/static/coffee/fixtures/metadata-string-entry.underscore @@ -0,0 +1 @@ +../../../templates/js/metadata-string-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/spec/models/metadata_spec.coffee b/cms/static/coffee/spec/models/metadata_spec.coffee new file mode 100644 index 0000000000..5ff65e1bfa --- /dev/null +++ b/cms/static/coffee/spec/models/metadata_spec.coffee @@ -0,0 +1,58 @@ +describe "CMS.Models.Metadata", -> + it "knows when the value has not been modified", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': false}) + expect(model.isModified()).toBeFalsy() + + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': true}) + model.setValue('original') + expect(model.isModified()).toBeFalsy() + + it "knows when the value has been modified", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': false}) + model.setValue('original') + expect(model.isModified()).toBeTruthy() + + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': true}) + model.setValue('modified') + expect(model.isModified()).toBeTruthy() + + it "tracks when values have been explicitly set", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'explicitly_set': false}) + expect(model.isExplicitlySet()).toBeFalsy() + model.setValue('original') + expect(model.isExplicitlySet()).toBeTruthy() + + it "has both 'display value' and a 'value' methods", -> + model = new CMS.Models.Metadata( + {'value': 'default', 'explicitly_set': false}) + expect(model.getValue()).toBeNull + expect(model.getDisplayValue()).toBe('default') + model.setValue('modified') + expect(model.getValue()).toBe('modified') + expect(model.getDisplayValue()).toBe('modified') + + it "has a clear method for reverting to the default", -> + model = new CMS.Models.Metadata( + {'value': 'original', 'default_value' : 'default', 'explicitly_set': true}) + model.clear() + expect(model.getValue()).toBeNull + expect(model.getDisplayValue()).toBe('default') + expect(model.isExplicitlySet()).toBeFalsy() + + it "has a getter for field name", -> + model = new CMS.Models.Metadata({'field_name': 'foo'}) + expect(model.getFieldName()).toBe('foo') + + it "has a getter for options", -> + model = new CMS.Models.Metadata({'options': ['foo', 'bar']}) + expect(model.getOptions()).toEqual(['foo', 'bar']) + + it "has a getter for type", -> + model = new CMS.Models.Metadata({'type': 'Integer'}) + expect(model.getType()).toBe(CMS.Models.Metadata.INTEGER_TYPE) + diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee new file mode 100644 index 0000000000..0c2069cf00 --- /dev/null +++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee @@ -0,0 +1,300 @@ +describe "Test Metadata Editor", -> + editorTemplate = readFixtures('metadata-editor.underscore') + numberEntryTemplate = readFixtures('metadata-number-entry.underscore') + stringEntryTemplate = readFixtures('metadata-string-entry.underscore') + optionEntryTemplate = readFixtures('metadata-option-entry.underscore') + + beforeEach -> + setFixtures($(" + + + +
-
- ${editor} -
-
- Save - Cancel -
-
+
+ + +
+ +
+
+ ${editor} +
+
+
+ ${_("Save")} + ${_("Cancel")} +
+
- Edit - Delete + ${_("Edit")} + ${_("Delete")}
- + ${preview} diff --git a/cms/templates/js/metadata-editor.underscore b/cms/templates/js/metadata-editor.underscore new file mode 100644 index 0000000000..03fdd28996 --- /dev/null +++ b/cms/templates/js/metadata-editor.underscore @@ -0,0 +1,6 @@ + diff --git a/cms/templates/js/metadata-number-entry.underscore b/cms/templates/js/metadata-number-entry.underscore new file mode 100644 index 0000000000..333233ef4e --- /dev/null +++ b/cms/templates/js/metadata-number-entry.underscore @@ -0,0 +1,8 @@ +
+ + + +
+<%= model.get('help') %> diff --git a/cms/templates/js/metadata-option-entry.underscore b/cms/templates/js/metadata-option-entry.underscore new file mode 100644 index 0000000000..4cb107e882 --- /dev/null +++ b/cms/templates/js/metadata-option-entry.underscore @@ -0,0 +1,16 @@ +
+ + + +
+<%= model.get('help') %> diff --git a/cms/templates/js/metadata-string-entry.underscore b/cms/templates/js/metadata-string-entry.underscore new file mode 100644 index 0000000000..759e3ad826 --- /dev/null +++ b/cms/templates/js/metadata-string-entry.underscore @@ -0,0 +1,8 @@ +
+ + + +
+<%= model.get('help') %> diff --git a/cms/templates/unit.html b/cms/templates/unit.html index cb34f42a09..36d643325d 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -78,22 +78,13 @@ % endif
-
Explanation
+
${_("Explanation")}
@@ -105,3 +107,5 @@
+ +<%include file="metadata-edit.html" /> diff --git a/cms/templates/widgets/raw-edit.html b/cms/templates/widgets/raw-edit.html index 9488552be5..2b1b0f9ef3 100644 --- a/cms/templates/widgets/raw-edit.html +++ b/cms/templates/widgets/raw-edit.html @@ -1,4 +1,6 @@ +
+
+ +
+
<%include file="metadata-edit.html" /> -
- -
diff --git a/cms/templates/widgets/sequence-edit.html b/cms/templates/widgets/sequence-edit.html index c70f2568fa..7956fbfbaf 100644 --- a/cms/templates/widgets/sequence-edit.html +++ b/cms/templates/widgets/sequence-edit.html @@ -29,7 +29,6 @@ - <%include file="metadata-edit.html" />
    @@ -50,5 +49,6 @@
+ <%include file="metadata-edit.html" /> diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py index 1b509a14f4..4857fe68ca 100644 --- a/cms/xmodule_namespace.py +++ b/cms/xmodule_namespace.py @@ -28,4 +28,4 @@ class CmsNamespace(Namespace): """ published_date = DateTuple(help="Date when the module was published", scope=Scope.settings) published_by = String(help="Id of the user who published this module", scope=Scope.settings) - empty = StringyBoolean(help="Whether this is an empty template", scope=Scope.settings, default=False) + diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index 768c51b25e..decce42368 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -13,6 +13,7 @@ class UserFactory(sf.UserFactory): """ User account for lms / cms """ + FACTORY_DJANGO_GET_OR_CREATE = ('username',) pass @@ -21,6 +22,7 @@ class UserProfileFactory(sf.UserProfileFactory): """ Demographics etc for the User """ + FACTORY_DJANGO_GET_OR_CREATE = ('user',) pass @@ -29,6 +31,7 @@ class RegistrationFactory(sf.RegistrationFactory): """ Activation key for registering the user account """ + FACTORY_DJANGO_GET_OR_CREATE = ('user',) pass diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 79e9b0afdb..45252dbb7b 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -4,7 +4,7 @@ from lettuce import world import time from urllib import quote_plus -from selenium.common.exceptions import WebDriverException +from selenium.common.exceptions import WebDriverException, StaleElementReferenceException from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -63,7 +63,7 @@ def css_click(css_selector): # Occassionally, MathJax or other JavaScript can cover up # an element temporarily. # If this happens, wait a second, then try again - time.sleep(1) + world.wait(1) world.browser.find_by_css(css_selector).click() @@ -79,6 +79,14 @@ def css_click_at(css, x=10, y=10): e.action_chains.perform() +@world.absorb +def id_click(elem_id): + """ + Perform a click on an element as specified by its id + """ + world.css_click('#%s' % elem_id) + + @world.absorb def css_fill(css_selector, text): world.browser.find_by_css(css_selector).first.fill(text) @@ -94,7 +102,12 @@ def css_text(css_selector): # Wait for the css selector to appear if world.is_css_present(css_selector): - return world.browser.find_by_css(css_selector).first.text + try: + return world.browser.find_by_css(css_selector).first.text + except StaleElementReferenceException: + # The DOM was still redrawing. Wait a second and try again. + world.wait(1) + return world.browser.find_by_css(css_selector).first.text else: return "" diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index eb6bdc18c9..9ac540138e 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -66,22 +66,51 @@ class ComplexEncoder(json.JSONEncoder): class CapaFields(object): attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) - max_attempts = StringyInteger(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) + max_attempts = StringyInteger( + display_name="Maximum Attempts", + help="Defines the number of times a student can try to answer this problem. If the value is not set, infinite attempts are allowed.", + values={"min": 1}, scope=Scope.settings + ) due = Date(help="Date that this problem is due by", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) - showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed", - values=["answered", "always", "attempted", "closed", "never"]) + showanswer = String( + display_name="Show Answer", + help="Defines when to show the answer to the problem. A default value can be set in Advanced Settings.", + scope=Scope.settings, default="closed", + values=[ + {"display_name": "Always", "value": "always"}, + {"display_name": "Answered", "value": "answered"}, + {"display_name": "Attempted", "value": "attempted"}, + {"display_name": "Closed", "value": "closed"}, + {"display_name": "Finished", "value": "finished"}, + {"display_name": "Past Due", "value": "past_due"}, + {"display_name": "Never", "value": "never"}] + ) force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) - rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings) + rerandomize = Randomization( + display_name="Randomization", help="Defines how often inputs are randomized when a student loads the problem. This setting only applies to problems that can have randomly generated numeric values. A default value can be set in Advanced Settings.", + default="always", scope=Scope.settings, values=[{"display_name": "Always", "value": "always"}, + {"display_name": "On Reset", "value": "onreset"}, + {"display_name": "Never", "value": "never"}, + {"display_name": "Per Student", "value": "per_student"}] + ) data = String(help="XML data for the problem", scope=Scope.content) correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={}) input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state) student_answers = Object(help="Dictionary with the current student responses", scope=Scope.user_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state) seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state) - weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) + weight = StringyFloat( + display_name="Problem Weight", + help="Defines the number of points each problem is worth. If the value is not set, each response field in the problem is worth one point.", + values={"min": 0, "step": .1}, + scope=Scope.settings + ) markdown = String(help="Markdown source of this module", scope=Scope.settings) - source_code = String(help="Source code for LaTeX and Word problems. This feature is not well-supported.", scope=Scope.settings) + source_code = String( + help="Source code for LaTeX and Word problems. This feature is not well-supported.", + scope=Scope.settings + ) class CapaModule(CapaFields, XModule): diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 0e32770afc..b3f0e19109 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -5,7 +5,7 @@ from pkg_resources import resource_string from xmodule.raw_module import RawDescriptor from .x_module import XModule -from xblock.core import Integer, Scope, String, Boolean, List +from xblock.core import Integer, Scope, String, List from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor from collections import namedtuple from .fields import Date, StringyFloat, StringyInteger, StringyBoolean @@ -48,27 +48,49 @@ class VersionInteger(Integer): class CombinedOpenEndedFields(object): - display_name = String(help="Display name for this module", default="Open Ended Grading", scope=Scope.settings) + display_name = String( + display_name="Display Name", + help="This name appears in the horizontal navigation at the top of the page.", + default="Open Ended Grading", scope=Scope.settings + ) current_task_number = StringyInteger(help="Current task that the student is on.", default=0, scope=Scope.user_state) task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state) state = String(help="Which step within the current task that the student is on.", default="initial", scope=Scope.user_state) student_attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) - ready_to_reset = StringyBoolean(help="If the problem is ready to be reset or not.", default=False, - scope=Scope.user_state) - attempts = StringyInteger(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings) - is_graded = StringyBoolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings) - accept_file_upload = StringyBoolean(help="Whether or not the problem accepts file uploads.", default=False, - scope=Scope.settings) - skip_spelling_checks = StringyBoolean(help="Whether or not to skip initial spelling checks.", default=True, - scope=Scope.settings) + ready_to_reset = StringyBoolean( + help="If the problem is ready to be reset or not.", default=False, + scope=Scope.user_state + ) + attempts = StringyInteger( + display_name="Maximum Attempts", + help="The number of times the student can try to answer this problem.", default=1, + scope=Scope.settings, values = {"min" : 1 } + ) + is_graded = StringyBoolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings) + accept_file_upload = StringyBoolean( + display_name="Allow File Uploads", + help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings + ) + skip_spelling_checks = StringyBoolean( + display_name="Disable Quality Filter", + help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.", + default=False, scope=Scope.settings + ) due = Date(help="Date that this problem is due by", default=None, scope=Scope.settings) - graceperiod = String(help="Amount of time after the due date that submissions will be accepted", default=None, - scope=Scope.settings) + graceperiod = String( + help="Amount of time after the due date that submissions will be accepted", + default=None, + scope=Scope.settings + ) version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) - weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) + weight = StringyFloat( + display_name="Problem Weight", + help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.", + scope=Scope.settings, values = {"min" : 0 , "step": ".1"} + ) markdown = String(help="Markdown source of this module", scope=Scope.settings) @@ -244,6 +266,6 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor): def non_editable_metadata_fields(self): non_editable_fields = super(CombinedOpenEndedDescriptor, self).non_editable_metadata_fields non_editable_fields.extend([CombinedOpenEndedDescriptor.due, CombinedOpenEndedDescriptor.graceperiod, - CombinedOpenEndedDescriptor.markdown]) + CombinedOpenEndedDescriptor.markdown, CombinedOpenEndedDescriptor.version]) return non_editable_fields diff --git a/common/lib/xmodule/xmodule/css/editor/edit.scss b/common/lib/xmodule/xmodule/css/editor/edit.scss index ac53bb5a70..d30f69bcd2 100644 --- a/common/lib/xmodule/xmodule/css/editor/edit.scss +++ b/common/lib/xmodule/xmodule/css/editor/edit.scss @@ -10,8 +10,6 @@ position: relative; @include linear-gradient(top, #d4dee8, #c9d5e2); padding: 5px; - border: 1px solid #3c3c3c; - border-radius: 3px 3px 0 0; border-bottom-color: #a5aaaf; @include clearfix; diff --git a/common/lib/xmodule/xmodule/css/problem/edit.scss b/common/lib/xmodule/xmodule/css/problem/edit.scss index be5455e901..249b767e5e 100644 --- a/common/lib/xmodule/xmodule/css/problem/edit.scss +++ b/common/lib/xmodule/xmodule/css/problem/edit.scss @@ -5,7 +5,7 @@ .advanced-toggle { @include white-button; height: auto; - margin-top: -1px; + margin-top: -4px; padding: 3px 9px; font-size: 12px; @@ -16,7 +16,7 @@ color: $darkGrey !important; pointer-events: none; cursor: none; - + &:hover { box-shadow: 0 0 0 0 !important; } @@ -27,7 +27,7 @@ width: 21px; height: 21px; padding: 0; - margin: 0 5px 0 15px; + margin: -1px 5px 0 15px; border-radius: 22px; border: 1px solid #a5aaaf; background: #e5ecf3; @@ -99,6 +99,13 @@ } } +.problem-editor { +// adding padding to simple editor only - adjacent selector is needed since there are no toggles for CodeMirror + .markdown-box+.CodeMirror { + padding: 10px; + } +} + .problem-editor-icon { display: inline-block; width: 26px; diff --git a/common/lib/xmodule/xmodule/css/sequence/display.scss b/common/lib/xmodule/xmodule/css/sequence/display.scss index e006e02773..ed09d5cf0f 100644 --- a/common/lib/xmodule/xmodule/css/sequence/display.scss +++ b/common/lib/xmodule/xmodule/css/sequence/display.scss @@ -170,7 +170,7 @@ nav.sequence-nav { font-family: $sans-serif; line-height: lh(); left: 0px; - opacity: 0; + opacity: 0.0; padding: 6px; position: absolute; top: 48px; @@ -204,7 +204,7 @@ nav.sequence-nav { p { display: block; margin-top: 4px; - opacity: 1; + opacity: 1.0; } } } @@ -248,12 +248,12 @@ nav.sequence-nav { } &:hover { - opacity: .5; + opacity: 0.5; } &.disabled { cursor: normal; - opacity: .4; + opacity: 0.4; } } } @@ -320,12 +320,12 @@ nav.sequence-bottom { outline: 0; &:hover { - opacity: .5; + opacity: 0.5; background-position: center 15px; } &.disabled { - opacity: .4; + opacity: 0.4; } &:focus { diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index bf575e74a3..f3f76dc0d6 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -41,7 +41,7 @@ div.video { &:hover { ul, div { - opacity: 1; + opacity: 1.0; } } @@ -158,7 +158,7 @@ div.video { ol.video_speeds { display: block; - opacity: 1; + opacity: 1.0; padding: 0; margin: 0; list-style: none; @@ -208,7 +208,7 @@ div.video { } &:hover, &:active, &:focus { - opacity: 1; + opacity: 1.0; background-color: #444; } } @@ -221,7 +221,7 @@ div.video { border: 1px solid #000; bottom: 46px; display: none; - opacity: 0; + opacity: 0.0; position: absolute; width: 133px; z-index: 10; @@ -264,7 +264,7 @@ div.video { &.open { .volume-slider-container { display: block; - opacity: 1; + opacity: 1.0; } } @@ -302,7 +302,7 @@ div.video { border: 1px solid #000; bottom: 46px; display: none; - opacity: 0; + opacity: 0.0; position: absolute; width: 45px; height: 125px; @@ -395,7 +395,7 @@ div.video { font-weight: 800; line-height: 46px; //height of play pause buttons margin-left: 0; - opacity: 1; + opacity: 1.0; padding: 0 lh(.5); position: relative; text-indent: -9999px; @@ -410,7 +410,7 @@ div.video { } &.off { - opacity: .7; + opacity: 0.7; } } } @@ -418,7 +418,7 @@ div.video { &:hover section.video-controls { ul, div { - opacity: 1; + opacity: 1.0; } div.slider { diff --git a/common/lib/xmodule/xmodule/css/videoalpha/display.scss b/common/lib/xmodule/xmodule/css/videoalpha/display.scss index bf575e74a3..f3f76dc0d6 100644 --- a/common/lib/xmodule/xmodule/css/videoalpha/display.scss +++ b/common/lib/xmodule/xmodule/css/videoalpha/display.scss @@ -41,7 +41,7 @@ div.video { &:hover { ul, div { - opacity: 1; + opacity: 1.0; } } @@ -158,7 +158,7 @@ div.video { ol.video_speeds { display: block; - opacity: 1; + opacity: 1.0; padding: 0; margin: 0; list-style: none; @@ -208,7 +208,7 @@ div.video { } &:hover, &:active, &:focus { - opacity: 1; + opacity: 1.0; background-color: #444; } } @@ -221,7 +221,7 @@ div.video { border: 1px solid #000; bottom: 46px; display: none; - opacity: 0; + opacity: 0.0; position: absolute; width: 133px; z-index: 10; @@ -264,7 +264,7 @@ div.video { &.open { .volume-slider-container { display: block; - opacity: 1; + opacity: 1.0; } } @@ -302,7 +302,7 @@ div.video { border: 1px solid #000; bottom: 46px; display: none; - opacity: 0; + opacity: 0.0; position: absolute; width: 45px; height: 125px; @@ -395,7 +395,7 @@ div.video { font-weight: 800; line-height: 46px; //height of play pause buttons margin-left: 0; - opacity: 1; + opacity: 1.0; padding: 0 lh(.5); position: relative; text-indent: -9999px; @@ -410,7 +410,7 @@ div.video { } &.off { - opacity: .7; + opacity: 0.7; } } } @@ -418,7 +418,7 @@ div.video { &:hover section.video-controls { ul, div { - opacity: 1; + opacity: 1.0; } div.slider { diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index 98082ddea2..aef4821839 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -8,8 +8,16 @@ from xblock.core import String, Scope class DiscussionFields(object): discussion_id = String(scope=Scope.settings) - discussion_category = String(scope=Scope.settings) - discussion_target = String(scope=Scope.settings) + discussion_category = String( + display_name="Category", + help="A category name for the discussion. This name appears in the left pane of the discussion forum for the course.", + scope=Scope.settings + ) + discussion_target = String( + display_name="Subcategory", + help="A subcategory name for the discussion. This name appears in the left pane of the discussion forum for the course.", + scope=Scope.settings + ) sort_key = String(scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index b0d666621f..ccc3e31f51 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -10,7 +10,7 @@ from .x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.modulestore.django import modulestore from .timeinfo import TimeInfo -from xblock.core import Object, Integer, Boolean, String, Scope +from xblock.core import Object, String, Scope from xmodule.fields import Date, StringyFloat, StringyInteger, StringyBoolean from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService @@ -22,24 +22,43 @@ USE_FOR_SINGLE_LOCATION = False LINK_TO_LOCATION = "" TRUE_DICT = [True, "True", "true", "TRUE"] MAX_SCORE = 1 -IS_GRADED = True +IS_GRADED = False EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff." class PeerGradingFields(object): - use_for_single_location = StringyBoolean(help="Whether to use this for a single location or as a panel.", - default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings) - link_to_location = String(help="The location this problem is linked to.", default=LINK_TO_LOCATION, - scope=Scope.settings) - is_graded = StringyBoolean(help="Whether or not this module is scored.", default=IS_GRADED, scope=Scope.settings) + use_for_single_location = StringyBoolean( + display_name="Show Single Problem", + help='When True, only the single problem specified by "Link to Problem Location" is shown. ' + 'When False, a panel is displayed with all problems available for peer grading.', + default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings + ) + link_to_location = String( + display_name="Link to Problem Location", + help='The location of the problem being graded. Only used when "Show Single Problem" is True.', + default=LINK_TO_LOCATION, scope=Scope.settings + ) + is_graded = StringyBoolean( + display_name="Graded", + help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.', + default=IS_GRADED, scope=Scope.settings + ) due_date = Date(help="Due date that should be displayed.", default=None, scope=Scope.settings) grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings) - max_grade = StringyInteger(help="The maximum grade that a student can receieve for this problem.", default=MAX_SCORE, - scope=Scope.settings) - student_data_for_location = Object(help="Student data for a given peer grading problem.", - scope=Scope.user_state) - weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) + max_grade = StringyInteger( + help="The maximum grade that a student can receive for this problem.", default=MAX_SCORE, + scope=Scope.settings, values={"min": 0} + ) + student_data_for_location = Object( + help="Student data for a given peer grading problem.", + scope=Scope.user_state + ) + weight = StringyFloat( + display_name="Problem Weight", + help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.", + scope=Scope.settings, values={"min": 0, "step": ".1"} + ) class PeerGradingModule(PeerGradingFields, XModule): @@ -590,3 +609,11 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor): #Specify whether or not to pass in open ended interface needs_open_ended_interface = True + + @property + def non_editable_metadata_fields(self): + non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields + non_editable_fields.extend([PeerGradingFields.due_date, PeerGradingFields.grace_period_string, + PeerGradingFields.max_grade]) + return non_editable_fields + diff --git a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml index d74517a3c3..f7d639ebfb 100644 --- a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml +++ b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml @@ -1,12 +1,6 @@ --- metadata: display_name: Open Ended Response - attempts: 1 - is_graded: False - version: 1 - skip_spelling_checks: False - accept_file_upload: False - weight: "" markdown: "" data: | diff --git a/common/lib/xmodule/xmodule/templates/html/empty.yaml b/common/lib/xmodule/xmodule/templates/html/empty.yaml index b6d867d7d6..40b005af28 100644 --- a/common/lib/xmodule/xmodule/templates/html/empty.yaml +++ b/common/lib/xmodule/xmodule/templates/html/empty.yaml @@ -1,7 +1,6 @@ --- metadata: display_name: Blank HTML Page - empty: True data: | diff --git a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml index 23d41d616f..5d88a18ad8 100644 --- a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml +++ b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml @@ -1,11 +1,7 @@ --- metadata: display_name: Peer Grading Interface - use_for_single_location: False - link_to_location: None - is_graded: False max_grade: 1 - weight: "" data: | diff --git a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml index a94b824cfb..56f802a6a3 100644 --- a/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/circuitschematic.yaml @@ -3,9 +3,7 @@ metadata: display_name: Circuit Schematic Builder rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished data: | Please make a voltage divider that splits the provided voltage evenly. diff --git a/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml b/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml index aadbe4075a..b5b0d71f4d 100644 --- a/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml @@ -2,9 +2,7 @@ metadata: display_name: Custom Python-Evaluated Input rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished data: |

diff --git a/common/lib/xmodule/xmodule/templates/problem/empty.yaml b/common/lib/xmodule/xmodule/templates/problem/empty.yaml index 39c9e7671c..97a2aef423 100644 --- a/common/lib/xmodule/xmodule/templates/problem/empty.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/empty.yaml @@ -2,11 +2,8 @@ metadata: display_name: Blank Common Problem rerandomize: never - showanswer: always + showanswer: finished markdown: "" - weight: "" - empty: True - attempts: "" data: | diff --git a/common/lib/xmodule/xmodule/templates/problem/emptyadvanced.yaml b/common/lib/xmodule/xmodule/templates/problem/emptyadvanced.yaml index bba7b3a8ac..3d696ec2fd 100644 --- a/common/lib/xmodule/xmodule/templates/problem/emptyadvanced.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/emptyadvanced.yaml @@ -2,10 +2,7 @@ metadata: display_name: Blank Advanced Problem rerandomize: never - showanswer: always - weight: "" - attempts: "" - empty: True + showanswer: finished data: | diff --git a/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml b/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml index b4c53a107b..0401a01c31 100644 --- a/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml @@ -2,9 +2,7 @@ metadata: display_name: Math Expression Input rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished data: |

diff --git a/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml index 3ef619d54b..ab1f22e3b2 100644 --- a/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/imageresponse.yaml @@ -2,9 +2,7 @@ metadata: display_name: Image Mapped Input rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished data: |

diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml index 3a35a35199..10d51de280 100644 --- a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml @@ -2,9 +2,7 @@ metadata: display_name: Multiple Choice rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished markdown: "A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early diff --git a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml index 1dc46f5f51..548fd94fab 100644 --- a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml @@ -2,9 +2,7 @@ metadata: display_name: Numerical Input rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished markdown: "A numerical input problem accepts a line of text input from the student, and evaluates the input for correctness based on its diff --git a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml index f523c7fdc5..c2edfb1cbc 100644 --- a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml @@ -2,9 +2,7 @@ metadata: display_name: Dropdown rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished markdown: "Dropdown problems give a limited set of options for students to respond with, and present those options in a format that encourages them to search for a specific answer rather than being immediately presented diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml index c018d3f6cf..64e3dc062f 100644 --- a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml @@ -2,9 +2,7 @@ metadata: display_name: Text Input rerandomize: never - showanswer: always - weight: "" - attempts: "" + showanswer: finished # Note, the extra newlines are needed to make the yaml parser add blank lines instead of folding markdown: "A text input problem accepts a line of text from the diff --git a/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml b/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml index 7f1c838ca9..53e9eeaae4 100644 --- a/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml +++ b/common/lib/xmodule/xmodule/templates/word_cloud/default.yaml @@ -1,9 +1,5 @@ --- metadata: display_name: Word cloud - version: 1 - num_inputs: 5 - num_top_words: 250 - display_student_percents: True data: {} children: [] diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 5fe57892be..a75dfc8d20 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -460,8 +460,8 @@ class ImportTestCase(BaseCourseTestCase): ) module = modulestore.get_instance(course.id, location) self.assertEqual(len(module.get_children()), 0) - self.assertEqual(module.num_inputs, '5') - self.assertEqual(module.num_top_words, '250') + self.assertEqual(module.num_inputs, 5) + self.assertEqual(module.num_top_words, 250) def test_cohort_config(self): """ diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py index e41bcdd73a..dd59ca2b48 100644 --- a/common/lib/xmodule/xmodule/tests/test_xml_module.py +++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py @@ -1,69 +1,141 @@ +# disable missing docstring +#pylint: disable=C0111 + from xmodule.x_module import XModuleFields -from xblock.core import Scope, String, Object -from xmodule.fields import Date, StringyInteger +from xblock.core import Scope, String, Object, Boolean +from xmodule.fields import Date, StringyInteger, StringyFloat from xmodule.xml_module import XmlDescriptor import unittest -from . import test_system +from .import test_system from mock import Mock +class CrazyJsonString(String): + def to_json(self, value): + return value + " JSON" + + class TestFields(object): # Will be returned by editable_metadata_fields. - max_attempts = StringyInteger(scope=Scope.settings, default=1000) + max_attempts = StringyInteger(scope=Scope.settings, default=1000, values={'min': 1, 'max': 10}) # Will not be returned by editable_metadata_fields because filtered out by non_editable_metadata_fields. due = Date(scope=Scope.settings) # Will not be returned by editable_metadata_fields because is not Scope.settings. student_answers = Object(scope=Scope.user_state) # Will be returned, and can override the inherited value from XModule. - display_name = String(scope=Scope.settings, default='local default') + display_name = String(scope=Scope.settings, default='local default', display_name='Local Display Name', + help='local help') + # Used for testing select type, effect of to_json method + string_select = CrazyJsonString( + scope=Scope.settings, + default='default value', + values=[{'display_name': 'first', 'value': 'value a'}, + {'display_name': 'second', 'value': 'value b'}] + ) + # Used for testing select type + float_select = StringyFloat(scope=Scope.settings, default=.999, values=[1.23, 0.98]) + # Used for testing float type + float_non_select = StringyFloat(scope=Scope.settings, default=.999, values={'min': 0, 'step': .3}) + # Used for testing that Booleans get mapped to select type + boolean_select = Boolean(scope=Scope.settings) class EditableMetadataFieldsTest(unittest.TestCase): - def test_display_name_field(self): editable_fields = self.get_xml_editable_fields({}) # Tests that the xblock fields (currently tags and name) get filtered out. # Also tests that xml_attributes is filtered out of XmlDescriptor. self.assertEqual(1, len(editable_fields), "Expected only 1 editable field for xml descriptor.") - self.assert_field_values(editable_fields, 'display_name', XModuleFields.display_name, - explicitly_set=False, inheritable=False, value=None, default_value=None) + self.assert_field_values( + editable_fields, 'display_name', XModuleFields.display_name, + explicitly_set=False, inheritable=False, value=None, default_value=None + ) def test_override_default(self): # Tests that explicitly_set is correct when a value overrides the default (not inheritable). editable_fields = self.get_xml_editable_fields({'display_name': 'foo'}) - self.assert_field_values(editable_fields, 'display_name', XModuleFields.display_name, - explicitly_set=True, inheritable=False, value='foo', default_value=None) + self.assert_field_values( + editable_fields, 'display_name', XModuleFields.display_name, + explicitly_set=True, inheritable=False, value='foo', default_value=None + ) - def test_additional_field(self): - descriptor = self.get_descriptor({'max_attempts' : '7'}) + def test_integer_field(self): + descriptor = self.get_descriptor({'max_attempts': '7'}) editable_fields = descriptor.editable_metadata_fields - self.assertEqual(2, len(editable_fields)) - self.assert_field_values(editable_fields, 'max_attempts', TestFields.max_attempts, - explicitly_set=True, inheritable=False, value=7, default_value=1000) - self.assert_field_values(editable_fields, 'display_name', XModuleFields.display_name, - explicitly_set=False, inheritable=False, value='local default', default_value='local default') + self.assertEqual(6, len(editable_fields)) + self.assert_field_values( + editable_fields, 'max_attempts', TestFields.max_attempts, + explicitly_set=True, inheritable=False, value=7, default_value=1000, type='Integer', + options=TestFields.max_attempts.values + ) + self.assert_field_values( + editable_fields, 'display_name', TestFields.display_name, + explicitly_set=False, inheritable=False, value='local default', default_value='local default' + ) editable_fields = self.get_descriptor({}).editable_metadata_fields - self.assert_field_values(editable_fields, 'max_attempts', TestFields.max_attempts, - explicitly_set=False, inheritable=False, value=1000, default_value=1000) + self.assert_field_values( + editable_fields, 'max_attempts', TestFields.max_attempts, + explicitly_set=False, inheritable=False, value=1000, default_value=1000, type='Integer', + options=TestFields.max_attempts.values + ) def test_inherited_field(self): - model_val = {'display_name' : 'inherited'} + model_val = {'display_name': 'inherited'} descriptor = self.get_descriptor(model_val) # Mimic an inherited value for display_name (inherited and inheritable are the same in this case). descriptor._inherited_metadata = model_val descriptor._inheritable_metadata = model_val editable_fields = descriptor.editable_metadata_fields - self.assert_field_values(editable_fields, 'display_name', XModuleFields.display_name, - explicitly_set=False, inheritable=True, value='inherited', default_value='inherited') + self.assert_field_values( + editable_fields, 'display_name', TestFields.display_name, + explicitly_set=False, inheritable=True, value='inherited', default_value='inherited' + ) - descriptor = self.get_descriptor({'display_name' : 'explicit'}) + descriptor = self.get_descriptor({'display_name': 'explicit'}) # Mimic the case where display_name WOULD have been inherited, except we explicitly set it. - descriptor._inheritable_metadata = {'display_name' : 'inheritable value'} + descriptor._inheritable_metadata = {'display_name': 'inheritable value'} descriptor._inherited_metadata = {} editable_fields = descriptor.editable_metadata_fields - self.assert_field_values(editable_fields, 'display_name', XModuleFields.display_name, - explicitly_set=True, inheritable=True, value='explicit', default_value='inheritable value') + self.assert_field_values( + editable_fields, 'display_name', TestFields.display_name, + explicitly_set=True, inheritable=True, value='explicit', default_value='inheritable value' + ) + + def test_type_and_options(self): + # test_display_name_field verifies that a String field is of type "Generic". + # test_integer_field verifies that a StringyInteger field is of type "Integer". + + descriptor = self.get_descriptor({}) + editable_fields = descriptor.editable_metadata_fields + + # Tests for select + self.assert_field_values( + editable_fields, 'string_select', TestFields.string_select, + explicitly_set=False, inheritable=False, value='default value', default_value='default value', + type='Select', options=[{'display_name': 'first', 'value': 'value a JSON'}, + {'display_name': 'second', 'value': 'value b JSON'}] + ) + + self.assert_field_values( + editable_fields, 'float_select', TestFields.float_select, + explicitly_set=False, inheritable=False, value=.999, default_value=.999, + type='Select', options=[1.23, 0.98] + ) + + self.assert_field_values( + editable_fields, 'boolean_select', TestFields.boolean_select, + explicitly_set=False, inheritable=False, value=None, default_value=None, + type='Select', options=[{'display_name': "True", "value": True}, {'display_name': "False", "value": False}] + ) + + # Test for float + self.assert_field_values( + editable_fields, 'float_non_select', TestFields.float_non_select, + explicitly_set=False, inheritable=False, value=.999, default_value=.999, + type='Float', options={'min': 0, 'step': .3} + ) + # Start of helper methods def get_xml_editable_fields(self, model_data): @@ -73,7 +145,6 @@ class EditableMetadataFieldsTest(unittest.TestCase): def get_descriptor(self, model_data): class TestModuleDescriptor(TestFields, XmlDescriptor): - @property def non_editable_metadata_fields(self): non_editable_fields = super(TestModuleDescriptor, self).non_editable_metadata_fields @@ -84,10 +155,19 @@ class EditableMetadataFieldsTest(unittest.TestCase): system.render_template = Mock(return_value="

Test Template HTML
") return TestModuleDescriptor(system=system, location=None, model_data=model_data) - def assert_field_values(self, editable_fields, name, field, explicitly_set, inheritable, value, default_value): + def assert_field_values(self, editable_fields, name, field, explicitly_set, inheritable, value, default_value, + type='Generic', options=[]): test_field = editable_fields[name] - self.assertEqual(field, test_field['field']) + + self.assertEqual(field.name, test_field['field_name']) + self.assertEqual(field.display_name, test_field['display_name']) + self.assertEqual(field.help, test_field['help']) + + self.assertEqual(field.to_json(value), test_field['value']) + self.assertEqual(field.to_json(default_value), test_field['default_value']) + + self.assertEqual(options, test_field['options']) + self.assertEqual(type, test_field['type']) + self.assertEqual(explicitly_set, test_field['explicitly_set']) self.assertEqual(inheritable, test_field['inheritable']) - self.assertEqual(value, test_field['value']) - self.assertEqual(default_value, test_field['default_value']) diff --git a/common/lib/xmodule/xmodule/word_cloud_module.py b/common/lib/xmodule/xmodule/word_cloud_module.py index 440da8b887..e38b8cf195 100644 --- a/common/lib/xmodule/xmodule/word_cloud_module.py +++ b/common/lib/xmodule/xmodule/word_cloud_module.py @@ -2,7 +2,7 @@ generate and view word cloud. On the client side we show: -If student does not yet anwered - `num_inputs` numbers of text inputs. +If student does not yet answered - `num_inputs` numbers of text inputs. If student have answered - words he entered and cloud. """ @@ -14,7 +14,8 @@ from xmodule.raw_module import RawDescriptor from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.x_module import XModule -from xblock.core import Scope, String, Object, Boolean, List, Integer +from xblock.core import Scope, Object, Boolean, List +from fields import StringyBoolean, StringyInteger log = logging.getLogger(__name__) @@ -31,22 +32,23 @@ def pretty_bool(value): class WordCloudFields(object): """XFields for word cloud.""" - display_name = String( - help="Display name for this module", - scope=Scope.settings - ) - num_inputs = Integer( - help="Number of inputs.", + num_inputs = StringyInteger( + display_name="Inputs", + help="Number of text boxes available for students to input words/sentences.", scope=Scope.settings, - default=5 + default=5, + values={"min": 1} ) - num_top_words = Integer( - help="Number of max words, which will be displayed.", + num_top_words = StringyInteger( + display_name="Maximum Words", + help="Maximum number of words to be displayed in generated word cloud.", scope=Scope.settings, - default=250 + default=250, + values={"min": 1} ) - display_student_percents = Boolean( - help="Display usage percents for each word?", + display_student_percents = StringyBoolean( + display_name="Show Percents", + help="Statistics are shown for entered words near that word.", scope=Scope.settings, default=True ) @@ -205,7 +207,7 @@ class WordCloudModule(WordCloudFields, XModule): # Update top_words. self.top_words = self.top_dict( temp_all_words, - int(self.num_top_words) + self.num_top_words ) # Save all_words in database. @@ -226,7 +228,7 @@ class WordCloudModule(WordCloudFields, XModule): 'element_id': self.location.html_id(), 'element_class': self.location.category, 'ajax_url': self.system.ajax_url, - 'num_inputs': int(self.num_inputs), + 'num_inputs': self.num_inputs, 'submitted': self.submitted } self.content = self.system.render_template('word_cloud.html', context) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 76ac6a1ff6..3ae70543cb 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1,4 +1,5 @@ import logging +import copy import yaml import os @@ -9,7 +10,7 @@ from pkg_resources import resource_listdir, resource_string, resource_isdir from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError -from xblock.core import XBlock, Scope, String +from xblock.core import XBlock, Scope, String, Integer, Float log = logging.getLogger(__name__) @@ -75,12 +76,13 @@ class HTMLSnippet(object): """ raise NotImplementedError( "get_html() must be provided by specific modules - not present in {0}" - .format(self.__class__)) + .format(self.__class__)) class XModuleFields(object): display_name = String( - help="Display name for this module", + display_name="Display Name", + help="This name appears in the horizontal navigation at the top of the page.", scope=Scope.settings, default=None ) @@ -356,7 +358,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): metadata_translations = { 'slug': 'url_name', 'name': 'display_name', - } + } # ============================= STRUCTURAL MANIPULATION =================== def __init__(self, @@ -458,7 +460,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): """ return False - # ================================= JSON PARSING =========================== @staticmethod def load_from_json(json_data, system, default_class=None): @@ -523,10 +524,10 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): # ================================= XML PARSING ============================ @staticmethod def load_from_xml(xml_data, - system, - org=None, - course=None, - default_class=None): + system, + org=None, + course=None, + default_class=None): """ This method instantiates the correct subclass of XModuleDescriptor based on the contents of xml_data. @@ -541,7 +542,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): class_ = XModuleDescriptor.load_class( etree.fromstring(xml_data).tag, default_class - ) + ) # leave next line, commented out - useful for low-level debugging # log.debug('[XModuleDescriptor.load_from_xml] tag=%s, class_=%s' % ( # etree.fromstring(xml_data).tag,class_)) @@ -625,7 +626,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): """ inherited_metadata = getattr(self, '_inherited_metadata', {}) inheritable_metadata = getattr(self, '_inheritable_metadata', {}) - metadata = {} + metadata_fields = {} for field in self.fields: if field.scope != Scope.settings or field in self.non_editable_metadata_fields: @@ -641,13 +642,39 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): if field.name in inherited_metadata: explicitly_set = False - metadata[field.name] = {'field': field, - 'value': value, - 'default_value': default_value, - 'inheritable': inheritable, - 'explicitly_set': explicitly_set } + # We support the following editors: + # 1. A select editor for fields with a list of possible values (includes Booleans). + # 2. Number editors for integers and floats. + # 3. A generic string editor for anything else (editing JSON representation of the value). + type = "Generic" + values = [] if field.values is None else copy.deepcopy(field.values) + if isinstance(values, tuple): + values = list(values) + if isinstance(values, list): + if len(values) > 0: + type = "Select" + for index, choice in enumerate(values): + json_choice = copy.deepcopy(choice) + if isinstance(json_choice, dict) and 'value' in json_choice: + json_choice['value'] = field.to_json(json_choice['value']) + else: + json_choice = field.to_json(json_choice) + values[index] = json_choice + elif isinstance(field, Integer): + type = "Integer" + elif isinstance(field, Float): + type = "Float" + metadata_fields[field.name] = {'field_name': field.name, + 'type': type, + 'display_name': field.display_name, + 'value': field.to_json(value), + 'options': values, + 'default_value': field.to_json(default_value), + 'inheritable': inheritable, + 'explicitly_set': explicitly_set, + 'help': field.help} - return metadata + return metadata_fields class DescriptorSystem(object): @@ -740,7 +767,7 @@ class ModuleSystem(object): s3_interface=None, cache=None, can_execute_unsafe_code=None, - ): + ): ''' Create a closure around the system environment. diff --git a/common/static/css/vendor/html5-input-polyfills/number-polyfill.css b/common/static/css/vendor/html5-input-polyfills/number-polyfill.css new file mode 100644 index 0000000000..f3d8805739 --- /dev/null +++ b/common/static/css/vendor/html5-input-polyfills/number-polyfill.css @@ -0,0 +1,87 @@ +/* HTML5 Number polyfill | Jonathan Stipe | https://github.com/jonstipe/number-polyfill*/ +div.number-spin-btn-container { + display: inline-block; + position: absolute; + vertical-align: middle; + margin: 0 0 0 3px; + padding: 0; + left: 74%; + top: 6px; +} + +div.number-spin-btn { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; + border-width: 2px; + border-color: #ededed #777777 #777777 #ededed; + border-style: solid; + background-color: #eeeeee; + width: 1em; + font-size: 14px; } + div.number-spin-btn:hover { + /* added blue hover color */ + background-color: rgb(85, 151, 221); + cursor: pointer; } + div.number-spin-btn:active { + border-width: 2px; + border-color: #5e5e5e #d8d8d8 #d8d8d8 #5e5e5e; + border-style: solid; + background-color: #999999; } + +div.number-spin-btn-up { + border-bottom-width: 1px; + -moz-border-radius: 0px; + -webkit-border-radius: 0px; + border-radius: 0px; + font-size: 14px; } + div.number-spin-btn-up:before { + border-width: 0 0.3em 0.3em 0.3em; + border-color: transparent transparent black transparent; + top: 25%; } + div.number-spin-btn-up:active { + border-bottom-width: 1px; } + div.number-spin-btn-up:active:before { + border-bottom-color: white; + top: 26%; + left: 51%; } + +div.number-spin-btn-down { + border-top-width: 1px; + -moz-border-radius: 0px 0px 3px 3px; + -webkit-border-radius: 0px 0px 3px 3px; + border-radius: 0px 0px 3px 3px; } + div.number-spin-btn-down:before { + border-width: 0.3em 0.3em 0 0.3em; + border-color: black transparent transparent transparent; + top: 75%; } + div.number-spin-btn-down:active { + border-top-width: 1px; } + div.number-spin-btn-down:active:before { + border-top-color: white; + top: 76%; + left: 51%; } + +div.number-spin-btn-up:before, +div.number-spin-btn-down:before { + content: ""; + width: 0; + height: 0; + border-style: solid; + position: absolute; + left: 50%; + margin: -0.15em 0 0 -0.3em; + padding: 0; } + +input:disabled + div.number-spin-btn-container > div.number-spin-btn-up:active, input:disabled + div.number-spin-btn-container > div.number-spin-btn-down:active { + border-color: #ededed #777777 #777777 #ededed; + border-style: solid; + background-color: #cccccc; } +input:disabled + div.number-spin-btn-container > div.number-spin-btn-up:before, input:disabled + div.number-spin-btn-container > div.number-spin-btn-up:active:before { + border-bottom-color: #999999; + top: 25%; + left: 50%; } +input:disabled + div.number-spin-btn-container > div.number-spin-btn-down:before, input:disabled + div.number-spin-btn-container > div.number-spin-btn-down:active:before { + border-top-color: #999999; + top: 75%; + left: 50%; } diff --git a/common/static/js/vendor/html5-input-polyfills/number-polyfill.js b/common/static/js/vendor/html5-input-polyfills/number-polyfill.js new file mode 100644 index 0000000000..9e95972c00 --- /dev/null +++ b/common/static/js/vendor/html5-input-polyfills/number-polyfill.js @@ -0,0 +1,298 @@ +// Generated by CoffeeScript 1.4.0 + +/* +HTML5 Number polyfill | Jonathan Stipe | https://github.com/jonstipe/number-polyfill +*/ + +(function() { + + (function($) { + var i; + i = document.createElement("input"); + i.setAttribute("type", "number"); + if (i.type === "text") { + $.fn.inputNumber = function() { + var clipValues, decrement, domMouseScrollHandler, extractNumDecimalDigits, getParams, increment, matchStep, mouseWheelHandler; + getParams = function(elem) { + var $elem, max, min, step, val; + $elem = $(elem); + step = $elem.attr('step'); + min = $elem.attr('min'); + max = $elem.attr('max'); + val = parseFloat($elem.val()); + step = /^-?\d+(?:\.\d+)?$/.test(step) ? parseFloat(step) : null; + min = /^-?\d+(?:\.\d+)?$/.test(min) ? parseFloat(min) : null; + max = /^-?\d+(?:\.\d+)?$/.test(max) ? parseFloat(max) : null; + if (isNaN(val)) { + val = min || 0; + } + return { + min: min, + max: max, + step: step, + val: val + }; + }; + clipValues = function(value, min, max) { + if ((max != null) && value > max) { + return max; + } else if ((min != null) && value < min) { + return min; + } else { + return value; + } + }; + extractNumDecimalDigits = function(input) { + var num, raisedNum; + if (input != null) { + num = 0; + raisedNum = input; + while (raisedNum !== Math.round(raisedNum)) { + num += 1; + raisedNum = input * Math.pow(10, num); + } + return num; + } else { + return 0; + } + }; + matchStep = function(value, min, max, step) { + var mod, raiseTo, raisedMod, raisedStep, raisedStepDown, raisedStepUp, raisedValue, stepDecimalDigits, stepDown, stepUp; + stepDecimalDigits = extractNumDecimalDigits(step); + if (step == null) { + return value; + } else if (stepDecimalDigits === 0) { + mod = (value - (min || 0)) % step; + if (mod === 0) { + return value; + } else { + stepDown = value - mod; + stepUp = stepDown + step; + if ((stepUp > max) || ((value - stepDown) < (stepUp - value))) { + return stepDown; + } else { + return stepUp; + } + } + } else { + raiseTo = Math.pow(10, stepDecimalDigits); + raisedStep = step * raiseTo; + raisedMod = (value - (min || 0)) * raiseTo % raisedStep; + if (raisedMod === 0) { + return value; + } else { + raisedValue = value * raiseTo; + raisedStepDown = raisedValue - raisedMod; + raisedStepUp = raisedStepDown + raisedStep; + if (((raisedStepUp / raiseTo) > max) || ((raisedValue - raisedStepDown) < (raisedStepUp - raisedValue))) { + return raisedStepDown / raiseTo; + } else { + return raisedStepUp / raiseTo; + } + } + } + }; + increment = function(elem) { + var newVal, params, raiseTo; + if (!$(elem).is(":disabled")) { + params = getParams(elem); + raiseTo = Math.pow(10, Math.max(extractNumDecimalDigits(params['val']), extractNumDecimalDigits(params['step']))); + newVal = (Math.round(params['val'] * raiseTo) + Math.round((params['step'] || 1) * raiseTo)) / raiseTo; + if ((params['max'] != null) && newVal > params['max']) { + newVal = params['max']; + } + newVal = matchStep(newVal, params['min'], params['max'], params['step']); + $(elem).val(newVal).change(); + } + return null; + }; + decrement = function(elem) { + var newVal, params, raiseTo; + if (!$(elem).is(":disabled")) { + params = getParams(elem); + raiseTo = Math.pow(10, Math.max(extractNumDecimalDigits(params['val']), extractNumDecimalDigits(params['step']))); + newVal = (Math.round(params['val'] * raiseTo) - Math.round((params['step'] || 1) * raiseTo)) / raiseTo; + if ((params['min'] != null) && newVal < params['min']) { + newVal = params['min']; + } + newVal = matchStep(newVal, params['min'], params['max'], params['step']); + $(elem).val(newVal).change(); + } + return null; + }; + domMouseScrollHandler = function(e) { + e.preventDefault(); + if (e.originalEvent.detail < 0) { + increment(this); + } else { + decrement(this); + } + return null; + }; + mouseWheelHandler = function(e) { + e.preventDefault(); + if (e.originalEvent.wheelDelta > 0) { + increment(this); + } else { + decrement(this); + } + return null; + }; + $(this).filter('input[type="number"]').each(function() { + var $downBtn, $elem, $upBtn, attrMutationCallback, attrObserver, btnContainer, downBtn, elem, halfHeight, upBtn; + elem = this; + $elem = $(elem); + halfHeight = ($elem.outerHeight() / 6) + 'px'; + upBtn = document.createElement('div'); + downBtn = document.createElement('div'); + $upBtn = $(upBtn); + $downBtn = $(downBtn); + btnContainer = document.createElement('div'); + $upBtn.addClass('number-spin-btn number-spin-btn-up').css('height', halfHeight); + $downBtn.addClass('number-spin-btn number-spin-btn-down').css('height', halfHeight); + btnContainer.appendChild(upBtn); + btnContainer.appendChild(downBtn); + $(btnContainer).addClass('number-spin-btn-container').insertAfter(elem); + $elem.on({ + focus: function(e) { + $elem.on({ + DOMMouseScroll: domMouseScrollHandler, + mousewheel: mouseWheelHandler + }); + return null; + }, + blur: function(e) { + $elem.off({ + DOMMouseScroll: domMouseScrollHandler, + mousewheel: mouseWheelHandler + }); + return null; + }, + keypress: function(e) { + var _ref, _ref1; + if (e.keyCode === 38) { + increment(this); + } else if (e.keyCode === 40) { + decrement(this); + } else if (((_ref = e.keyCode) !== 8 && _ref !== 9 && _ref !== 35 && _ref !== 36 && _ref !== 37 && _ref !== 39) && ((_ref1 = e.which) !== 45 && _ref1 !== 46 && _ref1 !== 48 && _ref1 !== 49 && _ref1 !== 50 && _ref1 !== 51 && _ref1 !== 52 && _ref1 !== 53 && _ref1 !== 54 && _ref1 !== 55 && _ref1 !== 56 && _ref1 !== 57)) { + e.preventDefault(); + } + return null; + }, + change: function(e) { + var newVal, params; + if (e.originalEvent != null) { + params = getParams(this); + newVal = clipValues(params['val'], params['min'], params['max']); + newVal = matchStep(newVal, params['min'], params['max'], params['step'], params['stepDecimal']); + $(this).val(newVal); + } + return null; + } + }); + $upBtn.on("mousedown", function(e) { + var releaseFunc, timeoutFunc; + increment(elem); + timeoutFunc = function(elem, incFunc) { + incFunc(elem); + $elem.data("timeoutID", window.setTimeout(timeoutFunc, 10, elem, incFunc)); + return null; + }; + releaseFunc = function(e) { + window.clearTimeout($elem.data("timeoutID")); + $(document).off('mouseup', releaseFunc); + $upBtn.off('mouseleave', releaseFunc); + return null; + }; + $(document).on('mouseup', releaseFunc); + $upBtn.on('mouseleave', releaseFunc); + $elem.data("timeoutID", window.setTimeout(timeoutFunc, 700, elem, increment)); + return null; + }); + $downBtn.on("mousedown", function(e) { + var releaseFunc, timeoutFunc; + decrement(elem); + timeoutFunc = function(elem, decFunc) { + decFunc(elem); + $elem.data("timeoutID", window.setTimeout(timeoutFunc, 10, elem, decFunc)); + return null; + }; + releaseFunc = function(e) { + window.clearTimeout($elem.data("timeoutID")); + $(document).off('mouseup', releaseFunc); + $downBtn.off('mouseleave', releaseFunc); + return null; + }; + $(document).on('mouseup', releaseFunc); + $downBtn.on('mouseleave', releaseFunc); + $elem.data("timeoutID", window.setTimeout(timeoutFunc, 700, elem, decrement)); + return null; + }); + $elem.css("textAlign", 'left'); + if ($elem.css("opacity") !== "1") { + $(btnContainer).css("opacity", $elem.css("opacity")); + } + if ($elem.css("visibility") !== "visible") { + $(btnContainer).css("visibility", $elem.css("visibility")); + } + if (elem.style.display !== "") { + $(btnContainer).css("display", $elem.css("display")); + } + if ((typeof WebKitMutationObserver !== "undefined" && WebKitMutationObserver !== null) || (typeof MutationObserver !== "undefined" && MutationObserver !== null)) { + attrMutationCallback = function(mutations, observer) { + var mutation, _i, _len; + for (_i = 0, _len = mutations.length; _i < _len; _i++) { + mutation = mutations[_i]; + if (mutation.type === "attributes") { + if (mutation.attributeName === "class") { + $(btnContainer).removeClass(mutation.oldValue).addClass(elem.className); + } else if (mutation.attributeName === "style") { + $(btnContainer).css({ + "opacity": elem.style.opacity, + "visibility": elem.style.visibility, + "display": elem.style.display + }); + } + } + } + return null; + }; + attrObserver = (typeof WebKitMutationObserver !== "undefined" && WebKitMutationObserver !== null) ? new WebKitMutationObserver(attrMutationCallback) : ((typeof MutationObserver !== "undefined" && MutationObserver !== null) ? new MutationObserver(attrMutationCallback) : null); + attrObserver.observe(elem, { + attributes: true, + attributeOldValue: true, + attributeFilter: ["class", "style"] + }); + } else if (typeof MutationEvent !== "undefined" && MutationEvent !== null) { + $elem.on("DOMAttrModified", function(evt) { + if (evt.originalEvent.attrName === "class") { + $(btnContainer).removeClass(evt.originalEvent.prevValue).addClass(evt.originalEvent.newValue); + } else if (evt.originalEvent.attrName === "style") { + $(btnContainer).css({ + "display": elem.style.display, + "visibility": elem.style.visibility, + "opacity": elem.style.opacity + }); + } + return null; + }); + } + return null; + }); + return $(this); + }; + $(function() { + $('input[type="number"]').inputNumber(); + return null; + }); + null; + } else { + $.fn.inputNumber = function() { + return $(this); + }; + null; + } + return null; + })(jQuery); + +}).call(this); diff --git a/common/static/js/vendor/tiny_mce/themes/advanced/skins/studio/ui.css b/common/static/js/vendor/tiny_mce/themes/advanced/skins/studio/ui.css index b2d3e494aa..fc97eb6450 100644 --- a/common/static/js/vendor/tiny_mce/themes/advanced/skins/studio/ui.css +++ b/common/static/js/vendor/tiny_mce/themes/advanced/skins/studio/ui.css @@ -25,8 +25,8 @@ /* Layout */ .studioSkin table.mceLayout {border:0;} -.studioSkin table.mceLayout tr.mceFirst td {border-top:1px solid #3c3c3c;} -.studioSkin table.mceLayout tr.mceLast td {border-bottom:1px solid #3c3c3c;} +.studioSkin table.mceLayout tr.mceFirst td {border-top: 1px solid #D1DCE6; border-left: none; border-right:none;} +.studioSkin table.mceLayout tr.mceLast td {border-bottom:none;} .studioSkin table.mceToolbar, .studioSkin tr.mceFirst .mceToolbar tr td, .studioSkin tr.mceLast .mceToolbar tr td {border:0; margin:0; padding:0;} .studioSkin td.mceToolbar { background: -webkit-linear-gradient(top, #d4dee8, #c9d5e2); @@ -36,11 +36,11 @@ background: linear-gradient(top, #d4dee8, #c9d5e2); border: 1px solid #3c3c3c; border-bottom-color: #a5aaaf; - border-radius: 3px 3px 0 0; + border-radius: 0; padding: 10px 10px 9px; vertical-align: top; } -.studioSkin .mceIframeContainer {border: 1px solid #3c3c3c; border-top: none;} +.studioSkin .mceIframeContainer {border: 1px solid white; border-top: none;} .studioSkin .mceStatusbar {background:#F0F0EE; font-size:9pt; line-height:16px; overflow:visible; color:#000; display:block; height:20px} .studioSkin .mceStatusbar div {float:left; margin:2px} .studioSkin .mceStatusbar a.mceResize {display:block; float:right; background:url(../../img/studio-icons.png) -800px 0; width:20px; height:20px; cursor:se-resize; outline:0} diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss index 88d3fd88a3..ddfe4a88f1 100644 --- a/lms/static/sass/_discussion.scss +++ b/lms/static/sass/_discussion.scss @@ -85,8 +85,8 @@ } @-webkit-keyframes fadeIn { - 0% { opacity: 0; } - 100% { opacity: 1; } + 0% { opacity: 0.0; } + 100% { opacity: 1.0; } } @@ -736,11 +736,11 @@ body.discussion { &.is-open { .browse-topic-drop-btn span { - opacity: 1; + opacity: 1.0; } .browse-topic-drop-icon { - opacity: 0; + opacity: 0.0; } &.is-dropped { @@ -788,7 +788,7 @@ body.discussion { &::-webkit-input-placeholder, &:-moz-placeholder, &:-ms-input-placeholder { - opacity: 1; + opacity: 1.0; } } } @@ -818,7 +818,7 @@ body.discussion { line-height: 58px; color: #333; text-shadow: 0 1px 0 rgba(255, 255, 255, .8); - opacity: 0; + opacity: 0.0; @include transition(opacity .2s); } } @@ -833,7 +833,7 @@ body.discussion { height: 16px; margin-left: -12px; background: url(../images/browse-icon.png) no-repeat; - opacity: 1; + opacity: 1.0; @include transition(none); } @@ -967,7 +967,7 @@ body.discussion { &::-webkit-input-placeholder, &:-moz-placeholder, &:-ms-input-placeholder { - opacity: 0; + opacity: 0.0; @include transition(opacity .2s); } @@ -2454,7 +2454,7 @@ body.discussion { font-style: italic; cursor:pointer; margin-right: 10px; - opacity:.8; + opacity: 0.8; span { cursor: pointer; @@ -2462,7 +2462,7 @@ body.discussion { &:hover { @include transition(opacity .2s); - opacity: 1; + opacity: 1.0; } } @@ -2475,7 +2475,7 @@ body.discussion { top:-13px; margin-right:35px; margin-top:13px; - opacity: 1; + opacity: 1.0; } .notpinned .icon { @@ -2523,11 +2523,11 @@ display:none; padding-right: 5px; font-style: italic; cursor:pointer; - opacity:.8; + opacity: 0.8; &:hover { @include transition(opacity .2s); - opacity: 1; + opacity: 1.0; } } diff --git a/lms/static/sass/base/_animations.scss b/lms/static/sass/base/_animations.scss index 0c95e6b5af..4c4620ca27 100644 --- a/lms/static/sass/base/_animations.scss +++ b/lms/static/sass/base/_animations.scss @@ -9,12 +9,12 @@ @mixin home-header-pop-up-keyframes { 0% { - opacity: 0; + opacity: 0.0; top: 300px; //@include transform(scale(0.9)); } 45% { - opacity: 1; + opacity: 1.0; } 65% { top: -40px; @@ -43,19 +43,19 @@ @mixin title-appear-keyframes { 0% { - opacity: 0; + opacity: 0.0; top: 60px; @include transform(scale(0.9)); } 20% { - opacity: 1; + opacity: 1.0; } 27% { // this % of total-time should be ~ 1.25s top: 40px; @include transform(scale(1)); } 90% { // this % of total-time is when 2nd half of animation starts - opacity: 1; + opacity: 1.0; top: 40px; @include transform(scale(1)); } @@ -79,24 +79,24 @@ @mixin home-appear-keyframes { 0% { - opacity: 0; + opacity: 0.0; top: 60px; @include transform(scale(0.9)); } 20% { - opacity: 1; + opacity: 1.0; } 30% { // this % of total-time should be ~ 1.25s top: 40px; @include transform(scale(1)); } 80% { // this % of total-time is when 2nd half of animation starts - opacity: 1; + opacity: 1.0; top: 40px; @include transform(scale(1)); } 100% { - opacity: 0; + opacity: 0.0; top: 60px; @include transform(scale(0.7)); } @@ -117,10 +117,10 @@ @mixin edx-appear-keyframes { 0% { - opacity: 0; + opacity: 0.0; } 100% { - opacity: 1; + opacity: 1.0; } } @@ -231,7 +231,7 @@ opacity: 0.9; } 80% { - opacity: 1; + opacity: 1.0; } 100% { bottom: 0px; diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss index 1d72ae3199..83aca09ab6 100644 --- a/lms/static/sass/course/_textbook.scss +++ b/lms/static/sass/course/_textbook.scss @@ -38,7 +38,7 @@ div.book-wrapper { line-height: 2.1em; text-align: right; color: #9a9a9a; - opacity: 0; + opacity: 0.0; @include transition(opacity .15s); } @@ -55,7 +55,7 @@ div.book-wrapper { background-color: transparent; .page-number { - opacity: 1; + opacity: 1.0; } } } @@ -119,7 +119,7 @@ div.book-wrapper { @include box-sizing(border-box); display: table; height: 100%; - opacity: 0; + opacity: 0.0; filter: alpha(opacity=0); text-indent: -9999px; @include transition; @@ -127,7 +127,7 @@ div.book-wrapper { width: 100%; &:hover { - opacity: 1; + opacity: 1.0; filter: alpha(opacity=100); } } diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss index 6cf6f6a602..24bda451a7 100644 --- a/lms/static/sass/course/courseware/_sidebar.scss +++ b/lms/static/sass/course/courseware/_sidebar.scss @@ -61,7 +61,7 @@ section.course-index { span.ui-icon { left: 0; background-image: url("/static/images/ui-icons_222222_256x240.png"); - opacity: .3; + opacity: 0.3; } } } @@ -146,7 +146,7 @@ section.course-index { @include box-shadow(inset 0 1px 14px 0 rgba(0,0,0, 0.1)); &:after { - opacity: 1; + opacity: 1.0; right: 15px; } } @@ -174,7 +174,7 @@ section.course-index { background: $sidebar-active-image; &:after { - opacity: 1; + opacity: 1.0; right: 15px; } diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/layout/_calculator.scss index 2819546f9f..c0a8764a8c 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/layout/_calculator.scss @@ -27,7 +27,7 @@ div.calc-main { width: 16px; &:hover { - opacity: .8; + opacity: 0.8; } &.closed { @@ -136,7 +136,7 @@ div.calc-main { &.shown { display: block; - opacity: 1; + opacity: 1.0; } dt { diff --git a/lms/static/sass/ie.scss b/lms/static/sass/ie.scss index 4b0f5aa3c0..e03b711bae 100644 --- a/lms/static/sass/ie.scss +++ b/lms/static/sass/ie.scss @@ -26,7 +26,7 @@ header.global { } h2 { - opacity: 1; + opacity: 1.0; } } @@ -51,7 +51,7 @@ header.global { text-decoration: none; &::before { - opacity: 1; + opacity: 1.0; } .name { diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index 9eab7c0a4f..0008bf1efe 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -429,7 +429,7 @@ &:hover { .sharing-message { - opacity: 1; + opacity: 1.0; top: 56px; } } @@ -470,7 +470,7 @@ width: 44px; &:hover { - opacity: 1; + opacity: 1.0; } img { @@ -514,7 +514,7 @@ &:hover { .icon { - opacity: 1; + opacity: 1.0; } } diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index b173647550..c0dac89199 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -74,7 +74,7 @@ &:hover { .title .icon { - opacity: 1; + opacity: 1.0; } } diff --git a/lms/static/sass/multicourse/_home.scss b/lms/static/sass/multicourse/_home.scss index ea8ddaf654..d4def21f5c 100644 --- a/lms/static/sass/multicourse/_home.scss +++ b/lms/static/sass/multicourse/_home.scss @@ -53,7 +53,7 @@ @include box-sizing(border-box); @include inline-block; left: 0px; - opacity: 1; + opacity: 1.0; padding: 20px 30px; top: 0px; @include transition(all, 0.2s, linear); @@ -312,7 +312,7 @@ text-decoration: none; &::before { - opacity: 1; + opacity: 1.0; } .name { diff --git a/lms/static/sass/shared/_course_object.scss b/lms/static/sass/shared/_course_object.scss index f78c483925..bd4a8dc049 100644 --- a/lms/static/sass/shared/_course_object.scss +++ b/lms/static/sass/shared/_course_object.scss @@ -117,7 +117,7 @@ .info-link { color: $link-color; - opacity: 1; + opacity: 1.0; } h2 {