diff --git a/.pylintrc b/.pylintrc index d1cdbb4780..9ea1e62ad4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -36,8 +36,13 @@ load-plugins= disable= # W0141: Used builtin function 'map' # W0142: Used * or ** magic +# R0201: Method could be a function +# R0901: Too many ancestors +# R0902: Too many instance attributes # R0903: Too few public methods (1/2) - W0141,W0142,R0903 +# R0904: Too many public methods +# R0913: Too many arguments + W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913 [REPORTS] @@ -133,7 +138,7 @@ bad-names=foo,bar,baz,toto,tutu,tata # Regular expression which should only match functions or classes name which do # not require a docstring -no-docstring-rgx=__.*__ +no-docstring-rgx=(__.*__|test_.*) [MISCELLANEOUS] diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 153d13dd13..589db4ac56 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -5,9 +5,11 @@ from lxml import html import re from django.http import HttpResponseBadRequest import logging +import django.utils -## TODO store as array of { date, content } and override course_info_module.definition_from_xml -## This should be in a class which inherits from XmlDescriptor +# # TODO store as array of { date, content } and override course_info_module.definition_from_xml +# # This should be in a class which inherits from XmlDescriptor +log = logging.getLogger(__name__) def get_course_updates(location): @@ -26,9 +28,11 @@ def get_course_updates(location): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = html.fromstring(course_updates.definition['data']) + course_html_parsed = html.fromstring(course_updates.data) except: - course_html_parsed = html.fromstring("
    ") + log.error("Cannot parse: " + course_updates.data) + escaped = django.utils.html.escape(course_updates.data) + course_html_parsed = html.fromstring("
    1. " + escaped + "
    ") # Confirm that root is
      , iterate over
    1. , pull out

      subs and then rest of val course_upd_collection = [] @@ -60,13 +64,15 @@ def update_course_updates(location, update, passed_id=None): try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: - return HttpResponseBadRequest + return HttpResponseBadRequest() # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = html.fromstring(course_updates.definition['data']) + course_html_parsed = html.fromstring(course_updates.data) except: - course_html_parsed = html.fromstring("
        ") + log.error("Cannot parse: " + course_updates.data) + escaped = django.utils.html.escape(course_updates.data) + course_html_parsed = html.fromstring("
        1. " + escaped + "
        ") # No try/catch b/c failure generates an error back to client new_html_parsed = html.fromstring('
      1. ' + update['date'] + '

        ' + update['content'] + '
      2. ') @@ -85,12 +91,18 @@ def update_course_updates(location, update, passed_id=None): passed_id = course_updates.location.url() + "/" + str(idx) # update db record - course_updates.definition['data'] = html.tostring(course_html_parsed) - modulestore('direct').update_item(location, course_updates.definition['data']) + course_updates.data = html.tostring(course_html_parsed) + modulestore('direct').update_item(location, course_updates.data) + + if (len(new_html_parsed) == 1): + content = new_html_parsed[0].tail + else: + content = "\n".join([html.tostring(ele) + for ele in new_html_parsed[1:]]) return {"id": passed_id, "date": update['date'], - "content": update['content']} + "content": content} def delete_course_update(location, update, passed_id): @@ -99,19 +111,21 @@ def delete_course_update(location, update, passed_id): Returns the resulting course_updates b/c their ids change. """ if not passed_id: - return HttpResponseBadRequest + return HttpResponseBadRequest() try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: - return HttpResponseBadRequest + return HttpResponseBadRequest() # TODO use delete_blank_text parser throughout and cache as a static var in a class # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = html.fromstring(course_updates.definition['data']) + course_html_parsed = html.fromstring(course_updates.data) except: - course_html_parsed = html.fromstring("
          ") + log.error("Cannot parse: " + course_updates.data) + escaped = django.utils.html.escape(course_updates.data) + course_html_parsed = html.fromstring("
          1. " + escaped + "
          ") if course_html_parsed.tag == 'ol': # ??? Should this use the id in the json or in the url or does it matter? @@ -122,9 +136,9 @@ def delete_course_update(location, update, passed_id): course_html_parsed.remove(element_to_delete) # update db record - course_updates.definition['data'] = html.tostring(course_html_parsed) + course_updates.data = html.tostring(course_html_parsed) store = modulestore('direct') - store.update_item(location, course_updates.definition['data']) + store.update_item(location, course_updates.data) return get_course_updates(location) @@ -133,7 +147,6 @@ def get_idx(passed_id): """ From the url w/ idx appended, get the idx. """ - # TODO compile this regex into a class static and reuse for each call - idx_matcher = re.search(r'.*/(\d+)$', passed_id) + idx_matcher = re.search(r'.*?/?(\d+)$', passed_id) if idx_matcher: return int(idx_matcher.group(1)) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 779d44e4b2..db7294c14c 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -1,54 +1,42 @@ Feature: Advanced (manual) course policy In order to specify course policy settings for which no custom user interface exists - I want to be able to manually enter JSON key/value pairs + I want to be able to manually enter JSON key /value pairs - Scenario: A course author sees only display_name on a newly created course + Scenario: A course author sees default advanced settings Given I have opened a new course in Studio When I select the Advanced Settings - Then I see only the display name + Then I see default advanced settings - @skip-phantom - Scenario: Test if there are no policy settings without existing UI controls + Scenario: Add new entries, and they appear alphabetically after save Given I am on the Advanced Course Settings page in Studio - When I delete the display name - Then there are no advanced policy settings - And I reload the page - Then there are no advanced policy settings - - @skip-phantom - Scenario: Test cancel editing key name - Given I am on the Advanced Course Settings page in Studio - When I edit the name of a policy key - And I press the "Cancel" notification button - Then the policy key name is unchanged - - Scenario: Test editing key name - Given I am on the Advanced Course Settings page in Studio - When I edit the name of a policy key - And I press the "Save" notification button - Then the policy key name is changed + Then the settings are alphabetized Scenario: Test cancel editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key And I press the "Cancel" notification button Then the policy key value is unchanged + And I reload the page + Then the policy key value is unchanged - @skip-phantom Scenario: Test editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key And I press the "Save" notification button Then the policy key value is changed - - Scenario: Add new entries, and they appear alphabetically after save - Given I am on the Advanced Course Settings page in Studio - When I create New Entries - Then they are alphabetized And I reload the page - Then they are alphabetized + Then the policy key value is changed Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio - When I create a JSON object + When I create a JSON object as a value Then it is displayed as formatted + And I reload the page + Then it is displayed as formatted + + Scenario: Test automatic quoting of non-JSON values + Given I am on the Advanced Course Settings page in Studio + When I create a non-JSON value not in quotes + Then it is displayed as a string + And I reload the page + Then it is displayed as a string diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 1024579b45..16562b6b15 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -1,8 +1,10 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * import time -from selenium.common.exceptions import WebDriverException -from selenium.webdriver.support import expected_conditions as EC +from terrain.steps import reload_the_page from nose.tools import assert_true, assert_false, assert_equal @@ -11,16 +13,20 @@ http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdrive """ from selenium.webdriver.common.keys import Keys +KEY_CSS = '.key input.policy-key' +VALUE_CSS = 'textarea.json' +DISPLAY_NAME_KEY = "display_name" +DISPLAY_NAME_VALUE = '"Robot Super Course"' ############### ACTIONS #################### + @step('I select the Advanced Settings$') def i_select_advanced_settings(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-settings-advanced a' - css_click(link_css) - # world.browser.click_link_by_text('Advanced Settings') + world.css_click(link_css) @step('I am on the Advanced Course Settings page in Studio$') @@ -29,35 +35,11 @@ def i_am_on_advanced_course_settings(step): step.given('I select the Advanced Settings') -# TODO: this is copied from terrain's step.py. Need to figure out how to share that code. -@step('I reload the page$') -def reload_the_page(step): - world.browser.reload() - - -@step(u'I edit the name of a policy key$') -def edit_the_name_of_a_policy_key(step): - policy_key_css = 'input.policy-key' - e = css_find(policy_key_css).first - e.type('_new') - - @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): - def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) - def is_invisible(driver): - return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,)) - css = 'a.%s-button' % name.lower() - wait_for(is_visible) + world.css_click_at(css) - try: - css_click_at(css) - wait_for(is_invisible) - except WebDriverException, e: - css_click_at(css) - wait_for(is_invisible) @step(u'I edit the value of a policy key$') def edit_the_value_of_a_policy_key(step): @@ -65,133 +47,86 @@ def edit_the_value_of_a_policy_key(step): It is hard to figure out how to get into the CodeMirror area, so cheat and do it from the policy key field :) """ - policy_key_css = 'input.policy-key' - e = css_find(policy_key_css).first + e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') -@step('I delete the display name$') -def delete_the_display_name(step): - delete_entry(0) - click_save() - - -@step('create New Entries$') -def create_new_entries(step): - create_entry("z", "apple") - create_entry("a", "zebra") - click_save() - - -@step('I create a JSON object$') +@step('I create a JSON object as a value$') def create_JSON_object(step): - create_entry("json", '{"key": "value", "key_2": "value_2"}') - click_save() + change_display_name_value(step, '{"key": "value", "key_2": "value_2"}') + + +@step('I create a non-JSON value not in quotes$') +def create_value_not_in_quotes(step): + change_display_name_value(step, 'quote me') ############### RESULTS #################### -@step('I see only the display name$') -def i_see_only_display_name(step): - assert_policy_entries(["display_name"], ['"Robot Super Course"']) +@step('I see default advanced settings$') +def i_see_default_advanced_settings(step): + # Test only a few of the existing properties (there are around 34 of them) + assert_policy_entries( + ["advanced_modules", DISPLAY_NAME_KEY, "show_calculator"], ["[]", DISPLAY_NAME_VALUE, "false"]) -@step('there are no advanced policy settings$') -def no_policy_settings(step): - keys_css = 'input.policy-key' - val_css = 'textarea.json' - k = world.browser.is_element_not_present_by_css(keys_css, 5) - v = world.browser.is_element_not_present_by_css(val_css, 5) - assert_true(k) - assert_true(v) - - -@step('they are alphabetized$') +@step('the settings are alphabetized$') def they_are_alphabetized(step): - assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"']) + key_elements = world.css_find(KEY_CSS) + all_keys = [] + for key in key_elements: + all_keys.append(key.value) + + assert_equal(sorted(all_keys), all_keys, "policy keys were not sorted") @step('it is displayed as formatted$') def it_is_formatted(step): - assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}']) + assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}']) -@step(u'the policy key name is unchanged$') -def the_policy_key_name_is_unchanged(step): - policy_key_css = 'input.policy-key' - val = css_find(policy_key_css).first.value - assert_equal(val, 'display_name') - - -@step(u'the policy key name is changed$') -def the_policy_key_name_is_changed(step): - policy_key_css = 'input.policy-key' - val = css_find(policy_key_css).first.value - assert_equal(val, 'display_name_new') +@step('it is displayed as a string') +def it_is_formatted(step): + assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"']) @step(u'the policy key value is unchanged$') def the_policy_key_value_is_unchanged(step): - policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' - val = css_find(policy_value_css).first.value - assert_equal(val, '"Robot Super Course"') + assert_equal(get_display_name_value(), DISPLAY_NAME_VALUE) @step(u'the policy key value is changed$') -def the_policy_key_value_is_unchanged(step): - policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' - val = css_find(policy_value_css).first.value - assert_equal(val, '"Robot Super Course X"') +def the_policy_key_value_is_changed(step): + assert_equal(get_display_name_value(), '"Robot Super Course X"') ############# HELPERS ############### -def create_entry(key, value): - # Scroll down the page so the button is visible - world.scroll_to_bottom() - css_click_at('a.new-advanced-policy-item', 10, 10) - new_key_css = 'div#__new_advanced_key__ input' - new_key_element = css_find(new_key_css).first - new_key_element.fill(key) -# For some reason have to get the instance for each command -# (get error that it is no longer attached to the DOM) -# Have to do all this because Selenium fill does not remove existing text - new_value_css = 'div.CodeMirror textarea' - css_find(new_value_css).last.fill("") - css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE) - css_find(new_value_css).last.fill(value) - # Add in a TAB key press because intermittently on ubuntu the - # last character of "value" above was not getting typed in - css_find(new_value_css).last._element.send_keys(Keys.TAB) - - -def delete_entry(index): - """ - Delete the nth entry where index is 0-based - """ - css = 'a.delete-button' - assert_true(world.browser.is_element_present_by_css(css, 5)) - delete_buttons = css_find(css) - assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index)) - delete_buttons[index].click() - - def assert_policy_entries(expected_keys, expected_values): - assert_entries('.key input.policy-key', expected_keys) - assert_entries('textarea.json', expected_values) + for counter in range(len(expected_keys)): + index = get_index_of(expected_keys[counter]) + assert_false(index == -1, "Could not find key: " + expected_keys[counter]) + assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect") -def assert_entries(css, expected_values): - webElements = css_find(css) - assert_equal(len(expected_values), len(webElements)) -# Sometimes get stale reference if I hold on to the array of elements - for counter in range(len(expected_values)): - assert_equal(expected_values[counter], css_find(css)[counter].value) +def get_index_of(expected_key): + for counter in range(len(world.css_find(KEY_CSS))): + # Sometimes get stale reference if I hold on to the array of elements + key = world.css_find(KEY_CSS)[counter].value + if key == expected_key: + return counter + + return -1 -def click_save(): - css = "a.save-button" - css_click_at(css) +def get_display_name_value(): + index = get_index_of(DISPLAY_NAME_KEY) + return world.css_find(VALUE_CSS)[index].value -def fill_last_field(value): - newValue = css_find('#__new_advanced_key__ input').first - newValue.fill(value) +def change_display_name_value(step, new_value): + e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] + display_name = get_display_name_value() + for count in range(len(display_name)): + e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE) + # Must delete "" before typing the JSON value + e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) + press_the_notification_button(step, "Save") diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature new file mode 100644 index 0000000000..bccb80b8d7 --- /dev/null +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -0,0 +1,24 @@ +Feature: Course checklists + + Scenario: A course author sees checklists defined by edX + Given I have opened a new course in Studio + When I select Checklists from the Tools menu + Then I see the four default edX checklists + + Scenario: A course author can mark tasks as complete + Given I have opened Checklists + Then I can check and uncheck tasks in a checklist + And They are correctly selected after I reload the page + + Scenario: A task can link to a location within Studio + Given I have opened Checklists + When I select a link to the course outline + Then I am brought to the course outline page + And I press the browser back button + Then I am brought back to the course outline in the correct state + + Scenario: A task can link to a location outside Studio + Given I have opened Checklists + When I select a link to help page + Then I am brought to the help page in a new window + diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py new file mode 100644 index 0000000000..dc399f5fac --- /dev/null +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -0,0 +1,123 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from nose.tools import assert_true, assert_equal +from terrain.steps import reload_the_page +from selenium.common.exceptions import StaleElementReferenceException + +############### ACTIONS #################### +@step('I select Checklists from the Tools menu$') +def i_select_checklists(step): + expand_icon_css = 'li.nav-course-tools i.icon-expand' + if world.browser.is_element_present_by_css(expand_icon_css): + world.css_click(expand_icon_css) + link_css = 'li.nav-course-tools-checklists a' + world.css_click(link_css) + + +@step('I have opened Checklists$') +def i_have_opened_checklists(step): + step.given('I have opened a new course in Studio') + step.given('I select Checklists from the Tools menu') + + +@step('I see the four default edX checklists$') +def i_see_default_checklists(step): + checklists = world.css_find('.checklist-title') + assert_equal(4, len(checklists)) + assert_true(checklists[0].text.endswith('Getting Started With Studio')) + assert_true(checklists[1].text.endswith('Draft a Rough Course Outline')) + assert_true(checklists[2].text.endswith("Explore edX\'s Support Tools")) + assert_true(checklists[3].text.endswith('Draft Your Course About Page')) + + +@step('I can check and uncheck tasks in a checklist$') +def i_can_check_and_uncheck_tasks(step): + # Use the 2nd checklist as a reference + verifyChecklist2Status(0, 7, 0) + toggleTask(1, 0) + verifyChecklist2Status(1, 7, 14) + toggleTask(1, 3) + verifyChecklist2Status(2, 7, 29) + toggleTask(1, 6) + verifyChecklist2Status(3, 7, 43) + toggleTask(1, 3) + verifyChecklist2Status(2, 7, 29) + + +@step('They are correctly selected after I reload the page$') +def tasks_correctly_selected_after_reload(step): + reload_the_page(step) + verifyChecklist2Status(2, 7, 29) + # verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects + toggleTask(1, 6) + verifyChecklist2Status(1, 7, 14) + + +@step('I select a link to the course outline$') +def i_select_a_link_to_the_course_outline(step): + clickActionLink(1, 0, 'Edit Course Outline') + + +@step('I am brought to the course outline page$') +def i_am_brought_to_course_outline(step): + assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text) + assert_equal(1, len(world.browser.windows)) + + +@step('I am brought back to the course outline in the correct state$') +def i_am_brought_back_to_course_outline(step): + step.given('I see the four default edX checklists') + # In a previous step, we selected (1, 0) in order to click the 'Edit Course Outline' link. + # Make sure the task is still showing as selected (there was a caching bug with the collection). + verifyChecklist2Status(1, 7, 14) + + +@step('I select a link to help page$') +def i_select_a_link_to_the_help_page(step): + clickActionLink(2, 0, 'Visit Studio Help') + + +@step('I am brought to the help page in a new window$') +def i_am_brought_to_help_page_in_new_window(step): + step.given('I see the four default edX checklists') + windows = world.browser.windows + assert_equal(2, len(windows)) + world.browser.switch_to_window(windows[1]) + assert_equal('http://help.edge.edx.org/', world.browser.url) + + + + +############### HELPER METHODS #################### +def verifyChecklist2Status(completed, total, percentage): + def verify_count(driver): + try: + statusCount = world.css_find('#course-checklist1 .status-count').first + return statusCount.text == str(completed) + except StaleElementReferenceException: + return False + + world.wait_for(verify_count) + assert_equal(str(total), world.css_find('#course-checklist1 .status-amount').first.text) + # Would like to check the CSS width, but not sure how to do that. + assert_equal(str(percentage), world.css_find('#course-checklist1 .viz-checklist-status-value .int').first.text) + + +def toggleTask(checklist, task): + world.css_click('#course-checklist' + str(checklist) +'-task' + str(task)) + + +def clickActionLink(checklist, task, actionText): + # toggle checklist item to make sure that the link button is showing + toggleTask(checklist, task) + action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task] + + # text will be empty initially, wait for it to populate + def verify_action_link_text(driver): + return action_link.text == actionText + + world.wait_for(verify_action_link_text) + action_link.click() + diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 2ec0427e1d..3878340af3 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,14 +1,10 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from lettuce.django import django_url from nose.tools import assert_true from nose.tools import assert_equal -from selenium.webdriver.support.ui import WebDriverWait -from selenium.common.exceptions import WebDriverException, StaleElementReferenceException -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.common.by import By -from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory -from terrain.factories import CourseFactory, GroupFactory from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates from auth.authz import get_user_by_email @@ -17,14 +13,15 @@ from logging import getLogger logger = getLogger(__name__) ########### STEP HELPERS ############## + @step('I (?:visit|access|open) the Studio homepage$') def i_visit_the_studio_homepage(step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. - world.browser.visit(django_url('/')) + world.visit('/') signin_css = 'a.action-signin' - assert world.browser.is_element_present_by_css(signin_css, 10) + assert world.is_css_present(signin_css) @step('I am logged into Studio$') @@ -45,12 +42,12 @@ def i_press_the_category_delete_icon(step, category): css = 'a.delete-button.delete-subsection-button span.delete-icon' else: assert False, 'Invalid category: %s' % category - css_click(css) + world.css_click(css) @step('I have opened a new course in Studio$') def i_have_opened_a_new_course(step): - clear_courses() + world.clear_courses() log_into_studio() create_a_course() @@ -61,7 +58,7 @@ def create_studio_user( email='robot+studio@edx.org', password='test', is_staff=False): - studio_user = UserFactory.build( + studio_user = world.UserFactory.build( username=uname, email=email, password=password, @@ -69,87 +66,20 @@ def create_studio_user( studio_user.set_password(password) studio_user.save() - registration = RegistrationFactory(user=studio_user) + registration = world.RegistrationFactory(user=studio_user) registration.register(studio_user) registration.activate() - user_profile = UserProfileFactory(user=studio_user) - - -def flush_xmodule_store(): - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" - _MODULESTORES = {} - modulestore().collection.drop() - update_templates() - - -def assert_css_with_text(css, text): - assert_true(world.browser.is_element_present_by_css(css, 5)) - assert_equal(world.browser.find_by_css(css).text, text) - - -def css_click(css): - ''' - First try to use the regular click method, - but if clicking in the middle of an element - doesn't work it might be that it thinks some other - element is on top of it there so click in the upper left - ''' - try: - css_find(css).first.click() - except WebDriverException, e: - css_click_at(css) - - -def css_click_at(css, x=10, y=10): - ''' - A method to click at x,y coordinates of the element - rather than in the center of the element - ''' - e = css_find(css).first - e.action_chains.move_to_element_with_offset(e._element, x, y) - e.action_chains.click() - e.action_chains.perform() - - -def css_fill(css, value): - world.browser.find_by_css(css).first.fill(value) - - -def css_find(css): - def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) - - world.browser.is_element_present_by_css(css, 5) - wait_for(is_visible) - return world.browser.find_by_css(css) - - -def wait_for(func): - WebDriverWait(world.browser.driver, 5).until(func) - - -def id_find(id): - return world.browser.find_by_id(id) - - -def clear_courses(): - flush_xmodule_store() + user_profile = world.UserProfileFactory(user=studio_user) def fill_in_course_info( name='Robot Super Course', org='MITx', num='101'): - css_fill('.new-course-name', name) - css_fill('.new-course-org', org) - css_fill('.new-course-number', num) + world.css_fill('.new-course-name', name) + world.css_fill('.new-course-org', org) + world.css_fill('.new-course-number', num) def log_into_studio( @@ -157,55 +87,56 @@ def log_into_studio( email='robot+studio@edx.org', password='test', is_staff=False): - create_studio_user(uname=uname, email=email, is_staff=is_staff) - world.browser.cookies.delete() - world.browser.visit(django_url('/')) - signin_css = 'a.action-signin' - world.browser.is_element_present_by_css(signin_css, 10) - # click the signin button - css_click(signin_css) + create_studio_user(uname=uname, email=email, is_staff=is_staff) + + world.browser.cookies.delete() + world.visit('/') + + signin_css = 'a.action-signin' + world.is_css_present(signin_css) + world.css_click(signin_css) login_form = world.browser.find_by_css('form#login_form') login_form.find_by_name('email').fill(email) login_form.find_by_name('password').fill(password) login_form.find_by_name('submit').click() - assert_true(world.browser.is_element_present_by_css('.new-course-button', 5)) + assert_true(world.is_css_present('.new-course-button')) def create_a_course(): - c = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') # Add the user to the instructor group of the course # so they will have the permissions to see it in studio - g = GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') + g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') u = get_user_by_email('robot+studio@edx.org') u.groups.add(g) u.save() world.browser.reload() course_link_css = 'span.class-name' - css_click(course_link_css) + world.css_click(course_link_css) course_title_css = 'span.course-title' - assert_true(world.browser.is_element_present_by_css(course_title_css, 5)) + assert_true(world.is_css_present(course_title_css)) def add_section(name='My Section'): link_css = 'a.new-courseware-section-button' - css_click(link_css) + world.css_click(link_css) name_css = 'input.new-section-name' save_css = 'input.new-section-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) span_css = 'span.section-name-span' - assert_true(world.browser.is_element_present_by_css(span_css, 5)) + assert_true(world.is_css_present(span_css)) def add_subsection(name='Subsection One'): css = 'a.new-subsection-item' - css_click(css) + world.css_click(css) name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature new file mode 100644 index 0000000000..e869bfe47a --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -0,0 +1,25 @@ +Feature: Course Settings + As a course author, I want to be able to configure my course settings. + + Scenario: User can set course dates + Given I have opened a new course in Studio + When I select Schedule and Details + And I set course dates + Then I see the set dates on refresh + + Scenario: User can clear previously set course dates (except start date) + Given I have set course dates + And I clear all the dates except start + Then I see cleared dates on refresh + + Scenario: User cannot clear the course start date + Given I have set course dates + And I clear the course start date + Then I receive a warning about course start date + And The previously set start date is shown on refresh + + Scenario: User can correct the course start date warning + Given I have tried to clear the course start + And I have entered a new course start date + Then The warning about course start date goes away + And My new course start date is shown on refresh diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py new file mode 100644 index 0000000000..9eb5b0951d --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -0,0 +1,165 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from terrain.steps import reload_the_page +from selenium.webdriver.common.keys import Keys +import time + +from nose.tools import assert_true, assert_false, assert_equal + +COURSE_START_DATE_CSS = "#course-start-date" +COURSE_END_DATE_CSS = "#course-end-date" +ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date" +ENROLLMENT_END_DATE_CSS = "#course-enrollment-end-date" + +COURSE_START_TIME_CSS = "#course-start-time" +COURSE_END_TIME_CSS = "#course-end-time" +ENROLLMENT_START_TIME_CSS = "#course-enrollment-start-time" +ENROLLMENT_END_TIME_CSS = "#course-enrollment-end-time" + +DUMMY_TIME = "3:30pm" +DEFAULT_TIME = "12:00am" + + +############### ACTIONS #################### +@step('I select Schedule and Details$') +def test_i_select_schedule_and_details(step): + expand_icon_css = 'li.nav-course-settings i.icon-expand' + if world.browser.is_element_present_by_css(expand_icon_css): + world.css_click(expand_icon_css) + link_css = 'li.nav-course-settings-schedule a' + world.css_click(link_css) + + +@step('I have set course dates$') +def test_i_have_set_course_dates(step): + step.given('I have opened a new course in Studio') + step.given('I select Schedule and Details') + step.given('And I set course dates') + + +@step('And I set course dates$') +def test_and_i_set_course_dates(step): + set_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + set_date_or_time(COURSE_END_DATE_CSS, '12/26/2013') + set_date_or_time(ENROLLMENT_START_DATE_CSS, '12/1/2013') + set_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013') + + set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) + + pause() + + +@step('Then I see the set dates on refresh$') +def test_then_i_see_the_set_dates_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013') + verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013') + verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013') + + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + # Unset times get set to 12 AM once the corresponding date has been set. + verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME) + verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME) + verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) + + +@step('And I clear all the dates except start$') +def test_and_i_clear_all_the_dates_except_start(step): + set_date_or_time(COURSE_END_DATE_CSS, '') + set_date_or_time(ENROLLMENT_START_DATE_CSS, '') + set_date_or_time(ENROLLMENT_END_DATE_CSS, '') + + pause() + + +@step('Then I see cleared dates on refresh$') +def test_then_i_see_cleared_dates_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_END_DATE_CSS, '') + verify_date_or_time(ENROLLMENT_START_DATE_CSS, '') + verify_date_or_time(ENROLLMENT_END_DATE_CSS, '') + + verify_date_or_time(COURSE_END_TIME_CSS, '') + verify_date_or_time(ENROLLMENT_START_TIME_CSS, '') + verify_date_or_time(ENROLLMENT_END_TIME_CSS, '') + + # Verify course start date (required) and time still there + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + + +@step('I clear the course start date$') +def test_i_clear_the_course_start_date(step): + set_date_or_time(COURSE_START_DATE_CSS, '') + + +@step('I receive a warning about course start date$') +def test_i_receive_a_warning_about_course_start_date(step): + assert_true(world.css_has_text('.message-error', 'The course must have an assigned start date.')) + assert_true('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) + assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) + + +@step('The previously set start date is shown on refresh$') +def test_the_previously_set_start_date_is_shown_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + + +@step('Given I have tried to clear the course start$') +def test_i_have_tried_to_clear_the_course_start(step): + step.given("I have set course dates") + step.given("I clear the course start date") + step.given("I receive a warning about course start date") + + +@step('I have entered a new course start date$') +def test_i_have_entered_a_new_course_start_date(step): + set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013') + pause() + + +@step('The warning about course start date goes away$') +def test_the_warning_about_course_start_date_goes_away(step): + assert_equal(0, len(world.css_find('.message-error'))) + assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) + assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) + + +@step('My new course start date is shown on refresh$') +def test_my_new_course_start_date_is_shown_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013') + # Time should have stayed from before attempt to clear date. + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + + +############### HELPER METHODS #################### +def set_date_or_time(css, date_or_time): + """ + Sets date or time field. + """ + world.css_fill(css, date_or_time) + e = world.css_find(css).first + # hit Enter to apply the changes + e._element.send_keys(Keys.ENTER) + + +def verify_date_or_time(css, date_or_time): + """ + Verifies date or time field. + """ + assert_equal(date_or_time, world.css_find(css).first.value) + + +def pause(): + """ + Must sleep briefly to allow last time save to finish, + else refresh of browser will fail. + """ + time.sleep(float(1)) diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature index 39d39b50aa..455313b0e2 100644 --- a/cms/djangoapps/contentstore/features/courses.feature +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -10,4 +10,4 @@ Feature: Create Course And I fill in the new course information And I press the "Save" button Then the Courseware page has loaded in Studio - And I see a link for adding a new section \ No newline at end of file + And I see a link for adding a new section diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index e394165f08..5da7720945 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * @@ -6,12 +9,12 @@ from common import * @step('There are no courses$') def no_courses(step): - clear_courses() + world.clear_courses() @step('I click the New Course button$') def i_click_new_course(step): - css_click('.new-course-button') + world.css_click('.new-course-button') @step('I fill in the new course information$') @@ -27,7 +30,7 @@ def i_create_a_course(step): @step('I click the course link in My Courses$') def i_click_the_course_link_in_my_courses(step): course_css = 'span.class-name' - css_click(course_css) + world.css_click(course_css) ############ ASSERTIONS ################### @@ -35,28 +38,28 @@ def i_click_the_course_link_in_my_courses(step): @step('the Courseware page has loaded in Studio$') def courseware_page_has_loaded_in_studio(step): course_title_css = 'span.course-title' - assert world.browser.is_element_present_by_css(course_title_css) + assert world.is_css_present(course_title_css) @step('I see the course listed in My Courses$') def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' - assert_css_with_text(course_css, 'Robot Super Course') + assert world.css_has_text(course_css, 'Robot Super Course') @step('the course is loaded$') def course_is_loaded(step): class_css = 'a.class-name' - assert_css_with_text(class_css, 'Robot Super Course') + assert world.css_has_text(course_css, 'Robot Super Cousre') @step('I am on the "([^"]*)" tab$') def i_am_on_tab(step, tab_name): header_css = 'div.inner-wrapper h1' - assert_css_with_text(header_css, tab_name) + assert world.css_has_text(header_css, tab_name) @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert_css_with_text(link_css, '+ New Section') + assert world.css_has_text(link_css, '+ New Section') diff --git a/cms/djangoapps/contentstore/features/factories.py b/cms/djangoapps/contentstore/features/factories.py deleted file mode 100644 index 087ceaaa2d..0000000000 --- a/cms/djangoapps/contentstore/features/factories.py +++ /dev/null @@ -1,34 +0,0 @@ -import factory -from student.models import User, UserProfile, Registration -from datetime import datetime -import uuid - - -class UserProfileFactory(factory.Factory): - FACTORY_FOR = UserProfile - - user = None - name = 'Robot Studio' - courseware = 'course.xml' - - -class RegistrationFactory(factory.Factory): - FACTORY_FOR = Registration - - user = None - activation_key = uuid.uuid4().hex - - -class UserFactory(factory.Factory): - FACTORY_FOR = User - - username = 'robot-studio' - email = 'robot+studio@edx.org' - password = 'test' - first_name = 'Robot' - last_name = 'Studio' - is_staff = False - is_active = True - is_superuser = False - last_login = datetime.now() - date_joined = datetime.now() diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index b5ddb48a09..0c0f5536a0 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_equal @@ -10,7 +13,7 @@ import time @step('I click the new section link$') def i_click_new_section_link(step): link_css = 'a.new-courseware-section-button' - css_click(link_css) + world.css_click(link_css) @step('I enter the section name and click save$') @@ -31,19 +34,19 @@ def i_have_added_new_section(step): @step('I click the Edit link for the release date$') def i_click_the_edit_link_for_the_release_date(step): button_css = 'div.section-published-date a.edit-button' - css_click(button_css) + world.css_click(button_css) @step('I save a new section release date$') def i_save_a_new_section_release_date(step): date_css = 'input.start-date.date.hasDatepicker' time_css = 'input.start-time.time.ui-timepicker-input' - css_fill(date_css, '12/25/2013') + world.css_fill(date_css, '12/25/2013') # hit TAB to get to the time field - e = css_find(date_css).first + e = world.css_find(date_css).first e._element.send_keys(Keys.TAB) - css_fill(time_css, '12:00am') - e = css_find(time_css).first + world.css_fill(time_css, '12:00am') + e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) time.sleep(float(1)) world.browser.click_link_by_text('Save') @@ -64,13 +67,13 @@ def i_see_my_section_name_with_quote_on_the_courseware_page(step): @step('I click to edit the section name$') def i_click_to_edit_section_name(step): - css_click('span.section-name-span') + world.css_click('span.section-name-span') @step('I see the complete section name with a quote in the editor$') def i_see_complete_section_name_with_quote_in_editor(step): css = '.edit-section-name' - assert world.browser.is_element_present_by_css(css, 5) + assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') @@ -85,7 +88,7 @@ def i_see_a_release_date_for_my_section(step): import re css = 'span.published-status' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) status_text = world.browser.find_by_css(css).text # e.g. 11/06/2012 at 16:25 @@ -99,20 +102,20 @@ def i_see_a_release_date_for_my_section(step): @step('I see a link to create a new subsection$') def i_see_a_link_to_create_a_new_subsection(step): css = 'a.new-subsection-item' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) @step('the section release date picker is not visible$') def the_section_release_date_picker_not_visible(step): css = 'div.edit-subsection-publish-settings' - assert False, world.browser.find_by_css(css).visible + assert not world.css_visible(css) @step('the section release date is updated$') def the_section_release_date_is_updated(step): css = 'span.published-status' - status_text = world.browser.find_by_css(css).text - assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') + status_text = world.css_text(css) + assert_equal(status_text, 'Will Release: 12/25/2013 at 12:00am') ############ HELPER METHODS ################### @@ -120,10 +123,10 @@ def the_section_release_date_is_updated(step): def save_section_name(name): name_css = '.new-section-name' save_css = '.new-section-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) def see_my_section_on_the_courseware_page(name): section_css = 'span.section-name-span' - assert_css_with_text(section_css, name) + assert world.css_has_text(section_css, name) diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index e8d0dd8229..6ca358183b 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * @@ -17,9 +20,10 @@ def i_press_the_button_on_the_registration_form(step): submit_css = 'form#register_form button#submit' # Workaround for click not working on ubuntu # for some unknown reason. - e = css_find(submit_css) + e = world.css_find(submit_css) e.type(' ') + @step('I should see be on the studio home page$') def i_should_see_be_on_the_studio_home_page(step): assert world.browser.find_by_css('div.inner-wrapper') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 52c10e41a8..762dea6838 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,30 +1,30 @@ Feature: Overview Toggle Section In order to quickly view the details of a course's section or to scan the inventory of sections - As a course author - I want to toggle the visibility of each section's subsection details in the overview listing + As a course author + I want to toggle the visibility of each section's subsection details in the overview listing Scenario: The default layout for the overview page is to show sections in expanded view Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expand/collapse for a course with no sections + Scenario: Expand /collapse for a course with no sections Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link Scenario: Collapse link appears after creating first section of a course Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded @skip-phantom Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section - And I navigate to the course overview page + And I navigate to the course overview page When I press the "section" delete icon And I confirm the alert Then I see the "Collapse All Sections" link diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 00aa39455d..7f717b731c 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -1,5 +1,7 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from terrain.factories import * from common import * from nose.tools import assert_true, assert_false, assert_equal @@ -9,16 +11,16 @@ logger = getLogger(__name__) @step(u'I have a course with no sections$') def have_a_course(step): - clear_courses() - course = CourseFactory.create() + world.clear_courses() + course = world.CourseFactory.create() @step(u'I have a course with 1 section$') def have_a_course_with_1_section(step): - clear_courses() - course = CourseFactory.create() - section = ItemFactory.create(parent_location=course.location) - subsection1 = ItemFactory.create( + world.clear_courses() + course = world.CourseFactory.create() + section = world.ItemFactory.create(parent_location=course.location) + subsection1 = world.ItemFactory.create( parent_location=section.location, template='i4x://edx/templates/sequential/Empty', display_name='Subsection One',) @@ -26,21 +28,21 @@ def have_a_course_with_1_section(step): @step(u'I have a course with multiple sections$') def have_a_course_with_two_sections(step): - clear_courses() - course = CourseFactory.create() - section = ItemFactory.create(parent_location=course.location) - subsection1 = ItemFactory.create( + world.clear_courses() + course = world.CourseFactory.create() + section = world.ItemFactory.create(parent_location=course.location) + subsection1 = world.ItemFactory.create( parent_location=section.location, template='i4x://edx/templates/sequential/Empty', display_name='Subsection One',) - section2 = ItemFactory.create( + section2 = world.ItemFactory.create( parent_location=course.location, display_name='Section Two',) - subsection2 = ItemFactory.create( + subsection2 = world.ItemFactory.create( parent_location=section2.location, template='i4x://edx/templates/sequential/Empty', display_name='Subsection Alpha',) - subsection3 = ItemFactory.create( + subsection3 = world.ItemFactory.create( parent_location=section2.location, template='i4x://edx/templates/sequential/Empty', display_name='Subsection Beta',) @@ -50,7 +52,7 @@ def have_a_course_with_two_sections(step): def navigate_to_the_course_overview_page(step): log_into_studio(is_staff=True) course_locator = '.class-name' - css_click(course_locator) + world.css_click(course_locator) @step(u'I navigate to the courseware page of a course with multiple sections') @@ -67,44 +69,44 @@ def i_add_a_section(step): @step(u'I click the "([^"]*)" link$') def i_click_the_text_span(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator, 5)) + assert_true(world.browser.is_element_present_by_css(span_locator)) # first make sure that the expand/collapse text is the one you expected assert_equal(world.browser.find_by_css(span_locator).value, text) - css_click(span_locator) + world.css_click(span_locator) @step(u'I collapse the first section$') def i_collapse_a_section(step): collapse_locator = 'section.courseware-section a.collapse' - css_click(collapse_locator) + world.css_click(collapse_locator) @step(u'I expand the first section$') def i_expand_a_section(step): expand_locator = 'section.courseware-section a.expand' - css_click(expand_locator) + world.css_click(expand_locator) @step(u'I see the "([^"]*)" link$') def i_see_the_span_with_text(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator, 5)) - assert_equal(world.browser.find_by_css(span_locator).value, text) - assert_true(world.browser.find_by_css(span_locator).visible) + assert_true(world.is_css_present(span_locator)) + assert_equal(world.css_find(span_locator).value, text) + assert_true(world.css_visible(span_locator)) @step(u'I do not see the "([^"]*)" link$') def i_do_not_see_the_span_with_text(step, text): # Note that the span will exist on the page but not be visible span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator)) - assert_false(world.browser.find_by_css(span_locator).visible) + assert_true(world.is_css_present(span_locator)) + assert_false(world.css_visible(span_locator)) @step(u'all sections are expanded$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' - subsections = world.browser.find_by_css(subsection_locator) + subsections = world.css_find(subsection_locator) for s in subsections: assert_true(s.visible) @@ -112,6 +114,6 @@ def all_sections_are_expanded(step): @step(u'all sections are collapsed$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' - subsections = world.browser.find_by_css(subsection_locator) + subsections = world.css_find(subsection_locator) for s in subsections: assert_false(s.visible) diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 1be5f4aeb9..e913c6a4bf 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -17,6 +17,14 @@ Feature: Create Subsection And I click to edit the subsection name Then I see the complete subsection name with a quote in the editor + Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) + Given I have opened a new course section in Studio + And I have added a new subsection + And I mark it as Homework + Then I see it marked as Homework + And I reload the page + Then I see it marked as Homework + @skip-phantom Scenario: Delete a subsection Given I have opened a new course section in Studio diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 88e1424898..4ab27fcb49 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_equal @@ -7,7 +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): - clear_courses() + world.clear_courses() log_into_studio() create_a_course() add_section() @@ -15,8 +18,7 @@ def i_have_opened_a_new_course_section(step): @step('I click the New Subsection link') def i_click_the_new_subsection_link(step): - css = 'a.new-subsection-item' - css_click(css) + world.css_click('a.new-subsection-item') @step('I enter the subsection name and click save$') @@ -31,14 +33,14 @@ def i_save_subsection_name_with_quote(step): @step('I click to edit the subsection name$') def i_click_to_edit_subsection_name(step): - css_click('span.subsection-name-value') + world.css_click('span.subsection-name-value') @step('I see the complete subsection name with a quote in the editor$') def i_see_complete_subsection_name_with_quote_in_editor(step): css = '.subsection-display-name-input' - assert world.browser.is_element_present_by_css(css, 5) - assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"') + assert world.is_css_present(css) + assert_equal(world.css_find(css).value, 'Subsection With "Quote"') @step('I have added a new subsection$') @@ -46,6 +48,17 @@ def i_have_added_a_new_subsection(step): add_subsection() +@step('I mark it as Homework$') +def i_mark_it_as_homework(step): + world.css_click('a.menu-toggle') + world.browser.click_link_by_text('Homework') + + +@step('I see it marked as Homework$') +def i_see_it_marked__as_homework(step): + assert_equal(world.css_find(".status-label").value, 'Homework') + + ############ ASSERTIONS ################### @@ -70,11 +83,12 @@ def the_subsection_does_not_exist(step): def save_subsection_name(name): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) + def see_subsection_name(name): css = 'span.subsection-name' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) css = 'span.subsection-name-value' - assert_css_with_text(css, name) + assert world.css_has_text(css, name) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 789226db1a..fc92205030 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -7,7 +7,7 @@ from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore from xmodule.modulestore import Location from xmodule.course_module import CourseDescriptor -from prompt import query_yes_no +from .prompt import query_yes_no from auth.authz import _delete_course_group diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 7ed4505c94..8ea6add88d 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -15,10 +15,10 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) module = store.clone_item(template_location, location) - data = module.definition['data'] + data = module.data if rewrite_static_links: data = replace_static_urls( - module.definition['data'], + module.data, None, course_namespace=Location([ module.location.tag, @@ -32,7 +32,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= return { 'id': module.location.url(), 'data': data, - 'metadata': module.metadata + # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata + 'metadata': module._model_data._kvs._metadata } @@ -70,23 +71,23 @@ def set_module_info(store, location, post_data): # 'apply' the submitted metadata, so we don't end up deleting system metadata if post_data.get('metadata') is not None: posted_metadata = post_data['metadata'] - + # update existing metadata with submitted metadata (which can be partial) # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key in posted_metadata.keys(): - + for metadata_key, value in posted_metadata.items(): + # let's strip out any metadata fields from the postback which have been identified as system metadata # and therefore should not be user-editable, so we should accept them back from the client if metadata_key in module.system_metadata_fields: del posted_metadata[metadata_key] elif posted_metadata[metadata_key] is None: # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in module.metadata: - del module.metadata[metadata_key] + if metadata_key in module._model_data: + del module._model_data[metadata_key] del posted_metadata[metadata_key] - - # overlay the new metadata over the modulestore sourced collection to support partial updates - module.metadata.update(posted_metadata) - + else: + module._model_data[metadata_key] = value + # commit to datastore - store.update_metadata(location, module.metadata) + # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata + store.update_metadata(location, module._model_data._kvs._metadata) diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py new file mode 100644 index 0000000000..f0889b0861 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -0,0 +1,96 @@ +""" Unit tests for checklist methods in views.py. """ +from contentstore.utils import get_modulestore, get_url_reverse +from contentstore.tests.test_course_settings import CourseTestCase +from xmodule.modulestore.inheritance import own_metadata +from xmodule.modulestore.tests.factories import CourseFactory +from django.core.urlresolvers import reverse +import json + + +class ChecklistTestCase(CourseTestCase): + """ Test for checklist get and put methods. """ + def setUp(self): + """ Creates the test course. """ + super(ChecklistTestCase, self).setUp() + self.course = CourseFactory.create(org='mitX', number='333', display_name='Checklists Course') + + def get_persisted_checklists(self): + """ Returns the checklists as persisted in the modulestore. """ + modulestore = get_modulestore(self.course.location) + return modulestore.get_item(self.course.location).checklists + + def test_get_checklists(self): + """ Tests the get checklists method. """ + checklists_url = get_url_reverse('Checklists', self.course) + response = self.client.get(checklists_url) + self.assertContains(response, "Getting Started With Studio") + payload = response.content + + # Now delete the checklists from the course and verify they get repopulated (for courses + # created before checklists were introduced). + self.course.checklists = None + modulestore = get_modulestore(self.course.location) + modulestore.update_metadata(self.course.location, own_metadata(self.course)) + self.assertEquals(self.get_persisted_checklists(), None) + response = self.client.get(checklists_url) + self.assertEquals(payload, response.content) + + def test_update_checklists_no_index(self): + """ No checklist index, should return all of them. """ + update_url = reverse('checklists_updates', kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name}) + + returned_checklists = json.loads(self.client.get(update_url).content) + self.assertListEqual(self.get_persisted_checklists(), returned_checklists) + + def test_update_checklists_index_ignored_on_get(self): + """ Checklist index ignored on get. """ + update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + 'checklist_index': 1}) + + returned_checklists = json.loads(self.client.get(update_url).content) + self.assertListEqual(self.get_persisted_checklists(), returned_checklists) + + def test_update_checklists_post_no_index(self): + """ No checklist index, will error on post. """ + update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name}) + response = self.client.post(update_url) + self.assertContains(response, 'Could not save checklist', status_code=400) + + def test_update_checklists_index_out_of_range(self): + """ Checklist index out of range, will error on post. """ + update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + 'checklist_index': 100}) + response = self.client.post(update_url) + self.assertContains(response, 'Could not save checklist', status_code=400) + + def test_update_checklists_index(self): + """ Check that an update of a particular checklist works. """ + update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + 'checklist_index': 2}) + payload = self.course.checklists[2] + self.assertFalse(payload.get('is_checked')) + payload['is_checked'] = True + + returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content) + self.assertTrue(returned_checklist.get('is_checked')) + self.assertEqual(self.get_persisted_checklists()[2], returned_checklist) + + def test_update_checklists_delete_unsupported(self): + """ Delete operation is not supported. """ + update_url = reverse('checklists_updates', kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + 'checklist_index': 100}) + response = self.client.delete(update_url) + self.assertContains(response, 'Unsupported request', status_code=400) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 9d533dffed..ce5bf36559 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -6,15 +6,16 @@ from django.conf import settings from django.core.urlresolvers import reverse from path import path from tempdir import mkdtemp_clean +from datetime import timedelta import json from fs.osfs import OSFS import copy from json import loads from django.contrib.auth.models import User -from cms.djangoapps.contentstore.utils import get_modulestore +from contentstore.utils import get_modulestore -from utils import ModuleStoreTestCase, parse_json +from .utils import ModuleStoreTestCase, parse_json from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore import Location @@ -25,6 +26,7 @@ from xmodule.contentstore.django import contentstore from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.inheritance import own_metadata from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor @@ -35,6 +37,14 @@ TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') +class MongoCollectionFindWrapper(object): + def __init__(self, original): + self.original = original + self.counter = 0 + + def find(self, query, *args, **kwargs): + self.counter = self.counter+1 + return self.original(query, *args, **kwargs) @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): @@ -99,6 +109,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(reverse_tabs, course_tabs) + def test_import_polls(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + module_store = modulestore('direct') + found = False + + item = None + items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None]) + found = len(items) > 0 + + self.assertTrue(found) + # check that there's actually content in the 'question' field + self.assertGreater(len(items[0].question),0) + def test_delete(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) @@ -109,10 +133,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) # make sure the parent no longer points to the child object which was deleted - self.assertTrue(sequential.location.url() in chapter.definition['children']) + self.assertTrue(sequential.location.url() in chapter.children) - self.client.post(reverse('delete_item'), - json.dumps({'id': sequential.location.url(), 'delete_children':'true', 'delete_all_versions':'true'}), + self.client.post(reverse('delete_item'), + json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}), "application/json") found = False @@ -127,9 +151,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) # make sure the parent no longer points to the child object which was deleted - self.assertFalse(sequential.location.url() in chapter.definition['children']) - - + self.assertFalse(sequential.location.url() in chapter.children) def test_about_overrides(self): ''' @@ -139,11 +161,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) - self.assertEqual(effort.definition['data'], '6 hours') + self.assertEqual(effort.data, '6 hours') # this one should be in a non-override folder effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None])) - self.assertEqual(effort.definition['data'], 'TBD') + self.assertEqual(effort.data, 'TBD') def test_remove_hide_progress_tab(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) @@ -153,7 +175,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') course = module_store.get_item(source_location) - self.assertNotIn('hide_progress_tab', course.metadata) + self.assertFalse(course.hide_progress_tab) def test_clone_course(self): @@ -191,6 +213,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) + def test_bad_contentstore_request(self): + resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') + self.assertEqual(resp.status_code, 400) + def test_delete_course(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) @@ -246,7 +272,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # compare what's on disk compared to what we have in our course with fs.open('grading_policy.json', 'r') as grading_policy: on_disk = loads(grading_policy.read()) - self.assertEqual(on_disk, course.definition['data']['grading_policy']) + self.assertEqual(on_disk, course.grading_policy) #check for policy.json self.assertTrue(fs.exists('policy.json')) @@ -255,7 +281,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): with fs.open('policy.json', 'r') as course_policy: on_disk = loads(course_policy.read()) self.assertIn('course/6.002_Spring_2012', on_disk) - self.assertEqual(on_disk['course/6.002_Spring_2012'], course.metadata) + self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course)) # remove old course delete_course(module_store, content_store, location) @@ -291,6 +317,28 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # note, we know the link it should be because that's what in the 'full' course in the test data self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + def test_prefetch_children(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + module_store = modulestore('direct') + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + wrapper = MongoCollectionFindWrapper(module_store.collection.find) + module_store.collection.find = wrapper.find + course = module_store.get_item(location, depth=2) + + # make sure we haven't done too many round trips to DB + # note we say 4 round trips here for 1) the course, 2 & 3) for the chapters and sequentials, and + # 4) because of the RT due to calculating the inherited metadata + self.assertEqual(wrapper.counter, 4) + + # make sure we pre-fetched a known sequential which should be at depth=2 + self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential', + 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) + + # make sure we don't have a specific vertical which should be at depth=3 + self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', + None]) in course.system.module_data) + def test_export_course_with_unknown_metadata(self): module_store = modulestore('direct') content_store = contentstore() @@ -302,10 +350,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course = module_store.get_item(location) + metadata = own_metadata(course) # add a bool piece of unknown metadata so we can verify we don't throw an exception - course.metadata['new_metadata'] = True + metadata['new_metadata'] = True - module_store.update_metadata(location, course.metadata) + module_store.update_metadata(location, metadata) print 'Exporting to tempdir = {0}'.format(root_dir) @@ -473,21 +522,20 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") context = problem.get_context() self.assertIn('markdown', context, "markdown is missing from context") - self.assertIn('markdown', problem.metadata, "markdown is missing from metadata") self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") def test_import_metadata_with_attempts_empty_string(self): import_from_xml(modulestore(), 'common/test/data/', ['simple']) module_store = modulestore('direct') did_load_item = False - try: + try: module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) did_load_item = True except ItemNotFoundError: pass # make sure we found the item (e.g. it didn't error while loading) - self.assertTrue(did_load_item) + self.assertTrue(did_load_item) def test_metadata_inheritance(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) @@ -499,8 +547,7 @@ class ContentStoreTest(ModuleStoreTestCase): # let's assert on the metadata_inheritance on an existing vertical for vertical in verticals: - self.assertIn('xqa_key', vertical.metadata) - self.assertEqual(course.metadata['xqa_key'], vertical.metadata['xqa_key']) + self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key) self.assertGreater(len(verticals), 0) @@ -510,36 +557,33 @@ class ContentStoreTest(ModuleStoreTestCase): # crate a new module and add it as a child to a vertical module_store.clone_item(source_template_location, new_component_location) parent = verticals[0] - module_store.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()]) + module_store.update_children(parent.location, parent.children + [new_component_location.url()]) # flush the cache - module_store.get_cached_metadata_inheritance_tree(new_component_location, -1) + module_store.refresh_cached_metadata_inheritance_tree(new_component_location) new_module = module_store.get_item(new_component_location) # check for grace period definition which should be defined at the course level - self.assertIn('graceperiod', new_module.metadata) + self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod) - self.assertEqual(parent.metadata['graceperiod'], new_module.metadata['graceperiod']) - - self.assertEqual(course.metadata['xqa_key'], new_module.metadata['xqa_key']) + self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key) # # now let's define an override at the leaf node level # - new_module.metadata['graceperiod'] = '1 day' - module_store.update_metadata(new_module.location, new_module.metadata) + new_module.lms.graceperiod = timedelta(1) + module_store.update_metadata(new_module.location, own_metadata(new_module)) # flush the cache and refetch - module_store.get_cached_metadata_inheritance_tree(new_component_location, -1) + module_store.refresh_cached_metadata_inheritance_tree(new_component_location) new_module = module_store.get_item(new_component_location) - self.assertIn('graceperiod', new_module.metadata) - self.assertEqual('1 day', new_module.metadata['graceperiod']) + self.assertEqual(timedelta(1), new_module.lms.graceperiod) class TemplateTestCase(ModuleStoreTestCase): - def test_template_cleanup(self): + def test_template_cleanup(self): module_store = modulestore('direct') # insert a bogus template in the store @@ -562,4 +606,3 @@ class TemplateTestCase(ModuleStoreTestCase): asserted = True self.assertTrue(asserted) - diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 5560d2e39b..fe90ad18aa 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,8 +1,6 @@ import datetime import json import copy -from util import converters -from util.converters import jsdate_to_time from django.contrib.auth.models import User from django.test.client import Client @@ -10,38 +8,18 @@ from django.core.urlresolvers import reverse from django.utils.timezone import UTC from xmodule.modulestore import Location -from cms.djangoapps.models.settings.course_details import (CourseDetails, +from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) -from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from cms.djangoapps.contentstore.utils import get_modulestore +from models.settings.course_grading import CourseGradingModel +from contentstore.utils import get_modulestore -from django.test import TestCase -from utils import ModuleStoreTestCase +from .utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.django import modulestore - - -# YYYY-MM-DDThh:mm:ss.s+/-HH:MM -class ConvertersTestCase(TestCase): - @staticmethod - def struct_to_datetime(struct_time): - return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour, - struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) - - def compare_dates(self, date1, date2, expected_delta): - dt1 = ConvertersTestCase.struct_to_datetime(date1) - dt2 = ConvertersTestCase.struct_to_datetime(date2) - self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + str(date2) + "!=" + str(expected_delta)) - - def test_iso_to_struct(self): - self.compare_dates(converters.jsdate_to_time("2013-01-01"), converters.jsdate_to_time("2012-12-31"), datetime.timedelta(days=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), converters.jsdate_to_time("2012-12-31T23"), datetime.timedelta(hours=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), converters.jsdate_to_time("2012-12-31T23:59"), datetime.timedelta(minutes=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), converters.jsdate_to_time("2012-12-31T23:59:59"), datetime.timedelta(seconds=1)) - +from xmodule.fields import Date class CourseTestCase(ModuleStoreTestCase): def setUp(self): @@ -104,7 +82,7 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(jsondetails['effort'], "effort somehow initialized") def test_update_and_fetch(self): - ## NOTE: I couldn't figure out how to validly test time setting w/ all the conversions + # # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions jsondetails = CourseDetails.fetch(self.course_location) jsondetails.syllabus = "bar" # encode - decode to convert date fields and other data which changes form @@ -170,19 +148,26 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") + @staticmethod + def struct_to_datetime(struct_time): + return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, + struct_time.tm_mday, struct_time.tm_hour, + struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) + def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: + date = Date() if field in encoded and encoded[field] is not None: - encoded_encoded = jsdate_to_time(encoded[field]) - dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded) + encoded_encoded = date.from_json(encoded[field]) + dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded) if isinstance(details[field], datetime.datetime): dt2 = details[field] else: - details_encoded = jsdate_to_time(details[field]) - dt2 = ConvertersTestCase.struct_to_datetime(details_encoded) + details_encoded = date.from_json(details[field]) + dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded) - expected_delta = datetime.timedelta(0) + expected_delta = datetime.timedelta(0) self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) else: self.fail(field + " missing from encoded but in details at " + context) @@ -246,8 +231,9 @@ class CourseGradingTest(CourseTestCase): altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") - test_grader.grace_period = {'hours' : 4, 'minutes' : 5, 'seconds': 0} + test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0} altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) + print test_grader.grace_period, altered_grader.grace_period self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") def test_update_grader_from_json(self): @@ -268,7 +254,7 @@ class CourseMetadataEditingTest(CourseTestCase): CourseTestCase.setUp(self) # add in the full class too import_from_xml(modulestore(), 'common/test/data/', ['full']) - self.fullcourse_location = Location(['i4x','edX','full','course','6.002_Spring_2012', None]) + self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]) def test_fetch_initial_fields(self): @@ -286,31 +272,31 @@ class CourseMetadataEditingTest(CourseTestCase): def test_update_from_json(self): test_model = CourseMetadata.update_from_json(self.course_location, - { "a" : 1, - "b_a_c_h" : { "c" : "test" }, - "test_text" : "a text string"}) + { "advertised_start" : "start A", + "testcenter_info" : { "c" : "test" }, + "days_early_for_beta" : 2}) self.update_check(test_model) # try fresh fetch to ensure persistence test_model = CourseMetadata.fetch(self.course_location) self.update_check(test_model) # now change some of the existing metadata test_model = CourseMetadata.update_from_json(self.course_location, - { "a" : 2, + { "advertised_start" : "start B", "display_name" : "jolly roger"}) self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value") - self.assertIn('a', test_model, 'Missing revised a metadata field') - self.assertEqual(test_model['a'], 2, "a not expected value") + self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') + self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value") def update_check(self, test_model): self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") - self.assertIn('a', test_model, 'Missing new a metadata field') - self.assertEqual(test_model['a'], 1, "a not expected value") - self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field') - self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value") - self.assertIn('test_text', test_model, 'Missing test_text metadata field') - self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value") + self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') + self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value") + self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field') + self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value") + self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') + self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value") def test_delete_key(self): @@ -321,5 +307,5 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertEqual(test_model['display_name'], 'Testing', "not expected value") self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') # check for deletion effectiveness - self.assertNotIn('showanswer', test_model, 'showanswer field still in') - self.assertNotIn('xqa_key', test_model, 'xqa_key field still in') \ No newline at end of file + self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in') + self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index c57f1322f5..80d4f0bbc2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -1,31 +1,145 @@ -from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase +'''unit tests for course_info views and models.''' +from contentstore.tests.test_course_settings import CourseTestCase from django.core.urlresolvers import reverse import json class CourseUpdateTest(CourseTestCase): + '''The do all and end all of unit test cases.''' def test_course_update(self): + '''Go through each interface and ensure it works.''' # first get the update to force the creation - url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course, - 'name': self.course_location.name}) + url = reverse('course_info', + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'name': self.course_location.name}) self.client.get(url) - content = '' + init_content = '' payload = {'content': content, 'date': 'January 8, 2013'} - url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course, - 'provided_id': ''}) + url = reverse('course_info_json', + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'provided_id': ''}) resp = self.client.post(url, json.dumps(payload), "application/json") payload = json.loads(resp.content) - self.assertHTMLEqual(content, payload['content'], "single iframe") + self.assertHTMLEqual(payload['content'], content) - url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course, - 'provided_id': payload['id']}) - content += '
          div

          p

          ' + first_update_url = reverse('course_info_json', + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'provided_id': payload['id']}) + content += '
          div

          p

          ' payload['content'] = content + resp = self.client.post(first_update_url, json.dumps(payload), + "application/json") + + self.assertHTMLEqual(content, json.loads(resp.content)['content'], + "iframe w/ div") + + # now put in an evil update + content = '
            ' + payload = {'content': content, + 'date': 'January 11, 2013'} + url = reverse('course_info_json', + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'provided_id': ''}) + resp = self.client.post(url, json.dumps(payload), "application/json") - self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div") + payload = json.loads(resp.content) + + self.assertHTMLEqual(content, payload['content'], "self closing ol") + + url = reverse('course_info_json', + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'provided_id': ''}) + resp = self.client.get(url) + payload = json.loads(resp.content) + self.assertTrue(len(payload) == 2) + + # can't test non-json paylod b/c expect_json throws error + # try json w/o required fields + self.assertContains( + self.client.post(url, json.dumps({'garbage': 1}), + "application/json"), + 'Failed to save', status_code=400) + + # now try to update a non-existent update + url = reverse('course_info_json', + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'provided_id': '9'}) + content = 'blah blah' + payload = {'content': content, + 'date': 'January 21, 2013'} + self.assertContains( + self.client.post(url, json.dumps(payload), "application/json"), + 'Failed to save', status_code=400) + + # update w/ malformed html + content = 'error' + payload = {'content': content, + 'date': 'January 11, 2013'} + url = reverse('course_info_json', kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'provided_id': ''}) + + self.assertContains( + self.client.post(url, json.dumps(payload), "application/json"), + ' 1: - raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) location = courses[0].location @@ -137,7 +138,7 @@ def compute_unit_state(unit): 'private' content is editabled and not visible in the LMS """ - if unit.metadata.get('is_draft', False): + if unit.cms.is_draft: try: modulestore('direct').get_item(unit.location) return UnitState.draft @@ -160,6 +161,38 @@ def update_item(location, value): else: get_modulestore(location).update_item(location, value) + +def get_url_reverse(course_page_name, course_module): + """ + Returns the course URL link to the specified location. This value is suitable to use as an href link. + + course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers' + or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of + course_page_names so that it can also be used for absolute (known) URLs. + + course_module is used to obtain the location, org, course, and name properties for a course, if + course_page_name corresponds to an attribute in CoursePageNames. + """ + url_name = getattr(CoursePageNames, course_page_name, None) + ctx_loc = course_module.location + + if CoursePageNames.ManageUsers == url_name: + return reverse(url_name, kwargs={"location": ctx_loc}) + elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading, + CoursePageNames.CourseOutline, CoursePageNames.Checklists]: + return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name}) + else: + return course_page_name + + +class CoursePageNames: + """ Constants for pages that are recognized by get_url_reverse method. """ + ManageUsers = "manage_users" + SettingsDetails = "settings_details" + SettingsGrading = "settings_grading" + CourseOutline = "course_index" + Checklists = "checklists" + def add_open_ended_panel_tab(course): """ Used to add the open ended panel tab to a course if it does not exist. @@ -175,4 +208,3 @@ def add_open_ended_panel_tab(course): course_tabs.append(OPEN_ENDED_PANEL) changed = True return changed, course_tabs - diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index b7fcc9988e..95566de515 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -18,7 +18,8 @@ from django.core.files.temp import NamedTemporaryFile # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' from PIL import Image -from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden +from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError +from django.http import HttpResponseNotFound from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.core.context_processors import csrf @@ -28,11 +29,15 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError +from xmodule.modulestore.inheritance import own_metadata +from xblock.core import Scope +from xblock.runtime import KeyValueStore, DbModel, InvalidScopeError from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str import static_replace from external_auth.views import ssl_login_shortcut +from xmodule.modulestore.mongo import MongoUsage from mitxmako.shortcuts import render_to_response, render_to_string from xmodule.modulestore.django import modulestore @@ -46,21 +51,20 @@ from xmodule.contentstore.content import StaticContent from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups -from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item -from .utils import add_open_ended_panel_tab +from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, \ + get_date_display, UnitState, get_course_for_item, get_url_reverse, add_open_ended_panel_tab from xmodule.modulestore.xml_importer import import_from_xml -from contentstore.course_info_model import get_course_updates,\ +from contentstore.course_info_model import get_course_updates, \ update_course_updates, delete_course_update from cache_toolbox.core import del_cached_content -from xmodule.timeparse import stringify_time from contentstore.module_info_model import get_module_info, set_module_info -from cms.djangoapps.models.settings.course_details import CourseDetails,\ +from models.settings.course_details import CourseDetails, \ CourseSettingsEncoder -from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from cms.djangoapps.contentstore.utils import get_modulestore +from models.settings.course_grading import CourseGradingModel +from contentstore.utils import get_modulestore from django.shortcuts import redirect -from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from models.settings.course_metadata import CourseMetadata # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' @@ -69,8 +73,7 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] -OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] -ADVANCED_COMPONENT_TYPES = ['annotatable'] + OPEN_ENDED_COMPONENT_TYPES +ADVANCED_COMPONENT_TYPES = ['annotatable', 'combinedopenended', 'peergrading'] ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @@ -112,7 +115,7 @@ def login_page(request): def howitworks(request): if request.user.is_authenticated(): return index(request) - else: + else: return render_to_response('howitworks.html', {}) # ==== Views for any logged-in user ================================== @@ -137,11 +140,8 @@ def index(request): return render_to_response('index.html', { 'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'), - 'courses': [(course.metadata.get('display_name'), - reverse('course_index', args=[ - course.location.org, - course.location.course, - course.location.name]), + 'courses': [(course.display_name, + get_url_reverse('CourseOutline', course), get_lms_link_for_item(course.location, course_id=course.location.course_id)) for course in courses], 'user': request.user, @@ -178,19 +178,15 @@ def course_index(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) lms_link = get_lms_link_for_item(location) upload_asset_callback_url = reverse('upload_asset', kwargs={ - 'org': org, - 'course': course, - 'coursename': name - }) + 'org': org, + 'course': course, + 'coursename': name + }) course = modulestore().get_item(location) sections = course.get_children() @@ -241,8 +237,13 @@ def edit_subsection(request, location): # remove all metadata from the generic dictionary that is presented in a more normalized UI - policy_metadata = dict((key, value) for key, value in item.metadata.iteritems() - if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields) + policy_metadata = dict( + (field.name, field.read_from(item)) + for field + in item.fields + if field.name not in ['display_name', 'start', 'due', 'format'] and + field.scope == Scope.settings + ) can_view_live = False subsection_units = item.get_children() @@ -253,18 +254,18 @@ def edit_subsection(request, location): break return render_to_response('edit_subsection.html', - {'subsection': item, - 'context_course': course, - 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), - 'lms_link': lms_link, - 'preview_link': preview_link, - 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), - 'parent_location': course.location, - 'parent_item': parent, - 'policy_metadata': policy_metadata, - 'subsection_units': subsection_units, - 'can_view_live': can_view_live - }) + {'subsection': item, + 'context_course': course, + 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), + 'lms_link': lms_link, + 'preview_link': preview_link, + 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), + 'parent_location': course.location, + 'parent_item': parent, + 'policy_metadata': policy_metadata, + 'subsection_units': subsection_units, + 'can_view_live': can_view_live + }) @login_required @@ -295,8 +296,7 @@ def edit_unit(request, location): # Check if there are any advanced modules specified in the course policy. These modules # should be specified as a list of strings, where the strings are the names of the modules # in ADVANCED_COMPONENT_TYPES that should be enabled for the course. - course_metadata = CourseMetadata.fetch(course.location) - course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) + course_advanced_keys = course.advanced_modules # Set component types according to course policy file component_types = list(COMPONENT_TYPES) @@ -310,16 +310,17 @@ def edit_unit(request, location): templates = modulestore().get_items(Location('i4x', 'edx', 'templates')) for template in templates: category = template.location.category + if category in course_advanced_keys: category = ADVANCED_COMPONENT_CATEGORY if category in component_types: - #This is a hack to create categories for different xmodules + # This is a hack to create categories for different xmodules component_templates[category].append(( - template.display_name, + template.display_name_with_default, template.location.url(), - 'markdown' in template.metadata, - 'empty' in template.metadata + hasattr(template, 'markdown') and template.markdown is not None, + template.cms.empty, )) components = [ @@ -363,11 +364,6 @@ def edit_unit(request, location): unit_state = compute_unit_state(item) - try: - published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date')) - except TypeError: - published_date = None - return render_to_response('unit.html', { 'context_course': course, 'active_tab': 'courseware', @@ -378,11 +374,11 @@ def edit_unit(request, location): 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, 'subsection': containing_subsection, - 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None, + 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.lms.start))) if containing_subsection.lms.start is not None else None, 'section': containing_section, 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), 'unit_state': unit_state, - 'published_date': published_date, + 'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None, }) @@ -414,7 +410,7 @@ def assignment_type_update(request, org, course, category, name): if request.method == 'GET': return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)), mimetype="application/json") - elif request.method == 'POST': # post or put, doesn't matter. + elif request.method == 'POST': # post or put, doesn't matter. return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)), mimetype="application/json") @@ -447,9 +443,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None): dispatch: The action to execute """ - instance_state, shared_state = load_preview_state(request, preview_id, location) descriptor = modulestore().get_item(location) - instance = load_preview_module(request, preview_id, descriptor, instance_state, shared_state) + instance = load_preview_module(request, preview_id, descriptor) # Let the module handle the AJAX try: ajax_return = instance.handle_ajax(dispatch, request.POST) @@ -460,46 +455,9 @@ def preview_dispatch(request, preview_id, location, dispatch=None): log.exception("error processing ajax call") raise - save_preview_state(request, preview_id, location, instance.get_instance_state(), instance.get_shared_state()) return HttpResponse(ajax_return) -def load_preview_state(request, preview_id, location): - """ - Load the state of a preview module from the request - - preview_id (str): An identifier specifying which preview this module is used for - location: The Location of the module to dispatch to - """ - if 'preview_states' not in request.session: - request.session['preview_states'] = defaultdict(dict) - - instance_state = request.session['preview_states'][preview_id, location].get('instance') - shared_state = request.session['preview_states'][preview_id, location].get('shared') - - return instance_state, shared_state - - -def save_preview_state(request, preview_id, location, instance_state, shared_state): - """ - Save the state of a preview module to the request - - preview_id (str): An identifier specifying which preview this module is used for - location: The Location of the module to dispatch to - instance_state: The instance state to save - shared_state: The shared state to save - """ - if 'preview_states' not in request.session: - request.session['preview_states'] = defaultdict(dict) - - # request.session doesn't notice indirect changes; so, must set its dict w/ every change to get - # it to persist: http://www.djangobook.com/en/2.0/chapter14.html - preview_states = request.session['preview_states'] - preview_states[preview_id, location]['instance'] = instance_state - preview_states[preview_id, location]['shared'] = shared_state - request.session['preview_states'] = preview_states # make session mgmt notice the update - - def render_from_lms(template_name, dictionary, context=None, namespace='main'): """ Render a template using the LMS MAKO_TEMPLATES @@ -507,6 +465,33 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'): return render_to_string(template_name, dictionary, context, namespace="lms." + namespace) +class SessionKeyValueStore(KeyValueStore): + def __init__(self, request, model_data): + self._model_data = model_data + self._session = request.session + + def get(self, key): + try: + return self._model_data[key.field_name] + except (KeyError, InvalidScopeError): + return self._session[tuple(key)] + + def set(self, key, value): + try: + self._model_data[key.field_name] = value + except (KeyError, InvalidScopeError): + self._session[tuple(key)] = value + + def delete(self, key): + try: + del self._model_data[key.field_name] + except (KeyError, InvalidScopeError): + del self._session[tuple(key)] + + def has(self, key): + return key in self._model_data or key in self._session + + def preview_module_system(request, preview_id, descriptor): """ Returns a ModuleSystem for the specified descriptor that is specialized for @@ -517,6 +502,14 @@ def preview_module_system(request, preview_id, descriptor): descriptor: An XModuleDescriptor """ + def preview_model_data(descriptor): + return DbModel( + SessionKeyValueStore(request, descriptor._model_data), + descriptor.module_class, + preview_id, + MongoUsage(preview_id, descriptor.location.url()), + ) + return ModuleSystem( ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'), # TODO (cpennington): Do we want to track how instructors are using the preview problems? @@ -527,6 +520,7 @@ def preview_module_system(request, preview_id, descriptor): debug=True, replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location), user=request.user, + xblock_model_data=preview_model_data, ) @@ -539,11 +533,11 @@ def get_preview_module(request, preview_id, descriptor): preview_id (str): An identifier specifying which preview this module is used for location: A Location """ - instance_state, shared_state = descriptor.get_sample_state()[0] - return load_preview_module(request, preview_id, descriptor, instance_state, shared_state) + + return load_preview_module(request, preview_id, descriptor) -def load_preview_module(request, preview_id, descriptor, instance_state, shared_state): +def load_preview_module(request, preview_id, descriptor): """ Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state @@ -555,12 +549,13 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ """ system = preview_module_system(request, preview_id, descriptor) try: - module = descriptor.xmodule_constructor(system)(instance_state, shared_state) + module = descriptor.xmodule(system) except: + log.debug("Unable to load preview module", exc_info=True) module = ErrorDescriptor.from_descriptor( descriptor, error_msg=exc_info_to_str(sys.exc_info()) - ).xmodule_constructor(system)(None, None) + ).xmodule(system) # cdodge: Special case if module.location.category == 'static_tab': @@ -578,11 +573,9 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ module.get_html = replace_static_urls( module.get_html, - module.metadata.get('data_dir', module.location.course), + getattr(module, 'data_dir', module.location.course), course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None]) ) - save_preview_state(request, preview_id, descriptor.location.url(), - module.get_instance_state(), module.get_shared_state()) return module @@ -596,7 +589,7 @@ def get_module_previews(request, descriptor): """ preview_html = [] for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()): - module = load_preview_module(request, str(idx), descriptor, instance_state, shared_state) + module = load_preview_module(request, str(idx), descriptor) preview_html.append(module.get_html()) return preview_html @@ -650,9 +643,11 @@ def delete_item(request): for parent_loc in parent_locs: parent = modulestore('direct').get_item(parent_loc) item_url = item_loc.url() - if item_url in parent.definition["children"]: - parent.definition["children"].remove(item_url) - modulestore('direct').update_children(parent.location, parent.definition["children"]) + if item_url in parent.children: + children = parent.children + children.remove(item_url) + parent.children = children + modulestore('direct').update_children(parent.location, parent.children) return HttpResponse() @@ -691,7 +686,7 @@ def save_item(request): # update existing metadata with submitted metadata (which can be partial) # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key in posted_metadata.keys(): + for metadata_key, value in posted_metadata.items(): # let's strip out any metadata fields from the postback which have been identified as system metadata # and therefore should not be user-editable, so we should accept them back from the client @@ -699,15 +694,15 @@ def save_item(request): del posted_metadata[metadata_key] elif posted_metadata[metadata_key] is None: # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in existing_item.metadata: - del existing_item.metadata[metadata_key] + if metadata_key in existing_item._model_data: + del existing_item._model_data[metadata_key] del posted_metadata[metadata_key] - - # overlay the new metadata over the modulestore sourced collection to support partial updates - existing_item.metadata.update(posted_metadata) + else: + existing_item._model_data[metadata_key] = value # commit to datastore - store.update_metadata(item_location, existing_item.metadata) + # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata + store.update_metadata(item_location, own_metadata(existing_item)) return HttpResponse() @@ -774,17 +769,14 @@ def clone_item(request): new_item = get_modulestore(template).clone_item(template, dest_location) - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - # replace the display name with an optional parameter passed in from the caller if display_name is not None: - new_item.metadata['display_name'] = display_name + new_item.display_name = display_name - get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata) + get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item)) if new_item.location.category not in DETACHED_CATEGORIES: - get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()]) return HttpResponse(json.dumps({'id': dest_location.url()})) @@ -799,9 +791,7 @@ def upload_asset(request, org, course, coursename): return HttpResponseBadRequest() # construct a location from the passed in path - location = ['i4x', org, course, 'course', coursename] - if not has_access(request.user, location): - return HttpResponseForbidden() + location = get_location_and_verify_access(request, org, course, coursename) # Does the course actually exist?!? Get anything from it to prove its existance @@ -832,7 +822,7 @@ def upload_asset(request, org, course, coursename): if thumbnail_content is not None: content.thumbnail_location = thumbnail_location - #then commit the content + # then commit the content contentstore().save(content) del_cached_content(content.location) @@ -875,7 +865,7 @@ def manage_users(request, location): }) -def create_json_response(errmsg = None): +def create_json_response(errmsg=None): if errmsg is not None: resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg})) else: @@ -953,11 +943,7 @@ def landing(request, org, course, coursename): @ensure_csrf_cookie def static_pages(request, org, course, coursename): - location = ['i4x', org, course, 'course', coursename] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, coursename) course = modulestore().get_item(location) @@ -1003,7 +989,7 @@ def reorder_static_tabs(request): for tab in course.tabs: if tab['type'] == 'static_tab': reordered_tabs.append({'type': 'static_tab', - 'name': tab_items[static_tab_idx].metadata.get('display_name'), + 'name': tab_items[static_tab_idx].display_name, 'url_slug': tab_items[static_tab_idx].location.name}) static_tab_idx += 1 else: @@ -1012,7 +998,7 @@ def reorder_static_tabs(request): # OK, re-assemble the static tabs in the new order course.tabs = reordered_tabs - modulestore('direct').update_metadata(course.location, course.metadata) + modulestore('direct').update_metadata(course.location, own_metadata(course)) return HttpResponse() @@ -1069,11 +1055,7 @@ def course_info(request, org, course, name, provided_id=None): org, course, name: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) course_module = modulestore().get_item(location) @@ -1112,21 +1094,25 @@ def course_info_updates(request, org, course, provided_id=None): if not has_access(request.user, location): raise PermissionDenied() - # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! - if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: - real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] - else: - real_method = request.method + real_method = get_request_method(request) if request.method == 'GET': - return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json") - elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE - return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json") + return HttpResponse(json.dumps(get_course_updates(location)), + mimetype="application/json") + elif real_method == 'DELETE': + try: + return HttpResponse(json.dumps(delete_course_update(location, + request.POST, provided_id)), mimetype="application/json") + except: + return HttpResponseBadRequest("Failed to delete", + content_type="text/plain") elif request.method == 'POST': try: - return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") + return HttpResponse(json.dumps(update_course_updates(location, + request.POST, provided_id)), mimetype="application/json") except: - return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain") + return HttpResponseBadRequest("Failed to save", + content_type="text/plain") @expect_json @@ -1139,11 +1125,7 @@ def module_info(request, module_location): if not has_access(request.user, location): raise PermissionDenied() - # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! - if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: - real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] - else: - real_method = request.method + real_method = get_request_method(request) rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true'] logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links)) @@ -1168,11 +1150,7 @@ def get_course_settings(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) course_module = modulestore().get_item(location) @@ -1195,11 +1173,7 @@ def course_config_graders_page(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) course_module = modulestore().get_item(location) course_details = CourseGradingModel.fetch(location) @@ -1219,18 +1193,13 @@ def course_config_advanced_page(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) course_module = modulestore().get_item(location) return render_to_response('settings_advanced.html', { 'context_course': course_module, 'course_location' : location, - 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST), 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)), }) @@ -1246,11 +1215,7 @@ def course_settings_updates(request, org, course, name, section): org, course: Attributes of the Location for the item to edit section: one of details, faculty, grading, problems, discussions """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + get_location_and_verify_access(request, org, course, name) if section == 'details': manager = CourseDetails @@ -1262,7 +1227,7 @@ def course_settings_updates(request, org, course, name, section): # Cannot just do a get w/o knowing the course name :-( return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course', name])), cls=CourseSettingsEncoder), mimetype="application/json") - elif request.method == 'POST': # post or put, doesn't matter. + elif request.method == 'POST': # post or put, doesn't matter. return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), mimetype="application/json") @@ -1278,31 +1243,24 @@ def course_grader_updates(request, org, course, name, grader_index=None): org, course: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] + location = get_location_and_verify_access(request, org, course, name) - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() - - if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: - real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] - else: - real_method = request.method + real_method = get_request_method(request) if real_method == 'GET': # Cannot just do a get w/o knowing the course name :-( - return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course', name]), grader_index)), + return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(location), grader_index)), mimetype="application/json") elif real_method == "DELETE": - # ??? Shoudl this return anything? Perhaps success fail? - CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course', name]), grader_index) + # ??? Should this return anything? Perhaps success fail? + CourseGradingModel.delete_grader(Location(location), grader_index) return HttpResponse() elif request.method == 'POST': # post or put, doesn't matter. - return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)), + return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(location), request.POST)), mimetype="application/json") -## NB: expect_json failed on ["key", "key2"] and json payload +# # NB: expect_json failed on ["key", "key2"] and json payload @login_required @ensure_csrf_cookie def course_advanced_updates(request, org, course, name): @@ -1312,17 +1270,9 @@ def course_advanced_updates(request, org, course, name): org, course: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) - # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! - if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: - real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] - else: - real_method = request.method + real_method = get_request_method(request) if real_method == 'GET': return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json") @@ -1353,6 +1303,94 @@ def course_advanced_updates(request, org, course, name): response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs)) return HttpResponse(response_json, mimetype="application/json") +@ensure_csrf_cookie +@login_required +def get_checklists(request, org, course, name): + """ + Send models, views, and html for displaying the course checklists. + + org, course, name: Attributes of the Location for the item to edit + """ + location = get_location_and_verify_access(request, org, course, name) + + modulestore = get_modulestore(location) + course_module = modulestore.get_item(location) + new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty') + template_module = modulestore.get_item(new_course_template) + + # If course was created before checklists were introduced, copy them over from the template. + copied = False + if not course_module.checklists: + course_module.checklists = template_module.checklists + copied = True + + checklists, modified = expand_checklist_action_urls(course_module) + if copied or modified: + modulestore.update_metadata(location, own_metadata(course_module)) + return render_to_response('checklists.html', + { + 'context_course': course_module, + 'checklists': checklists + }) + + +@ensure_csrf_cookie +@login_required +def update_checklist(request, org, course, name, checklist_index=None): + """ + restful CRUD operations on course checklists. The payload is a json rep of + the modified checklist. For PUT or POST requests, the index of the + checklist being modified must be included; the returned payload will + be just that one checklist. For GET requests, the returned payload + is a json representation of the list of all checklists. + + org, course, name: Attributes of the Location for the item to edit + """ + location = get_location_and_verify_access(request, org, course, name) + modulestore = get_modulestore(location) + course_module = modulestore.get_item(location) + + real_method = get_request_method(request) + if real_method == 'POST' or real_method == 'PUT': + if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists): + index = int(checklist_index) + course_module.checklists[index] = json.loads(request.body) + checklists, modified = expand_checklist_action_urls(course_module) + modulestore.update_metadata(location, own_metadata(course_module)) + return HttpResponse(json.dumps(checklists[index]), mimetype="application/json") + else: + return HttpResponseBadRequest( + "Could not save checklist state because the checklist index was out of range or unspecified.", + content_type="text/plain") + elif request.method == 'GET': + # In the JavaScript view initialize method, we do a fetch to get all the checklists. + checklists, modified = expand_checklist_action_urls(course_module) + if modified: + modulestore.update_metadata(location, own_metadata(course_module)) + return HttpResponse(json.dumps(checklists), mimetype="application/json") + else: + return HttpResponseBadRequest("Unsupported request.", content_type="text/plain") + + +def expand_checklist_action_urls(course_module): + """ + Gets the checklists out of the course module and expands their action urls + if they have not yet been expanded. + + Returns the checklists with modified urls, as well as a boolean + indicating whether or not the checklists were modified. + """ + checklists = course_module.checklists + modified = False + for checklist in checklists: + if not checklist.get('action_urls_expanded', False): + for item in checklist.get('items'): + item['action_url'] = get_url_reverse(item.get('action_url'), course_module) + checklist['action_urls_expanded'] = True + modified = True + + return checklists, modified + @login_required @ensure_csrf_cookie @@ -1362,18 +1400,13 @@ def asset_index(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() - + location = get_location_and_verify_access(request, org, course, name) upload_asset_callback_url = reverse('upload_asset', kwargs={ - 'org': org, - 'course': course, - 'coursename': name - }) + 'org': org, + 'course': course, + 'coursename': name + }) course_module = modulestore().get_item(location) @@ -1455,13 +1488,10 @@ def create_new_course(request): new_course = modulestore('direct').clone_item(template, dest_location) if display_name is not None: - new_course.metadata['display_name'] = display_name - - # we need a 'data_dir' for legacy reasons - new_course.metadata['data_dir'] = uuid4().hex + new_course.display_name = display_name # set a default start date to now - new_course.metadata['start'] = stringify_time(time.gmtime()) + new_course.start = time.gmtime() initialize_course_tabs(new_course) @@ -1480,23 +1510,19 @@ def initialize_course_tabs(course): # This logic is repeated in xmodule/modulestore/tests/factories.py # so if you change anything here, you need to also change it there. course.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, + {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"}] - modulestore('direct').update_metadata(course.location.url(), course.own_metadata) + modulestore('direct').update_metadata(course.location.url(), own_metadata(course)) @ensure_csrf_cookie @login_required def import_course(request, org, course, name): - location = ['i4x', org, course, 'course', name] - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) if request.method == 'POST': filename = request.FILES['course-data'].name @@ -1559,20 +1585,14 @@ def import_course(request, org, course, name): return render_to_response('import.html', { 'context_course': course_module, 'active_tab': 'import', - 'successful_import_redirect_url': reverse('course_index', args=[ - course_module.location.org, - course_module.location.course, - course_module.location.name]) + 'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module) }) @ensure_csrf_cookie @login_required def generate_export_course(request, org, course, name): - location = ['i4x', org, course, 'course', name] - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() + location = get_location_and_verify_access(request, org, course, name) loc = Location(location) export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") @@ -1584,7 +1604,7 @@ def generate_export_course(request, org, course, name): logging.debug('root = {0}'.format(root_dir)) export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name) - #filename = root_dir / name + '.tar.gz' + # filename = root_dir / name + '.tar.gz' logging.debug('tar file being generated at {0}'.format(export_file.name)) tf = tarfile.open(name=export_file.name, mode='w:gz') @@ -1605,11 +1625,9 @@ def generate_export_course(request, org, course, name): @login_required def export_course(request, org, course, name): - location = ['i4x', org, course, 'course', name] + location = get_location_and_verify_access(request, org, course, name) + course_module = modulestore().get_item(location) - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() return render_to_response('export.html', { 'context_course': course_module, @@ -1624,3 +1642,39 @@ def event(request): console logs don't get distracted :-) ''' return HttpResponse(True) + + +def render_404(request): + return HttpResponseNotFound(render_to_string('404.html', {})) + + +def render_500(request): + return HttpResponseServerError(render_to_string('500.html', {})) + + +def get_location_and_verify_access(request, org, course, name): + """ + Create the location tuple verify that the user has permissions + to view the location. Returns the location. + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + return location + + +def get_request_method(request): + """ + Using HTTP_X_HTTP_METHOD_OVERRIDE, in the request metadata, determine + what type of request came from the client, and return it. + """ + # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! + if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: + real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] + else: + real_method = request.method + + return real_method diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index b27f4e3804..876000c7fe 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -1,13 +1,13 @@ -from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder import time from contentstore.utils import get_modulestore -from util.converters import jsdate_to_time, time_to_date -from cms.djangoapps.models.settings import course_grading -from cms.djangoapps.contentstore.utils import update_item +from models.settings import course_grading +from contentstore.utils import update_item +from xmodule.fields import Date import re import logging @@ -43,25 +43,25 @@ class CourseDetails(object): temploc = course_location._replace(category='about', name='syllabus') try: - course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data'] + course.syllabus = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass temploc = temploc._replace(name='overview') try: - course.overview = get_modulestore(temploc).get_item(temploc).definition['data'] + course.overview = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass temploc = temploc._replace(name='effort') try: - course.effort = get_modulestore(temploc).get_item(temploc).definition['data'] + course.effort = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass temploc = temploc._replace(name='video') try: - raw_video = get_modulestore(temploc).get_item(temploc).definition['data'] + raw_video = get_modulestore(temploc).get_item(temploc).data course.intro_video = CourseDetails.parse_video_tag(raw_video) except ItemNotFoundError: pass @@ -80,8 +80,14 @@ class CourseDetails(object): dirty = False + # In the descriptor's setter, the date is converted to JSON using Date's to_json method. + # Calling to_json on something that is already JSON doesn't work. Since reaching directly + # into the model is nasty, convert the JSON Date to a Python date, which is what the + # setter expects as input. + date = Date() + if 'start_date' in jsondict: - converted = jsdate_to_time(jsondict['start_date']) + converted = date.from_json(jsondict['start_date']) else: converted = None if converted != descriptor.start: @@ -89,7 +95,7 @@ class CourseDetails(object): descriptor.start = converted if 'end_date' in jsondict: - converted = jsdate_to_time(jsondict['end_date']) + converted = date.from_json(jsondict['end_date']) else: converted = None @@ -98,7 +104,7 @@ class CourseDetails(object): descriptor.end = converted if 'enrollment_start' in jsondict: - converted = jsdate_to_time(jsondict['enrollment_start']) + converted = date.from_json(jsondict['enrollment_start']) else: converted = None @@ -107,7 +113,7 @@ class CourseDetails(object): descriptor.enrollment_start = converted if 'enrollment_end' in jsondict: - converted = jsdate_to_time(jsondict['enrollment_end']) + converted = date.from_json(jsondict['enrollment_end']) else: converted = None @@ -116,7 +122,7 @@ class CourseDetails(object): descriptor.enrollment_end = converted if dirty: - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. @@ -133,7 +139,6 @@ class CourseDetails(object): recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) update_item(temploc, recomposed_video_tag) - # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm # it persisted correctly return CourseDetails.fetch(course_location) @@ -178,6 +183,6 @@ class CourseSettingsEncoder(json.JSONEncoder): elif isinstance(obj, Location): return obj.dict() elif isinstance(obj, time.struct_time): - return time_to_date(obj) + return Date().to_json(obj) else: return JSONEncoder.default(self, obj) diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 3d0b8f78af..ee9b4ac0eb 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -1,7 +1,6 @@ from xmodule.modulestore import Location from contentstore.utils import get_modulestore -import re -from util import converters +from datetime import timedelta class CourseGradingModel(object): @@ -91,7 +90,7 @@ class CourseGradingModel(object): descriptor.raw_grader = graders_parsed descriptor.grade_cutoffs = jsondict['grade_cutoffs'] - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period']) return CourseGradingModel.fetch(course_location) @@ -119,7 +118,7 @@ class CourseGradingModel(object): else: descriptor.raw_grader.append(grader) - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) @@ -134,7 +133,7 @@ class CourseGradingModel(object): descriptor = get_modulestore(course_location).get_item(course_location) descriptor.grade_cutoffs = cutoffs - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return cutoffs @@ -156,11 +155,11 @@ class CourseGradingModel(object): graceperiodjson = graceperiodjson['grace_period'] # lms requires these to be in a fixed order - grace_rep = "{0[hours]:d} hours {0[minutes]:d} minutes {0[seconds]:d} seconds".format(graceperiodjson) + grace_timedelta = timedelta(**graceperiodjson) descriptor = get_modulestore(course_location).get_item(course_location) - descriptor.metadata['graceperiod'] = grace_rep - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + descriptor.lms.graceperiod = grace_timedelta + get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata) @staticmethod def delete_grader(course_location, index): @@ -176,7 +175,7 @@ class CourseGradingModel(object): del descriptor.raw_grader[index] # force propagation to definition descriptor.raw_grader = descriptor.raw_grader - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) # NOTE cannot delete cutoffs. May be useful to reset @staticmethod @@ -189,7 +188,7 @@ class CourseGradingModel(object): descriptor = get_modulestore(course_location).get_item(course_location) descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS'] - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return descriptor.grade_cutoffs @@ -202,8 +201,8 @@ class CourseGradingModel(object): course_location = Location(course_location) descriptor = get_modulestore(course_location).get_item(course_location) - if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod'] - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + del descriptor.lms.graceperiod + get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata) @staticmethod def get_section_grader_type(location): @@ -212,7 +211,7 @@ class CourseGradingModel(object): descriptor = get_modulestore(location).get_item(location) return { - "graderType": descriptor.metadata.get('format', u"Not Graded"), + "graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded', "location": location, "id": 99 # just an arbitrary value to } @@ -224,23 +223,41 @@ class CourseGradingModel(object): descriptor = get_modulestore(location).get_item(location) if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded": - descriptor.metadata['format'] = jsondict.get('graderType') - descriptor.metadata['graded'] = True + descriptor.lms.format = jsondict.get('graderType') + descriptor.lms.graded = True else: - if 'format' in descriptor.metadata: del descriptor.metadata['format'] - if 'graded' in descriptor.metadata: del descriptor.metadata['graded'] + del descriptor.lms.format + del descriptor.lms.graded - get_modulestore(location).update_metadata(location, descriptor.metadata) + get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata) @staticmethod def convert_set_grace_period(descriptor): - # 5 hours 59 minutes 59 seconds => { hours: 5, minutes : 59, seconds : 59} - rawgrace = descriptor.metadata.get('graceperiod', None) + # 5 hours 59 minutes 59 seconds => converted to iso format + rawgrace = descriptor.lms.graceperiod if rawgrace: - parsedgrace = {str(key): int(val) for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)} - return parsedgrace - else: return None + hours_from_days = rawgrace.days*24 + seconds = rawgrace.seconds + hours_from_seconds = int(seconds / 3600) + hours = hours_from_days + hours_from_seconds + seconds -= hours_from_seconds * 3600 + minutes = int(seconds / 60) + seconds -= minutes * 60 + + graceperiod = {'hours': 0, 'minutes': 0, 'seconds': 0} + if hours > 0: + graceperiod['hours'] = hours + + if minutes > 0: + graceperiod['minutes'] = minutes + + if seconds > 0: + graceperiod['seconds'] = seconds + + return graceperiod + else: + return None @staticmethod def parse_grader(json_grader): diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 2747cc0751..83768ca381 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -1,21 +1,26 @@ from xmodule.modulestore import Location from contentstore.utils import get_modulestore from xmodule.x_module import XModuleDescriptor -import copy +from xmodule.modulestore.inheritance import own_metadata +from xblock.core import Scope +from xmodule.course_module import CourseDescriptor class CourseMetadata(object): ''' - For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. - The objects have no predefined attrs but instead are obj encodings of the editable metadata. + For CRUD operations on metadata fields which do not have specific editors + on the other pages including any user generated ones. + The objects have no predefined attrs but instead are obj encodings of the + editable metadata. ''' - # __new_advanced_key__ is used by client not server; so, could argue against it being here - FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] + FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', + 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', 'checklists'] @classmethod def fetch(cls, course_location): """ - Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model. + Fetch the key:value editable course details for the given course from + persistence and return a CourseMetadata model. """ if not isinstance(course_location, Location): course_location = Location(course_location) @@ -24,23 +29,26 @@ class CourseMetadata(object): descriptor = get_modulestore(course_location).get_item(course_location) - for k, v in descriptor.metadata.iteritems(): - if k not in cls.FILTERED_LIST: - course[k] = v - + for field in descriptor.fields + descriptor.lms.fields: + if field.scope != Scope.settings: + continue + + if field.name not in cls.FILTERED_LIST: + course[field.name] = field.read_json(descriptor) + return course @classmethod - def update_from_json(cls, course_location, jsondict, filter_tabs=True): + def update_from_json(cls, course_location, jsondict): """ Decode the json into CourseMetadata and save any changed attrs to the db. - + Ensures none of the fields are in the blacklist. """ descriptor = get_modulestore(course_location).get_item(course_location) dirty = False - + #Copy the filtered list to avoid permanently changing the class attribute filtered_list = copy.copy(cls.FILTERED_LIST) #Don't filter on the tab attribute if filter_tabs is False @@ -49,29 +57,41 @@ class CourseMetadata(object): for k, v in jsondict.iteritems(): # should it be an error if one of the filtered list items is in the payload? - if k not in filtered_list and (k not in descriptor.metadata or descriptor.metadata[k] != v): + if k in filtered_list: + continue + + if hasattr(descriptor, k) and getattr(descriptor, k) != v: dirty = True - descriptor.metadata[k] = v + value = getattr(CourseDescriptor, k).from_json(v) + setattr(descriptor, k, value) + elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k: + dirty = True + value = getattr(CourseDescriptor.lms, k).from_json(v) + setattr(descriptor.lms, k, value) if dirty: - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + get_modulestore(course_location).update_metadata(course_location, + own_metadata(descriptor)) - # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm - # it persisted correctly + # Could just generate and return a course obj w/o doing any db reads, + # but I put the reads in as a means to confirm it persisted correctly return cls.fetch(course_location) @classmethod def delete_key(cls, course_location, payload): ''' - Remove the given metadata key(s) from the course. payload can be a single key or [key..] + Remove the given metadata key(s) from the course. payload can be a + single key or [key..] ''' descriptor = get_modulestore(course_location).get_item(course_location) for key in payload['deleteKeys']: - if key in descriptor.metadata: - del descriptor.metadata[key] + if hasattr(descriptor, key): + delattr(descriptor, key) + elif hasattr(descriptor.lms, key): + delattr(descriptor.lms, key) - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + get_modulestore(course_location).update_metadata(course_location, + own_metadata(descriptor)) return cls.fetch(course_location) - \ No newline at end of file diff --git a/cms/envs/common.py b/cms/envs/common.py index a83f61d8f9..12fa09947a 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -113,6 +113,7 @@ TEMPLATE_LOADERS = ( MIDDLEWARE_CLASSES = ( 'contentserver.middleware.StaticContentServer', + 'request_cache.middleware.RequestCache', 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 9164c02e3f..5612db1396 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -96,6 +96,13 @@ CACHES = { 'KEY_PREFIX': 'general', 'VERSION': 4, 'KEY_FUNCTION': 'util.memcache.safe_key', + }, + + 'mongo_metadata_inheritance': { + 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + 'LOCATION': '/var/tmp/mongo_metadata_inheritance', + 'TIMEOUT': 300, + 'KEY_FUNCTION': 'util.memcache.safe_key', } } @@ -120,8 +127,7 @@ DEBUG_TOOLBAR_PANELS = ( 'debug_toolbar.panels.sql.SQLDebugPanel', 'debug_toolbar.panels.signals.SignalDebugPanel', 'debug_toolbar.panels.logger.LoggingPanel', -# This is breaking Mongo updates-- Christina is investigating. -# 'debug_toolbar_mongo.panel.MongoDebugPanel', + 'debug_toolbar_mongo.panel.MongoDebugPanel', # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and # Django=1.3.1/1.4 where requests to views get duplicated (your method gets @@ -136,4 +142,4 @@ DEBUG_TOOLBAR_CONFIG = { # To see stacktraces for MongoDB queries, set this to True. # Stacktraces slow down page loads drastically (for pages with lots of queries). -# DEBUG_TOOLBAR_MONGO_STACKTRACES = False +DEBUG_TOOLBAR_MONGO_STACKTRACES = False diff --git a/cms/envs/test.py b/cms/envs/test.py index abe03edd41..d7992cb471 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -98,6 +98,13 @@ CACHES = { 'KEY_PREFIX': 'general', 'VERSION': 4, 'KEY_FUNCTION': 'util.memcache.safe_key', + }, + + 'mongo_metadata_inheritance': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': '/var/tmp/mongo_metadata_inheritance', + 'TIMEOUT': 300, + 'KEY_FUNCTION': 'util.memcache.safe_key', } } diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py index 93428a3404..6e88fed439 100644 --- a/cms/one_time_startup.py +++ b/cms/one_time_startup.py @@ -1,5 +1,15 @@ from dogapi import dog_http_api, dog_stats_api from django.conf import settings +from xmodule.modulestore.django import modulestore +from request_cache.middleware import RequestCache + +from django.core.cache import get_cache, InvalidCacheBackendError + +cache = get_cache('mongo_metadata_inheritance') +for store_name in settings.MODULESTORE: + store = modulestore(store_name) + store.metadata_inheritance_cache_subsystem = cache + store.request_cache = RequestCache.get_request_cache() if hasattr(settings, 'DATADOG_API'): dog_http_api.api_key = settings.DATADOG_API diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html index 0312fdd344..6be22e2116 100644 --- a/cms/static/client_templates/advanced_entry.html +++ b/cms/static/client_templates/advanced_entry.html @@ -1,16 +1,11 @@
          1. -
            +
            - - Keys are case sensitive and cannot contain spaces or start with a number +
            - -
            - Delete -
          2. \ No newline at end of file diff --git a/cms/static/client_templates/checklist.html b/cms/static/client_templates/checklist.html new file mode 100644 index 0000000000..ec6ff4e892 --- /dev/null +++ b/cms/static/client_templates/checklist.html @@ -0,0 +1,61 @@ +<% var allChecked = itemsChecked == items.length; %> +
            + class="course-checklist is-completed" + <% } else { %> + class="course-checklist" + <% } %> + id="<%= 'course-checklist' + checklistIndex %>"> + <% var widthPercentage = 'width:' + percentChecked + '%;'; %> + + <%= percentChecked %>% of checklist completed +
            +

            + + <%= checklistShortDescription %>

            + + Tasks Completed: <%= itemsChecked %>/<%= items.length %> + + +
            + +
              + <% var taskIndex = 0; %> + <% _.each(items, function(item) { %> + <% var checked = item['is_checked']; %> +
            • + class="task is-completed" + <% } else { %> + class="task" + <% } %> + > + <% var taskId = 'course-checklist' + checklistIndex + '-task' + taskIndex; %> + + checked="checked" + <% } %> + > + + + <% if (item['action_text'] !== '' && item['action_url'] !== '') { %> + + <% } %> +
            • + + <% taskIndex+=1; }) %> + +
            +
            \ No newline at end of file diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index 2249813b04..e7a66b5bc0 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -1,12 +1,12 @@ { - "js_files": [ - "/static/js/vendor/RequireJS.js", - "/static/js/vendor/jquery.min.js", - "/static/js/vendor/jquery-ui.min.js", - "/static/js/vendor/jquery.ui.draggable.js", - "/static/js/vendor/jquery.cookie.js", - "/static/js/vendor/json2.js", - "/static/js/vendor/underscore-min.js", - "/static/js/vendor/backbone-min.js" + "static_files": [ + "js/vendor/RequireJS.js", + "js/vendor/jquery.min.js", + "js/vendor/jquery-ui.min.js", + "js/vendor/jquery.ui.draggable.js", + "js/vendor/jquery.cookie.js", + "js/vendor/json2.js", + "js/vendor/underscore-min.js", + "js/vendor/backbone-min.js" ] } diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 7f5fa4adce..42127b2800 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -34,7 +34,10 @@ class CMS.Views.UnitEdit extends Backbone.View @$('.components').sortable( handle: '.drag-handle' - update: (event, ui) => @model.save(children: @components()) + update: (event, ui) => + payload = children : @components() + options = success : => @model.unset('children') + @model.save(payload, options) helper: 'clone' opacity: '0.5' placeholder: 'component-placeholder' @@ -109,7 +112,14 @@ class CMS.Views.UnitEdit extends Backbone.View id: $component.data('id') }, => $component.remove() - @model.save(children: @components()) + # b/c we don't vigilantly keep children up to date + # get rid of it before it hurts someone + # sorry for the js, i couldn't figure out the coffee equivalent + `_this.model.save({children: _this.components()}, + {success: function(model) { + model.unset('children'); + }} + );` ) deleteDraft: (event) -> @@ -157,7 +167,7 @@ class CMS.Views.UnitEdit extends Backbone.View class CMS.Views.UnitEdit.NameEdit extends Backbone.View events: - "keyup .unit-display-name-input": "saveName" + 'change .unit-display-name-input': 'saveName' initialize: => @model.on('change:metadata', @render) @@ -180,29 +190,10 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View # Treat the metadata dictionary as immutable metadata = $.extend({}, @model.get('metadata')) metadata.display_name = @$('.unit-display-name-input').val() + @model.save(metadata: metadata) + # Update name shown in the right-hand side location summary. $('.unit-location .editing .unit-name').html(metadata.display_name) - inputField = this.$el.find('input') - - # add a spinner - @$spinner.css({ - 'position': 'absolute', - 'top': Math.floor(inputField.position().top + (inputField.outerHeight() / 2) + 3), - 'left': inputField.position().left + inputField.outerWidth() - 24, - 'margin-top': '-10px' - }); - inputField.after(@$spinner); - @$spinner.fadeIn(10) - - # save the name after a slight delay - if @timer - clearTimeout @timer - @timer = setTimeout( => - @model.save(metadata: metadata) - @timer = null - @$spinner.delay(500).fadeOut(150) - , 500) - class CMS.Views.UnitEdit.LocationState extends Backbone.View initialize: => @model.on('change:state', @render) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index d8b32cb0e8..7466233331 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -14,10 +14,6 @@ $(document).ready(function () { // scopes (namely the course-info tab) window.$modalCover = $modalCover; - // Control whether template caching in local memory occurs (see template_loader.js). Caching screws up development but may - // be a good optimization in production (it works fairly well) - window.cachetemplates = false; - $body.append($modalCover); $newComponentItem = $('.new-component-item'); $newComponentTypePicker = $('.new-component'); @@ -76,10 +72,7 @@ $(document).ready(function () { }); // general link management - new window/tab - $('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').click(function (e) { - window.open($(this).attr('href')); - e.preventDefault(); - }); + $('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').bind('click', linkNewWindow); // general link management - lean modal window $('a[rel="modal"]').attr('title', 'This link will open in a modal window').leanModal({overlay: 0.50, closeButton: '.action-modal-close' }); @@ -87,6 +80,10 @@ $(document).ready(function () { (e).preventDefault(); }); + // general link management - smooth scrolling page links + $('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink); + + // toggling overview section details $(function () { if ($('.courseware-section').length > 0) { @@ -95,9 +92,9 @@ $(document).ready(function () { }); $('.toggle-button-sections').bind('click', toggleSections); - // autosave when a field is updated on the subsection page - $body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue); - $('.subsection-display-name-input, .unit-subtitle, .policy-list-name, .policy-list-value').each(function (i) { + // autosave when leaving input field + $body.on('change', '.subsection-display-name-input', saveSubsection); + $('.subsection-display-name-input').each(function () { this.val = $(this).val(); }); $("#start_date, #start_time, #due_date, #due_time").bind('change', autosaveInput); @@ -113,11 +110,6 @@ $(document).ready(function () { // add new/delete subsection $('.new-subsection-item').bind('click', addNewSubsection); $('.delete-subsection-button').bind('click', deleteSubsection); - // add/remove policy metadata button click handlers - $('.add-policy-data').bind('click', addPolicyMetadata); - $('.remove-policy-data').bind('click', removePolicyMetadata); - $body.on('click', '.policy-list-element .save-button', savePolicyMetadata); - $body.on('click', '.policy-list-element .cancel-button', cancelPolicyMetadata); $('.sync-date').bind('click', syncReleaseDate); @@ -156,10 +148,27 @@ $(document).ready(function () { }); }); -// function collapseAll(e) { -// $('.branch').addClass('collapsed'); -// $('.expand-collapse-icon').removeClass('collapse').addClass('expand'); -// } +function smoothScrollLink(e) { + (e).preventDefault(); + + $.smoothScroll({ + offset: -200, + easing: 'swing', + speed: 1000, + scrollElement: null, + scrollTarget: $(this).attr('href') + }); +} + +function linkNewWindow(e) { + window.open($(e.target).attr('href')); + e.preventDefault(); +} + +// On AWS instances, base.js gets wrapped in a separate scope as part of Django static +// pipelining (note, this doesn't happen on local runtimes). So if we set it on window, +// when we can access it from other scopes (namely the checklists) +window.cmsLinkNewWindow = linkNewWindow; function toggleSections(e) { e.preventDefault(); @@ -219,56 +228,6 @@ function syncReleaseDate(e) { $("#start_time").val(""); } -function addPolicyMetadata(e) { - e.preventDefault(); - var template = $('#add-new-policy-element-template > li'); - var newNode = template.clone(); - var _parent_el = $(this).parent('ol:.policy-list'); - newNode.insertBefore('.add-policy-data'); - $('.remove-policy-data').bind('click', removePolicyMetadata); - newNode.find('.policy-list-name').focus(); -} - -function savePolicyMetadata(e) { - e.preventDefault(); - - var $policyElement = $(this).parents('.policy-list-element'); - saveSubsection() - $policyElement.removeClass('new-policy-list-element'); - $policyElement.find('.policy-list-name').attr('disabled', 'disabled'); - $policyElement.removeClass('editing'); -} - -function cancelPolicyMetadata(e) { - e.preventDefault(); - - var $policyElement = $(this).parents('.policy-list-element'); - if (!$policyElement.hasClass('editing')) { - $policyElement.remove(); - } else { - $policyElement.removeClass('new-policy-list-element'); - $policyElement.find('.policy-list-name').val($policyElement.data('currentValues')[0]); - $policyElement.find('.policy-list-value').val($policyElement.data('currentValues')[1]); - } - $policyElement.removeClass('editing'); -} - -function removePolicyMetadata(e) { - e.preventDefault(); - - if (!confirm('Are you sure you wish to delete this item. It cannot be reversed!')) - return; - - policy_name = $(this).data('policy-name'); - var _parent_el = $(this).parent('li:.policy-list-element'); - if ($(_parent_el).hasClass("new-policy-list-element")) { - _parent_el.remove(); - } else { - _parent_el.appendTo("#policy-to-delete"); - } - saveSubsection() -} - function getEdxTimeFromDateTimeVals(date_val, time_val, format) { var edxTimeStr = null; @@ -277,7 +236,7 @@ function getEdxTimeFromDateTimeVals(date_val, time_val, format) { time_val = '00:00'; // Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing - date = Date.parse(date_val + " " + time_val); + var date = Date.parse(date_val + " " + time_val); if (format == null) format = 'yyyy-MM-ddTHH:mm'; @@ -294,32 +253,8 @@ function getEdxTimeFromDateTimeInputs(date_id, time_id, format) { return getEdxTimeFromDateTimeVals(input_date, input_time, format); } -function checkForNewValue(e) { - if ($(this).parents('.new-policy-list-element')[0]) { - return; - } - - if (this.val) { - this.hasChanged = this.val != $(this).val(); - } else { - this.hasChanged = false; - } - - this.val = $(this).val(); - if (this.hasChanged) { - if (this.saveTimer) { - clearTimeout(this.saveTimer); - } - - this.saveTimer = setTimeout(function () { - $changedInput = $(e.target); - saveSubsection(); - this.saveTimer = null; - }, 500); - } -} - function autosaveInput(e) { + var self = this; if (this.saveTimer) { clearTimeout(this.saveTimer); } @@ -327,11 +262,12 @@ function autosaveInput(e) { this.saveTimer = setTimeout(function () { $changedInput = $(e.target); saveSubsection(); - this.saveTimer = null; + self.saveTimer = null; }, 500); } function saveSubsection() { + // Spinner is no longer used by subsection name, but is still used by date and time pickers on the right. if ($changedInput && !$changedInput.hasClass('no-spinner')) { $spinner.css({ 'position': 'absolute', @@ -354,20 +290,6 @@ function saveSubsection() { metadata[$(el).data("metadata-name")] = el.value; } - // now add 'free-formed' metadata which are presented to the user as dual input fields (name/value) - $('ol.policy-list > li.policy-list-element').each(function (i, element) { - var name = $(element).children('.policy-list-name').val(); - metadata[name] = $(element).children('.policy-list-value').val(); - }); - - // now add any 'removed' policy metadata which is stored in a separate hidden div - // 'null' presented to the server means 'remove' - $("#policy-to-delete > li.policy-list-element").each(function (i, element) { - var name = $(element).children('.policy-list-name').val(); - if (name != "") - metadata[name] = null; - }); - // Piece back together the date/time UI elements into one date/time string // NOTE: our various "date/time" metadata elements don't always utilize the same formatting string // so make sure we're passing back the correct format @@ -382,6 +304,7 @@ function saveSubsection() { data: JSON.stringify({ 'id': id, 'metadata': metadata}), success: function () { $spinner.delay(500).fadeOut(150); + $changedInput = null; }, error: function () { showToastMessage('There has been an error while saving your changes.'); diff --git a/cms/static/js/models/checklists.js b/cms/static/js/models/checklists.js new file mode 100644 index 0000000000..823ac584d2 --- /dev/null +++ b/cms/static/js/models/checklists.js @@ -0,0 +1,24 @@ +// Model for checklists_view.js. +CMS.Models.Checklist = Backbone.Model.extend({ +}); + +CMS.Models.ChecklistCollection = Backbone.Collection.extend({ + model : CMS.Models.Checklist, + + parse: function(response) { + _.each(response, + function( element, idx ) { + element.id = idx; + }); + + return response; + }, + + // Disable caching so the browser back button will work (checklists have links to other + // places within Studio). + fetch: function (options) { + options.cache = false; + return Backbone.Collection.prototype.fetch.call(this, options); + } +}); + diff --git a/cms/static/js/models/settings/advanced.js b/cms/static/js/models/settings/advanced.js index 5741eb88dd..adc259239d 100644 --- a/cms/static/js/models/settings/advanced.js +++ b/cms/static/js/models/settings/advanced.js @@ -1,27 +1,15 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = {}; CMS.Models.Settings.Advanced = Backbone.Model.extend({ - // the key for a newly added policy-- before the user has entered a key value - new_key : "__new_advanced_key__", defaults: { // the properties are whatever the user types in (in addition to whatever comes originally from the server) }, // which keys to send as the deleted keys on next save deleteKeys : [], - blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based] validate: function (attrs) { - var errors = {}; - for (var key in attrs) { - if (key === this.new_key || _.isEmpty(key)) { - errors[key] = "A key must be entered."; - } - else if (_.contains(this.blacklistKeys, key)) { - errors[key] = key + " is a reserved keyword or can be edited on another screen"; - } - } - if (!_.isEmpty(errors)) return errors; + // Keys can no longer be edited. We are currently not validating values. }, save : function (attrs, options) { diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 148df7a325..d41545cca9 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -37,6 +37,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ // Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs // A bit funny in that the video key validation is asynchronous; so, it won't stop the validation. var errors = {}; + if (newattrs.start_date === null) { + errors.start_date = "The course must have an assigned start date."; + } if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) { errors.end_date = "The course end date cannot be before the course start date."; } diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js index 3492ca677a..45161b3c28 100644 --- a/cms/static/js/template_loader.js +++ b/cms/static/js/template_loader.js @@ -1,78 +1,79 @@ // // TODO Figure out how to initialize w/ static views from server (don't call .load but instead inject in django as strings) // so this only loads the lazily loaded ones. -(function() { - if (typeof window.templateLoader == 'function') return; - - var templateLoader = { - templateVersion: "0.0.15", - templates: {}, - loadRemoteTemplate: function(templateName, filename, callback) { - if (!this.templates[templateName]) { - var self = this; - jQuery.ajax({url : filename, - success : function(data) { - self.addTemplate(templateName, data); - self.saveLocalTemplates(); - callback(data); - }, - error : function(xhdr, textStatus, errorThrown) { - console.log(textStatus); }, - dataType : "html" - }) - } - else { - callback(this.templates[templateName]); - } - }, - - addTemplate: function(templateName, data) { - // is there a reason this doesn't go ahead and compile the template? _.template(data) - // I suppose localstorage use would still req raw string rather than compiled version, but that sd work - // if it maintains a separate cache of uncompiled ones - this.templates[templateName] = data; - }, - - localStorageAvailable: function() { - try { - // window.cachetemplates is global set in base.js to turn caching on/off - return window.cachetemplates && 'localStorage' in window && window['localStorage'] !== null; - } catch (e) { - return false; - } - }, - - saveLocalTemplates: function() { - if (this.localStorageAvailable) { - localStorage.setItem("templates", JSON.stringify(this.templates)); - localStorage.setItem("templateVersion", this.templateVersion); - } - }, - - loadLocalTemplates: function() { - if (this.localStorageAvailable) { - var templateVersion = localStorage.getItem("templateVersion"); - if (templateVersion && templateVersion == this.templateVersion) { - var templates = localStorage.getItem("templates"); - if (templates) { - templates = JSON.parse(templates); - for (var x in templates) { - if (!this.templates[x]) { - this.addTemplate(x, templates[x]); - } - } - } - } - else { - localStorage.removeItem("templates"); - localStorage.removeItem("templateVersion"); - } - } - } +(function () { + if (typeof window.templateLoader == 'function') return; + + var templateLoader = { + templateVersion: "0.0.15", + templates: {}, + // Control whether template caching in local memory occurs. Caching screws up development but may + // be a good optimization in production (it works fairly well). + cacheTemplates: false, + loadRemoteTemplate: function (templateName, filename, callback) { + if (!this.templates[templateName]) { + var self = this; + jQuery.ajax({url: filename, + success: function (data) { + self.addTemplate(templateName, data); + self.saveLocalTemplates(); + callback(data); + }, + error: function (xhdr, textStatus, errorThrown) { + console.log(textStatus); + }, + dataType: "html" + }) + } + else { + callback(this.templates[templateName]); + } + }, + + addTemplate: function (templateName, data) { + // is there a reason this doesn't go ahead and compile the template? _.template(data) + // I suppose localstorage use would still req raw string rather than compiled version, but that sd work + // if it maintains a separate cache of uncompiled ones + this.templates[templateName] = data; + }, + + localStorageAvailable: function () { + try { + return this.cacheTemplates && 'localStorage' in window && window['localStorage'] !== null; + } catch (e) { + return false; + } + }, + + saveLocalTemplates: function () { + if (this.localStorageAvailable()) { + localStorage.setItem("templates", JSON.stringify(this.templates)); + localStorage.setItem("templateVersion", this.templateVersion); + } + }, + + loadLocalTemplates: function () { + if (this.localStorageAvailable()) { + var templateVersion = localStorage.getItem("templateVersion"); + if (templateVersion && templateVersion == this.templateVersion) { + var templates = localStorage.getItem("templates"); + if (templates) { + templates = JSON.parse(templates); + for (var x in templates) { + if (!this.templates[x]) { + this.addTemplate(x, templates[x]); + } + } + } + } + else { + localStorage.removeItem("templates"); + localStorage.removeItem("templateVersion"); + } + } + } - - }; templateLoader.loadLocalTemplates(); window.templateLoader = templateLoader; - })(); +})(); diff --git a/cms/static/js/views/checklists_view.js b/cms/static/js/views/checklists_view.js new file mode 100644 index 0000000000..e553c7a2ca --- /dev/null +++ b/cms/static/js/views/checklists_view.js @@ -0,0 +1,89 @@ +if (!CMS.Views['Checklists']) CMS.Views.Checklists = {}; + +CMS.Views.Checklists = Backbone.View.extend({ + // takes CMS.Models.Checklists as model + + events : { + 'click .course-checklist .checklist-title' : "toggleChecklist", + 'click .course-checklist .task input' : "toggleTask", + 'click a[rel="external"]' : window.cmsLinkNewWindow + }, + + initialize : function() { + var self = this; + + this.collection.fetch({ + complete: function () { + window.templateLoader.loadRemoteTemplate("checklist", + "/static/client_templates/checklist.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); + } + ); + }, + error: CMS.ServerError + } + ); + }, + + render: function() { + // catch potential outside call before template loaded + if (!this.template) return this; + + this.$el.empty(); + + var self = this; + _.each(this.collection.models, + function(checklist, index) { + self.$el.append(self.renderTemplate(checklist, index)); + }); + + return this; + }, + + renderTemplate: function (checklist, index) { + var checklistItems = checklist.attributes['items']; + var itemsChecked = 0; + _.each(checklistItems, + function(checklist) { + if (checklist['is_checked']) { + itemsChecked +=1; + } + }); + var percentChecked = Math.round((itemsChecked/checklistItems.length)*100); + return this.template({ + checklistIndex : index, + checklistShortDescription : checklist.attributes['short_description'], + items: checklistItems, + itemsChecked: itemsChecked, + percentChecked: percentChecked}); + }, + + toggleChecklist : function(e) { + e.preventDefault(); + $(e.target).closest('.course-checklist').toggleClass('is-collapsed'); + }, + + toggleTask : function (e) { + var self = this; + + var completed = 'is-completed'; + var $checkbox = $(e.target); + var $task = $checkbox.closest('.task'); + $task.toggleClass(completed); + + var checklist_index = $checkbox.data('checklist'); + var task_index = $checkbox.data('task'); + var model = this.collection.at(checklist_index); + model.attributes.items[task_index].is_checked = $task.hasClass(completed); + model.save({}, + { + success : function() { + var updatedTemplate = self.renderTemplate(model, checklist_index); + self.$el.find('#course-checklist'+checklist_index).first().replaceWith(updatedTemplate); + }, + error : CMS.ServerError + }); + } +}); \ No newline at end of file diff --git a/cms/static/js/views/course_info_edit.js b/cms/static/js/views/course_info_edit.js index 8382fb15eb..ce959fd443 100644 --- a/cms/static/js/views/course_info_edit.js +++ b/cms/static/js/views/course_info_edit.js @@ -142,8 +142,11 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ onDelete: function(event) { event.preventDefault(); - // TODO ask for confirmation - // remove the dom element and delete the model + + if (!confirm('Are you sure you want to delete this update? This action cannot be undone.')) { + return; + } + var targetModel = this.eventModel(event); this.modelDom(event).remove(); var cacheThis = this; diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index a933bbdb9b..e3ff098efb 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -6,14 +6,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ // Model class is CMS.Models.Settings.Advanced events : { - 'click .delete-button' : "deleteEntry", - 'click .new-button' : "addEntry", - // update model on changes - 'change .policy-key' : "updateKey", - // keypress to catch alpha keys and backspace/delete on some browsers - 'keypress .policy-key' : "showSaveCancelButtons", - // keyup to catch backspace/delete reliably - 'keyup .policy-key' : "showSaveCancelButtons", 'focus :input' : "focusInput", 'blur :input' : "blurInput" // TODO enable/disable save based on validation (currently enabled whenever there are changes) @@ -95,16 +87,11 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ mirror.setValue(stringValue); } catch(quotedE) { // TODO: validation error - console.log("Error with JSON, even after converting to String."); - console.log(quotedE); + // console.log("Error with JSON, even after converting to String."); + // console.log(quotedE); JSONValue = undefined; } } - else { - // TODO: validation error - console.log("Error with JSON, but will not convert to String."); - console.log(e); - } } if (JSONValue !== undefined) { self.clearValidationErrors(); @@ -113,7 +100,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ } }); }, - showMessage: function (type) { this.$el.find(".message-status").removeClass("is-shown"); if (type) { @@ -128,56 +114,19 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ else { // This is the case of the page first rendering, or when Cancel is pressed. this.hideSaveCancelButtons(); - this.toggleNewButton(true); } }, - showSaveCancelButtons: function(event) { if (!this.buttonsVisible) { - if (event && (event.type === 'keypress' || event.type === 'keyup')) { - // check whether it's really an altering event: note, String.fromCharCode(keyCode) will - // give positive values for control/command/option-letter combos; so, don't use it - if (!((event.charCode && String.fromCharCode(event.charCode) !== "") || - // 8 = backspace, 46 = delete - event.keyCode === 8 || event.keyCode === 46)) return; - } this.$el.find(".message-status").removeClass("is-shown"); $('.wrapper-notification').addClass('is-shown'); this.buttonsVisible = true; } }, - hideSaveCancelButtons: function() { $('.wrapper-notification').removeClass('is-shown'); this.buttonsVisible = false; }, - - toggleNewButton: function (enable) { - var newButton = this.$el.find(".new-button"); - if (enable) { - newButton.removeClass('disabled'); - } - else { - newButton.addClass('disabled'); - } - }, - - deleteEntry : function(event) { - event.preventDefault(); - // find out which entry - var li$ = $(event.currentTarget).closest('li'); - // Not data b/c the validation view uses it for a selector - var key = $('.key', li$).attr('id'); - - delete this.selectorToField[this.fieldToSelectorMap[key]]; - delete this.fieldToSelectorMap[key]; - if (key !== this.model.new_key) { - this.model.deleteKeys.push(key); - this.model.unset(key); - } - li$.remove(); - this.showSaveCancelButtons(); - }, saveView : function(event) { // TODO one last verification scan: // call validateKey on each to ensure proper format @@ -201,102 +150,15 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ error : CMS.ServerError }); }, - addEntry : function() { - var listEle$ = this.$el.find('.course-advanced-policy-list'); - var newEle = this.renderTemplate("", ""); - listEle$.append(newEle); - // need to re-find b/c replaceWith seems to copy rather than use the specific ele instance - var policyValueDivs = this.$el.find('#' + this.model.new_key).closest('li').find('.json'); - // only 1 but hey, let's take advantage of the context mechanism - _.each(policyValueDivs, this.attachJSONEditor, this); - this.toggleNewButton(false); - }, - updateKey : function(event) { - var parentElement = $(event.currentTarget).closest('.key'); - // old key: either the key as in the model or new_key. - // That is, it doesn't change as the val changes until val is accepted. - var oldKey = parentElement.attr('id'); - // TODO: validation of keys with spaces. For now at least trim strings to remove initial and - // trailing whitespace - var newKey = $.trim($(event.currentTarget).val()); - if (oldKey !== newKey) { - // TODO: is it OK to erase other validation messages? - this.clearValidationErrors(); - - if (!this.validateKey(oldKey, newKey)) return; - - if (this.model.has(newKey)) { - var error = {}; - error[oldKey] = 'You have already defined "' + newKey + '" in the manual policy definitions.'; - error[newKey] = "You tried to enter a duplicate of this key."; - this.model.trigger("invalid", this.model, error); - return false; - } - - // explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success - // method which is uglier I think?) - var newEntryModel = {}; - // set the new key's value to the old one's - newEntryModel[newKey] = (oldKey === this.model.new_key ? '' : this.model.get(oldKey)); - - var validation = this.model.validate(newEntryModel); - if (validation) { - if (_.has(validation, newKey)) { - // swap to the key which the map knows about - validation[oldKey] = validation[newKey]; - } - this.model.trigger("invalid", this.model, validation); - // abandon update - return; - } - - // Now safe to actually do the update - this.model.set(newEntryModel); - - // update maps - var selector = this.fieldToSelectorMap[oldKey]; - this.selectorToField[selector] = newKey; - this.fieldToSelectorMap[newKey] = selector; - delete this.fieldToSelectorMap[oldKey]; - - if (oldKey !== this.model.new_key) { - // mark the old key for deletion and delete from field maps - this.model.deleteKeys.push(oldKey); - this.model.unset(oldKey) ; - } - else { - // id for the new entry will now be the key value. Enable new entry button. - this.toggleNewButton(true); - } - - // check for newkey being the name of one which was previously deleted in this session - var wasDeleting = this.model.deleteKeys.indexOf(newKey); - if (wasDeleting >= 0) { - this.model.deleteKeys.splice(wasDeleting, 1); - } - - // Update the ID to the new value. - parentElement.attr('id', newKey); - - } - }, - validateKey : function(oldKey, newKey) { - // model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here - // TODO ensure there's no spaces or illegal chars (note some checking for spaces currently done in model's - // validate method. - return true; - }, - renderTemplate: function (key, value) { var newKeyId = _.uniqueId('policy_key_'), newEle = this.template({ key : key, value : JSON.stringify(value, null, 4), keyUniqueId: newKeyId, valueUniqueId: _.uniqueId('policy_value_')}); - this.fieldToSelectorMap[(_.isEmpty(key) ? this.model.new_key : key)] = newKeyId; - this.selectorToField[newKeyId] = (_.isEmpty(key) ? this.model.new_key : key); + this.fieldToSelectorMap[key] = newKeyId; + this.selectorToField[newKeyId] = key; return newEle; }, - focusInput : function(event) { $(event.target).prev().addClass("is-focused"); }, diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 9bd8feab8c..3e1690f0b6 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -101,6 +101,12 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ cacheModel.save(fieldName, newVal); } } + else { + // Clear date (note that this clears the time as well, as date and time are linked). + // Note also that the validation logic prevents us from clearing the start date + // (start date is required by the back end). + cacheModel.save(fieldName, null); + } }; // instrument as date and time pickers diff --git a/cms/static/js/views/validating_view.js b/cms/static/js/views/validating_view.js index 041e779030..3376e5fe9b 100644 --- a/cms/static/js/views/validating_view.js +++ b/cms/static/js/views/validating_view.js @@ -25,11 +25,7 @@ CMS.Views.ValidatingView = Backbone.View.extend({ for (var field in error) { var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); this._cacheValidationErrors.push(ele); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').addClass('error'); - } - else $(ele).addClass('error'); + this.getInputElements(ele).addClass('error'); $(ele).parent().append(this.errorTemplate({message : error[field]})); } }, @@ -37,12 +33,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({ clearValidationErrors : function() { // error is object w/ fields and error strings while (this._cacheValidationErrors.length > 0) { - var ele = this._cacheValidationErrors.pop(); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').removeClass('error'); - } - else $(ele).removeClass('error'); + var ele = this._cacheValidationErrors.pop(); + this.getInputElements(ele).removeClass('error'); $(ele).nextAll('.message-error').remove(); } }, @@ -65,5 +57,16 @@ CMS.Views.ValidatingView = Backbone.View.extend({ }, inputUnfocus : function(event) { $("label[for='" + event.currentTarget.id + "']").removeClass("is-focused"); + }, + + getInputElements: function(ele) { + var inputElements = 'input, textarea'; + if ($(ele).is(inputElements)) { + return $(ele); + } + else { + // put error on the contained inputs + return $(ele).find(inputElements); + } } }); diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 5d4bc7c773..cadb442b7c 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -1,7 +1,7 @@ -// studio base styling +// studio - base styling // ==================== -// basic reset +// basic setup html { font-size: 62.5%; overflow-y: scroll; @@ -9,7 +9,7 @@ html { body { @include font-size(16); - min-width: 980px; + min-width: $fg-min-width; background: $gray-l5; line-height: 1.6; color: $baseFontColor; @@ -214,7 +214,7 @@ h1 { color: $gray-l2; } - .title, .title-1 { + .title-1 { @include font-size(32); margin: 0; padding: 0; @@ -283,8 +283,8 @@ h1 { .title-3 { @include font-size(16); - margin: 0 0 ($baseline/4) 0; - font-weight: 500; + margin: 0 0 ($baseline/2) 0; + font-weight: 600; } .title-4 { @@ -327,7 +327,8 @@ h1 { } } - .nav-related { + // navigation + .nav-related, .nav-page { .nav-item { margin-bottom: ($baseline/4); @@ -350,10 +351,11 @@ h1 { // layout - grandfathered .main-wrapper { position: relative; - margin: 0 40px; + margin: 40px; } .inner-wrapper { + @include clearfix(); position: relative; max-width: 1280px; margin: auto; @@ -363,6 +365,12 @@ h1 { } } +.main-column { + clear: both; + float: left; + width: 70%; +} + .sidebar { float: right; width: 28%; @@ -378,94 +386,6 @@ h1 { // ==================== -// forms -input[type="text"], -input[type="email"], -input[type="password"], -textarea.text { - padding: 6px 8px 8px; - @include box-sizing(border-box); - border: 1px solid $mediumGrey; - border-radius: 2px; - @include linear-gradient($lightGrey, tint($lightGrey, 90%)); - background-color: $lightGrey; - @include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset); - font-family: 'Open Sans', sans-serif; - font-size: 11px; - color: $baseFontColor; - outline: 0; - - &::-webkit-input-placeholder, - &:-moz-placeholder, - &:-ms-input-placeholder { - color: #979faf; - } - - &:focus { - @include linear-gradient($paleYellow, tint($paleYellow, 90%)); - outline: 0; - } -} - -// forms - specific -input.search { - padding: 6px 15px 8px 30px; - @include box-sizing(border-box); - border: 1px solid $darkGrey; - border-radius: 20px; - background: url(../img/search-icon.png) no-repeat 8px 7px #edf1f5; - font-family: 'Open Sans', sans-serif; - color: $baseFontColor; - outline: 0; - - &::-webkit-input-placeholder { - color: #979faf; - } -} - -label { - font-size: 12px; -} - -code { - padding: 0 4px; - border-radius: 3px; - background: #eee; - font-family: Monaco, monospace; -} - -.CodeMirror { - font-size: 13px; - border: 1px solid $darkGrey; - background: #fff; -} - -.text-editor { - width: 100%; - min-height: 80px; - padding: 10px; - @include box-sizing(border-box); - border: 1px solid $mediumGrey; - @include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.3)); - background-color: #edf1f5; - @include box-shadow(0 1px 2px rgba(0, 0, 0, 0.1) inset); - font-family: Monaco, monospace; -} - -// ==================== - -// UI - chrome -.window { - @include clearfix(); - @include border-radius(3px); - @include box-shadow(0 1px 1px $shadow-l1); - margin-bottom: $baseline; - border: 1px solid $gray-l2; - background: $white; -} - -// ==================== - // UI - actions .new-unit-item, .new-subsection-item, @@ -772,6 +692,10 @@ hr.divide { word-wrap: break-word; } +hr.divider { + @extend .sr; +} + // ==================== // js dependant @@ -846,14 +770,4 @@ body.hide-wip { .wip-box { display: none; } -} - -// ==================== - -// needed fudges for now -body.dashboard { - - .my-classes { - margin-top: $baseline; - } } \ No newline at end of file diff --git a/cms/static/sass/_calendar.scss b/cms/static/sass/_calendar.scss deleted file mode 100644 index 4c007bb561..0000000000 --- a/cms/static/sass/_calendar.scss +++ /dev/null @@ -1,367 +0,0 @@ -section.cal { - @include box-sizing(border-box); - @include clearfix; - padding: 20px; - - > header { - display: none; - @include clearfix; - margin-bottom: 10px; - opacity: .4; - @include transition; - text-shadow: 0 1px 0 #fff; - - &:hover { - opacity: 1; - } - - h2 { - @include inline-block(); - text-transform: uppercase; - letter-spacing: 1px; - font-size: 14px; - padding: 6px 6px 6px 0; - font-size: 12px; - margin: 0; - } - - ul { - @include inline-block; - float: right; - margin: 0; - padding: 0; - - &.actions { - float: left; - } - - li { - @include inline-block; - margin-right: 6px; - border-right: 1px solid #ddd; - padding: 0 6px 0 0; - - &:last-child { - border-right: 0; - margin-right: 0; - padding-right: 0; - } - - a { - @include inline-block(); - font-size: 12px; - @include inline-block; - margin: 0 6px; - font-style: italic; - } - - ul { - @include inline-block(); - margin: 0; - - li { - @include inline-block(); - padding: 0; - border-left: 0; - } - } - } - } - } - - ol { - list-style: none; - @include clearfix; - border: 1px solid lighten( $dark-blue , 30% ); - background: #FFF; - width: 100%; - @include box-sizing(border-box); - margin: 0; - padding: 0; - @include box-shadow(0 0 5px lighten($dark-blue, 45%)); - @include border-radius(3px); - overflow: hidden; - - > li { - border-right: 1px solid lighten($dark-blue, 40%); - border-bottom: 1px solid lighten($dark-blue, 40%); - @include box-sizing(border-box); - float: left; - width: flex-grid(3) + ((flex-gutter() * 3) / 4); - background-color: $light-blue; - @include box-shadow(inset 0 0 0 1px lighten($light-blue, 8%)); - - &:hover { - li.create-module { - opacity: 1; - } - } - - &:nth-child(4n) { - border-right: 0; - } - - header { - border-bottom: 1px solid lighten($dark-blue, 40%); - @include box-shadow(0 2px 2px $light-blue); - display: block; - margin-bottom: 2px; - background: #FFF; - - h1 { - font-size: 14px; - text-transform: uppercase; - border-bottom: 1px solid lighten($dark-blue, 60%); - padding: 6px; - color: $bright-blue; - margin: 0; - - a { - color: $bright-blue; - display: block; - padding: 6px; - margin: -6px; - - &:hover { - color: darken($bright-blue, 10%); - background: lighten($yellow, 10%); - } - } - } - - ul { - margin: 0; - padding: 0; - - li { - background: #fff; - color: #888; - border-bottom: 0; - font-size: 12px; - @include box-shadow(none); - } - } - } - - ul { - list-style: none; - margin: 0 0 1px 0; - padding: 0; - - li { - border-bottom: 1px solid darken($light-blue, 6%); - // @include box-shadow(0 1px 0 lighten($light-blue, 4%)); - overflow: hidden; - position: relative; - text-shadow: 0 1px 0 #fff; - - &:hover { - background-color: lighten($yellow, 14%); - - a.draggable { - background-color: lighten($yellow, 14%); - opacity: 1; - } - } - - &.editable { - padding: 3px 6px; - } - - a { - color: lighten($dark-blue, 10%); - display: block; - padding: 6px 35px 6px 6px; - - &:hover { - background-color: lighten($yellow, 10%); - } - - &.draggable { - background-color: $light-blue; - opacity: .3; - padding: 0; - - &:hover { - background-color: lighten($yellow, 10%); - } - } - } - - &.create-module { - position: relative; - opacity: 0; - @include transition(all 3s ease-in-out); - background: darken($light-blue, 2%); - - > div { - background: $dark-blue; - @include box-shadow(0 0 5px darken($light-blue, 60%)); - @include box-sizing(border-box); - display: none; - margin-left: 3%; - padding: 10px; - @include position(absolute, 30px 0 0 0); - width: 90%; - z-index: 99; - - ul { - li { - border-bottom: 0; - background: none; - - input { - @include box-sizing(border-box); - width: 100%; - } - - select { - @include box-sizing(border-box); - width: 100%; - - option { - font-size: 14px; - } - } - - div { - margin-top: 10px; - } - - a { - color: $light-blue; - float: right; - - &:first-child { - float: left; - } - - &:hover { - color: #fff; - } - } - } - } - } - } - } - } - } - } - - section.new-section { - margin: 10px 0 40px; - @include inline-block(); - position: relative; - - > a { - @extend .button; - display: block; - } - - section { - display: none; - @include position(absolute, 30px 0 0 0); - background: rgba(#000, .8); - min-width: 300px; - padding: 10px; - @include box-sizing(border-box); - @include border-radius(3px); - z-index: 99; - - &:before { - content: " "; - display: block; - background: rgba(#000, .8); - width: 10px; - height: 10px; - @include position(absolute, -5px 0 0 20%); - @include transform(rotate(45deg)); - } - - form { - - ul { - list-style: none; - - li { - border-bottom: 0; - background: none; - margin-bottom: 6px; - - input { - width: 100%; - @include box-sizing(border-box); - border-color: #000; - padding: 6px; - } - - select { - width: 100%; - @include box-sizing(border-box); - - option { - font-size: 14px; - } - } - - a { - float: right; - - &:first-child { - float: left; - } - } - } - } - } - } - } -} - -body.content -section.cal { - width: flex-grid(3); - float: left; - overflow: scroll; - @include box-sizing(border-box); - opacity: .4; - @include transition(); - - &:hover { - opacity: 1; - } - - > header { - @include transition; - overflow: hidden; - - > a { - display: none; - } - - ul { - float: none; - display: block; - - li { - - ul { - display: inline; - } - } - } - } - - ol { - li { - @include box-sizing(border-box); - width: 100%; - border-right: 0; - - &.create-module { - display: none; - } - } - } -} diff --git a/cms/static/sass/_cms_mixins.scss b/cms/static/sass/_cms_mixins.scss index b8d9a8ae2e..015a94b762 100644 --- a/cms/static/sass/_cms_mixins.scss +++ b/cms/static/sass/_cms_mixins.scss @@ -1,3 +1,6 @@ +// studio - utilities - mixins and extends +// ==================== + @mixin clearfix { &:after { content: ''; diff --git a/cms/static/sass/_courseware.scss b/cms/static/sass/_courseware.scss deleted file mode 100644 index 45ea111b6f..0000000000 --- a/cms/static/sass/_courseware.scss +++ /dev/null @@ -1,689 +0,0 @@ - -input.courseware-unit-search-input { - float: left; - width: 260px; - background-color: #fff; -} - -.branch { - - .section-item { - @include clearfix(); - - .details { - display: block; - float: left; - margin-bottom: 0; - width: 650px; - } - - .gradable-status { - float: right; - position: relative; - top: -4px; - right: 50px; - width: 145px; - - .status-label { - position: absolute; - top: 2px; - right: -5px; - display: none; - width: 110px; - padding: 5px 40px 5px 10px; - @include border-radius(3px); - color: $lightGrey; - text-align: right; - font-size: 12px; - font-weight: bold; - line-height: 16px; - } - - .menu-toggle { - z-index: 10; - position: absolute; - top: 0; - right: 5px; - padding: 5px; - color: $mediumGrey; - - &:hover, &.is-active { - color: $blue; - } - } - - .menu { - z-index: 1; - display: none; - opacity: 0.0; - position: absolute; - top: -1px; - left: 5px; - margin: 0; - padding: 8px 12px; - background: $white; - border: 1px solid $mediumGrey; - font-size: 12px; - @include border-radius(4px); - @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); - @include transition(opacity .15s); - - - li { - width: 115px; - margin-bottom: 3px; - padding-bottom: 3px; - border-bottom: 1px solid $lightGrey; - - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border: none; - - a { - color: $darkGrey; - } - } - } - - a { - color: $blue; - - &.is-selected { - font-weight: bold; - } - } - } - - // dropdown state - &.is-active { - - .menu { - z-index: 1000; - display: block; - opacity: 1.0; - } - - .menu-toggle { - z-index: 10000; - } - } - - // set state - &.is-set { - - .menu-toggle { - color: $blue; - } - - .status-label { - display: block; - color: $blue; - } - } - } - } - } - - -.courseware-section { - position: relative; - background: #fff; - border-radius: 3px; - border: 1px solid $mediumGrey; - margin-top: 15px; - padding-bottom: 12px; - @include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1)); - - &:first-child { - margin-top: 0; - } - - &.collapsed { - padding-bottom: 0; - } - - label { - float: left; - line-height: 29px; - } - - .datepair { - float: left; - margin-left: 10px; - } - - .section-published-date { - position: absolute; - top: 19px; - right: 90px; - padding: 4px 10px; - border-radius: 3px; - background: $lightGrey; - text-align: right; - - .published-status { - font-size: 12px; - margin-right: 15px; - - strong { - font-weight: bold; - } - } - - .schedule-button { - @include blue-button; - } - - .edit-button { - @include blue-button; - } - - .schedule-button, - .edit-button { - font-size: 11px; - padding: 3px 15px 5px; - } - } - - .datepair .date, - .datepair .time { - padding-left: 0; - padding-right: 0; - border: none; - background: none; - @include box-shadow(none); - font-size: 13px; - font-weight: bold; - color: $blue; - cursor: pointer; - } - - .datepair .date { - width: 80px; - } - - .datepair .time { - width: 65px; - } - - &.collapsed .subsection-list, - .collapsed .subsection-list, - .collapsed > ol { - display: none !important; - } - - header { - min-height: 75px; - @include clearfix(); - - .item-details, .section-published-date { - - } - - .item-details { - display: inline-block; - padding: 20px 0 10px 0; - @include clearfix(); - - .section-name { - float: left; - margin-right: 10px; - width: 350px; - font-size: 19px; - font-weight: bold; - color: $blue; - } - - .section-name-span { - cursor: pointer; - @include transition(color .15s); - - &:hover { - color: $orange; - } - } - - .section-name-edit { - position: relative; - width: 400px; - background: $white; - - input { - font-size: 16px; - } - - .save-button { - @include blue-button; - padding: 7px 20px 7px; - margin-right: 5px; - } - - .cancel-button { - @include white-button; - padding: 7px 20px 7px; - } - } - - .section-published-date { - float: right; - width: 265px; - margin-right: 220px; - @include border-radius(3px); - background: $lightGrey; - - .published-status { - font-size: 12px; - margin-right: 15px; - - strong { - font-weight: bold; - } - } - - .schedule-button { - @include blue-button; - } - - .edit-button { - @include blue-button; - } - - .schedule-button, - .edit-button { - font-size: 11px; - padding: 3px 15px 5px; - - } - } - - .gradable-status { - position: absolute; - top: 20px; - right: 70px; - width: 145px; - - .status-label { - position: absolute; - top: 0; - right: 2px; - display: none; - width: 100px; - padding: 10px 35px 10px 10px; - @include border-radius(3px); - background: $lightGrey; - color: $lightGrey; - text-align: right; - font-size: 12px; - font-weight: bold; - line-height: 16px; - } - - .menu-toggle { - z-index: 10; - position: absolute; - top: 2px; - right: 5px; - padding: 5px; - color: $lightGrey; - - &:hover, &.is-active { - color: $blue; - } - } - - .menu { - z-index: 1; - display: none; - opacity: 0.0; - position: absolute; - top: -1px; - left: 2px; - margin: 0; - padding: 8px 12px; - background: $white; - border: 1px solid $mediumGrey; - font-size: 12px; - @include border-radius(4px); - @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); - @include transition(opacity .15s); - @include transition(display .15s); - - - li { - width: 115px; - margin-bottom: 3px; - padding-bottom: 3px; - border-bottom: 1px solid $lightGrey; - - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border: none; - - a { - color: $darkGrey; - } - } - } - - a { - - &.is-selected { - font-weight: bold; - } - } - } - - // dropdown state - &.is-active { - - .menu { - z-index: 1000; - display: block; - opacity: 1.0; - } - - - .menu-toggle { - z-index: 10000; - } - } - - // set state - &.is-set { - - .menu-toggle { - color: $blue; - } - - .status-label { - display: block; - color: $blue; - } - } - - float: left; - padding: 21px 0 0; - } - } - - .item-actions { - margin-top: 21px; - margin-right: 12px; - - .edit-button, - .delete-button { - margin-top: -3px; - } - } - - .expand-collapse-icon { - float: left; - margin: 29px 6px 16px 16px; - @include transition(none); - - &.expand { - background-position: 0 0; - } - - &.collapsed { - - } - } - - .drag-handle { - margin-left: 11px; - } - } - - h3 { - font-size: 19px; - font-weight: 700; - color: $blue; - } - - .section-name-span { - cursor: pointer; - @include transition(color .15s); - - &:hover { - color: $orange; - } - } - - .section-name-form { - margin-bottom: 15px; - } - - .section-name-edit { - input { - font-size: 16px; - } - - .save-button { - @include blue-button; - padding: 7px 20px 7px; - margin-right: 5px; - } - - .cancel-button { - @include white-button; - padding: 7px 20px 7px; - } - } - - h4 { - font-size: 12px; - color: #878e9d; - - strong { - font-weight: bold; - } - } - - .list-header { - @include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); - background-color: #ced2db; - border-radius: 3px 3px 0 0; - } - - .subsection-list { - margin: 0 12px; - - > ol { - @include tree-view; - border-top-width: 0; - } - } - - &.new-section { - - header { - height: auto; - @include clearfix(); - } - - .expand-collapse-icon { - visibility: hidden; - } - - .item-details { - padding: 25px 0 0 0; - - .section-name { - float: none; - width: 100%; - } - } - } -} - -.toggle-button-sections { - display: none; - position: relative; - float: right; - margin-top: 10px; - - font-size: 13px; - color: $darkGrey; - - &.is-shown { - display: block; - } - - .ss-icon { - @include border-radius(20px); - position: relative; - top: -1px; - display: inline-block; - margin-right: 2px; - line-height: 5px; - font-size: 11px; - } - - .label { - display: inline-block; - } -} - -.new-section-name, -.new-subsection-name-input { - width: 515px; -} - -.new-section-name-save, -.new-subsection-name-save { - @include blue-button; - padding: 4px 20px 7px; - margin: 0 5px; - color: #fff !important; -} - -.new-section-name-cancel, -.new-subsection-name-cancel { - @include white-button; - padding: 4px 20px 7px; - color: #8891a1 !important; -} - -.dummy-calendar { - display: none; - position: absolute; - top: 55px; - left: 110px; - z-index: 9999; - border: 1px solid #3C3C3C; - @include box-shadow(0 1px 15px rgba(0, 0, 0, .2)); -} - -.unit-name-input { - padding: 20px 40px; - - label { - display: block; - } - - input { - width: 100%; - font-size: 20px; - } -} - -.preview { - background: url(../img/preview.jpg) center top no-repeat; -} - -.edit-subsection-publish-settings { - display: none; - position: fixed; - top: 100px; - left: 50%; - z-index: 99999; - width: 600px; - margin-left: -300px; - background: #fff; - text-align: center; - - .settings { - padding: 40px; - } - - h3 { - font-size: 34px; - font-weight: 300; - } - - .picker { - margin: 30px 0 65px; - } - - .description { - margin-top: 30px; - font-size: 14px; - line-height: 20px; - } - - strong { - font-weight: 700; - } - - .start-date, - .start-time { - font-size: 19px; - } - - .save-button { - @include blue-button; - margin-right: 10px; - } - - .cancel-button { - @include white-button; - } - - .save-button, - .cancel-button { - font-size: 16px; - } -} - -.collapse-all-button { - float: right; - margin-top: 10px; - font-size: 13px; - color: $darkGrey; -} - -// sort/drag and drop -.ui-droppable { - @include transition (padding 0.5s ease-in-out 0s); - min-height: 20px; - padding: 0; - - &.dropover { - padding: 15px 0; - } -} - -.ui-draggable-dragging { - @include box-shadow(0 1px 2px rgba(0, 0, 0, .3)); - border: 1px solid $darkGrey; - opacity : 0.2; - &:hover { - opacity : 1.0; - .section-item { - background: $yellow !important; - } - } - - // hiding unit button - temporary fix until this semantically corrected - .new-unit-item { - display: none; - } -} - -ol.ui-droppable .branch:first-child .section-item { - border-top: none; -} - diff --git a/cms/static/sass/_dashboard.scss b/cms/static/sass/_dashboard.scss deleted file mode 100644 index 0d4d046e57..0000000000 --- a/cms/static/sass/_dashboard.scss +++ /dev/null @@ -1,114 +0,0 @@ -.class-list { - margin-top: 20px; - border-radius: 3px; - border: 1px solid $darkGrey; - background: #fff; - @include box-shadow(0 1px 2px rgba(0, 0, 0, .1)); - - li { - position: relative; - border-bottom: 1px solid $mediumGrey; - - &:last-child { - border-bottom: none; - } - - .class-link { - z-index: 100; - display: block; - padding: 20px 25px; - line-height: 1.3; - - &:hover { - background: $paleYellow; - - + .view-live-button { - opacity: 1.0; - pointer-events: auto; - } - } - } - } - - .class-name { - display: block; - font-size: 19px; - font-weight: 300; - } - - .detail { - font-size: 14px; - font-weight: 400; - margin-right: 20px; - color: #3c3c3c; - } - - // view live button - .view-live-button { - z-index: 10000; - position: absolute; - top: 15px; - right: $baseline; - padding: ($baseline/4) ($baseline/2); - opacity: 0; - pointer-events: none; - - &:hover { - opacity: 1.0; - pointer-events: auto; - } - } -} - -.new-course { - padding: 15px 25px; - margin-top: 20px; - border-radius: 3px; - border: 1px solid $darkGrey; - background: #fff; - box-shadow: 0 1px 2px rgba(0, 0, 0, .1); - @include clearfix; - - .row { - margin-bottom: 15px; - @include clearfix; - } - - .column { - float: left; - width: 48%; - } - - .column:first-child { - margin-right: 4%; - } - - .course-info { - width: 600px; - } - - label { - display: block; - font-size: 13px; - font-weight: 700; - } - - .new-course-org, - .new-course-number, - .new-course-name { - width: 100%; - } - - .new-course-name { - font-size: 19px; - font-weight: 300; - } - - .new-course-save { - @include blue-button; - } - - .new-course-cancel { - @include white-button; - } -} \ No newline at end of file diff --git a/cms/static/sass/_extends.scss b/cms/static/sass/_extends.scss deleted file mode 100644 index 5907481bd1..0000000000 --- a/cms/static/sass/_extends.scss +++ /dev/null @@ -1,78 +0,0 @@ -.faded-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1) 50%, - rgba(200,200,200, 0))); - height: 1px; - width: 100%; -} - -.faded-hr-divider-medium { - @include background-image(linear-gradient(180deg, rgba(240,240,240, 0) 0%, - rgba(240,240,240, 1) 50%, - rgba(240,240,240, 0))); - height: 1px; - width: 100%; -} - -.faded-hr-divider-light { - @include background-image(linear-gradient(180deg, rgba(255,255,255, 0) 0%, - rgba(255,255,255, 0.8) 50%, - rgba(255,255,255, 0))); - height: 1px; - width: 100%; -} - -.faded-vertical-divider { - @include background-image(linear-gradient(90deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1) 50%, - rgba(200,200,200, 0))); - height: 100%; - width: 1px; -} - -.faded-vertical-divider-light { - @include background-image(linear-gradient(90deg, rgba(255,255,255, 0) 0%, - rgba(255,255,255, 0.6) 50%, - rgba(255,255,255, 0))); - height: 100%; - width: 1px; -} - -.vertical-divider { - @extend .faded-vertical-divider; - position: relative; - - &::after { - @extend .faded-vertical-divider-light; - content: ""; - display: block; - position: absolute; - left: 1px; - } -} - -.horizontal-divider { - border: none; - @extend .faded-hr-divider; - position: relative; - - &::after { - @extend .faded-hr-divider-light; - content: ""; - display: block; - position: absolute; - top: 1px; - } -} - -.fade-right-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1))); - border: none; -} - -.fade-left-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 1) 0%, - rgba(200,200,200, 0))); - border: none; -} \ No newline at end of file diff --git a/cms/static/sass/_landing.scss b/cms/static/sass/_landing.scss deleted file mode 100644 index 16f1b5b5a7..0000000000 --- a/cms/static/sass/_landing.scss +++ /dev/null @@ -1,126 +0,0 @@ -// This is a temporary page, which will be replaced once we have a more extensive course catalog and marketing site for edX labs. - -.class-landing { - - .main-wrapper { - width: 700px !important; - margin: 100px auto; - } - - .class-info { - padding: 30px 40px 40px; - @extend .window; - - hgroup { - padding-bottom: 26px; - border-bottom: 1px solid $mediumGrey; - } - - h1 { - float: none; - font-size: 30px; - font-weight: 300; - margin: 0; - } - - h2 { - color: #5d6779; - } - - .class-actions { - @include clearfix; - padding: 15px 0; - margin-bottom: 18px; - border-bottom: 1px solid $mediumGrey; - } - - .log-in-form { - @include clearfix; - padding: 15px 0 20px; - margin-bottom: 18px; - border-bottom: 1px solid $mediumGrey; - - .log-in-submit-button { - @include blue-button; - padding: 6px 20px 8px; - margin: 24px 0 0; - } - - .column { - float: left; - width: 41%; - margin-right: 1%; - - &.submit { - width: 16%; - margin-right: 0; - } - - label { - float: left; - } - } - - input { - width: 100%; - font-family: $sans-serif; - font-size: 13px; - } - - .forgot-button { - float: right; - margin-bottom: 6px; - font-size: 12px; - } - } - - .sign-up-button { - @include blue-button; - display: block; - width: 250px; - margin: auto; - } - - .log-in-button { - @include white-button; - float: right; - } - - .sign-up-button, - .log-in-button { - padding: 8px 0 12px; - font-size: 18px; - font-weight: 300; - text-align: center; - } - - .class-description { - margin-top: 30px; - font-size: 14px; - } - - p + p { - margin-top: 22px; - } - } - - .edx-labs-logo-small { - display: block; - width: 124px; - height: 30px; - margin: auto; - background: url(../img/edx-labs-logo-small.png) no-repeat; - text-indent: -9999px; - overflow: hidden; - } - - .edge-logo { - display: block; - width: 143px; - height: 39px; - margin: auto; - background: url(../images/edge-logo-small.png) no-repeat; - text-indent: -9999px; - overflow: hidden; - } -} \ No newline at end of file diff --git a/cms/static/sass/_layout.scss b/cms/static/sass/_layout.scss deleted file mode 100644 index 43308a973c..0000000000 --- a/cms/static/sass/_layout.scss +++ /dev/null @@ -1,125 +0,0 @@ -body { - @include clearfix(); - height: 100%; - font: 14px $body-font-family; - background-color: lighten($dark-blue, 62%); - background-image: url('/static/img/noise.png'); - - > section { - display: table; - table-layout: fixed; - width: 100%; - } - - > header { - background: $dark-blue; - @include background-image(url('/static/img/noise.png'), linear-gradient(lighten($dark-blue, 10%), $dark-blue)); - border-bottom: 1px solid darken($dark-blue, 15%); - @include box-shadow(inset 0 -1px 0 lighten($dark-blue, 10%)); - @include box-sizing(border-box); - color: #fff; - display: block; - float: none; - padding: 0 20px; - text-shadow: 0 -1px 0 darken($dark-blue, 15%); - width: 100%; - - nav { - @include clearfix; - - > a { - @include hide-text; - background: url('/static/img/menu.png') 0 center no-repeat; - border-right: 1px solid darken($dark-blue, 10%); - @include box-shadow(1px 0 0 lighten($dark-blue, 10%)); - display: block; - float: left; - height: 19px; - padding: 8px 10px 8px 0; - width: 14px; - - &:hover, &:focus { - opacity: .7; - } - } - - h2 { - border-right: 1px solid darken($dark-blue, 10%); - @include box-shadow(1px 0 0 lighten($dark-blue, 10%)); - float: left; - font-size: 14px; - margin: 0; - text-transform: uppercase; - -webkit-font-smoothing: antialiased; - - a { - color: #fff; - padding: 8px 20px; - display: block; - - &:hover { - background-color: rgba(darken($dark-blue, 15%), .5); - color: $yellow; - } - } - } - - a { - color: rgba(#fff, .8); - - &:hover { - color: rgba(#fff, .6); - } - } - - ul { - float: left; - margin: 0; - padding: 0; - @include clearfix; - - &.user-nav { - float: right; - border-left: 1px solid darken($dark-blue, 10%); - } - - li { - border-right: 1px solid darken($dark-blue, 10%); - float: left; - @include box-shadow(1px 0 0 lighten($dark-blue, 10%)); - - a { - padding: 8px 20px; - display: block; - - &:hover { - background-color: rgba(darken($dark-blue, 15%), .5); - color: $yellow; - } - - &.new-module { - &:before { - @include inline-block; - content: "+"; - font-weight: bold; - margin-right: 10px; - } - } - } - } - } - } - } - - &.content { - section.main-content { - border-left: 2px solid $dark-blue; - @include box-sizing(border-box); - width: flex-grid(9) + flex-gutter(); - float: left; - @include box-shadow( -2px 0 0 lighten($dark-blue, 55%)); - @include transition(); - background: #FFF; - } - } -} diff --git a/cms/static/sass/_lms.scss b/cms/static/sass/_lms.scss deleted file mode 100644 index 1ddc48edaf..0000000000 --- a/cms/static/sass/_lms.scss +++ /dev/null @@ -1,69 +0,0 @@ -.component { - font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif; - font-size: 16px; - line-height: 1.6; - color: #3c3c3c; - - a { - color: #1d9dd9; - text-decoration: none; - } - - p { - font-size: 16px; - line-height: 1.6; - } - - h1 { - float: none; - } - - h2 { - color: #646464; - font-size: 19px; - font-weight: 300; - letter-spacing: 1px; - margin-bottom: 15px; - margin-left: 0; - text-transform: uppercase; - } - - h3 { - font-size: 19px; - font-weight: 400; - } - - h4 { - background: none; - padding: 0; - border: none; - @include box-shadow(none); - font-size: 16px; - font-weight: 400; - } - - code { - margin: 0 2px; - padding: 0px 5px; - border-radius: 3px; - border: 1px solid #eaeaea; - white-space: nowrap; - font-family: Monaco, monospace; - font-size: 14px; - background-color: #f8f8f8; - } - - p + h2, ul + h2, ol + h2, p + h3 { - margin-top: 40px; - } - - p + p, ul + p, ol + p { - margin-top: 20px; - } - - p { - color: #3c3c3c; - font: normal 1em/1.6em; - margin: 0px; - } -} \ No newline at end of file diff --git a/cms/static/sass/_login.scss b/cms/static/sass/_login.scss deleted file mode 100644 index c2bff74638..0000000000 --- a/cms/static/sass/_login.scss +++ /dev/null @@ -1,139 +0,0 @@ -.edx-studio-logo-large { - display: block; - width: 224px; - height: 45px; - margin: 100px auto 30px; - background: url(../img/edx-studio-large.png) no-repeat; -} - -.sign-up-box, -.log-in-box { - width: 500px; - margin: auto; - border-radius: 3px; - - header { - height: 36px; - border-radius: 3px 3px 0 0; - border: 1px solid #2c2e33; - @include linear-gradient(top, #686b76, #54565e); - color: #fff; - @include box-shadow(0 1px 1px rgba(0, 0, 0, 0.2), 0 -1px 0px rgba(255, 255, 255, 0.05) inset, 0 1px 0 rgba(255, 255, 255, .25) inset); - - h1 { - float: none; - margin: 5px 0; - font-size: 15px; - font-weight: 300; - text-align: center; - } - } - - form { - padding: 40px; - border: 1px solid $darkGrey; - border-top-width: 0; - border-radius: 0 0 3px 3px; - background: #fff; - @include box-shadow(0 1px 2px rgba(0, 0, 0, .1)); - } - - label { - display: block; - margin-bottom: 5px; - font-size: 13px; - font-weight: 700; - } - - input[type="text"], - input[type="email"], - input[type="password"] { - width: 100%; - font-size: 20px; - font-weight: 300; - } - - .row { - @include clearfix; - margin-bottom: 24px; - - .split { - float: left; - width: 48%; - - &:first-child { - margin-right: 4%; - } - } - } - - .form-actions { - @include clearfix; - margin-top: 32px; - margin-bottom: 5px; - text-align: center; - } - - .log-in-button, - .create-account-button { - @include blue-button; - padding: 8px 0 10px; - font-family: $sans-serif; - @include transition(all .15s); - } - - .create-account-button { - padding: 10px 40px 12px; - margin-bottom: 10px; - } - - .enrolled { - font-size: 14px; - } - - .sign-up-button { - @include white-button; - padding: 7px 0 9px; - } - - .log-in-button, - .sign-up-button { - @include box-sizing(border-box); - float: left; - width: 45%; - } - - .or { - float: left; - display: inline-block; - width: 10%; - font-size: 15px; - line-height: 36px; - color: $darkGrey; - text-align: center; - } - - .forgot-button { - float: right; - font-size: 11px; - font-weight: 400; - line-height: 21px; - } - - .log-in-extra { - margin-top: 10px; - text-align: right; - font-size: 13px; - } - - #login_error, - #register_error { - display: none; - margin-bottom: 30px; - padding: 5px 10px; - border-radius: 3px; - background: $error-red; - font-size: 14px; - color: #fff; - } -} \ No newline at end of file diff --git a/cms/static/sass/_module-header.scss b/cms/static/sass/_module-header.scss deleted file mode 100644 index e2af263618..0000000000 --- a/cms/static/sass/_module-header.scss +++ /dev/null @@ -1,128 +0,0 @@ -section.video-new, section.video-edit, section.problem-new, section.problem-edit { - position: absolute; - top: 72px; - right: 0; - background: #fff; - width: flex-grid(6); - @include box-shadow(0 0 6px #666); - border: 1px solid #333; - border-right: 0; - z-index: 4; - - > header { - background: #666; - @include clearfix; - color: #fff; - padding: 6px; - border-bottom: 1px solid #333; - -webkit-font-smoothing: antialiased; - - h2 { - float: left; - font-size: 14px; - } - - a { - color: #fff; - - &.save-update { - float: right; - } - - &.cancel { - float: left; - } - } - - } - - > section { - padding: 20px; - - > header { - h1 { - font-size: 24px; - margin: 12px 0; - } - - section { - &.status-settings { - ul { - list-style: none; - @include border-radius(2px); - border: 1px solid #999; - @include inline-block(); - - li { - @include inline-block(); - border-right: 1px solid #999; - padding: 6px; - - &:last-child { - border-right: 0; - } - - &.current { - background: #eee; - } - } - } - - a.settings { - @include inline-block(); - margin: 0 20px; - border: 1px solid #999; - padding: 6px; - } - - select { - float: right; - } - } - - &.meta { - background: #eee; - padding: 10px; - margin: 20px 0; - @include clearfix(); - - div { - float: left; - margin-right: 20px; - - h2 { - font-size: 14px; - @include inline-block(); - } - - p { - @include inline-block(); - } - } - } - } - } - - section.notes { - margin-top: 20px; - padding: 6px; - background: #eee; - border: 1px solid #ccc; - - textarea { - @include box-sizing(border-box); - display: block; - width: 100%; - } - - h2 { - font-size: 14px; - margin-bottom: 6px; - } - - input[type="submit"]{ - margin-top: 10px; - } - } - } -} diff --git a/cms/static/sass/_problem.scss b/cms/static/sass/_problem.scss deleted file mode 100644 index 66acacf65c..0000000000 --- a/cms/static/sass/_problem.scss +++ /dev/null @@ -1,24 +0,0 @@ -section.problem-new, section.problem-edit { - > section { - textarea { - @include box-sizing(border-box); - display: block; - width: 100%; - } - - div.preview { - background: #eee; - @include box-sizing(border-box); - height: 40px; - padding: 10px; - width: 100%; - } - - a.save { - @extend .button; - @include inline-block(); - margin-top: 20px; - } - } -} - diff --git a/cms/static/sass/_reset.scss b/cms/static/sass/_reset.scss index ee03a0fca3..87a6e51232 100644 --- a/cms/static/sass/_reset.scss +++ b/cms/static/sass/_reset.scss @@ -1,3 +1,10 @@ +// studio - utilities - reset +// ==================== + +// * { +// @include box-sizing(border-box); +// } + html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, @@ -18,7 +25,7 @@ time, mark, audio, video { font: inherit; vertical-align: baseline; } -/* HTML5 display-role reset for older browsers */ + article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; @@ -38,12 +45,6 @@ q:before, q:after { content: none; } -/* remember to define visible focus styles! -:focus { - outline: ?????; -} */ - -/* remember to highlight inserts somehow! */ ins { text-decoration: none; } @@ -56,10 +57,11 @@ table { border-spacing: 0; } -/* Reset styles to remove ui-lightness jquery ui theme -from the tabs component (used in the add component problem tab menu) -*/ +// ==================== +// grandfathered styles + +// reset styles to remove ui-lightness jquery ui theme from the tabs component (used in the add component problem tab menu) .ui-tabs { padding: 0; white-space: normal; @@ -118,10 +120,7 @@ from the tabs component (used in the add component problem tab menu) padding: 0; } -/* reapplying the tab styles from unit.scss after -removing jquery ui ui-lightness styling -*/ - +// reapplying the tab styles from unit.scss after removing jquery ui ui-lightness styling .problem-type-tabs { border:none; list-style-type: none; @@ -146,26 +145,4 @@ removing jquery ui ui-lightness styling border: 0px; } } -/* - li { - float:left; - display:inline-block; - text-align:center; - width: auto; - //@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); - //background-color: tint($lightBluishGrey, 20%); - //@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset); - opacity:.8; - - &:hover { - opacity:1; - } - - &.current { - border: 0px; - //@include active; - opacity:1; - } - } -*/ } \ No newline at end of file diff --git a/cms/static/sass/_section.scss b/cms/static/sass/_section.scss deleted file mode 100644 index 97818326be..0000000000 --- a/cms/static/sass/_section.scss +++ /dev/null @@ -1,239 +0,0 @@ -section#unit-wrapper { - section.filters { - @include clearfix; - display: none; - opacity: .4; - margin-bottom: 10px; - @include transition; - - &:hover { - opacity: 1; - } - - h2 { - @include inline-block(); - text-transform: uppercase; - letter-spacing: 1px; - font-size: 14px; - padding: 6px 6px 6px 0; - font-size: 12px; - margin: 0; - } - - ul { - @include clearfix(); - list-style: none; - margin: 0; - padding: 0; - - li { - @include inline-block; - margin-right: 6px; - border-right: 1px solid #ddd; - padding-right: 6px; - - &.search { - float: right; - border: 0; - } - - a { - &.more { - font-size: 12px; - @include inline-block; - margin: 0 6px; - font-style: italic; - } - } - } - } - } - - div.content { - display: table; - border: 1px solid lighten($dark-blue, 40%); - width: 100%; - @include border-radius(3px); - @include box-shadow(0 0 4px lighten($dark-blue, 50%)); - - section { - header { - background: #fff; - padding: 6px; - border-bottom: 1px solid lighten($dark-blue, 60%); - @include clearfix; - - h2 { - color: $bright-blue; - // float: left; - font-size: 14px; - letter-spacing: 1px; - // line-height: 20px; - text-transform: uppercase; - margin: 0; - } - } - - &.modules { - @include box-sizing(border-box); - display: table-cell; - width: flex-grid(6, 9); - border-right: 1px solid lighten($dark-blue, 40%); - - &.empty { - text-align: center; - vertical-align: middle; - - a { - @extend .button; - @include inline-block(); - margin-top: 10px; - } - } - - ol { - list-style: none; - margin: 0; - padding: 0; - - li { - border-bottom: 1px solid lighten($dark-blue, 60%); - - a { - color: #000; - } - - ol { - list-style: none; - margin: 0; - padding: 0; - - li { - padding: 6px; - position: relative; - - &:last-child { - border-bottom: 0; - } - - &:hover { - background-color: lighten($yellow, 10%); - - a.draggable { - opacity: 1; - } - } - - a.draggable { - float: right; - opacity: .4; - } - - &.group { - padding: 0; - - header { - padding: 6px; - background: none; - - h3 { - font-size: 14px; - margin: 0; - } - } - - ol { - border-left: 4px solid #999; - border-bottom: 0; - margin: 0; - padding: 0; - - li { - &:last-child { - border-bottom: 0; - } - } - } - } - } - } - } - } - } - - &.scratch-pad { - @include box-sizing(border-box); - display: table-cell; - width: flex-grid(3, 9) + flex-gutter(9); - vertical-align: top; - - ol { - list-style: none; - margin: 0; - padding: 0; - - li { - background: $light-blue; - - &:last-child { - border-bottom: 0; - } - - &.new-module a { - background-color: darken($light-blue, 2%); - border-bottom: 1px solid darken($light-blue, 8%); - - &:hover { - background-color: lighten($yellow, 10%); - } - } - - a { - color: $dark-blue; - } - - ul { - list-style: none; - margin: 0; - padding: 0; - - li { - padding: 6px; - border-collapse: collapse; - border-bottom: 1px solid darken($light-blue, 8%); - position: relative; - - &:last-child { - border-bottom: 1px solid darken($light-blue, 8%); - } - - &:hover { - background-color: lighten($yellow, 10%); - - a.draggable { - opacity: 1; - } - } - - - &.empty { - padding: 12px; - - a { - @extend .button; - display: block; - text-align: center; - } - } - - a.draggable { - opacity: .3; - } - } - } - } - } - } - } - } -} diff --git a/cms/static/sass/_subsection.scss b/cms/static/sass/_subsection.scss deleted file mode 100644 index a39c0d757a..0000000000 --- a/cms/static/sass/_subsection.scss +++ /dev/null @@ -1,295 +0,0 @@ -.subsection .main-wrapper { - margin: 40px; -} - -.subsection .inner-wrapper { - @include clearfix(); -} - -.subsection-body { - padding: 32px 40px; - @include clearfix; - - > div { - margin-bottom: 40px; - } - - input { - font-size: 14px; - } - - .unit-subtitle { - display: block; - width: 100%; - } - - .sortable-unit-list { - ol { - @include tree-view; - } - } - - .policy-list { - input[disabled] { - border: none; - @include box-shadow(none); - } - - .policy-list-name { - margin-right: 5px; - margin-bottom: 10px; - } - - .policy-list-value { - width: 320px; - margin-right: 10px; - } - } - - .policy-list-element { - .save-button, - .cancel-button { - display: none; - } - - .edit-icon { - margin-right: 8px; - } - - &.editing, - &.new-policy-list-element { - .policy-list-name, - .policy-list-value { - border: 1px solid #b0b6c2; - @include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, .3)); - background-color: #edf1f5; - @include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset); - } - } - } - - .new-policy-list-element { - padding: 10px 10px 0; - margin: 0 -10px 10px; - border-radius: 3px; - background: $mediumGrey; - - .save-button { - @include blue-button; - margin-bottom: 10px; - } - - .cancel-button { - @include white-button; - } - - .edit-icon { - display: none; - } - - .delete-icon { - display: none; - } - } - - .new-policy-item { - margin: 10px 0; - - .plus-icon-small { - position: relative; - top: -1px; - vertical-align: middle; - } - } -} - -.subsection-name-input { - label { - display: block; - } - - input { - width: 100%; - font-size: 20px; - } -} - -.scheduled-date-input, -.due-date-input { - @include clearfix; - - .date-input, - .time-input { - display: inline-block; - width: 100px; - } - - .inherits-check { - label { - font-size: 13px; - } - } - - .notice { - margin-top: 6px; - font-size: 11px; - color: #999; - } -} - -.due-date-input { - label { - display: inline-block !important; - margin-right: 10px; - } - - a { - font-size: 11px; - font-weight: bold; - text-transform: uppercase; - } - - .date-setter { - @include clearfix; - display: none; - } - - .remove-date { - display: block; - } -} - -.row.visibility { - label { - display: inline-block !important; - margin-right: 10px; - line-height: 21px; - } - - a { - display: inline-block; - height: 31px; - margin-right: 8px; - vertical-align: middle; - font-size: 11px; - font-weight: 700; - line-height: 31px; - text-transform: uppercase; - } - - .large-toggle { - width: 41px; - background: url(../img/large-toggles.png) no-repeat; - background-position: 0 -50px; - - .hidden { - background-position: 0 -5px; - } - } -} - -.gradable { - - label { - display: inline-block; - vertical-align: top; - } - - .gradable-status { - position: relative; - top: -4px; - display: inline-block; - margin-left: 10px; - width: 65%; - - .status-label { - margin: 0; - padding: 0; - background: transparent; - color: $blue; - border: none; - font-size: 11px; - font-weight: bold; - text-transform: uppercase; - } - - .menu-toggle { - z-index: 100; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 20px; - background: transparent; - - &:hover, &.is-active { - color: $blue; - } - } - - .menu { - z-index: 1; - position: absolute; - top: -12px; - left: -7px; - display: none; - width: 100%; - margin: 0; - padding: 8px 12px; - opacity: 0.0; - background: $white; - border: 1px solid $mediumGrey; - font-size: 12px; - @include border-radius(4px); - @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); - @include transition(opacity .15s); - - - li { - margin-bottom: 3px; - padding-bottom: 3px; - border-bottom: 1px solid $lightGrey; - - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border: none; - } - } - - a { - - &.is-selected { - font-weight: bold; - } - } - } - - // dropdown state - &.is-active { - - .menu { - z-index: 10000; - display: block; - opacity: 1.0; - } - - .menu-toggle { - z-index: 1000; - } - } - - // set state - &.is-set { - - .menu-toggle { - color: $blue; - } - - .status-label { - display: block; - color: $blue; - } - } - } -} \ No newline at end of file diff --git a/cms/static/sass/_unit.scss b/cms/static/sass/_unit.scss deleted file mode 100644 index b7600e4205..0000000000 --- a/cms/static/sass/_unit.scss +++ /dev/null @@ -1,667 +0,0 @@ -.unit .main-wrapper { - @include clearfix(); - margin: 40px; -} - -//Problem Selector tab menu requirements -.js .tabs .tab { - display: none; -} -//end problem selector reqs - -.main-column { - clear: both; - float: left; - width: 70%; -} - -.unit-body.published { - .components > li { - border: none; - - .rendered-component { - padding: 0 20px; - } - } -} - -.unit-body { - .breadcrumbs { - border-radius: 3px 3px 0 0; - border-bottom: 1px solid #cbd1db; - @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0) 70%); - background-color: #edf1f5; - @include box-shadow(0 1px 0 rgba(255, 255, 255, .7) inset); - @include clearfix; - - li { - float: left; - } - - a, - .current-page { - display: block; - padding: 15px 35px 15px 30px; - font-size: 14px; - background: url(../img/breadcrumb-arrow.png) no-repeat right center; - } - } - - h2 { - margin: 30px 40px 30px 0; - color: #646464; - font-size: 19px; - font-weight: 300; - letter-spacing: 1px; - text-transform: uppercase; - } - - .components { - - > li { - position: relative; - z-index: 10; - margin: 20px 40px; - - - - .title { - margin: 0 0 15px 0; - color: $mediumGrey; - - .value { - } - } - - &.new-component-item { - margin: 20px 0px; - border-top: 1px solid $mediumGrey; - box-shadow: 0 2px 1px rgba(182, 182, 182, 0.75) inset; - background-color: $lightGrey; - margin-bottom: 0px; - padding-bottom: 20px; - - .new-component-button { - display: block; - padding: 20px; - text-align: center; - color: #edf1f5; - } - - h5 { - margin: 20px 0px; - color: #fff; - font-weight: 600; - font-size: 18px; - } - - .rendered-component { - display: none; - background: #fff; - border-radius: 3px 3px 0 0; - } - - .new-component-type { - - a, - li { - display: inline-block; - } - - a { - border: 1px solid $mediumGrey; - width: 100px; - height: 100px; - color: #fff; - margin-right: 15px; - margin-bottom: 20px; - border-radius: 8px; - font-size: 15px; - line-height: 14px; - text-align: center; - @include box-shadow(0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset); - - .name { - position: absolute; - bottom: 5px; - left: 0; - width: 100%; - padding: 10px; - @include box-sizing(border-box); - color: #fff; - } - } - } - - .new-component-templates { - display: none; - margin: 20px 40px 20px 40px; - border-radius: 3px; - border: 1px solid $mediumGrey; - background-color: #fff; - @include box-shadow(0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset); - @include clearfix; - - .cancel-button { - margin: 20px 0px 10px 10px; - @include white-button; - } - - .problem-type-tabs { - display: none; - } - - // specific menu types - &.new-component-problem { - padding-bottom:10px; - - .ss-icon, .editor-indicator { - display: inline-block; - } - - .problem-type-tabs { - display: inline-block; - } - } - } - - .new-component-type, - .new-component-template { - @include clearfix; - - a { - position: relative; - border: 1px solid $darkGreen; - background: tint($green,20%); - color: #fff; - - &:hover { - background: $brightGreen; - } - } - } - - .problem-type-tabs { - list-style-type: none; - border-radius: 0; - width: 100%; - @include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); - background-color: $lightBluishGrey; - @include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset); - - li:first-child { - margin-left: 20px; - } - - li { - float:left; - display:inline-block; - text-align:center; - width: auto; - @include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); - background-color: tint($lightBluishGrey, 10%); - @include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset); - opacity:.8; - - &:hover { - opacity:1; - background-color: tint($lightBluishGrey, 20%); - } - - &.ui-state-active { - border: 0px; - @include active; - opacity:1; - } - } - - a{ - display: block; - padding: 15px 25px; - font-size: 15px; - line-height: 16px; - text-align: center; - color: #3c3c3c; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3); - } - } - - .new-component-template { - - a { - background: #fff; - border: 0px; - color: #3c3c3c; - @include transition (none); - - &:hover { - background: tint($green,30%); - color: #fff; - @include transition(background-color .15s); - } - } - - li { - border:none; - border-bottom: 1px dashed $lightGrey; - color: #fff; - } - - li:first-child { - a { - border-top: 0px; - } - } - - li:nth-child(2) { - a { - border-radius: 0px; - } - } - - a { - @include clearfix(); - display: block; - padding: 7px 20px; - border-bottom: none; - font-weight: 500; - - .name { - float: left; - - .ss-icon { - @include transition(opacity .15s); - display: inline-block; - top: 1px; - margin-right: 5px; - opacity: 0.5; - width: 17; - height: 21px; - vertical-align: middle; - } - } - - .editor-indicator { - @include transition(opacity .15s); - float: right; - position: relative; - top: 3px; - font-size: 12px; - opacity: 0.3; - } - - .ss-icon, .editor-indicator { - display: none; - } - - &:hover { - color: #fff; - - .ss-icon { - opacity: 1.0; - } - - .editor-indicator { - opacity: 1.0; - } - } - } - - // specific editor types - .empty { - - a { - line-height: 1.4; - font-weight: 400; - background: #fff; - color: #3c3c3c; - - - &:hover { - background: tint($green,30%); - color: #fff; - } - } - } - } - - .new-component { - text-align: center; - - h5 { - color: $darkGreen; - } - - } - } - } - } - - .component { - border: 1px solid $lightBluishGrey2; - border-radius: 3px; - background: #fff; - @include transition(none); - - &:hover { - border-color: #6696d7; - - .drag-handle { - background-color: $blue; - border-color: $blue; - } - } - - &.editing { - border: 1px solid $lightBluishGrey2; - z-index: auto; - - .drag-handle, - .component-actions { - display: none; - } - } - - &.component-placeholder { - border-color: #6696d7; - } - - .component-actions { - position: absolute; - top: 7px; - right: 9px; - } - - .drag-handle { - position: absolute; - display: block; - top: -1px; - right: -16px; - z-index: 10; - width: 15px; - height: 100%; - border-radius: 0 3px 3px 0; - border: 1px solid $lightBluishGrey2; - background: url(../img/white-drag-handles.png) center no-repeat $lightBluishGrey2; - cursor: move; - @include transition(none); - } - } - - .xmodule_display { - padding: 40px 20px 20px; - overflow-x: auto; - - h1 { - float: none; - margin-left: 0; - } - } - - .wrapper-component-editor { - z-index: 9999; - position: relative; - background: $lightBluishGrey2; - } - - .component-editor { - @include edit-box; - @include box-shadow(none); - display: none; - padding: 20px; - border-radius: 2px 2px 0 0; - - .metadata_edit { - margin-bottom: 20px; - font-size: 13px; - - li { - margin-bottom: 10px; - } - - label { - display: inline-block; - margin-right: 10px; - } - } - - h3 { - margin-bottom: 10px; - font-size: 18px; - font-weight: 700; - } - - h5 { - margin-bottom: 8px; - color: #fff; - font-weight: 700; - } - - .save-button { - margin-top: 10px; - margin: 15px 8px 0 0; - } - } -} - -.unit-settings { - .window-contents { - padding: 10px 20px; - } - - .unit-actions { - border-bottom: none; - padding-bottom: 0; - } - - .published-alert { - display: none; - padding: 10px; - border: 1px solid #edbd3c; - border-radius: 3px; - background: #fbf6e1; - font-size: 14px; - line-height: 1.4; - - div { - margin-top: 15px; - } - } - - input[type="radio"] { - margin-right: 7px; - } - - .status { - font-size: 12px; - - strong { - font-weight: 700; - } - } - - .preview-button, .view-button { - @include white-button; - margin-bottom: 10px; - } - - .publish-button { - @include orange-button; - } - - .delete-button { - @include blue-button; - } - - .delete-draft { - display: inline-block; - } - - .delete-button, - .preview-button, - .publish-button, - .view-button { - font-size: 11px; - margin-top: 10px; - padding: 6px 15px 8px; - } -} - -.unit-history { - &.collapsed { - h4 { - border-bottom: none; - border-radius: 3px; - } - - .window-contents { - display: none; - } - } - - ol { - border: 1px solid #ced2db; - - li { - display: block; - padding: 6px 8px 8px 10px; - background: #edf1f5; - font-size: 12px; - - &:hover { - background: #fffcf1; - - .item-actions { - display: block; - } - } - - &.checked { - background: #d1dae3; - } - - .item-actions { - display: none; - } - - input[type="radio"] { - margin-right: 7px; - } - } - } -} - -.unit-location { - .url { - width: 100%; - margin-bottom: 10px; - @include box-shadow(none); - } - - .draft-tag, - .hidden-tag, - .private-tag, - .has-new-draft-tag { - font-size: 8px; - } - - .window-contents > ol { - @include tree-view; - - .section-item { - display: inline-block; - width: 100%; - font-size: 11px; - padding: 2px 8px 4px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - @include box-sizing(border-box); - } - - ol { - .section-item { - padding-left: 20px; - } - - .new-unit-item { - margin-left: 20px; - } - } - - ol ol { - .section-item { - padding-left: 34px; - } - - .new-unit-item { - margin: 0 0 10px 41px; - } - } - } -} - -.edit-state-draft { - .visibility, - - .edit-draft-message, - .view-button { - display: none; - } - - .published-alert { - display: block; - } -} - -.edit-state-public { - .delete-draft, - .component-actions, - .new-component-item, - .editing-draft-alert, - .publish-draft-message, - .preview-button { - display: none; - } - - .published-alert { - display: block; - } - - .drag-handle { - display: none !important; - } -} - -.edit-state-private { - .delete-draft, - .publish-draft, - .editing-draft-alert, - .create-draft, - .view-button { - display: none; - } -} - -// editing units from courseware -body.unit { - - .component { - padding-top: 30px; - - .component-actions { - @include box-sizing(border-box); - position: absolute; - width: 100%; - padding: 15px; - top: 0; - left: 0; - border-bottom: 1px solid $lightBluishGrey2; - background: $lightGrey; - } - - &.editing { - padding-top: 0; - } - } -} diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 4d8e26b2f9..29ebf69271 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -1,3 +1,6 @@ +// studio - utilities - variables +// ==================== + $baseline: 20px; // grid @@ -12,11 +15,18 @@ $fg-min-width: 900px; // type $sans-serif: 'Open Sans', $verdana; $body-line-height: golden-ratio(.875em, 1); -$error-red: rgb(253, 87, 87); // colors - new for re-org $black: rgb(0,0,0); +$black-t0: rgba(0,0,0,0.125); +$black-t1: rgba(0,0,0,0.25); +$black-t2: rgba(0,0,0,0.50); +$black-t3: rgba(0,0,0,0.75); $white: rgb(255,255,255); +$white-t0: rgba(255,255,255,0.125); +$white-t1: rgba(255,255,255,0.25); +$white-t2: rgba(255,255,255,0.50); +$white-t3: rgba(255,255,255,0.75); $gray: rgb(127,127,127); $gray-l1: tint($gray,20%); @@ -24,6 +34,7 @@ $gray-l2: tint($gray,40%); $gray-l3: tint($gray,60%); $gray-l4: tint($gray,80%); $gray-l5: tint($gray,90%); +$gray-l6: tint($gray,95%); $gray-d1: shade($gray,20%); $gray-d2: shade($gray,40%); $gray-d3: shade($gray,60%); @@ -39,6 +50,12 @@ $blue-d1: shade($blue,20%); $blue-d2: shade($blue,40%); $blue-d3: shade($blue,60%); $blue-d4: shade($blue,80%); +$blue-s1: saturate($blue,15%); +$blue-s2: saturate($blue,30%); +$blue-s3: saturate($blue,45%); +$blue-u1: desaturate($blue,15%); +$blue-u2: desaturate($blue,30%); +$blue-u3: desaturate($blue,45%); $pink: rgb(183, 37, 103); $pink-l1: tint($pink,20%); @@ -50,6 +67,29 @@ $pink-d1: shade($pink,20%); $pink-d2: shade($pink,40%); $pink-d3: shade($pink,60%); $pink-d4: shade($pink,80%); +$pink-s1: saturate($pink,15%); +$pink-s2: saturate($pink,30%); +$pink-s3: saturate($pink,45%); +$pink-u1: desaturate($pink,15%); +$pink-u2: desaturate($pink,30%); +$pink-u3: desaturate($pink,45%); + +$red: rgb(178, 6, 16); +$red-l1: tint($red,20%); +$red-l2: tint($red,40%); +$red-l3: tint($red,60%); +$red-l4: tint($red,80%); +$red-l5: tint($red,90%); +$red-d1: shade($red,20%); +$red-d2: shade($red,40%); +$red-d3: shade($red,60%); +$red-d4: shade($red,80%); +$red-s1: saturate($red,15%); +$red-s2: saturate($red,30%); +$red-s3: saturate($red,45%); +$red-u1: desaturate($red,15%); +$red-u2: desaturate($red,30%); +$red-u3: desaturate($red,45%); $green: rgb(37, 184, 90); $green-l1: tint($green,20%); @@ -61,6 +101,12 @@ $green-d1: shade($green,20%); $green-d2: shade($green,40%); $green-d3: shade($green,60%); $green-d4: shade($green,80%); +$green-s1: saturate($green,15%); +$green-s2: saturate($green,30%); +$green-s3: saturate($green,45%); +$green-u1: desaturate($green,15%); +$green-u2: desaturate($green,30%); +$green-u3: desaturate($green,45%); $yellow: rgb(231, 214, 143); $yellow-l1: tint($yellow,20%); @@ -72,6 +118,29 @@ $yellow-d1: shade($yellow,20%); $yellow-d2: shade($yellow,40%); $yellow-d3: shade($yellow,60%); $yellow-d4: shade($yellow,80%); +$yellow-s1: saturate($yellow,15%); +$yellow-s2: saturate($yellow,30%); +$yellow-s3: saturate($yellow,45%); +$yellow-u1: desaturate($yellow,15%); +$yellow-u2: desaturate($yellow,30%); +$yellow-u3: desaturate($yellow,45%); + +$orange: rgb(237, 189, 60); +$orange-l1: tint($orange,20%); +$orange-l2: tint($orange,40%); +$orange-l3: tint($orange,60%); +$orange-l4: tint($orange,80%); +$orange-l5: tint($orange,90%); +$orange-d1: shade($orange,20%); +$orange-d2: shade($orange,40%); +$orange-d3: shade($orange,60%); +$orange-d4: shade($orange,80%); +$orange-s1: saturate($orange,15%); +$orange-s2: saturate($orange,30%); +$orange-s3: saturate($orange,45%); +$orange-u1: desaturate($orange,15%); +$orange-u2: desaturate($orange,30%); +$orange-u3: desaturate($orange,45%); $shadow: rgba(0,0,0,0.2); $shadow-l1: rgba(0,0,0,0.1); @@ -80,8 +149,6 @@ $shadow-d1: rgba(0,0,0,0.4); // colors - inherited $baseFontColor: #3c3c3c; $offBlack: #3c3c3c; -$orange: #edbd3c; -$red: #b20610; $green: #108614; $lightGrey: #edf1f5; $mediumGrey: #b0b6c2; @@ -94,4 +161,5 @@ $brightGreen: rgb(22, 202, 87); $disabledGreen: rgb(124, 206, 153); $darkGreen: rgb(52, 133, 76); $lightBluishGrey: rgb(197, 207, 223); -$lightBluishGrey2: rgb(213, 220, 228); \ No newline at end of file +$lightBluishGrey2: rgb(213, 220, 228); +$error-red: rgb(253, 87, 87); diff --git a/cms/static/sass/_video.scss b/cms/static/sass/_video.scss deleted file mode 100644 index b68176e2db..0000000000 --- a/cms/static/sass/_video.scss +++ /dev/null @@ -1,33 +0,0 @@ -section.video-new, section.video-edit { - > section { - - section.upload { - padding: 6px; - margin-bottom: 10px; - border: 1px solid #ddd; - - a.upload-button { - @extend .button; - @include inline-block(); - } - } - - section.in-use { - h2 { - font-size: 14px; - } - - div { - background: #eee; - text-align: center; - padding: 6px; - } - } - - a.save-update { - @extend .button; - @include inline-block(); - margin-top: 20px; - } - } -} diff --git a/cms/static/sass/_week.scss b/cms/static/sass/_week.scss deleted file mode 100644 index b638a36f5c..0000000000 --- a/cms/static/sass/_week.scss +++ /dev/null @@ -1,256 +0,0 @@ -section.week-edit, -section.week-new, -section.sequence-edit { - - > header { - border-bottom: 2px solid #333; - @include clearfix(); - - div { - @include clearfix(); - padding: 6px 20px; - - h1 { - font-size: 18px; - text-transform: uppercase; - letter-spacing: 1px; - float: left; - } - - p { - float: right; - } - - &.week { - background: #eee; - font-size: 12px; - border-bottom: 1px solid #ccc; - - h2 { - font-size: 12px; - @include inline-block(); - margin-right: 20px; - } - - ul { - list-style: none; - @include inline-block(); - - li { - @include inline-block(); - margin-right: 10px; - - p { - float: none; - } - } - } - } - } - - section.goals { - background: #eee; - padding: 6px 20px; - border-top: 1px solid #ccc; - - ul { - list-style: none; - color: #999; - - li { - margin-bottom: 6px; - - &:last-child { - margin-bottom: 0; - } - } - } - } - } - - > section.content { - @include box-sizing(border-box); - padding: 20px; - - section.filters { - @include clearfix; - margin-bottom: 10px; - background: #efefef; - border: 1px solid #ddd; - - ul { - @include clearfix(); - list-style: none; - padding: 6px; - - li { - @include inline-block(); - - &.advanced { - float: right; - } - } - } - } - - > div { - display: table; - border: 1px solid; - width: 100%; - - section { - header { - background: #eee; - padding: 6px; - border-bottom: 1px solid #ccc; - @include clearfix; - - h2 { - text-transform: uppercase; - letter-spacing: 1px; - font-size: 12px; - float: left; - } - } - - &.modules { - @include box-sizing(border-box); - display: table-cell; - width: flex-grid(6, 9); - border-right: 1px solid #333; - - &.empty { - text-align: center; - vertical-align: middle; - - a { - @extend .button; - @include inline-block(); - margin-top: 10px; - } - } - - ol { - list-style: none; - border-bottom: 1px solid #333; - - li { - border-bottom: 1px solid #333; - - &:last-child{ - border-bottom: 0; - } - - a { - color: #000; - } - - ol { - list-style: none; - - li { - padding: 6px; - - &:hover { - a.draggable { - opacity: 1; - } - } - - a.draggable { - float: right; - opacity: .5; - } - - &.group { - padding: 0; - - header { - padding: 6px; - background: none; - - h3 { - font-size: 14px; - } - } - - - ol { - border-left: 4px solid #999; - border-bottom: 0; - - li { - &:last-child { - border-bottom: 0; - } - } - } - } - } - } - } - } - } - - &.scratch-pad { - @include box-sizing(border-box); - display: table-cell; - width: flex-grid(3, 9) + flex-gutter(9); - vertical-align: top; - - ol { - list-style: none; - border-bottom: 1px solid #999; - - li { - border-bottom: 1px solid #999; - background: #f9f9f9; - - &:last-child { - border-bottom: 0; - } - - ul { - list-style: none; - - li { - padding: 6px; - - &:last-child { - border-bottom: 0; - } - - &:hover { - a.draggable { - opacity: 1; - } - } - - &.empty { - padding: 12px; - - a { - @extend .button; - display: block; - text-align: center; - } - } - - a.draggable { - float: right; - opacity: .3; - } - - a { - color: #000; - } - } - } - - } - } - } - } - } - } -} diff --git a/cms/static/sass/_content-types.scss b/cms/static/sass/assets/_content-types.scss similarity index 100% rename from cms/static/sass/_content-types.scss rename to cms/static/sass/assets/_content-types.scss diff --git a/cms/static/sass/_fonts.scss b/cms/static/sass/assets/_fonts.scss similarity index 100% rename from cms/static/sass/_fonts.scss rename to cms/static/sass/assets/_fonts.scss diff --git a/cms/static/sass/_graphics.scss b/cms/static/sass/assets/_graphics.scss similarity index 100% rename from cms/static/sass/_graphics.scss rename to cms/static/sass/assets/_graphics.scss diff --git a/cms/static/sass/_keyframes.scss b/cms/static/sass/assets/_keyframes.scss similarity index 100% rename from cms/static/sass/_keyframes.scss rename to cms/static/sass/assets/_keyframes.scss diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index dceac4233d..6a1f1bb252 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -1,39 +1,52 @@ +// studio - css architecture +// ==================== + +// bourbon libs and resets @import 'bourbon/bourbon'; @import 'bourbon/addons/button'; @import 'vendor/normalize'; -@import 'keyframes'; - @import 'reset'; + +// utilities +@import 'variables'; @import 'mixins'; +@import 'cms_mixins'; -@import "fonts"; -@import "variables"; -@import "cms_mixins"; -@import "extends"; -@import "base"; -@import "header"; -@import "footer"; -@import "dashboard"; -@import "courseware"; -@import "subsection"; -@import "unit"; -@import "assets"; -@import "static-pages"; -@import "users"; -@import "import"; -@import "export"; -@import "settings"; -@import "course-info"; -@import "landing"; -@import "graphics"; -@import "modal"; -@import "alerts"; -@import "login"; -@import "account"; -@import "index"; -@import 'jquery-ui-calendar'; +// assets +@import 'assets/fonts'; +@import 'assets/graphics'; +@import 'assets/keyframes'; -@import 'content-types'; +// base +@import 'base'; +// elements +@import 'elements/header'; +@import 'elements/footer'; +@import 'elements/navigation'; +@import 'elements/forms'; +@import 'elements/modal'; +@import 'elements/alerts'; +@import 'elements/jquery-ui-calendar'; + +// specific views +@import 'views/account'; +@import 'views/assets'; +@import 'views/updates'; +@import 'views/dashboard'; +@import 'views/export'; +@import 'views/index'; +@import 'views/import'; +@import 'views/outline'; +@import 'views/settings'; +@import 'views/static-pages'; +@import 'views/subsection'; +@import 'views/unit'; +@import 'views/users'; +@import 'views/checklists'; + +@import 'assets/content-types'; + +// xblock-related @import 'module/module-styles.scss'; @import 'descriptor/module-styles.scss'; diff --git a/cms/static/sass/_alerts.scss b/cms/static/sass/elements/_alerts.scss similarity index 96% rename from cms/static/sass/_alerts.scss rename to cms/static/sass/elements/_alerts.scss index bd7f687f67..9c15f811e0 100644 --- a/cms/static/sass/_alerts.scss +++ b/cms/static/sass/elements/_alerts.scss @@ -1,3 +1,6 @@ +// studio - elements - alerts, notifications, prompts +// ==================== + // notifications .wrapper-notification { @include clearfix(); diff --git a/cms/static/sass/_footer.scss b/cms/static/sass/elements/_footer.scss similarity index 93% rename from cms/static/sass/_footer.scss rename to cms/static/sass/elements/_footer.scss index 66a9ce0e95..b1c0f57bb2 100644 --- a/cms/static/sass/_footer.scss +++ b/cms/static/sass/elements/_footer.scss @@ -1,4 +1,6 @@ -//studio global footer +// studio - elements - global footer +// ==================== + .wrapper-footer { margin: ($baseline*1.5) 0 $baseline 0; padding: $baseline; diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss new file mode 100644 index 0000000000..384ffc0509 --- /dev/null +++ b/cms/static/sass/elements/_forms.scss @@ -0,0 +1,76 @@ +// studio - elements - forms +// ==================== + +// forms - general +input[type="text"], +input[type="email"], +input[type="password"], +textarea.text { + padding: 6px 8px 8px; + @include box-sizing(border-box); + border: 1px solid $mediumGrey; + border-radius: 2px; + @include linear-gradient($lightGrey, tint($lightGrey, 90%)); + background-color: $lightGrey; + @include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset); + font-family: 'Open Sans', sans-serif; + font-size: 11px; + color: $baseFontColor; + outline: 0; + + &::-webkit-input-placeholder, + &:-moz-placeholder, + &:-ms-input-placeholder { + color: #979faf; + } + + &:focus { + @include linear-gradient($paleYellow, tint($paleYellow, 90%)); + outline: 0; + } +} + +// forms - specific +input.search { + padding: 6px 15px 8px 30px; + @include box-sizing(border-box); + border: 1px solid $darkGrey; + border-radius: 20px; + background: url(../img/search-icon.png) no-repeat 8px 7px #edf1f5; + font-family: 'Open Sans', sans-serif; + color: $baseFontColor; + outline: 0; + + &::-webkit-input-placeholder { + color: #979faf; + } +} + +label { + font-size: 12px; +} + +code { + padding: 0 4px; + border-radius: 3px; + background: #eee; + font-family: Monaco, monospace; +} + +.CodeMirror { + font-size: 13px; + border: 1px solid $darkGrey; + background: #fff; +} + +.text-editor { + width: 100%; + min-height: 80px; + padding: 10px; + @include box-sizing(border-box); + border: 1px solid $mediumGrey; + @include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.3)); + background-color: #edf1f5; + @include box-shadow(0 1px 2px rgba(0, 0, 0, 0.1) inset); + font-family: Monaco, monospace; +} \ No newline at end of file diff --git a/cms/static/sass/_header.scss b/cms/static/sass/elements/_header.scss similarity index 99% rename from cms/static/sass/_header.scss rename to cms/static/sass/elements/_header.scss index ca1092f44b..e8df37f57f 100644 --- a/cms/static/sass/_header.scss +++ b/cms/static/sass/elements/_header.scss @@ -1,4 +1,4 @@ -// studio global header and navigation +// studio - elements - global header // ==================== .wrapper-header { diff --git a/cms/static/sass/_jquery-ui-calendar.scss b/cms/static/sass/elements/_jquery-ui-calendar.scss similarity index 94% rename from cms/static/sass/_jquery-ui-calendar.scss rename to cms/static/sass/elements/_jquery-ui-calendar.scss index 96cffc059f..3d20bde642 100644 --- a/cms/static/sass/_jquery-ui-calendar.scss +++ b/cms/static/sass/elements/_jquery-ui-calendar.scss @@ -1,3 +1,6 @@ +// studio - elements - JQUI calendar +// ==================== + .ui-datepicker { border-color: $darkGrey; border-radius: 2px; diff --git a/cms/static/sass/_modal.scss b/cms/static/sass/elements/_modal.scss similarity index 94% rename from cms/static/sass/_modal.scss rename to cms/static/sass/elements/_modal.scss index f9fbf81a8f..b81baf4565 100644 --- a/cms/static/sass/_modal.scss +++ b/cms/static/sass/elements/_modal.scss @@ -1,3 +1,6 @@ +// studio - elements - modal windows +// ==================== + .modal-cover { display: none; position: fixed; diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss new file mode 100644 index 0000000000..066c47298b --- /dev/null +++ b/cms/static/sass/elements/_navigation.scss @@ -0,0 +1,24 @@ +// studio - elements - navigation +// ==================== + +// common + +// ==================== + +// primary + +// ==================== + +// right hand side + +// ==================== + +// tabs + +// ==================== + +// dropdown + +// ==================== + +// \ No newline at end of file diff --git a/cms/static/sass/_account.scss b/cms/static/sass/views/_account.scss similarity index 99% rename from cms/static/sass/_account.scss rename to cms/static/sass/views/_account.scss index 650743979f..1206db5e76 100644 --- a/cms/static/sass/_account.scss +++ b/cms/static/sass/views/_account.scss @@ -1,5 +1,6 @@ -// Studio - Sign In/Up +// studio - views - sign up/in // ==================== + body.signup, body.signin { .wrapper-content { diff --git a/cms/static/sass/_assets.scss b/cms/static/sass/views/_assets.scss similarity index 97% rename from cms/static/sass/_assets.scss rename to cms/static/sass/views/_assets.scss index d9b215faec..779dc56684 100644 --- a/cms/static/sass/_assets.scss +++ b/cms/static/sass/views/_assets.scss @@ -1,4 +1,8 @@ -.uploads { +// studio - views - assets +// ==================== + +body.course.uploads { + input.asset-search-input { float: left; width: 260px; diff --git a/cms/static/sass/views/_checklists.scss b/cms/static/sass/views/_checklists.scss new file mode 100644 index 0000000000..e9ad540483 --- /dev/null +++ b/cms/static/sass/views/_checklists.scss @@ -0,0 +1,347 @@ +// Studio - Course Settings +// ==================== +body.course.checklists { + + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + // checklists - general + .course-checklist { + @extend .window; + margin: 0 0 ($baseline*2) 0; + + &:last-child { + margin-bottom: 0; + } + + // visual status + .viz-checklist-status { + @include text-hide(); + @include size(100%,($baseline/4)); + position: relative; + display: block; + margin: 0; + background: $gray-l4; + + .viz-checklist-status-value { + @include transition(width 2s ease-in-out .25s); + position: absolute; + top: 0; + left: 0; + width: 0%; + height: ($baseline/4); + background: $green; + + .int { + @include text-sr(); + } + } + } + // 0% of checklist completed + + // header/title + header { + @include clearfix(); + @include box-shadow(inset 0 -1px 1px $shadow-l1); + margin-bottom: 0; + border-bottom: 1px solid $gray-l3; + padding: $baseline ($baseline*1.5); + + .checklist-title { + @include transition(color .15s .25s ease-in-out); + width: flex-grid(6, 9); + margin: 0 flex-gutter() 0 0; + float: left; + + .ui-toggle-expansion { + @include transition(rotate .15s ease-in-out .25s); + @include font-size(14); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/2); + color: $gray-l4; + } + + &.is-selectable { + cursor: pointer; + + &:hover { + color: $blue; + + .ui-toggle-expansion { + color: $blue; + } + } + } + } + + .checklist-status { + @include font-size(13); + width: flex-grid(3, 9); + float: right; + margin-top: ($baseline/2); + text-align: right; + color: $gray-l2; + + + .icon-confirm { + @include font-size(20); + display: inline-block; + vertical-align: middle; + margin-left: ($baseline/2); + color: $gray-l4; + } + + .status-count { + @include font-size(16); + margin-left: ($baseline/4); + margin-right: ($baseline/4); + color: $gray-d3; + font-weight: 600; + } + + .status-amount { + @include font-size(16); + margin-left: ($baseline/4); + color: $gray-d3; + font-weight: 600; + } + } + } + + // checklist actions + .course-checklist-actions { + @include clearfix(); + @include box-shadow(inset 0 1px 1px $shadow-l1); + @include transition(border .15s ease-in-out .25s); + border-top: 1px solid $gray-l2; + padding: $baseline ($baseline*1.5); + background: $gray-l4; + + .action-primary { + @include green-button(); + float: left; + + .icon-add { + @include font-size(12); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + + .action-secondary { + @include font-size(14); + @include grey-button(); + font-weight: 400; + float: right; + + .icon-delete { + @include font-size(12); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + } + + // state - collapsed + &.is-collapsed { + + header { + @include box-shadow(none); + + .checklist-title { + + .ui-toggle-expansion { + @include transform(rotate(-90deg)); + @include transform-origin(50% 50%); + } + } + } + + .list-tasks { + height: 0; + } + } + + // state - completed + &.is-completed { + + .viz-checklist-status { + + .viz-checklist-status-value { + width: 100%; + } + } + + header { + + .checklist-title, .icon-confirm { + color: $green; + } + + .checklist-status { + + .status-count, .status-amount, .icon-confirm { + color: $green; + } + } + } + } + + // state - not available + .is-not-available { + + } + } + + // list of tasks + .list-tasks { + height: auto; + overflow: hidden; + + .task { + @include transition(background .15s ease-in-out .25s); + @include transition(border .15s ease-in-out .25s); + @include clearfix(); + position: relative; + border-top: 1px solid $white; + border-bottom: 1px solid $gray-l5; + padding: $baseline ($baseline*1.5); + background: $white; + opacity: 1.0; + + + &:last-child { + margin-bottom: 0; + border-bottom: none; + } + + .task-input { + display: inline-block; + vertical-align: text-top; + float: left; + margin: ($baseline/2) flex-gutter() 0 0; + } + + .task-details { + display: inline-block; + vertical-align: text-top; + float: left; + width: flex-grid(6,9); + font-weight: 500; + + .task-name { + @include transition(color .15s .25s ease-in-out); + vertical-align: baseline; + cursor: pointer; + margin-bottom: 0; + } + + .task-description { + @include transition(color .15s .25s ease-in-out); + @include font-size(14); + color: $gray-l2; + } + + .task-support { + @include transition(opacity .15s .25s ease-in-out); + @include font-size(12); + opacity: 0; + pointer-events: none; + } + } + + .task-actions { + @include transition(opacity .15s .25s ease-in-out); + @include clearfix(); + display: inline-block; + vertical-align: middle; + float: right; + width: flex-grid(2,9); + margin: ($baseline/2) 0 0 flex-gutter(); + opacity: 0; + pointer-events: none; + text-align: right; + + .action-primary { + @include blue-button; + @include transition(all .15s); + @include font-size(12); + font-weight: 600; + text-align: center; + } + + .action-secondary { + @include font-size(13); + margin-top: ($baseline/2); + } + } + + // state - hover + &:hover { + background: $blue-l5; + border-bottom-color: $blue-l4; + border-top-color: $blue-l4; + opacity: 1.0; + + .task-details { + + .task-support { + opacity: 1.0; + pointer-events: auto; + } + } + + .task-actions { + opacity: 1.0; + pointer-events: auto; + } + } + + + // state - completed + &.is-completed { + background: $gray-l6; + border-top-color: $gray-l5; + border-bottom-color: $gray-l5; + + .task-name { + color: $gray-l2; + } + + .task-actions { + + .action-primary { + @include grey-button; + @include font-size(12); + font-weight: 600; + text-align: center; + } + } + + &:hover { + background: $gray-l5; + border-bottom-color: $gray-l4; + border-top-color: $gray-l4; + + .task-details { + opacity:1.0; + } + } + } + } + } + + .content-supplementary { + width: flex-grid(3, 12); + } +} \ No newline at end of file diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss new file mode 100644 index 0000000000..a02c4e0c29 --- /dev/null +++ b/cms/static/sass/views/_dashboard.scss @@ -0,0 +1,124 @@ +// studio - views - user dashboard +// ==================== + +body.dashboard { + + .my-classes { + margin-top: $baseline; + } + + .class-list { + margin-top: 20px; + border-radius: 3px; + border: 1px solid $darkGrey; + background: #fff; + @include box-shadow(0 1px 2px rgba(0, 0, 0, .1)); + + li { + position: relative; + border-bottom: 1px solid $mediumGrey; + + &:last-child { + border-bottom: none; + } + + .class-link { + z-index: 100; + display: block; + padding: 20px 25px; + line-height: 1.3; + + &:hover { + background: $paleYellow; + + + .view-live-button { + opacity: 1.0; + pointer-events: auto; + } + } + } + } + + .class-name { + display: block; + font-size: 19px; + font-weight: 300; + } + + .detail { + font-size: 14px; + font-weight: 400; + margin-right: 20px; + color: #3c3c3c; + } + + // view live button + .view-live-button { + z-index: 10000; + position: absolute; + top: 15px; + right: $baseline; + padding: ($baseline/4) ($baseline/2); + opacity: 0; + pointer-events: none; + + &:hover { + opacity: 1.0; + pointer-events: auto; + } + } + } + + .new-course { + padding: 15px 25px; + margin-top: 20px; + border-radius: 3px; + border: 1px solid $darkGrey; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, .1); + @include clearfix; + + .row { + margin-bottom: 15px; + @include clearfix; + } + + .column { + float: left; + width: 48%; + } + + .column:first-child { + margin-right: 4%; + } + + .course-info { + width: 600px; + } + + label { + display: block; + font-size: 13px; + font-weight: 700; + } + + .new-course-org, + .new-course-number, + .new-course-name { + width: 100%; + } + + .new-course-name { + font-size: 19px; + font-weight: 300; + } + + .new-course-save { + @include blue-button; + } + + .new-course-cancel { + @include white-button; + } + } +} \ No newline at end of file diff --git a/cms/static/sass/_export.scss b/cms/static/sass/views/_export.scss similarity index 96% rename from cms/static/sass/_export.scss rename to cms/static/sass/views/_export.scss index e1ab7eb605..933bb50252 100644 --- a/cms/static/sass/_export.scss +++ b/cms/static/sass/views/_export.scss @@ -1,4 +1,8 @@ -.export { +// studio - views - course export +// ==================== + +body.course.export { + .export-overview { @extend .window; @include clearfix; @@ -118,6 +122,4 @@ } } } - - } \ No newline at end of file diff --git a/cms/static/sass/_import.scss b/cms/static/sass/views/_import.scss similarity index 95% rename from cms/static/sass/_import.scss rename to cms/static/sass/views/_import.scss index a0a1f5e512..e5fb955348 100644 --- a/cms/static/sass/_import.scss +++ b/cms/static/sass/views/_import.scss @@ -1,4 +1,8 @@ -.import { +// studio - views - course import +// ==================== + +body.course.import { + .import-overview { @extend .window; @include clearfix; diff --git a/cms/static/sass/_index.scss b/cms/static/sass/views/_index.scss similarity index 99% rename from cms/static/sass/_index.scss rename to cms/static/sass/views/_index.scss index e0f6d0f2b7..f4087a8605 100644 --- a/cms/static/sass/_index.scss +++ b/cms/static/sass/views/_index.scss @@ -1,5 +1,7 @@ -// how it works/not signed in index -.index { +// studio - views - how it works +// ==================== + +body.index { &.not-signedin { diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss new file mode 100644 index 0000000000..0d72e2d2bf --- /dev/null +++ b/cms/static/sass/views/_outline.scss @@ -0,0 +1,680 @@ +// studio - views - course outline +// ==================== + +body.course.outline { + + input.courseware-unit-search-input { + float: left; + width: 260px; + background-color: #fff; + } + + .branch { + + .section-item { + @include clearfix(); + + .details { + display: block; + float: left; + margin-bottom: 0; + width: 650px; + } + + .gradable-status { + float: right; + position: relative; + top: -4px; + right: 50px; + width: 145px; + + .status-label { + position: absolute; + top: 2px; + right: -5px; + display: none; + width: 110px; + padding: 5px 40px 5px 10px; + @include border-radius(3px); + color: $lightGrey; + text-align: right; + font-size: 12px; + font-weight: bold; + line-height: 16px; + } + + .menu-toggle { + z-index: 10; + position: absolute; + top: 0; + right: 5px; + padding: 5px; + color: $mediumGrey; + + &:hover, &.is-active { + color: $blue; + } + } + + .menu { + z-index: 1; + display: none; + opacity: 0.0; + position: absolute; + top: -1px; + left: 5px; + margin: 0; + padding: 8px 12px; + background: $white; + border: 1px solid $mediumGrey; + font-size: 12px; + @include border-radius(4px); + @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); + @include transition(opacity .15s); + + + li { + width: 115px; + margin-bottom: 3px; + padding-bottom: 3px; + border-bottom: 1px solid $lightGrey; + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border: none; + + a { + color: $darkGrey; + } + } + } + + a { + color: $blue; + + &.is-selected { + font-weight: bold; + } + } + } + + // dropdown state + &.is-active { + + .menu { + z-index: 1000; + display: block; + opacity: 1.0; + } + + .menu-toggle { + z-index: 10000; + } + } + + // set state + &.is-set { + + .menu-toggle { + color: $blue; + } + + .status-label { + display: block; + color: $blue; + } + } + } + } + } + + + .courseware-section { + position: relative; + background: #fff; + border-radius: 3px; + border: 1px solid $mediumGrey; + margin-top: 15px; + padding-bottom: 12px; + @include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1)); + + &:first-child { + margin-top: 0; + } + + &.collapsed { + padding-bottom: 0; + } + + label { + float: left; + line-height: 29px; + } + + .datepair { + float: left; + margin-left: 10px; + } + + .section-published-date { + position: absolute; + top: 19px; + right: 90px; + padding: 4px 10px; + border-radius: 3px; + background: $lightGrey; + text-align: right; + + .published-status { + font-size: 12px; + margin-right: 15px; + + strong { + font-weight: bold; + } + } + + .schedule-button { + @include blue-button; + } + + .edit-button { + @include blue-button; + } + + .schedule-button, + .edit-button { + font-size: 11px; + padding: 3px 15px 5px; + } + } + + .datepair .date, + .datepair .time { + padding-left: 0; + padding-right: 0; + border: none; + background: none; + @include box-shadow(none); + font-size: 13px; + font-weight: bold; + color: $blue; + cursor: pointer; + } + + .datepair .date { + width: 80px; + } + + .datepair .time { + width: 65px; + } + + &.collapsed .subsection-list, + .collapsed .subsection-list, + .collapsed > ol { + display: none !important; + } + + header { + min-height: 75px; + @include clearfix(); + + .item-details, .section-published-date { + + } + + .item-details { + display: inline-block; + padding: 20px 0 10px 0; + @include clearfix(); + + .section-name { + float: left; + margin-right: 10px; + width: 350px; + font-size: 19px; + font-weight: bold; + color: $blue; + } + + .section-name-span { + cursor: pointer; + @include transition(color .15s); + + &:hover { + color: $orange; + } + } + + .section-name-edit { + position: relative; + width: 400px; + background: $white; + + input { + font-size: 16px; + } + + .save-button { + @include blue-button; + padding: 7px 20px 7px; + margin-right: 5px; + } + + .cancel-button { + @include white-button; + padding: 7px 20px 7px; + } + } + + .section-published-date { + float: right; + width: 265px; + margin-right: 220px; + @include border-radius(3px); + background: $lightGrey; + + .published-status { + font-size: 12px; + margin-right: 15px; + + strong { + font-weight: bold; + } + } + + .schedule-button { + @include blue-button; + } + + .edit-button { + @include blue-button; + } + + .schedule-button, + .edit-button { + font-size: 11px; + padding: 3px 15px 5px; + + } + } + + .gradable-status { + position: absolute; + top: 20px; + right: 70px; + width: 145px; + + .status-label { + position: absolute; + top: 0; + right: 2px; + display: none; + width: 100px; + padding: 10px 35px 10px 10px; + @include border-radius(3px); + background: $lightGrey; + color: $lightGrey; + text-align: right; + font-size: 12px; + font-weight: bold; + line-height: 16px; + } + + .menu-toggle { + z-index: 10; + position: absolute; + top: 2px; + right: 5px; + padding: 5px; + color: $lightGrey; + + &:hover, &.is-active { + color: $blue; + } + } + + .menu { + z-index: 1; + display: none; + opacity: 0.0; + position: absolute; + top: -1px; + left: 2px; + margin: 0; + padding: 8px 12px; + background: $white; + border: 1px solid $mediumGrey; + font-size: 12px; + @include border-radius(4px); + @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); + @include transition(opacity .15s); + @include transition(display .15s); + + + li { + width: 115px; + margin-bottom: 3px; + padding-bottom: 3px; + border-bottom: 1px solid $lightGrey; + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border: none; + + a { + color: $darkGrey; + } + } + } + + a { + + &.is-selected { + font-weight: bold; + } + } + } + + // dropdown state + &.is-active { + + .menu { + z-index: 1000; + display: block; + opacity: 1.0; + } + + + .menu-toggle { + z-index: 10000; + } + } + + // set state + &.is-set { + + .menu-toggle { + color: $blue; + } + + .status-label { + display: block; + color: $blue; + } + } + + float: left; + padding: 21px 0 0; + } + } + + .item-actions { + margin-top: 21px; + margin-right: 12px; + + .edit-button, + .delete-button { + margin-top: -3px; + } + } + + .expand-collapse-icon { + float: left; + margin: 29px 6px 16px 16px; + @include transition(none); + + &.expand { + background-position: 0 0; + } + + &.collapsed { + + } + } + + .drag-handle { + margin-left: 11px; + } + } + + h3 { + font-size: 19px; + font-weight: 700; + color: $blue; + } + + .section-name-span { + cursor: pointer; + @include transition(color .15s); + + &:hover { + color: $orange; + } + } + + .section-name-form { + margin-bottom: 15px; + } + + .section-name-edit { + input { + font-size: 16px; + } + + .save-button { + @include blue-button; + padding: 7px 20px 7px; + margin-right: 5px; + } + + .cancel-button { + @include white-button; + padding: 7px 20px 7px; + } + } + + h4 { + font-size: 12px; + color: #878e9d; + + strong { + font-weight: bold; + } + } + + .list-header { + @include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); + background-color: #ced2db; + border-radius: 3px 3px 0 0; + } + + .subsection-list { + margin: 0 12px; + + > ol { + @include tree-view; + border-top-width: 0; + } + } + + &.new-section { + + header { + height: auto; + @include clearfix(); + } + + .expand-collapse-icon { + visibility: hidden; + } + + .item-details { + padding: 25px 0 0 0; + + .section-name { + float: none; + width: 100%; + } + } + } + } + + .toggle-button-sections { + display: none; + position: relative; + float: right; + margin-top: 10px; + + font-size: 13px; + color: $darkGrey; + + &.is-shown { + display: block; + } + + .ss-icon { + @include border-radius(20px); + position: relative; + top: -1px; + display: inline-block; + margin-right: 2px; + line-height: 5px; + font-size: 11px; + } + + .label { + display: inline-block; + } + } + + .new-section-name, + .new-subsection-name-input { + width: 515px; + } + + .new-section-name-save, + .new-subsection-name-save { + @include blue-button; + padding: 4px 20px 7px; + margin: 0 5px; + color: #fff !important; + } + + .new-section-name-cancel, + .new-subsection-name-cancel { + @include white-button; + padding: 4px 20px 7px; + color: #8891a1 !important; + } + + .dummy-calendar { + display: none; + position: absolute; + top: 55px; + left: 110px; + z-index: 9999; + border: 1px solid #3C3C3C; + @include box-shadow(0 1px 15px rgba(0, 0, 0, .2)); + } + + .preview { + background: url(../img/preview.jpg) center top no-repeat; + } + + .edit-subsection-publish-settings { + display: none; + position: fixed; + top: 100px; + left: 50%; + z-index: 99999; + width: 600px; + margin-left: -300px; + background: #fff; + text-align: center; + + .settings { + padding: 40px; + } + + h3 { + font-size: 34px; + font-weight: 300; + } + + .picker { + margin: 30px 0 65px; + } + + .description { + margin-top: 30px; + font-size: 14px; + line-height: 20px; + } + + strong { + font-weight: 700; + } + + .start-date, + .start-time { + font-size: 19px; + } + + .save-button { + @include blue-button; + margin-right: 10px; + } + + .cancel-button { + @include white-button; + } + + .save-button, + .cancel-button { + font-size: 16px; + } + } + + .collapse-all-button { + float: right; + margin-top: 10px; + font-size: 13px; + color: $darkGrey; + } + + // sort/drag and drop + .ui-droppable { + @include transition (padding 0.5s ease-in-out 0s); + min-height: 20px; + padding: 0; + + &.dropover { + padding: 15px 0; + } + } + + .ui-draggable-dragging { + @include box-shadow(0 1px 2px rgba(0, 0, 0, .3)); + border: 1px solid $darkGrey; + opacity : 0.2; + &:hover { + opacity : 1.0; + .section-item { + background: $yellow !important; + } + } + + // hiding unit button - temporary fix until this semantically corrected + .new-unit-item { + display: none; + } + } + + ol.ui-droppable .branch:first-child .section-item { + border-top: none; + } +} \ No newline at end of file diff --git a/cms/static/sass/_settings.scss b/cms/static/sass/views/_settings.scss similarity index 99% rename from cms/static/sass/_settings.scss rename to cms/static/sass/views/_settings.scss index d8011dd651..307ebad0a8 100644 --- a/cms/static/sass/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -1,5 +1,6 @@ -// Studio - Course Settings +// studio - views - course settings // ==================== + body.course.settings { .content-primary, .content-supplementary { @@ -239,13 +240,9 @@ body.course.settings { // not editable fields .field.is-not-editable { - - label, .label { - color: $gray-l3; - } - - input { - opacity: 0.25; + + & label.is-focused { + color: $gray-d2; } } diff --git a/cms/static/sass/_static-pages.scss b/cms/static/sass/views/_static-pages.scss similarity index 72% rename from cms/static/sass/_static-pages.scss rename to cms/static/sass/views/_static-pages.scss index 138e817769..759b03cfc7 100644 --- a/cms/static/sass/_static-pages.scss +++ b/cms/static/sass/views/_static-pages.scss @@ -1,4 +1,8 @@ -.static-pages { +// studio - views - course static pages +// ==================== + +body.course.static-pages { + .new-static-page-button { @include grey-button; display: block; @@ -16,6 +20,51 @@ margin: 0 0 5px 0; } } + + .wrapper-component-editor { + z-index: 9999; + position: relative; + background: $lightBluishGrey2; + } + + .component-editor { + @include edit-box; + @include box-shadow(none); + display: none; + padding: 20px; + border-radius: 2px 2px 0 0; + + .metadata_edit { + margin-bottom: 20px; + font-size: 13px; + + li { + margin-bottom: 10px; + } + + label { + display: inline-block; + margin-right: 10px; + } + } + + h3 { + margin-bottom: 10px; + font-size: 18px; + font-weight: 700; + } + + h5 { + margin-bottom: 8px; + color: #fff; + font-weight: 700; + } + + .save-button { + margin-top: 10px; + margin: 15px 8px 0 0; + } + } } .component-editor { @@ -35,6 +84,7 @@ } .component { + position: relative; border: 1px solid $mediumGrey; border-top: none; @@ -56,10 +106,13 @@ } .drag-handle { + position: absolute; + display: block; top: 0; right: 0; z-index: 11; width: 35px; + height: 100%; border: none; background: url(../img/drag-handles.png) center no-repeat #fff; @@ -69,6 +122,7 @@ } .component-actions { + position: absolute; top: 26px; right: 44px; } diff --git a/cms/static/sass/views/_subsection.scss b/cms/static/sass/views/_subsection.scss new file mode 100644 index 0000000000..cd29759db8 --- /dev/null +++ b/cms/static/sass/views/_subsection.scss @@ -0,0 +1,372 @@ +// studio - views - course subsection +// ==================== + +body.course.subsection { + + .unit-settings { + .window-contents { + padding: 10px 20px; + } + + .unit-actions { + border-bottom: none; + padding-bottom: 0; + } + + .published-alert { + display: none; + padding: 10px; + border: 1px solid #edbd3c; + border-radius: 3px; + background: #fbf6e1; + font-size: 14px; + line-height: 1.4; + + div { + margin-top: 15px; + } + } + + input[type="radio"] { + margin-right: 7px; + } + + .status { + font-size: 12px; + + strong { + font-weight: 700; + } + } + + .preview-button, .view-button { + @include white-button; + margin-bottom: 10px; + } + + .publish-button { + @include orange-button; + } + + .delete-button { + @include blue-button; + } + + .delete-draft { + display: inline-block; + } + + .delete-button, + .preview-button, + .publish-button, + .view-button { + font-size: 11px; + margin-top: 10px; + padding: 6px 15px 8px; + } + } + + .unit-history { + &.collapsed { + h4 { + border-bottom: none; + border-radius: 3px; + } + + .window-contents { + display: none; + } + } + + ol { + border: 1px solid #ced2db; + + li { + display: block; + padding: 6px 8px 8px 10px; + background: #edf1f5; + font-size: 12px; + + &:hover { + background: #fffcf1; + + .item-actions { + display: block; + } + } + + &.checked { + background: #d1dae3; + } + + .item-actions { + display: none; + } + + input[type="radio"] { + margin-right: 7px; + } + } + } + } + + .unit-location { + .url { + width: 100%; + margin-bottom: 10px; + @include box-shadow(none); + } + + .draft-tag, + .hidden-tag, + .private-tag, + .has-new-draft-tag { + font-size: 8px; + } + + .window-contents > ol { + @include tree-view; + + .section-item { + display: inline-block; + width: 100%; + font-size: 11px; + padding: 2px 8px 4px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + @include box-sizing(border-box); + } + + ol { + .section-item { + padding-left: 20px; + } + + .new-unit-item { + margin-left: 20px; + } + } + + ol ol { + .section-item { + padding-left: 34px; + } + + .new-unit-item { + margin: 0 0 10px 41px; + } + } + } + } + + .subsection-body { + padding: 32px 40px; + @include clearfix; + + > div { + margin-bottom: 40px; + } + + input { + font-size: 14px; + } + + .sortable-unit-list { + ol { + @include tree-view; + } + } + } + + .subsection-name-input { + label { + display: block; + } + + input { + width: 100%; + font-size: 20px; + } + } + + .scheduled-date-input, + .due-date-input { + @include clearfix; + + .date-input, + .time-input { + display: inline-block; + width: 100px; + } + + .inherits-check { + label { + font-size: 13px; + } + } + + .notice { + margin-top: 6px; + font-size: 11px; + color: #999; + } + } + + .due-date-input { + label { + display: inline-block !important; + margin-right: 10px; + } + + a { + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + } + + .date-setter { + @include clearfix; + display: none; + } + + .remove-date { + display: block; + } + } + + .row.visibility { + label { + display: inline-block !important; + margin-right: 10px; + line-height: 21px; + } + + a { + display: inline-block; + height: 31px; + margin-right: 8px; + vertical-align: middle; + font-size: 11px; + font-weight: 700; + line-height: 31px; + text-transform: uppercase; + } + + .large-toggle { + width: 41px; + background: url(../img/large-toggles.png) no-repeat; + background-position: 0 -50px; + + .hidden { + background-position: 0 -5px; + } + } + } + + .gradable { + + label { + display: inline-block; + vertical-align: top; + } + + .gradable-status { + position: relative; + top: -4px; + display: inline-block; + margin-left: 10px; + width: 65%; + + .status-label { + margin: 0; + padding: 0; + background: transparent; + color: $blue; + border: none; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + } + + .menu-toggle { + z-index: 100; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 20px; + background: transparent; + + &:hover, &.is-active { + color: $blue; + } + } + + .menu { + z-index: 1; + position: absolute; + top: -12px; + left: -7px; + display: none; + width: 100%; + margin: 0; + padding: 8px 12px; + opacity: 0.0; + background: $white; + border: 1px solid $mediumGrey; + font-size: 12px; + @include border-radius(4px); + @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); + @include transition(opacity .15s); + + + li { + margin-bottom: 3px; + padding-bottom: 3px; + border-bottom: 1px solid $lightGrey; + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border: none; + } + } + + a { + + &.is-selected { + font-weight: bold; + } + } + } + + // dropdown state + &.is-active { + + .menu { + z-index: 10000; + display: block; + opacity: 1.0; + } + + .menu-toggle { + z-index: 1000; + } + } + + // set state + &.is-set { + + .menu-toggle { + color: $blue; + } + + .status-label { + display: block; + color: $blue; + } + } + } + } +} \ No newline at end of file diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss new file mode 100644 index 0000000000..e74690d9ec --- /dev/null +++ b/cms/static/sass/views/_unit.scss @@ -0,0 +1,681 @@ +// studio - views - unit +// ==================== + +body.course.unit { + + .unit .main-wrapper { + @include clearfix(); + margin: 40px; + } + + //Problem Selector tab menu requirements + .js .tabs .tab { + display: none; + } + //end problem selector reqs + + .main-column { + clear: both; + float: left; + width: 70%; + } + + .unit-body.published { + .components > li { + border: none; + + .rendered-component { + padding: 0 20px; + } + } + } + + .unit-body { + + .unit-name-input { + padding: 20px 40px; + + label { + display: block; + } + + input { + width: 100%; + font-size: 20px; + } + } + + .breadcrumbs { + border-radius: 3px 3px 0 0; + border-bottom: 1px solid #cbd1db; + @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0) 70%); + background-color: #edf1f5; + @include box-shadow(0 1px 0 rgba(255, 255, 255, .7) inset); + @include clearfix; + + li { + float: left; + } + + a, + .current-page { + display: block; + padding: 15px 35px 15px 30px; + font-size: 14px; + background: url(../img/breadcrumb-arrow.png) no-repeat right center; + } + } + + h2 { + margin: 30px 40px 30px 0; + color: #646464; + font-size: 19px; + font-weight: 300; + letter-spacing: 1px; + text-transform: uppercase; + } + + .components { + + > li { + position: relative; + z-index: 10; + margin: 20px 40px; + + + + .title { + margin: 0 0 15px 0; + color: $mediumGrey; + + .value { + } + } + + &.new-component-item { + margin: 20px 0px; + border-top: 1px solid $mediumGrey; + box-shadow: 0 2px 1px rgba(182, 182, 182, 0.75) inset; + background-color: $lightGrey; + margin-bottom: 0px; + padding-bottom: 20px; + + .new-component-button { + display: block; + padding: 20px; + text-align: center; + color: #edf1f5; + } + + h5 { + margin: 20px 0px; + color: #fff; + font-weight: 600; + font-size: 18px; + } + + .rendered-component { + display: none; + background: #fff; + border-radius: 3px 3px 0 0; + } + + .new-component-type { + + a, + li { + display: inline-block; + } + + a { + border: 1px solid $mediumGrey; + width: 100px; + height: 100px; + color: #fff; + margin-right: 15px; + margin-bottom: 20px; + border-radius: 8px; + font-size: 15px; + line-height: 14px; + text-align: center; + @include box-shadow(0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset); + + .name { + position: absolute; + bottom: 5px; + left: 0; + width: 100%; + padding: 10px; + @include box-sizing(border-box); + color: #fff; + } + } + } + + .new-component-templates { + display: none; + margin: 20px 40px 20px 40px; + border-radius: 3px; + border: 1px solid $mediumGrey; + background-color: #fff; + @include box-shadow(0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset); + @include clearfix; + + .cancel-button { + margin: 20px 0px 10px 10px; + @include white-button; + } + + .problem-type-tabs { + display: none; + } + + // specific menu types + &.new-component-problem { + padding-bottom:10px; + + .ss-icon, .editor-indicator { + display: inline-block; + } + + .problem-type-tabs { + display: inline-block; + } + } + } + + .new-component-type, + .new-component-template { + @include clearfix; + + a { + position: relative; + border: 1px solid $darkGreen; + background: tint($green,20%); + color: #fff; + + &:hover { + background: $brightGreen; + } + } + } + + .problem-type-tabs { + list-style-type: none; + border-radius: 0; + width: 100%; + @include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); + background-color: $lightBluishGrey; + @include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset); + + li:first-child { + margin-left: 20px; + } + + li { + float:left; + display:inline-block; + text-align:center; + width: auto; + @include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); + background-color: tint($lightBluishGrey, 10%); + @include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset); + opacity:.8; + + &:hover { + opacity:1; + background-color: tint($lightBluishGrey, 20%); + } + + &.ui-state-active { + border: 0px; + @include active; + opacity:1; + } + } + + a{ + display: block; + padding: 15px 25px; + font-size: 15px; + line-height: 16px; + text-align: center; + color: #3c3c3c; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3); + } + } + + .new-component-template { + + a { + background: #fff; + border: 0px; + color: #3c3c3c; + @include transition (none); + + &:hover { + background: tint($green,30%); + color: #fff; + @include transition(background-color .15s); + } + } + + li { + border:none; + border-bottom: 1px dashed $lightGrey; + color: #fff; + } + + li:first-child { + a { + border-top: 0px; + } + } + + li:nth-child(2) { + a { + border-radius: 0px; + } + } + + a { + @include clearfix(); + display: block; + padding: 7px 20px; + border-bottom: none; + font-weight: 500; + + .name { + float: left; + + .ss-icon { + @include transition(opacity .15s); + display: inline-block; + top: 1px; + margin-right: 5px; + opacity: 0.5; + width: 17; + height: 21px; + vertical-align: middle; + } + } + + .editor-indicator { + @include transition(opacity .15s); + float: right; + position: relative; + top: 3px; + font-size: 12px; + opacity: 0.3; + } + + .ss-icon, .editor-indicator { + display: none; + } + + &:hover { + color: #fff; + + .ss-icon { + opacity: 1.0; + } + + .editor-indicator { + opacity: 1.0; + } + } + } + + // specific editor types + .empty { + + a { + line-height: 1.4; + font-weight: 400; + background: #fff; + color: #3c3c3c; + + + &:hover { + background: tint($green,30%); + color: #fff; + } + } + } + } + + .new-component { + text-align: center; + + h5 { + color: $darkGreen; + } + + } + } + } + } + + .component { + border: 1px solid $lightBluishGrey2; + border-radius: 3px; + background: #fff; + @include transition(none); + + &:hover { + border-color: #6696d7; + + .drag-handle { + background-color: $blue; + border-color: $blue; + } + } + + &.editing { + border: 1px solid $lightBluishGrey2; + z-index: auto; + + .drag-handle, + .component-actions { + display: none; + } + } + + &.component-placeholder { + border-color: #6696d7; + } + + .drag-handle { + position: absolute; + display: block; + top: -1px; + right: -16px; + z-index: 10; + width: 15px; + height: 100%; + border-radius: 0 3px 3px 0; + border: 1px solid $lightBluishGrey2; + background: url(../img/white-drag-handles.png) center no-repeat $lightBluishGrey2; + cursor: move; + @include transition(none); + } + } + + .xmodule_display { + padding: 40px 20px 20px; + overflow-x: auto; + + h1 { + float: none; + margin-left: 0; + } + } + + .wrapper-component-editor { + z-index: 9999; + position: relative; + background: $lightBluishGrey2; + } + + .component-editor { + @include edit-box; + @include box-shadow(none); + display: none; + padding: 20px; + border-radius: 2px 2px 0 0; + + .metadata_edit { + margin-bottom: 20px; + font-size: 13px; + + li { + margin-bottom: 10px; + } + + label { + display: inline-block; + margin-right: 10px; + } + } + + h3 { + margin-bottom: 10px; + font-size: 18px; + font-weight: 700; + } + + h5 { + margin-bottom: 8px; + color: #fff; + font-weight: 700; + } + + .save-button { + margin-top: 10px; + margin: 15px 8px 0 0; + } + } + } + + .unit-settings { + .window-contents { + padding: 10px 20px; + } + + .unit-actions { + border-bottom: none; + padding-bottom: 0; + } + + .published-alert { + display: none; + padding: 10px; + border: 1px solid #edbd3c; + border-radius: 3px; + background: #fbf6e1; + font-size: 14px; + line-height: 1.4; + + div { + margin-top: 15px; + } + } + + input[type="radio"] { + margin-right: 7px; + } + + .status { + font-size: 12px; + + strong { + font-weight: 700; + } + } + + .preview-button, .view-button { + @include white-button; + margin-bottom: 10px; + } + + .publish-button { + @include orange-button; + } + + .delete-button { + @include blue-button; + } + + .delete-draft { + display: inline-block; + } + + .delete-button, + .preview-button, + .publish-button, + .view-button { + font-size: 11px; + margin-top: 10px; + padding: 6px 15px 8px; + } + } + + .unit-history { + &.collapsed { + h4 { + border-bottom: none; + border-radius: 3px; + } + + .window-contents { + display: none; + } + } + + ol { + border: 1px solid #ced2db; + + li { + display: block; + padding: 6px 8px 8px 10px; + background: #edf1f5; + font-size: 12px; + + &:hover { + background: #fffcf1; + + .item-actions { + display: block; + } + } + + &.checked { + background: #d1dae3; + } + + .item-actions { + display: none; + } + + input[type="radio"] { + margin-right: 7px; + } + } + } + } + + .unit-location { + .url { + width: 100%; + margin-bottom: 10px; + @include box-shadow(none); + } + + .draft-tag, + .hidden-tag, + .private-tag, + .has-new-draft-tag { + font-size: 8px; + } + + .window-contents > ol { + @include tree-view; + + .section-item { + display: inline-block; + width: 100%; + font-size: 11px; + padding: 2px 8px 4px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + @include box-sizing(border-box); + } + + ol { + .section-item { + padding-left: 20px; + } + + .new-unit-item { + margin-left: 20px; + } + } + + ol ol { + .section-item { + padding-left: 34px; + } + + .new-unit-item { + margin: 0 0 10px 41px; + } + } + } + } + + .edit-state-draft { + .visibility, + + .edit-draft-message, + .view-button { + display: none; + } + + .published-alert { + display: block; + } + } + + .edit-state-public { + .delete-draft, + .component-actions, + .new-component-item, + .editing-draft-alert, + .publish-draft-message, + .preview-button { + display: none; + } + + .published-alert { + display: block; + } + + .drag-handle { + display: none !important; + } + } + + .edit-state-private { + .delete-draft, + .publish-draft, + .editing-draft-alert, + .create-draft, + .view-button { + display: none; + } + } +} + +// editing units from courseware +body.unit { + + .component { + padding-top: 30px; + + .component-actions { + @include box-sizing(border-box); + position: absolute; + width: 100%; + padding: 15px; + top: 0; + left: 0; + border-bottom: 1px solid $lightBluishGrey2; + background: $lightGrey; + } + + &.editing { + padding-top: 0; + } + } +} \ No newline at end of file diff --git a/cms/static/sass/_course-info.scss b/cms/static/sass/views/_updates.scss similarity index 97% rename from cms/static/sass/_course-info.scss rename to cms/static/sass/views/_updates.scss index 5a2a5a9432..8d92c9d860 100644 --- a/cms/static/sass/_course-info.scss +++ b/cms/static/sass/views/_updates.scss @@ -1,4 +1,8 @@ -.course-info { +// studio - views - course updates +// ==================== + +body.course.updates { + h2 { margin-bottom: 24px; font-size: 22px; diff --git a/cms/static/sass/_users.scss b/cms/static/sass/views/_users.scss similarity index 94% rename from cms/static/sass/_users.scss rename to cms/static/sass/views/_users.scss index e107bdbb6d..ecaa319707 100644 --- a/cms/static/sass/_users.scss +++ b/cms/static/sass/views/_users.scss @@ -1,4 +1,8 @@ -.users { +// studio - views - course users +// ==================== + +body.course.users { + .new-user-form { display: none; padding: 15px 20px; diff --git a/cms/templates/404.html b/cms/templates/404.html new file mode 100644 index 0000000000..a45a223bad --- /dev/null +++ b/cms/templates/404.html @@ -0,0 +1,14 @@ +<%inherit file="base.html" /> +<%block name="title">Page Not Found + +<%block name="content"> + +
            +
            + +

            Page not found

            +

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

            +
            +
            + + \ No newline at end of file diff --git a/cms/templates/500.html b/cms/templates/500.html new file mode 100644 index 0000000000..2645b0067b --- /dev/null +++ b/cms/templates/500.html @@ -0,0 +1,13 @@ +<%inherit file="base.html" /> +<%block name="title">Server Error + +<%block name="content"> + +
            +
            +

            Currently the edX servers are down

            +

            Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

            +
            +
            + + \ No newline at end of file diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index a5a9144b07..ea759d38af 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> <%block name="bodyclass">is-signedin course uploads -<%block name="title">Uploads & Files +<%block name="title">Files & Uploads <%namespace name='static' file='static_content.html'/> diff --git a/cms/templates/base.html b/cms/templates/base.html index 498897bd11..e852b5d7fe 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -6,10 +6,10 @@ - <%block name="title"></%block> | + <%block name="title"></%block> | % if context_course: <% ctx_loc = context_course.location %> - ${context_course.display_name} | + ${context_course.display_name_with_default} | % endif edX Studio @@ -22,7 +22,7 @@ - + <%block name="header_extras"> @@ -45,6 +45,7 @@ + + + + + + + + + +<%block name="content"> +
            +
            +
            + Tools +

            Course Checklists

            +
            +
            +
            + +
            +
            +
            +
            +

            Current Checklists

            +
            +
            + + +
            +
            + diff --git a/cms/templates/course_index.html b/cms/templates/course_index.html deleted file mode 100644 index 5c8772c1ed..0000000000 --- a/cms/templates/course_index.html +++ /dev/null @@ -1,14 +0,0 @@ -<%inherit file="base.html" /> - -<%include file="widgets/header.html"/> - -<%block name="content"> -
            - - <%include file="widgets/navigation.html"/> - -
            -
            - -
            - diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html index 55dcaaa068..f9166bf166 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -2,7 +2,7 @@ <%namespace name='static' file='static_content.html'/> -<%block name="title">Updates +<%block name="title">Course Updates <%block name="bodyclass">is-signedin course course-info updates diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 43775122d4..eb5a9a9824 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -22,7 +22,7 @@
            - +
            @@ -31,18 +31,6 @@
            - - - - diff --git a/cms/templates/widgets/metadata-edit.html b/cms/templates/widgets/metadata-edit.html index 590baec3c9..51fe400f88 100644 --- a/cms/templates/widgets/metadata-edit.html +++ b/cms/templates/widgets/metadata-edit.html @@ -1,18 +1,17 @@ -% if metadata: <% import hashlib hlskey = hashlib.md5(module.location.url()).hexdigest() %> -% endif diff --git a/cms/templates/widgets/navigation.html b/cms/templates/widgets/navigation.html deleted file mode 100644 index f7e79bceb3..0000000000 --- a/cms/templates/widgets/navigation.html +++ /dev/null @@ -1,101 +0,0 @@ -
            -
            - - -
              -
            • -

              Sort:

              - -
            • - -
            • -

              Filter:

              - - More -
            • -
            • - Hide goals -
            • - -
            -
            - -
              - % for week in weeks: -
            1. -
              -

              ${week.url_name}

              -
                - % if 'goals' in week.metadata: - % for goal in week.metadata['goals']: -
              • ${goal}
              • - % endfor - % else: -
              • Please create a learning goal for this week
              • - % endif -
              -
              - -
                - % for module in week.get_children(): -
              • - - ${module.display_name} -
              • - % endfor - <%include file="module-dropdown.html"/> -
              -
            2. - %endfor -
            - -
            - + Add New Section - - -
            -
            - diff --git a/cms/templates/widgets/sequence-edit.html b/cms/templates/widgets/sequence-edit.html index e9d796784d..c70f2568fa 100644 --- a/cms/templates/widgets/sequence-edit.html +++ b/cms/templates/widgets/sequence-edit.html @@ -40,7 +40,7 @@ ${child.display_name} + data-preview-type="${child.module_class.js_module_name}">${child.display_name_with_default} handle %endfor diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index 8e23b05bf8..5ac05e79eb 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -22,7 +22,7 @@ This def will enumerate through a passed in subsection and list all of the units
            - ${unit.display_name} + ${unit.display_name_with_default} % if actions:
            @@ -39,7 +39,7 @@ This def will enumerate through a passed in subsection and list all of the units
          - + diff --git a/cms/urls.py b/cms/urls.py index d43b9bc44c..e1eae3352a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -42,36 +42,52 @@ urlpatterns = ('', 'contentstore.views.remove_user', name='remove_user'), url(r'^(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)/remove_user$', 'contentstore.views.remove_user', name='remove_user'), - url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', 'contentstore.views.course_info', name='course_info'), - url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', 'contentstore.views.course_info_updates', name='course_info'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)/section/(?P
          [^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)/(?P.*)$', 'contentstore.views.course_grader_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', + 'contentstore.views.course_info', name='course_info'), + url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', + 'contentstore.views.course_info_updates', name='course_info_json'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)$', + 'contentstore.views.get_course_settings', name='settings_details'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)$', + 'contentstore.views.course_config_graders_page', name='settings_grading'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)/section/(?P
          [^/]+).*$', + 'contentstore.views.course_settings_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)/(?P.*)$', + 'contentstore.views.course_grader_updates', name='course_settings'), # This is the URL to initially render the course advanced settings. - url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)$', 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)$', + 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'), # This is the URL used by BackBone for updating and re-fetching the model. - url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)/update.*$', 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)/update.*$', + 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'), - url(r'^(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'), + url(r'^(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/gradeas.*$', + 'contentstore.views.assignment_type_update', name='assignment_type_update'), - url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', + url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', + 'contentstore.views.static_pages', name='static_pages'), - url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'), - url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), - url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), + url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', + 'contentstore.views.edit_static', name='edit_static'), + url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', + 'contentstore.views.edit_tabs', name='edit_tabs'), + url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', + 'contentstore.views.asset_index', name='asset_index'), # this is a generic method to return the data/metadata associated with a xmodule - url(r'^module_info/(?P.*)$', 'contentstore.views.module_info', name='module_info'), + url(r'^module_info/(?P.*)$', + 'contentstore.views.module_info', name='module_info'), # temporary landing page for a course - url(r'^edge/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.landing', name='landing'), + url(r'^edge/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', + 'contentstore.views.landing', name='landing'), url(r'^not_found$', 'contentstore.views.not_found', name='not_found'), url(r'^server_error$', 'contentstore.views.server_error', name='server_error'), - url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'), + url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', + 'contentstore.views.asset_index', name='asset_index'), # temporary landing page for edge url(r'^edge$', 'contentstore.views.edge', name='edge'), @@ -83,6 +99,9 @@ urlpatterns = ('', # User creation and updating views urlpatterns += ( + url(r'^(?P[^/]+)/(?P[^/]+)/checklists/(?P[^/]+)$', 'contentstore.views.get_checklists', name='checklists'), + url(r'^(?P[^/]+)/(?P[^/]+)/checklists/(?P[^/]+)/update(/)?(?P.+)?.*$', + 'contentstore.views.update_checklist', name='checklists_updates'), url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'), url(r'^signup$', 'contentstore.views.signup', name='signup'), @@ -100,7 +119,13 @@ urlpatterns += ( ) if settings.ENABLE_JASMINE: - ## Jasmine + # # Jasmine urlpatterns = urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),) urlpatterns = patterns(*urlpatterns) + +# Custom error pages +handler404 = 'contentstore.views.render_404' +handler500 = 'contentstore.views.render_500' + + diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py new file mode 100644 index 0000000000..cad3110574 --- /dev/null +++ b/cms/xmodule_namespace.py @@ -0,0 +1,46 @@ +""" +Namespace defining common fields used by Studio for all blocks +""" + +import datetime + +from xblock.core import Namespace, Boolean, Scope, ModelType, String + + +class StringyBoolean(Boolean): + """ + Reads strings from JSON as booleans. + + If the string is 'true' (case insensitive), then return True, + otherwise False. + + JSON values that aren't strings are returned as is + """ + def from_json(self, value): + if isinstance(value, basestring): + return value.lower() == 'true' + return value + + +class DateTuple(ModelType): + """ + ModelType that stores datetime objects as time tuples + """ + def from_json(self, value): + return datetime.datetime(*value[0:6]) + + def to_json(self, value): + if value is None: + return None + + return list(value.timetuple()) + + +class CmsNamespace(Namespace): + """ + Namespace with fields common to all blocks in Studio + """ + is_draft = Boolean(help="Whether this module is a draft", default=False, scope=Scope.settings) + 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/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py index c5e887801e..8e9e70046d 100644 --- a/common/djangoapps/contentserver/middleware.py +++ b/common/djangoapps/contentserver/middleware.py @@ -5,6 +5,7 @@ from django.http import HttpResponse, Http404, HttpResponseNotModified from xmodule.contentstore.django import contentstore from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG +from xmodule.modulestore import InvalidLocationError from cache_toolbox.core import get_cached_content, set_cached_content from xmodule.exceptions import NotFoundError @@ -13,7 +14,14 @@ class StaticContentServer(object): def process_request(self, request): # look to see if the request is prefixed with 'c4x' tag if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'): - loc = StaticContent.get_location_from_path(request.path) + try: + loc = StaticContent.get_location_from_path(request.path) + except InvalidLocationError: + # return a 'Bad Request' to browser as we have a malformed Location + response = HttpResponse() + response.status_code = 400 + return response + # first look in our cache so we don't have to round-trip to the DB content = get_cached_content(loc) if content is None: diff --git a/common/djangoapps/course_groups/cohorts.py b/common/djangoapps/course_groups/cohorts.py index c362ed4e89..7924012bfe 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/common/djangoapps/course_groups/cohorts.py @@ -15,6 +15,24 @@ from .models import CourseUserGroup log = logging.getLogger(__name__) +# tl;dr: global state is bad. capa reseeds random every time a problem is loaded. Even +# if and when that's fixed, it's a good idea to have a local generator to avoid any other +# code that messes with the global random module. +_local_random = None + +def local_random(): + """ + Get the local random number generator. In a function so that we don't run + random.Random() at import time. + """ + # ironic, isn't it? + global _local_random + + if _local_random is None: + _local_random = random.Random() + + return _local_random + def is_course_cohorted(course_id): """ Given a course id, return a boolean for whether or not the course is @@ -129,13 +147,7 @@ def get_cohort(user, course_id): return None # Put user in a random group, creating it if needed - choice = random.randrange(0, n) - group_name = choices[choice] - - # Victor: we are seeing very strange behavior on prod, where almost all users - # end up in the same group. Log at INFO to try to figure out what's going on. - log.info("DEBUG: adding user {0} to cohort {1}. choice={2}".format( - user, group_name,choice)) + group_name = local_random().choice(choices) group, created = CourseUserGroup.objects.get_or_create( course_id=course_id, diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 88d9c1f508..94d52ff6df 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -76,7 +76,7 @@ class TestCohorts(django.test.TestCase): "id": to_id(name)}) for name in discussions) - course.metadata["discussion_topics"] = topics + course.discussion_topics = topics d = {"cohorted": cohorted} if cohorted_discussions is not None: @@ -88,7 +88,7 @@ class TestCohorts(django.test.TestCase): if auto_cohort_groups is not None: d["auto_cohort_groups"] = auto_cohort_groups - course.metadata["cohort_config"] = d + course.cohort_config = d def setUp(self): diff --git a/cms/djangoapps/__init__.py b/common/djangoapps/request_cache/__init__.py similarity index 100% rename from cms/djangoapps/__init__.py rename to common/djangoapps/request_cache/__init__.py diff --git a/common/djangoapps/request_cache/middleware.py b/common/djangoapps/request_cache/middleware.py new file mode 100644 index 0000000000..9d3dffdf27 --- /dev/null +++ b/common/djangoapps/request_cache/middleware.py @@ -0,0 +1,20 @@ +import threading + +_request_cache_threadlocal = threading.local() +_request_cache_threadlocal.data = {} + +class RequestCache(object): + @classmethod + def get_request_cache(cls): + return _request_cache_threadlocal + + def clear_request_cache(self): + _request_cache_threadlocal.data = {} + + def process_request(self, request): + self.clear_request_cache() + return None + + def process_response(self, request, response): + self.clear_request_cache() + return response \ No newline at end of file diff --git a/common/djangoapps/status/tests.py b/common/djangoapps/status/tests.py index 1695663ac5..bf60017036 100644 --- a/common/djangoapps/status/tests.py +++ b/common/djangoapps/status/tests.py @@ -4,7 +4,7 @@ import os from django.test.utils import override_settings from tempfile import NamedTemporaryFile -from status import get_site_status_msg +from .status import get_site_status_msg # Get a name where we can put test files TMP_FILE = NamedTemporaryFile(delete=False) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 54bdd77297..56b1293c2d 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -75,10 +75,15 @@ class UserProfile(models.Model): GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other')) gender = models.CharField(blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES) - LEVEL_OF_EDUCATION_CHOICES = (('p_se', 'Doctorate in science or engineering'), - ('p_oth', 'Doctorate in another field'), + + # [03/21/2013] removed these, but leaving comment since there'll still be + # p_se and p_oth in the existing data in db. + # ('p_se', 'Doctorate in science or engineering'), + # ('p_oth', 'Doctorate in another field'), + LEVEL_OF_EDUCATION_CHOICES = (('p', 'Doctorate'), ('m', "Master's or professional degree"), ('b', "Bachelor's degree"), + ('a', "Associate's degree"), ('hs', "Secondary/high school"), ('jhs', "Junior secondary/junior high/middle school"), ('el', "Elementary/primary school"), diff --git a/common/djangoapps/__init__.py b/common/djangoapps/student/tests/__init__.py similarity index 100% rename from common/djangoapps/__init__.py rename to common/djangoapps/student/tests/__init__.py diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py new file mode 100644 index 0000000000..f74188725a --- /dev/null +++ b/common/djangoapps/student/tests/factories.py @@ -0,0 +1,59 @@ +from student.models import (User, UserProfile, Registration, + CourseEnrollmentAllowed, CourseEnrollment) +from django.contrib.auth.models import Group +from datetime import datetime +from factory import Factory, SubFactory +from uuid import uuid4 + + +class GroupFactory(Factory): + FACTORY_FOR = Group + + name = 'staff_MITx/999/Robot_Super_Course' + + +class UserProfileFactory(Factory): + FACTORY_FOR = UserProfile + + user = None + name = 'Robot Test' + level_of_education = None + gender = 'm' + mailing_address = None + goals = 'World domination' + + +class RegistrationFactory(Factory): + FACTORY_FOR = Registration + + user = None + activation_key = uuid4().hex + + +class UserFactory(Factory): + FACTORY_FOR = User + + username = 'robot' + email = 'robot+test@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Test' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime(2012, 1, 1) + date_joined = datetime(2011, 1, 1) + + +class CourseEnrollmentFactory(Factory): + FACTORY_FOR = CourseEnrollment + + user = SubFactory(UserFactory) + course_id = 'edX/toy/2012_Fall' + + +class CourseEnrollmentAllowedFactory(Factory): + FACTORY_FOR = CourseEnrollmentAllowed + + email = 'test@edx.org' + course_id = 'edX/test/2012_Fall' diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests/tests.py similarity index 97% rename from common/djangoapps/student/tests.py rename to common/djangoapps/student/tests/tests.py index 6a2d75e3d8..4638da44b2 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -9,8 +9,8 @@ import logging from django.test import TestCase from mock import Mock -from .models import unique_id_for_user -from .views import process_survey_link, _cert_info +from student.models import unique_id_for_user +from student.views import process_survey_link, _cert_info COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 6ac57c1182..8267816e2c 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -44,9 +44,8 @@ from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access -from courseware.models import StudentModuleCache from courseware.views import get_module_for_descriptor, jump_to -from courseware.module_render import get_instance_module +from courseware.model_data import ModelDataCache from statsd import statsd @@ -312,13 +311,13 @@ def change_enrollment(request): course = course_from_id(course_id) except ItemNotFoundError: log.warning("User {0} tried to enroll in non-existent course {1}" - .format(user.username, enrollment.course_id)) + .format(user.username, course_id)) return {'success': False, 'error': 'The course requested does not exist.'} if not has_access(user, course, 'enroll'): return {'success': False, 'error': 'enrollment in {} not allowed at this time' - .format(course.display_name)} + .format(course.display_name_with_default)} org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", @@ -326,7 +325,12 @@ def change_enrollment(request): "course:{0}".format(course_num), "run:{0}".format(run)]) - enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) + try: + enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) + except IntegrityError: + # If we've already created this enrollment in a separate transaction, + # then just continue + pass return {'success': True} elif action == "unenroll": @@ -370,14 +374,14 @@ def login_user(request, error=""): try: user = User.objects.get(email=email) except User.DoesNotExist: - log.warning("Login failed - Unknown user email: {0}".format(email)) + log.warning(u"Login failed - Unknown user email: {0}".format(email)) return HttpResponse(json.dumps({'success': False, 'value': 'Email or password is incorrect.'})) # TODO: User error message username = user.username user = authenticate(username=username, password=password) if user is None: - log.warning("Login failed - password for {0} is invalid".format(email)) + log.warning(u"Login failed - password for {0} is invalid".format(email)) return HttpResponse(json.dumps({'success': False, 'value': 'Email or password is incorrect.'})) @@ -393,7 +397,7 @@ def login_user(request, error=""): log.critical("Login failed - Could not create session. Is memcached running?") log.exception(e) - log.info("Login success - {0} ({1})".format(username, email)) + log.info(u"Login success - {0} ({1})".format(username, email)) try_change_enrollment(request) @@ -401,7 +405,7 @@ def login_user(request, error=""): return HttpResponse(json.dumps({'success': True})) - log.warning("Login failed - Account not active for user {0}, resending activation".format(username)) + log.warning(u"Login failed - Account not active for user {0}, resending activation".format(username)) reactivation_email_for_user(user) not_activated_msg = "This account has not been activated. We have " + \ @@ -1071,14 +1075,14 @@ def accept_name_change(request): @csrf_exempt def test_center_login(request): - # errors are returned by navigating to the error_url, adding a query parameter named "code" + # errors are returned by navigating to the error_url, adding a query parameter named "code" # which contains the error code describing the exceptional condition. def makeErrorURL(error_url, error_code): log.error("generating error URL with error code {}".format(error_code)) return "{}?code={}".format(error_url, error_code); - + # get provided error URL, which will be used as a known prefix for returning error messages to the - # Pearson shell. + # Pearson shell. error_url = request.POST.get("errorURL") # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson @@ -1089,12 +1093,12 @@ def test_center_login(request): # calculate SHA for query string # TODO: figure out how to get the original query string, so we can hash it and compare. - - + + if 'clientCandidateID' not in request.POST: return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")); client_candidate_id = request.POST.get("clientCandidateID") - + # TODO: check remaining parameters, and maybe at least log if they're not matching # expected values.... # registration_id = request.POST.get("registrationID") @@ -1108,12 +1112,12 @@ def test_center_login(request): return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")); # find testcenter_registration that matches the provided exam code: - # Note that we could rely in future on either the registrationId or the exam code, - # or possibly both. But for now we know what to do with an ExamSeriesCode, + # Note that we could rely in future on either the registrationId or the exam code, + # or possibly both. But for now we know what to do with an ExamSeriesCode, # while we currently have no record of RegistrationID values at all. if 'vueExamSeriesCode' not in request.POST: - # we are not allowed to make up a new error code, according to Pearson, - # so instead of "missingExamSeriesCode", we use a valid one that is + # we are not allowed to make up a new error code, according to Pearson, + # so instead of "missingExamSeriesCode", we use a valid one that is # inaccurate but at least distinct. (Sigh.) log.error("missing exam series code for cand ID {}".format(client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")); @@ -1127,11 +1131,11 @@ def test_center_login(request): if not registrations: log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")); - + # TODO: figure out what to do if there are more than one registrations.... # for now, just take the first... registration = registrations[0] - + course_id = registration.course_id course = course_from_id(course_id) # assume it will be found.... if not course: @@ -1149,19 +1153,19 @@ def test_center_login(request): if not timelimit_descriptor: log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); - - timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, + + timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, + timelimit_descriptor, depth=None) + timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, timelimit_module_cache, course_id, position=None) if not timelimit_module.category == 'timelimit': log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); - + if timelimit_module and timelimit_module.has_ended: log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")); - + # check if we need to provide an accommodation: time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME', 'ET30MN' : 'ADD30MIN', @@ -1174,27 +1178,24 @@ def test_center_login(request): # special, hard-coded client ID used by Pearson shell for testing: if client_candidate_id == "edX003671291147": time_accommodation_code = 'TESTING' - + if time_accommodation_code: timelimit_module.accommodation_code = time_accommodation_code - instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache) - instance_module.state = timelimit_module.get_instance_state() - instance_module.save() log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) - + # UGLY HACK!!! - # Login assumes that authentication has occurred, and that there is a + # Login assumes that authentication has occurred, and that there is a # backend annotation on the user object, indicating which backend # against which the user was authenticated. We're authenticating here # against the registration entry, and assuming that the request given # this information is correct, we allow the user to be logged in # without a password. This could all be formalized in a backend object - # that does the above checking. + # that does the above checking. # TODO: (brian) create a backend class to do this. - # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") + # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) + testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") login(request, testcenteruser.user) - + # And start the test: return jump_to(request, course_id, location) diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py index 0881d86124..c8cc0c9e4b 100644 --- a/common/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -1,7 +1,11 @@ from lettuce import before, after, world from splinter.browser import Browser from logging import getLogger -import time + +# Let the LMS and CMS do their one-time setup +# For example, setting up mongo caches +from lms import one_time_startup +from cms import one_time_startup logger = getLogger(__name__) logger.info("Loading the lettuce acceptance testing terrain file...") @@ -11,6 +15,9 @@ from django.core.management import call_command @before.harvest def initial_setup(server): + ''' + Launch the browser once before executing the tests + ''' # Launch the browser app (choose one of these below) world.browser = Browser('chrome') # world.browser = Browser('phantomjs') @@ -19,14 +26,18 @@ def initial_setup(server): @before.each_scenario def reset_data(scenario): - # Clean out the django test database defined in the - # envs/acceptance.py file: mitx_all/db/test_mitx.db + ''' + Clean out the django test database defined in the + envs/acceptance.py file: mitx_all/db/test_mitx.db + ''' logger.debug("Flushing the test database...") call_command('flush', interactive=False) @after.all def teardown_browser(total): - # Quit firefox + ''' + Quit the browser after executing the tests + ''' world.browser.quit() pass diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py new file mode 100644 index 0000000000..f0df456c80 --- /dev/null +++ b/common/djangoapps/terrain/course_helpers.py @@ -0,0 +1,140 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from .factories import * +from django.conf import settings +from django.http import HttpRequest +from django.contrib.auth.models import User +from django.contrib.auth import authenticate, login +from django.contrib.auth.middleware import AuthenticationMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from student.models import CourseEnrollment +from xmodule.modulestore.django import _MODULESTORES, modulestore +from xmodule.templates import update_templates +from bs4 import BeautifulSoup +import os.path +from urllib import quote_plus +from lettuce.django import django_url + + +@world.absorb +def create_user(uname): + + # If the user already exists, don't try to create it again + if len(User.objects.filter(username=uname)) > 0: + return + + portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + portal_user.set_password('test') + portal_user.save() + + registration = world.RegistrationFactory(user=portal_user) + registration.register(portal_user) + registration.activate() + + user_profile = world.UserProfileFactory(user=portal_user) + + +@world.absorb +def log_in(username, password): + ''' + Log the user in programatically + ''' + + # Authenticate the user + user = authenticate(username=username, password=password) + assert(user is not None and user.is_active) + + # Send a fake HttpRequest to log the user in + # We need to process the request using + # Session middleware and Authentication middleware + # to ensure that session state can be stored + request = HttpRequest() + SessionMiddleware().process_request(request) + AuthenticationMiddleware().process_request(request) + login(request, user) + + # Save the session + request.session.save() + + # Retrieve the sessionid and add it to the browser's cookies + cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key} + try: + world.browser.cookies.add(cookie_dict) + + # WebDriver has an issue where we cannot set cookies + # before we make a GET request, so if we get an error, + # we load the '/' page and try again + except: + world.browser.visit(django_url('/')) + world.browser.cookies.add(cookie_dict) + + +@world.absorb +def register_by_course_id(course_id, is_staff=False): + create_user('robot') + u = User.objects.get(username='robot') + if is_staff: + u.is_staff = True + u.save() + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + + + +@world.absorb +def save_the_course_content(path='/tmp'): + html = world.browser.html.encode('ascii', 'ignore') + soup = BeautifulSoup(html) + + # get rid of the header, we only want to compare the body + soup.head.decompose() + + # for now, remove the data-id attributes, because they are + # causing mismatches between cms-master and master + for item in soup.find_all(attrs={'data-id': re.compile('.*')}): + del item['data-id'] + + # we also need to remove them from unrendered problems, + # where they are contained in the text of divs instead of + # in attributes of tags + # Be careful of whether or not it was the last attribute + # and needs a trailing space + for item in soup.find_all(text=re.compile(' data-id=".*?" ')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) + + for item in soup.find_all(text=re.compile(' data-id=".*?"')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) + + # prettify the html so it will compare better, with + # each HTML tag on its own line + output = soup.prettify() + + # use string slicing to grab everything after 'courseware/' in the URL + u = world.browser.url + section_url = u[u.find('courseware/') + 11:] + + + if not os.path.exists(path): + os.makedirs(path) + + filename = '%s.html' % (quote_plus(section_url)) + f = open('%s/%s' % (path, filename), 'w') + f.write(output) + f.close + + +@world.absorb +def clear_courses(): + # Flush and initialize the module store + # It needs the templates because it creates new records + # by cloning from the template. + # Note that if your test module gets in some weird state + # (though it shouldn't), do this manually + # from the bash shell to drop it: + # $ mongo test_xmodule --eval "db.dropDatabase()" + _MODULESTORES = {} + modulestore().collection.drop() + update_templates() diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index bb7ae012c8..768c51b25e 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -1,166 +1,64 @@ -from student.models import User, UserProfile, Registration -from django.contrib.auth.models import Group -from datetime import datetime -from factory import Factory -from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from time import gmtime -from uuid import uuid4 -from xmodule.timeparse import stringify_time +''' +Factories are defined in other modules and absorbed here into the +lettuce world so that they can be used by both unit tests +and integration / BDD tests. +''' +import student.tests.factories as sf +import xmodule.modulestore.tests.factories as xf +from lettuce import world -class GroupFactory(Factory): - FACTORY_FOR = Group - - name = 'staff_MITx/999/Robot_Super_Course' - - -class UserProfileFactory(Factory): - FACTORY_FOR = UserProfile - - user = None - name = 'Robot Test' - level_of_education = None - gender = 'm' - mailing_address = None - goals = 'World domination' - - -class RegistrationFactory(Factory): - FACTORY_FOR = Registration - - user = None - activation_key = uuid4().hex - - -class UserFactory(Factory): - FACTORY_FOR = User - - username = 'robot' - email = 'robot+test@edx.org' - password = 'test' - first_name = 'Robot' - last_name = 'Test' - is_staff = False - is_active = True - is_superuser = False - last_login = datetime(2012, 1, 1) - date_joined = datetime(2011, 1, 1) - - -def XMODULE_COURSE_CREATION(class_to_create, **kwargs): - return XModuleCourseFactory._create(class_to_create, **kwargs) - - -def XMODULE_ITEM_CREATION(class_to_create, **kwargs): - return XModuleItemFactory._create(class_to_create, **kwargs) - - -class XModuleCourseFactory(Factory): +@world.absorb +class UserFactory(sf.UserFactory): """ - Factory for XModule courses. + User account for lms / cms """ - - ABSTRACT_FACTORY = True - _creation_function = (XMODULE_COURSE_CREATION,) - - @classmethod - def _create(cls, target_class, *args, **kwargs): - - template = Location('i4x', 'edx', 'templates', 'course', 'Empty') - org = kwargs.get('org') - number = kwargs.get('number') - display_name = kwargs.get('display_name') - location = Location('i4x', org, number, - 'course', Location.clean(display_name)) - - store = modulestore('direct') - - # Write the data to the mongo datastore - new_course = store.clone_item(template, location) - - # This metadata code was copied from cms/djangoapps/contentstore/views.py - if display_name is not None: - new_course.metadata['display_name'] = display_name - - new_course.metadata['data_dir'] = uuid4().hex - new_course.metadata['start'] = stringify_time(gmtime()) - new_course.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}, - {"type": "progress", "name": "Progress"}] - - # Update the data in the mongo datastore - store.update_metadata(new_course.location.url(), new_course.own_metadata) - - return new_course - - -class Course: pass -class CourseFactory(XModuleCourseFactory): - FACTORY_FOR = Course - - template = 'i4x://edx/templates/course/Empty' - org = 'MITx' - number = '999' - display_name = 'Robot Super Course' - - -class XModuleItemFactory(Factory): +@world.absorb +class UserProfileFactory(sf.UserProfileFactory): """ - Factory for XModule items. + Demographics etc for the User """ - - ABSTRACT_FACTORY = True - _creation_function = (XMODULE_ITEM_CREATION,) - - @classmethod - def _create(cls, target_class, *args, **kwargs): - """ - kwargs must include parent_location, template. Can contain display_name - target_class is ignored - """ - - DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] - - parent_location = Location(kwargs.get('parent_location')) - template = Location(kwargs.get('template')) - display_name = kwargs.get('display_name') - - store = modulestore('direct') - - # This code was based off that in cms/djangoapps/contentstore/views.py - parent = store.get_item(parent_location) - dest_location = parent_location._replace(category=template.category, name=uuid4().hex) - - new_item = store.clone_item(template, dest_location) - - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - - # replace the display name with an optional parameter passed in from the caller - if display_name is not None: - new_item.metadata['display_name'] = display_name - - store.update_metadata(new_item.location.url(), new_item.own_metadata) - - if new_item.location.category not in DETACHED_CATEGORIES: - store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) - - return new_item - - -class Item: pass -class ItemFactory(XModuleItemFactory): - FACTORY_FOR = Item +@world.absorb +class RegistrationFactory(sf.RegistrationFactory): + """ + Activation key for registering the user account + """ + pass - parent_location = 'i4x://MITx/999/course/Robot_Super_Course' - template = 'i4x://edx/templates/chapter/Empty' - display_name = 'Section One' + +@world.absorb +class GroupFactory(sf.GroupFactory): + """ + Groups for user permissions for courses + """ + pass + + +@world.absorb +class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowed): + """ + Users allowed to enroll in the course outside of the usual window + """ + pass + + +@world.absorb +class CourseFactory(xf.CourseFactory): + """ + Courseware courses + """ + pass + + +@world.absorb +class ItemFactory(xf.ItemFactory): + """ + Everything included inside a course + """ + pass diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 88fba697b2..a8a32db173 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,14 +1,12 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from factories import * +from .course_helpers import * +from .ui_helpers import * from lettuce.django import django_url -from django.contrib.auth.models import User -from student.models import CourseEnrollment -from urllib import quote_plus -from nose.tools import assert_equals -from bs4 import BeautifulSoup +from nose.tools import assert_equals, assert_in import time -import re -import os.path from logging import getLogger logger = getLogger(__name__) @@ -16,7 +14,7 @@ logger = getLogger(__name__) @step(u'I wait (?:for )?"(\d+)" seconds?$') def wait(step, seconds): - time.sleep(float(seconds)) + world.wait(seconds) @step('I reload the page$') @@ -24,44 +22,49 @@ def reload_the_page(step): world.browser.reload() +@step('I press the browser back button$') +def browser_back(step): + world.browser.driver.back() + + @step('I (?:visit|access|open) the homepage$') def i_visit_the_homepage(step): - world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('header.global', 10) + world.visit('/') + assert world.is_css_present('header.global') @step(u'I (?:visit|access|open) the dashboard$') def i_visit_the_dashboard(step): - world.browser.visit(django_url('/dashboard')) - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + world.visit('/dashboard') + assert world.is_css_present('section.container.dashboard') @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.is_css_present('section.container.dashboard') assert world.browser.title == 'Dashboard' @step(u'I (?:visit|access|open) the courses page$') def i_am_on_the_courses_page(step): - world.browser.visit(django_url('/courses')) - assert world.browser.is_element_present_by_css('section.courses') + world.visit('/courses') + assert world.is_css_present('section.courses') @step(u'I press the "([^"]*)" button$') def and_i_press_the_button(step, value): button_css = 'input[value="%s"]' % value - world.browser.find_by_css(button_css).first.click() + world.css_click(button_css) @step(u'I click the link with the text "([^"]*)"$') def click_the_link_with_the_text_group1(step, linktext): - world.browser.find_link_by_text(linktext).first.click() + world.click_link(linktext) @step('I should see that the path is "([^"]*)"$') def i_should_see_that_the_path_is(step, path): - assert world.browser.url == django_url(path) + assert world.url_equals(path) @step(u'the page title should be "([^"]*)"$') @@ -69,10 +72,20 @@ def the_page_title_should_be(step, title): assert_equals(world.browser.title, title) +@step(u'the page title should contain "([^"]*)"$') +def the_page_title_should_contain(step, title): + assert(title in world.browser.title) + + +@step('I log in$') +def i_log_in(step): + world.log_in('robot', 'test') + + @step('I am a logged in user$') def i_am_logged_in_user(step): - create_user('robot') - log_in('robot@edx.org', 'test') + world.create_user('robot') + world.log_in('robot', 'test') @step('I am not logged in$') @@ -80,126 +93,48 @@ def i_am_not_logged_in(step): world.browser.cookies.delete() -@step('I am registered for a course$') -def i_am_registered_for_a_course(step): - create_user('robot') - u = User.objects.get(username='robot') - CourseEnrollment.objects.get_or_create(user=u, course_id='MITx/6.002x/2012_Fall') - - -@step('I am registered for course "([^"]*)"$') -def i_am_registered_for_course_by_id(step, course_id): - register_by_course_id(course_id) - - @step('I am staff for course "([^"]*)"$') def i_am_staff_for_course_by_id(step, course_id): - register_by_course_id(course_id, True) + world.register_by_course_id(course_id, True) -@step('I log in$') -def i_log_in(step): - log_in('robot@edx.org', 'test') +@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') +def click_the_link_called(step, text): + world.click_link(text) + + +@step(r'should see that the url is "([^"]*)"$') +def should_have_the_url(step, url): + assert_equals(world.browser.url, url) + + +@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') +def should_see_a_link_called(step, text): + assert len(world.browser.find_link_by_text(text)) > 0 + + +@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') +def should_see_in_the_page(step, text): + assert_in(text, world.css_text('body')) + + +@step('I am logged in$') +def i_am_logged_in(step): + world.create_user('robot') + world.log_in('robot', 'test') + world.browser.visit(django_url('/')) + + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() @step(u'I am an edX user$') def i_am_an_edx_user(step): - create_user('robot') - -#### helper functions - -@world.absorb -def scroll_to_bottom(): - # Maximize the browser - world.browser.execute_script("window.scrollTo(0, screen.height);") + world.create_user('robot') -@world.absorb -def create_user(uname): - portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') - portal_user.set_password('test') - portal_user.save() - - registration = RegistrationFactory(user=portal_user) - registration.register(portal_user) - registration.activate() - - user_profile = UserProfileFactory(user=portal_user) - - -@world.absorb -def log_in(email, password): - world.browser.cookies.delete() - world.browser.visit(django_url('/')) - world.browser.is_element_present_by_css('header.global', 10) - world.browser.click_link_by_href('#login-modal') - login_form = world.browser.find_by_css('form#login_form') - login_form.find_by_name('email').fill(email) - login_form.find_by_name('password').fill(password) - login_form.find_by_name('submit').click() - - # wait for the page to redraw - assert world.browser.is_element_present_by_css('.content-wrapper', 10) - - -@world.absorb -def register_by_course_id(course_id, is_staff=False): - create_user('robot') - u = User.objects.get(username='robot') - if is_staff: - u.is_staff = True - u.save() - CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) - - -@world.absorb -def save_the_html(path='/tmp'): - u = world.browser.url - html = world.browser.html.encode('ascii', 'ignore') - filename = '%s.html' % quote_plus(u) - f = open('%s/%s' % (path, filename), 'w') - f.write(html) - f.close - - -@world.absorb -def save_the_course_content(path='/tmp'): - html = world.browser.html.encode('ascii', 'ignore') - soup = BeautifulSoup(html) - - # get rid of the header, we only want to compare the body - soup.head.decompose() - - # for now, remove the data-id attributes, because they are - # causing mismatches between cms-master and master - for item in soup.find_all(attrs={'data-id': re.compile('.*')}): - del item['data-id'] - - # we also need to remove them from unrendered problems, - # where they are contained in the text of divs instead of - # in attributes of tags - # Be careful of whether or not it was the last attribute - # and needs a trailing space - for item in soup.find_all(text=re.compile(' data-id=".*?" ')): - s = unicode(item.string) - item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) - - for item in soup.find_all(text=re.compile(' data-id=".*?"')): - s = unicode(item.string) - item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) - - # prettify the html so it will compare better, with - # each HTML tag on its own line - output = soup.prettify() - - # use string slicing to grab everything after 'courseware/' in the URL - u = world.browser.url - section_url = u[u.find('courseware/') + 11:] - - if not os.path.exists(path): - os.makedirs(path) - - filename = '%s.html' % (quote_plus(section_url)) - f = open('%s/%s' % (path, filename), 'w') - f.write(output) - f.close +@step(u'User "([^"]*)" is an edX user$') +def registered_edx_user(step, uname): + world.create_user(uname) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py new file mode 100644 index 0000000000..d4d99e17b5 --- /dev/null +++ b/common/djangoapps/terrain/ui_helpers.py @@ -0,0 +1,117 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +import time +from urllib import quote_plus +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from lettuce.django import django_url + + +@world.absorb +def wait(seconds): + time.sleep(float(seconds)) + + +@world.absorb +def wait_for(func): + WebDriverWait(world.browser.driver, 5).until(func) + + +@world.absorb +def visit(url): + world.browser.visit(django_url(url)) + + +@world.absorb +def url_equals(url): + return world.browser.url == django_url(url) + + +@world.absorb +def is_css_present(css_selector): + return world.browser.is_element_present_by_css(css_selector, wait_time=4) + + +@world.absorb +def css_has_text(css_selector, text): + return world.css_text(css_selector) == text + + +@world.absorb +def css_find(css): + def is_visible(driver): + return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) + + world.browser.is_element_present_by_css(css, 5) + wait_for(is_visible) + return world.browser.find_by_css(css) + + +@world.absorb +def css_click(css_selector): + ''' + First try to use the regular click method, + but if clicking in the middle of an element + doesn't work it might be that it thinks some other + element is on top of it there so click in the upper left + ''' + try: + world.browser.find_by_css(css_selector).click() + + except WebDriverException: + # Occassionally, MathJax or other JavaScript can cover up + # an element temporarily. + # If this happens, wait a second, then try again + time.sleep(1) + world.browser.find_by_css(css_selector).click() + + +@world.absorb +def css_click_at(css, x=10, y=10): + ''' + A method to click at x,y coordinates of the element + rather than in the center of the element + ''' + e = css_find(css).first + e.action_chains.move_to_element_with_offset(e._element, x, y) + e.action_chains.click() + e.action_chains.perform() + + +@world.absorb +def css_fill(css_selector, text): + world.browser.find_by_css(css_selector).first.fill(text) + + +@world.absorb +def click_link(partial_text): + world.browser.find_link_by_partial_text(partial_text).first.click() + + +@world.absorb +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 + else: + return "" + + +@world.absorb +def css_visible(css_selector): + return world.browser.find_by_css(css_selector).visible + + +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close diff --git a/common/djangoapps/util/converters.py b/common/djangoapps/util/converters.py deleted file mode 100644 index ec2d29ecfa..0000000000 --- a/common/djangoapps/util/converters.py +++ /dev/null @@ -1,30 +0,0 @@ -import time -import datetime -import re -import calendar - - -def time_to_date(time_obj): - """ - Convert a time.time_struct to a true universal time (can pass to js Date constructor) - """ - # TODO change to using the isoformat() function on datetime. js date can parse those - return calendar.timegm(time_obj) * 1000 - - -def jsdate_to_time(field): - """ - Convert a universal time (iso format) or msec since epoch to a time obj - """ - if field is None: - return field - elif isinstance(field, basestring): - # ISO format but ignores time zone assuming it's Z. - d = datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable - return d.utctimetuple() - elif isinstance(field, (int, long, float)): - return time.gmtime(field / 1000) - elif isinstance(field, time.struct_time): - return field - else: - raise ValueError("Couldn't convert %r to time" % field) diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 7b19c27553..d398dfef0d 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -33,7 +33,7 @@ def wrap_xmodule(get_html, module, template, context=None): def _get_html(): context.update({ 'content': get_html(), - 'display_name': module.metadata.get('display_name') if module.metadata is not None else None, + 'display_name': module.display_name, 'class_': module.__class__.__name__, 'module_name': module.js_module_name }) @@ -108,42 +108,25 @@ def add_histogram(get_html, module, user): histogram = grade_histogram(module_id) render_histogram = len(histogram) > 0 - # TODO (ichuang): Remove after fall 2012 LMS migration done - if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): - [filepath, filename] = module.definition.get('filename', ['', None]) - osfs = module.system.filestore - if filename is not None and osfs.exists(filename): - # if original, unmangled filename exists then use it (github - # doesn't like symlinks) - filepath = filename - data_dir = osfs.root_path.rsplit('/')[-1] - giturl = module.metadata.get('giturl', 'https://github.com/MITx') - edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath) - else: - edit_link = False - # Need to define all the variables that are about to be used - giturl = "" - data_dir = "" - source_file = module.metadata.get('source_file', '') # source used to generate the problem XML, eg latex or word + source_file = module.lms.source_file # source used to generate the problem XML, eg latex or word # useful to indicate to staff if problem has been released or not # TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here now = time.gmtime() is_released = "unknown" - mstart = getattr(module.descriptor, 'start') + mstart = module.descriptor.lms.start + if mstart is not None: is_released = "Yes!" if (now > mstart) else "Not yet" - staff_context = {'definition': module.definition.get('data'), - 'metadata': json.dumps(module.metadata, indent=4), + staff_context = {'fields': [(field.name, getattr(module, field.name)) for field in module.fields], + 'lms_fields': [(field.name, getattr(module.lms, field.name)) for field in module.lms.fields], 'location': module.location, - 'xqa_key': module.metadata.get('xqa_key', ''), + 'xqa_key': module.lms.xqa_key, 'source_file': source_file, - 'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file), 'category': str(module.__class__.__name__), # Template uses element_id in js function names, so can't allow dashes 'element_id': module.location.html_id().replace('-', '_'), - 'edit_link': edit_link, 'user': user, 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'), 'histogram': json.dumps(histogram), diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 8b32686985..696b12377f 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -16,7 +16,6 @@ This is used by capa_module. from __future__ import division from datetime import datetime -import json import logging import math import numpy @@ -32,18 +31,16 @@ from xml.sax.saxutils import unescape from copy import deepcopy import chem -import chem.chemcalc -import chem.chemtools import chem.miller import verifiers import verifiers.draganddrop import calc -from correctmap import CorrectMap +from .correctmap import CorrectMap import eia import inputtypes import customrender -from util import contextualize_text, convert_files_to_filenames +from .util import contextualize_text, convert_files_to_filenames import xqueue_interface # to be replaced with auto-registering @@ -70,15 +67,12 @@ global_context = {'random': random, 'scipy': scipy, 'calc': calc, 'eia': eia, - 'chemcalc': chem.chemcalc, - 'chemtools': chem.chemtools, - 'miller': chem.miller, 'draganddrop': verifiers.draganddrop} # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"] -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) #----------------------------------------------------------------------------- # main class for this module @@ -97,8 +91,13 @@ class LoncapaProblem(object): - problem_text (string): xml defining the problem - id (string): identifier for this problem; often a filename (no spaces) - - state (dict): student state - - seed (int): random number generator seed (int) + - seed (int): random number generator seed (int) + - state (dict): containing the following keys: + - 'seed' - (int) random number generator seed + - 'student_answers' - (dict) maps input id to the stored answer for that input + - 'correct_map' (CorrectMap) a map of each input to their 'correctness' + - 'done' - (bool) indicates whether or not this problem is considered done + - 'input_state' - (dict) maps input_id to a dictionary that holds the state for that input - system (ModuleSystem): ModuleSystem instance which provides OS, rendering, and user context @@ -108,21 +107,25 @@ class LoncapaProblem(object): self.do_reset() self.problem_id = id self.system = system - self.seed = seed + if self.system is None: + raise Exception() - if state: - if 'seed' in state: - self.seed = state['seed'] - if 'student_answers' in state: - self.student_answers = state['student_answers'] - if 'correct_map' in state: - self.correct_map.set_dict(state['correct_map']) - if 'done' in state: - self.done = state['done'] + state = state if state else {} - # TODO: Does this deplete the Linux entropy pool? Is this fast enough? - if not self.seed: + # Set seed according to the following priority: + # 1. Contained in problem's state + # 2. Passed into capa_problem via constructor + # 3. Assign from the OS's random number generator + self.seed = state.get('seed', seed) + if self.seed is None: self.seed = struct.unpack('i', os.urandom(4))[0] + self.student_answers = state.get('student_answers', {}) + if 'correct_map' in state: + self.correct_map.set_dict(state['correct_map']) + self.done = state.get('done', False) + self.input_state = state.get('input_state', {}) + + # Convert startouttext and endouttext to proper problem_text = re.sub("startouttext\s*/", "text", problem_text) @@ -186,6 +189,7 @@ class LoncapaProblem(object): return {'seed': self.seed, 'student_answers': self.student_answers, 'correct_map': self.correct_map.get_dict(), + 'input_state': self.input_state, 'done': self.done} def get_max_score(self): @@ -235,6 +239,20 @@ class LoncapaProblem(object): self.correct_map.set_dict(cmap.get_dict()) return cmap + def ungraded_response(self, xqueue_msg, queuekey): + ''' + Handle any responses from the xqueue that do not contain grades + Will try to pass the queue message to all inputtypes that can handle ungraded responses + + Does not return any value + ''' + # check against each inputtype + for the_input in self.inputs.values(): + # if the input type has an ungraded function, pass in the values + if hasattr(the_input, 'ungraded_response'): + the_input.ungraded_response(xqueue_msg, queuekey) + + def is_queued(self): ''' Returns True if any part of the problem has been submitted to an external queue @@ -349,7 +367,7 @@ class LoncapaProblem(object): dispatch = get['dispatch'] return self.inputs[input_id].handle_ajax(dispatch, get) else: - log.warning("Could not find matching input for id: %s" % problem_id) + log.warning("Could not find matching input for id: %s" % input_id) return {} @@ -525,11 +543,15 @@ class LoncapaProblem(object): value = "" if self.student_answers and problemid in self.student_answers: value = self.student_answers[problemid] - + + if input_id not in self.input_state: + self.input_state[input_id] = {} + # do the rendering state = {'value': value, 'status': status, 'id': input_id, + 'input_state': self.input_state[input_id], 'feedback': {'message': msg, 'hint': hint, 'hintmode': hintmode, }} diff --git a/common/lib/capa/capa/checker.py b/common/lib/capa/capa/checker.py index f583a5ea7d..15358aac9e 100755 --- a/common/lib/capa/capa/checker.py +++ b/common/lib/capa/capa/checker.py @@ -12,8 +12,8 @@ from path import path from cStringIO import StringIO from collections import defaultdict -from calc import UndefinedVariable -from capa_problem import LoncapaProblem +from .calc import UndefinedVariable +from .capa_problem import LoncapaProblem from mako.lookup import TemplateLookup logging.basicConfig(format="%(levelname)s %(message)s") diff --git a/common/lib/capa/capa/chem/tests.py b/common/lib/capa/capa/chem/tests.py index 571526f915..f422fcf0d1 100644 --- a/common/lib/capa/capa/chem/tests.py +++ b/common/lib/capa/capa/chem/tests.py @@ -2,7 +2,7 @@ import codecs from fractions import Fraction import unittest -from chemcalc import (compare_chemical_expression, divide_chemical_expression, +from .chemcalc import (compare_chemical_expression, divide_chemical_expression, render_to_html, chemical_equations_equal) import miller @@ -277,7 +277,6 @@ class Test_Render_Equations(unittest.TestCase): def test_render9(self): s = "5[Ni(NH3)4]^2+ + 5/2SO4^2-" - #import ipdb; ipdb.set_trace() out = render_to_html(s) correct = u'5[Ni(NH3)4]2++52SO42-' log(out + ' ------- ' + correct, 'html') diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index ea56863a2f..b726f765d8 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -47,7 +47,7 @@ class CorrectMap(object): queuestate=None, **kwargs): if answer_id is not None: - self.cmap[answer_id] = {'correctness': correctness, + self.cmap[str(answer_id)] = {'correctness': correctness, 'npoints': npoints, 'msg': msg, 'hint': hint, diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py index a925a5970d..60d3ce578b 100644 --- a/common/lib/capa/capa/customrender.py +++ b/common/lib/capa/capa/customrender.py @@ -6,7 +6,7 @@ These tags do not have state, so they just get passed the system (for access to and the xml element. """ -from registry import TagRegistry +from .registry import TagRegistry import logging import re @@ -15,9 +15,9 @@ import json from lxml import etree import xml.sax.saxutils as saxutils -from registry import TagRegistry +from .registry import TagRegistry -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) registry = TagRegistry() diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 9781f10ae6..2febfbd5d2 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -37,20 +37,20 @@ graded status as'status' # makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a # general css and layout strategy for capa, document it, then implement it. -from collections import namedtuple import json import logging from lxml import etree import re import shlex # for splitting quoted strings import sys -import os import pyparsing -from registry import TagRegistry +from .registry import TagRegistry from capa.chem import chemcalc +import xqueue_interface +from datetime import datetime -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) ######################################################################### @@ -97,7 +97,8 @@ class Attribute(object): """ val = element.get(self.name) if self.default == self._sentinel and val is None: - raise ValueError('Missing required attribute {0}.'.format(self.name)) + raise ValueError( + 'Missing required attribute {0}.'.format(self.name)) if val is None: # not required, so return default @@ -132,6 +133,8 @@ class InputTypeBase(object): * 'id' -- the id of this input, typically "{problem-location}_{response-num}_{input-num}" * 'status' (answered, unanswered, unsubmitted) + * 'input_state' -- dictionary containing any inputtype-specific state + that has been preserved * 'feedback' (dictionary containing keys for hints, errors, or other feedback from previous attempt. Specifically 'message', 'hint', 'hintmode'. If 'hintmode' is 'always', the hint is always displayed.) @@ -149,7 +152,8 @@ class InputTypeBase(object): self.id = state.get('id', xml.get('id')) if self.id is None: - raise ValueError("input id state is None. xml is {0}".format(etree.tostring(xml))) + raise ValueError("input id state is None. xml is {0}".format( + etree.tostring(xml))) self.value = state.get('value', '') @@ -157,6 +161,7 @@ class InputTypeBase(object): self.msg = feedback.get('message', '') self.hint = feedback.get('hint', '') self.hintmode = feedback.get('hintmode', None) + self.input_state = state.get('input_state', {}) # put hint above msg if it should be displayed if self.hintmode == 'always': @@ -169,14 +174,15 @@ class InputTypeBase(object): self.process_requirements() # Call subclass "constructor" -- means they don't have to worry about calling - # super().__init__, and are isolated from changes to the input constructor interface. + # super().__init__, and are isolated from changes to the input + # constructor interface. self.setup() except Exception as err: # Something went wrong: add xml to message, but keep the traceback - msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err)) + msg = "Error in xml '{x}': {err} ".format( + x=etree.tostring(xml), err=str(err)) raise Exception, msg, sys.exc_info()[2] - @classmethod def get_attributes(cls): """ @@ -186,7 +192,6 @@ class InputTypeBase(object): """ return [] - def process_requirements(self): """ Subclasses can declare lists of required and optional attributes. This @@ -196,7 +201,8 @@ class InputTypeBase(object): Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set self.to_render, containing the names of attributes that should be included in the context by default. """ - # Use local dicts and sets so that if there are exceptions, we don't end up in a partially-initialized state. + # Use local dicts and sets so that if there are exceptions, we don't + # end up in a partially-initialized state. loaded = {} to_render = set() for a in self.get_attributes(): @@ -226,7 +232,7 @@ class InputTypeBase(object): get: a dictionary containing the data that was sent with the ajax call Output: - a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. + a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. """ pass @@ -247,8 +253,9 @@ class InputTypeBase(object): 'value': self.value, 'status': self.status, 'msg': self.msg, - } - context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render) + } + context.update((a, v) for ( + a, v) in self.loaded_attributes.iteritems() if a in self.to_render) context.update(self._extra_context()) return context @@ -371,7 +378,6 @@ class ChoiceGroup(InputTypeBase): return [Attribute("show_correctness", "always"), Attribute("submitted_message", "Answer received.")] - def _extra_context(self): return {'input_type': self.html_input_type, 'choices': self.choices, @@ -436,7 +442,6 @@ class JavascriptInput(InputTypeBase): Attribute('display_class', None), Attribute('display_file', None), ] - def setup(self): # Need to provide a value that JSON can parse if there is no # student-supplied value yet. @@ -459,7 +464,6 @@ class TextLine(InputTypeBase): template = "textline.html" tags = ['textline'] - @classmethod def get_attributes(cls): """ @@ -474,12 +478,12 @@ class TextLine(InputTypeBase): # Attributes below used in setup(), not rendered directly. Attribute('math', None, render=False), - # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x + # TODO: 'dojs' flag is temporary, for backwards compatibility with + # 8.02x Attribute('dojs', None, render=False), Attribute('preprocessorClassName', None, render=False), Attribute('preprocessorSrc', None, render=False), - ] - + ] def setup(self): self.do_math = bool(self.loaded_attributes['math'] or @@ -490,12 +494,12 @@ class TextLine(InputTypeBase): self.preprocessor = None if self.do_math: # Preprocessor to insert between raw input and Mathjax - self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'], - 'script_src': self.loaded_attributes['preprocessorSrc']} + self.preprocessor = { + 'class_name': self.loaded_attributes['preprocessorClassName'], + 'script_src': self.loaded_attributes['preprocessorSrc']} if None in self.preprocessor.values(): self.preprocessor = None - def _extra_context(self): return {'do_math': self.do_math, 'preprocessor': self.preprocessor, } @@ -539,7 +543,8 @@ class FileSubmission(InputTypeBase): """ # Check if problem has been queued self.queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue + # Flag indicating that the problem has been queued, 'msg' is length of + # queue if self.status == 'incomplete': self.status = 'queued' self.queue_len = self.msg @@ -547,7 +552,6 @@ class FileSubmission(InputTypeBase): def _extra_context(self): return {'queue_len': self.queue_len, } - return context registry.register(FileSubmission) @@ -562,8 +566,9 @@ class CodeInput(InputTypeBase): template = "codeinput.html" tags = ['codeinput', - 'textbox', # Another (older) name--at some point we may want to make it use a - # non-codemirror editor. + 'textbox', + # Another (older) name--at some point we may want to make it use a + # non-codemirror editor. ] # pulled out for testing @@ -586,22 +591,29 @@ class CodeInput(InputTypeBase): Attribute('tabsize', 4, transform=int), ] - def setup(self): + def setup_code_response_rendering(self): """ Implement special logic: handle queueing state, and default input. """ - # if no student input yet, then use the default input given by the problem - if not self.value: - self.value = self.xml.text + # if no student input yet, then use the default input given by the + # problem + if not self.value and self.xml.text: + self.value = self.xml.text.strip() # Check if problem has been queued self.queue_len = 0 - # Flag indicating that the problem has been queued, 'msg' is length of queue + # Flag indicating that the problem has been queued, 'msg' is length of + # queue if self.status == 'incomplete': self.status = 'queued' self.queue_len = self.msg self.msg = self.submitted_msg + + def setup(self): + ''' setup this input type ''' + self.setup_code_response_rendering() + def _extra_context(self): """Defined queue_len, add it """ return {'queue_len': self.queue_len, } @@ -610,8 +622,164 @@ registry.register(CodeInput) #----------------------------------------------------------------------------- + + +class MatlabInput(CodeInput): + ''' + InputType for handling Matlab code input + + TODO: API_KEY will go away once we have a way to specify it per-course + Example: + + Initial Text + + %api_key=API_KEY + + + ''' + template = "matlabinput.html" + tags = ['matlabinput'] + + plot_submitted_msg = ("Submitted. As soon as a response is returned, " + "this message will be replaced by that feedback.") + + def setup(self): + ''' + Handle matlab-specific parsing + ''' + self.setup_code_response_rendering() + + xml = self.xml + self.plot_payload = xml.findtext('./plot_payload') + + # Check if problem has been queued + self.queuename = 'matlab' + self.queue_msg = '' + if 'queue_msg' in self.input_state and self.status in ['queued','incomplete', 'unsubmitted']: + self.queue_msg = self.input_state['queue_msg'] + if 'queued' in self.input_state and self.input_state['queuestate'] is not None: + self.status = 'queued' + self.queue_len = 1 + self.msg = self.plot_submitted_msg + + + def handle_ajax(self, dispatch, get): + ''' + Handle AJAX calls directed to this input + + Args: + - dispatch (str) - indicates how we want this ajax call to be handled + - get (dict) - dictionary of key-value pairs that contain useful data + Returns: + + ''' + + if dispatch == 'plot': + return self._plot_data(get) + return {} + + def ungraded_response(self, queue_msg, queuekey): + ''' + Handle the response from the XQueue + Stores the response in the input_state so it can be rendered later + + Args: + - queue_msg (str) - message returned from the queue. The message to be rendered + - queuekey (str) - a key passed to the queue. Will be matched up to verify that this is the response we're waiting for + + Returns: + nothing + ''' + # check the queuekey against the saved queuekey + if('queuestate' in self.input_state and self.input_state['queuestate'] == 'queued' + and self.input_state['queuekey'] == queuekey): + msg = self._parse_data(queue_msg) + # save the queue message so that it can be rendered later + self.input_state['queue_msg'] = msg + self.input_state['queuestate'] = None + self.input_state['queuekey'] = None + + def _extra_context(self): + ''' Set up additional context variables''' + extra_context = { + 'queue_len': self.queue_len, + 'queue_msg': self.queue_msg + } + return extra_context + + def _parse_data(self, queue_msg): + ''' + Parses the message out of the queue message + Args: + queue_msg (str) - a JSON encoded string + Returns: + returns the value for the the key 'msg' in queue_msg + ''' + try: + result = json.loads(queue_msg) + except (TypeError, ValueError): + log.error("External message should be a JSON serialized dict." + " Received queue_msg = %s" % queue_msg) + raise + msg = result['msg'] + return msg + + + def _plot_data(self, get): + ''' + AJAX handler for the plot button + Args: + get (dict) - should have key 'submission' which contains the student submission + Returns: + dict - 'success' - whether or not we successfully queued this submission + - 'message' - message to be rendered in case of error + ''' + # only send data if xqueue exists + if self.system.xqueue is None: + return {'success': False, 'message': 'Cannot connect to the queue'} + + # pull relevant info out of get + response = get['submission'] + + # construct xqueue headers + qinterface = self.system.xqueue['interface'] + qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat) + callback_url = self.system.xqueue['construct_callback']('ungraded_response') + anonymous_student_id = self.system.anonymous_student_id + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + + anonymous_student_id + + self.id) + xheader = xqueue_interface.make_xheader( + lms_callback_url = callback_url, + lms_key = queuekey, + queue_name = self.queuename) + + # save the input state + self.input_state['queuekey'] = queuekey + self.input_state['queuestate'] = 'queued' + + + # construct xqueue body + student_info = {'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime} + contents = {'grader_payload': self.plot_payload, + 'student_info': json.dumps(student_info), + 'student_response': response} + + (error, msg) = qinterface.send_to_queue(header=xheader, + body = json.dumps(contents)) + + return {'success': error == 0, 'message': msg} + + +registry.register(MatlabInput) + + +#----------------------------------------------------------------------------- + class Schematic(InputTypeBase): """ + InputType for the schematic editor """ template = "schematicinput.html" @@ -630,7 +798,6 @@ class Schematic(InputTypeBase): Attribute('initial_value', None), Attribute('submit_analyses', None), ] - return context registry.register(Schematic) @@ -660,12 +827,12 @@ class ImageInput(InputTypeBase): Attribute('height'), Attribute('width'), ] - def setup(self): """ if value is of the form [x,y] then parse it and send along coordinates of previous answer """ - m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', '')) + m = re.match('\[([0-9]+),([0-9]+)]', + self.value.strip().replace(' ', '')) if m: # Note: we subtract 15 to compensate for the size of the dot on the screen. # (is a 30x30 image--lms/static/green-pointer.png). @@ -673,7 +840,6 @@ class ImageInput(InputTypeBase): else: (self.gx, self.gy) = (0, 0) - def _extra_context(self): return {'gx': self.gx, @@ -730,7 +896,7 @@ class VseprInput(InputTypeBase): registry.register(VseprInput) -#-------------------------------------------------------------------------------- +#------------------------------------------------------------------------- class ChemicalEquationInput(InputTypeBase): @@ -794,7 +960,8 @@ class ChemicalEquationInput(InputTypeBase): result['error'] = "Couldn't parse formula: {0}".format(p) except Exception: # this is unexpected, so log - log.warning("Error while previewing chemical formula", exc_info=True) + log.warning( + "Error while previewing chemical formula", exc_info=True) result['error'] = "Error while rendering preview" return result @@ -843,25 +1010,29 @@ class DragAndDropInput(InputTypeBase): 'can_reuse': ""} tag_attrs['target'] = {'id': Attribute._sentinel, - 'x': Attribute._sentinel, - 'y': Attribute._sentinel, - 'w': Attribute._sentinel, - 'h': Attribute._sentinel} + 'x': Attribute._sentinel, + 'y': Attribute._sentinel, + 'w': Attribute._sentinel, + 'h': Attribute._sentinel} dic = dict() for attr_name in tag_attrs[tag_type].keys(): dic[attr_name] = Attribute(attr_name, - default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag) + default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag) if tag_type == 'draggable' and not self.no_labels: dic['label'] = dic['label'] or dic['id'] + if tag_type == 'draggable': + dic['target_fields'] = [parse(target, 'target') for target in + tag.iterchildren('target')] + return dic # add labels to images?: self.no_labels = Attribute('no_labels', - default="False").parse_from_xml(self.xml) + default="False").parse_from_xml(self.xml) to_js = dict() @@ -870,16 +1041,16 @@ class DragAndDropInput(InputTypeBase): # outline places on image where to drag adn drop to_js['target_outline'] = Attribute('target_outline', - default="False").parse_from_xml(self.xml) + default="False").parse_from_xml(self.xml) # one draggable per target? to_js['one_per_target'] = Attribute('one_per_target', - default="True").parse_from_xml(self.xml) + default="True").parse_from_xml(self.xml) # list of draggables to_js['draggables'] = [parse(draggable, 'draggable') for draggable in - self.xml.iterchildren('draggable')] + self.xml.iterchildren('draggable')] # list of targets to_js['targets'] = [parse(target, 'target') for target in - self.xml.iterchildren('target')] + self.xml.iterchildren('target')] # custom background color for labels: label_bg_color = Attribute('label_bg_color', @@ -892,7 +1063,7 @@ class DragAndDropInput(InputTypeBase): registry.register(DragAndDropInput) -#-------------------------------------------------------------------------------------------------------------------- +#------------------------------------------------------------------------- class EditAMoleculeInput(InputTypeBase): @@ -930,6 +1101,7 @@ registry.register(EditAMoleculeInput) #----------------------------------------------------------------------------- + class DesignProtein2dInput(InputTypeBase): """ An input type for design of a protein in 2D. Integrates with the Protex java applet. @@ -965,6 +1137,7 @@ registry.register(DesignProtein2dInput) #----------------------------------------------------------------------------- + class EditAGeneInput(InputTypeBase): """ An input type for editing a gene. Integrates with the genex java applet. @@ -1001,6 +1174,7 @@ registry.register(EditAGeneInput) #--------------------------------------------------------------------- + class AnnotationInput(InputTypeBase): """ Input type for annotations: students can enter some notes or other text @@ -1033,13 +1207,14 @@ class AnnotationInput(InputTypeBase): def setup(self): xml = self.xml - self.debug = False # set to True to display extra debug info with input - self.return_to_annotation = True # return only works in conjunction with annotatable xmodule + self.debug = False # set to True to display extra debug info with input + self.return_to_annotation = True # return only works in conjunction with annotatable xmodule self.title = xml.findtext('./title', 'Annotation Exercise') self.text = xml.findtext('./text') self.comment = xml.findtext('./comment') - self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:') + self.comment_prompt = xml.findtext( + './comment_prompt', 'Type a commentary below:') self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:') self.options = self._find_options() @@ -1057,7 +1232,7 @@ class AnnotationInput(InputTypeBase): 'id': index, 'description': option.text, 'choice': option.get('choice') - } for (index, option) in enumerate(elements) ] + } for (index, option) in enumerate(elements)] def _validate_options(self): ''' Raises a ValueError if the choice attribute is missing or invalid. ''' @@ -1067,7 +1242,8 @@ class AnnotationInput(InputTypeBase): if choice is None: raise ValueError('Missing required choice attribute.') elif choice not in valid_choices: - raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices))) + raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format( + choice, ', '.join(valid_choices))) def _unpack(self, json_value): ''' Unpacks the json input state into a dict. ''' @@ -1085,20 +1261,20 @@ class AnnotationInput(InputTypeBase): return { 'options_value': options_value, - 'has_options_value': len(options_value) > 0, # for convenience + 'has_options_value': len(options_value) > 0, # for convenience 'comment_value': comment_value, } def _extra_context(self): extra_context = { - 'title': self.title, - 'text': self.text, - 'comment': self.comment, - 'comment_prompt': self.comment_prompt, - 'tag_prompt': self.tag_prompt, - 'options': self.options, - 'return_to_annotation': self.return_to_annotation, - 'debug': self.debug + 'title': self.title, + 'text': self.text, + 'comment': self.comment, + 'comment_prompt': self.comment_prompt, + 'tag_prompt': self.tag_prompt, + 'options': self.options, + 'return_to_annotation': self.return_to_annotation, + 'debug': self.debug } extra_context.update(self._unpack(self.value)) @@ -1106,4 +1282,3 @@ class AnnotationInput(InputTypeBase): return extra_context registry.register(AnnotationInput) - diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index d49d030df5..2035c42661 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -28,15 +28,15 @@ from collections import namedtuple from shapely.geometry import Point, MultiPoint # specific library imports -from calc import evaluator, UndefinedVariable -from correctmap import CorrectMap +from .calc import evaluator, UndefinedVariable +from .correctmap import CorrectMap from datetime import datetime -from util import * +from .util import * from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import xqueue_interface -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) #----------------------------------------------------------------------------- @@ -128,21 +128,25 @@ class LoncapaResponse(object): for abox in inputfields: if abox.tag not in self.allowed_inputfields: - msg = "%s: cannot have input field %s" % (unicode(self), abox.tag) - msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '') + msg = "%s: cannot have input field %s" % ( + unicode(self), abox.tag) + msg += "\nSee XML source line %s" % getattr( + xml, 'sourceline', '') raise LoncapaProblemError(msg) if self.max_inputfields and len(inputfields) > self.max_inputfields: msg = "%s: cannot have more than %s input fields" % ( unicode(self), self.max_inputfields) - msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '') + msg += "\nSee XML source line %s" % getattr( + xml, 'sourceline', '') raise LoncapaProblemError(msg) for prop in self.required_attributes: if not xml.get(prop): msg = "Error in problem specification: %s missing required attribute %s" % ( unicode(self), prop) - msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '') + msg += "\nSee XML source line %s" % getattr( + xml, 'sourceline', '') raise LoncapaProblemError(msg) # ordered list of answer_id values for this response @@ -163,7 +167,8 @@ class LoncapaResponse(object): for entry in self.inputfields: answer = entry.get('correct_answer') if answer: - self.default_answer_map[entry.get('id')] = contextualize_text(answer, self.context) + self.default_answer_map[entry.get( + 'id')] = contextualize_text(answer, self.context) if hasattr(self, 'setup_response'): self.setup_response() @@ -211,7 +216,8 @@ class LoncapaResponse(object): Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id. ''' new_cmap = self.get_score(student_answers) - self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap) + self.get_hints(convert_files_to_filenames( + student_answers), new_cmap, old_cmap) # log.debug('new_cmap = %s' % new_cmap) return new_cmap @@ -231,26 +237,27 @@ class LoncapaResponse(object): # hint specified by function? hintfn = hintgroup.get('hintfn') if hintfn: - ''' - Hint is determined by a function defined in the ''' - snippets = [{'snippet': """ + snippets = [{'snippet': r"""
          Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\) @@ -891,7 +914,7 @@ class CustomResponse(LoncapaResponse): correct[0] ='incorrect'
          """}, - {'snippet': """ +
          diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index 89cb5a5ee9..72d82c683b 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -2,7 +2,7 @@ import fs import fs.osfs import os -from mock import Mock +from mock import Mock, MagicMock import xml.sax.saxutils as saxutils @@ -16,6 +16,11 @@ def tst_render_template(template, context): """ return '
          {0}
          '.format(saxutils.escape(repr(context))) +def calledback_url(dispatch = 'score_update'): + return dispatch + +xqueue_interface = MagicMock() +xqueue_interface.send_to_queue.return_value = (0, 'Success!') test_system = Mock( ajax_url='courses/course_id/modx/a_location', @@ -26,7 +31,7 @@ test_system = Mock( user=Mock(), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), debug=True, - xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, + xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10}, node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), anonymous_student_id='student' ) diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 7aa299d20d..aa401b70cd 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -1,6 +1,7 @@ from lxml import etree from abc import ABCMeta, abstractmethod + class ResponseXMLFactory(object): """ Abstract base class for capa response XML factories. Subclasses override create_response_element and @@ -13,7 +14,7 @@ class ResponseXMLFactory(object): """ Subclasses override to return an etree element representing the capa response XML (e.g. ). - + The tree should NOT contain any input elements (such as ) as these will be added later.""" return None @@ -25,7 +26,7 @@ class ResponseXMLFactory(object): return None def build_xml(self, **kwargs): - """ Construct an XML string for a capa response + """ Construct an XML string for a capa response based on **kwargs. **kwargs is a dictionary that will be passed @@ -37,7 +38,7 @@ class ResponseXMLFactory(object): *question_text*: The text of the question to display, wrapped in

          tags. - + *explanation_text*: The detailed explanation that will be shown if the user answers incorrectly. @@ -75,7 +76,7 @@ class ResponseXMLFactory(object): for i in range(0, int(num_responses)): response_element = self.create_response_element(**kwargs) root.append(response_element) - + # Add input elements for j in range(0, int(num_inputs)): input_element = self.create_input_element(**kwargs) @@ -135,7 +136,7 @@ class ResponseXMLFactory(object): # Names of group elements group_element_names = {'checkbox': 'checkboxgroup', 'radio': 'radiogroup', - 'multiple': 'choicegroup' } + 'multiple': 'choicegroup'} # Retrieve **kwargs choices = kwargs.get('choices', [True]) @@ -151,13 +152,11 @@ class ResponseXMLFactory(object): choice_element = etree.SubElement(group_element, "choice") choice_element.set("correct", "true" if correct_val else "false") - # Add some text describing the choice - etree.SubElement(choice_element, "startouttext") - etree.text = "Choice description" - etree.SubElement(choice_element, "endouttext") - # Add a name identifying the choice, if one exists + # For simplicity, we use the same string as both the + # name attribute and the text of the element if name: + choice_element.text = str(name) choice_element.set("name", str(name)) return group_element @@ -217,7 +216,7 @@ class CustomResponseXMLFactory(ResponseXMLFactory): *answer*: Inline script that calculates the answer """ - + # Retrieve **kwargs cfn = kwargs.get('cfn', None) expect = kwargs.get('expect', None) @@ -247,7 +246,7 @@ class SchematicResponseXMLFactory(ResponseXMLFactory): def create_response_element(self, **kwargs): """ Create the XML element. - + Uses *kwargs*: *answer*: The Python script used to evaluate the answer. @@ -274,6 +273,7 @@ class SchematicResponseXMLFactory(ResponseXMLFactory): For testing, we create a bare-bones version of .""" return etree.Element("schematic") + class CodeResponseXMLFactory(ResponseXMLFactory): """ Factory for creating XML trees """ @@ -286,9 +286,9 @@ class CodeResponseXMLFactory(ResponseXMLFactory): def create_response_element(self, **kwargs): """ Create a XML element: - + Uses **kwargs: - + *initial_display*: The code that initially appears in the textbox [DEFAULT: "Enter code here"] *answer_display*: The answer to display to the student @@ -328,6 +328,7 @@ class CodeResponseXMLFactory(ResponseXMLFactory): # return None here return None + class ChoiceResponseXMLFactory(ResponseXMLFactory): """ Factory for creating XML trees """ @@ -356,13 +357,13 @@ class FormulaResponseXMLFactory(ResponseXMLFactory): *num_samples*: The number of times to sample the student's answer to numerically compare it to the correct answer. - + *tolerance*: The tolerance within which answers will be accepted - [DEFAULT: 0.01] + [DEFAULT: 0.01] *answer*: The answer to the problem. Can be a formula string - or a Python variable defined in a script - (e.g. "$calculated_answer" for a Python variable + or a Python variable defined in a script + (e.g. "$calculated_answer" for a Python variable called calculated_answer) [REQUIRED] @@ -387,7 +388,7 @@ class FormulaResponseXMLFactory(ResponseXMLFactory): # Set the sample information sample_str = self._sample_str(sample_dict, num_samples, tolerance) response_element.set("samples", sample_str) - + # Set the tolerance responseparam_element = etree.SubElement(response_element, "responseparam") @@ -408,7 +409,7 @@ class FormulaResponseXMLFactory(ResponseXMLFactory): # We could sample a different range, but for simplicity, # we use the same sample string for the hints - # that we used previously. + # that we used previously. formulahint_element.set("samples", sample_str) formulahint_element.set("answer", str(hint_prompt)) @@ -436,10 +437,11 @@ class FormulaResponseXMLFactory(ResponseXMLFactory): high_range_vals = [str(f[1]) for f in sample_dict.values()] sample_str = (",".join(sample_dict.keys()) + "@" + ",".join(low_range_vals) + ":" + - ",".join(high_range_vals) + + ",".join(high_range_vals) + "#" + str(num_samples)) return sample_str + class ImageResponseXMLFactory(ResponseXMLFactory): """ Factory for producing XML """ @@ -450,9 +452,9 @@ class ImageResponseXMLFactory(ResponseXMLFactory): def create_input_element(self, **kwargs): """ Create the element. - + Uses **kwargs: - + *src*: URL for the image file [DEFAULT: "/static/image.jpg"] *width*: Width of the image [DEFAULT: 100] @@ -490,7 +492,7 @@ class ImageResponseXMLFactory(ResponseXMLFactory): input_element.set("src", str(src)) input_element.set("width", str(width)) input_element.set("height", str(height)) - + if rectangle: input_element.set("rectangle", rectangle) @@ -499,6 +501,7 @@ class ImageResponseXMLFactory(ResponseXMLFactory): return input_element + class JavascriptResponseXMLFactory(ResponseXMLFactory): """ Factory for producing XML """ @@ -522,7 +525,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory): # Both display_src and display_class given, # or neither given - assert((display_src and display_class) or + assert((display_src and display_class) or (not display_src and not display_class)) # Create the element @@ -552,6 +555,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory): """ Create the element """ return etree.Element("javascriptinput") + class MultipleChoiceResponseXMLFactory(ResponseXMLFactory): """ Factory for producing XML """ @@ -564,6 +568,7 @@ class MultipleChoiceResponseXMLFactory(ResponseXMLFactory): kwargs['choice_type'] = 'multiple' return ResponseXMLFactory.choicegroup_input_xml(**kwargs) + class TrueFalseResponseXMLFactory(ResponseXMLFactory): """ Factory for producing XML """ @@ -576,6 +581,7 @@ class TrueFalseResponseXMLFactory(ResponseXMLFactory): kwargs['choice_type'] = 'multiple' return ResponseXMLFactory.choicegroup_input_xml(**kwargs) + class OptionResponseXMLFactory(ResponseXMLFactory): """ Factory for producing XML""" @@ -620,7 +626,7 @@ class StringResponseXMLFactory(ResponseXMLFactory): def create_response_element(self, **kwargs): """ Create a XML element. - + Uses **kwargs: *answer*: The correct answer (a string) [REQUIRED] @@ -642,7 +648,7 @@ class StringResponseXMLFactory(ResponseXMLFactory): # Create the element response_element = etree.Element("stringresponse") - # Set the answer attribute + # Set the answer attribute response_element.set("answer", str(answer)) # Set the case sensitivity @@ -667,6 +673,7 @@ class StringResponseXMLFactory(ResponseXMLFactory): def create_input_element(self, **kwargs): return ResponseXMLFactory.textline_input_xml(**kwargs) + class AnnotationResponseXMLFactory(ResponseXMLFactory): """ Factory for creating XML trees """ def create_response_element(self, **kwargs): @@ -679,17 +686,17 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory): input_element = etree.Element("annotationinput") text_children = [ - {'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') }, - {'tag': 'text', 'text': kwargs.get('text', 'texty text') }, - {'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') }, - {'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') }, - {'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') } + {'tag': 'title', 'text': kwargs.get('title', 'super cool annotation')}, + {'tag': 'text', 'text': kwargs.get('text', 'texty text')}, + {'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah')}, + {'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below')}, + {'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag')} ] for child in text_children: etree.SubElement(input_element, child['tag']).text = child['text'] - default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')] + default_options = [('green', 'correct'),('eggs', 'incorrect'), ('ham', 'partially-correct')] options = kwargs.get('options', default_options) options_element = etree.SubElement(input_element, 'options') @@ -698,4 +705,3 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory): option_element.text = description return input_element - diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 6c74d06ef4..e99308587e 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -7,7 +7,7 @@ import json import mock from capa.capa_problem import LoncapaProblem -from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory +from .response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory from . import test_system class CapaHtmlRenderTest(unittest.TestCase): diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 287caad28f..250cedd549 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -23,6 +23,7 @@ import xml.sax.saxutils as saxutils from . import test_system from capa import inputtypes +from mock import ANY # just a handy shortcut lookup_tag = inputtypes.registry.get_class_for_tag @@ -300,6 +301,98 @@ class CodeInputTest(unittest.TestCase): self.assertEqual(context, expected) +class MatlabTest(unittest.TestCase): + ''' + Test Matlab input types + ''' + def setUp(self): + self.rows = '10' + self.cols = '80' + self.tabsize = '4' + self.mode = "" + self.payload = "payload" + self.linenumbers = 'true' + self.xml = """ + + {payload} + + """.format(r = self.rows, + c = self.cols, + tabsize = self.tabsize, + m = self.mode, + payload = self.payload, + ln = self.linenumbers) + elt = etree.fromstring(self.xml) + state = {'value': 'print "good evening"', + 'status': 'incomplete', + 'feedback': {'message': '3'}, } + + self.input_class = lookup_tag('matlabinput') + self.the_input = self.input_class(test_system, elt, state) + + + def test_rendering(self): + context = self.the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'print "good evening"', + 'status': 'queued', + 'msg': self.input_class.submitted_msg, + 'mode': self.mode, + 'rows': self.rows, + 'cols': self.cols, + 'queue_msg': '', + 'linenumbers': 'true', + 'hidden': '', + 'tabsize': int(self.tabsize), + 'queue_len': '3', + } + + self.assertEqual(context, expected) + + + def test_rendering_with_state(self): + state = {'value': 'print "good evening"', + 'status': 'incomplete', + 'input_state': {'queue_msg': 'message'}, + 'feedback': {'message': '3'}, } + elt = etree.fromstring(self.xml) + + input_class = lookup_tag('matlabinput') + the_input = self.input_class(test_system, elt, state) + context = the_input._get_render_context() + + expected = {'id': 'prob_1_2', + 'value': 'print "good evening"', + 'status': 'queued', + 'msg': self.input_class.submitted_msg, + 'mode': self.mode, + 'rows': self.rows, + 'cols': self.cols, + 'queue_msg': 'message', + 'linenumbers': 'true', + 'hidden': '', + 'tabsize': int(self.tabsize), + 'queue_len': '3', + } + + self.assertEqual(context, expected) + + def test_plot_data(self): + get = {'submission': 'x = 1234;'} + response = self.the_input.handle_ajax("plot", get) + + test_system.xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY) + + self.assertTrue(response['success']) + self.assertTrue(self.the_input.input_state['queuekey'] is not None) + self.assertEqual(self.the_input.input_state['queuestate'], 'queued') + + + class SchematicTest(unittest.TestCase): ''' @@ -557,14 +650,14 @@ class DragAndDropTest(unittest.TestCase): "target_outline": "false", "base_image": "/static/images/about_1.png", "draggables": [ -{"can_reuse": "", "label": "Label 1", "id": "1", "icon": ""}, -{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", }, -{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": ""}, -{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": ""}, -{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": ""}, -{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": ""}, -{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": ""}, -{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": ""}], +{"can_reuse": "", "label": "Label 1", "id": "1", "icon": "", "target_fields": []}, +{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", "target_fields": []}, +{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": "", "target_fields": []}, +{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": "", "target_fields": []}, +{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": "", "target_fields": []}, +{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": "", "target_fields": []}, +{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": "", "target_fields": []}, +{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": "", "target_fields": []}], "one_per_target": "True", "targets": [ {"y": "90", "x": "210", "id": "t1", "w": "90", "h": "90"}, diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index e024909d75..e009c26aef 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -17,6 +17,7 @@ from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat + class ResponseTest(unittest.TestCase): """ Base class for tests of capa responses.""" @@ -35,16 +36,21 @@ class ResponseTest(unittest.TestCase): correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness) + def assert_answer_format(self, problem): + answers = problem.get_question_answers() + self.assertTrue(answers['1_2_1'] is not None) + def assert_multiple_grade(self, problem, correct_answers, incorrect_answers): for input_str in correct_answers: result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') self.assertEqual(result, 'correct', - msg="%s should be marked correct" % str(input_str)) + msg="%s should be marked correct" % str(input_str)) for input_str in incorrect_answers: result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') self.assertEqual(result, 'incorrect', - msg="%s should be marked incorrect" % str(input_str)) + msg="%s should be marked incorrect" % str(input_str)) + class MultiChoiceResponseTest(ResponseTest): from response_xml_factory import MultipleChoiceResponseXMLFactory @@ -60,7 +66,7 @@ class MultiChoiceResponseTest(ResponseTest): def test_named_multiple_choice_grade(self): problem = self.build_problem(choices=[False, True, False], - choice_names=["foil_1", "foil_2", "foil_3"]) + choice_names=["foil_1", "foil_2", "foil_3"]) # Ensure that we get the expected grades self.assert_grade(problem, 'choice_foil_1', 'incorrect') @@ -91,7 +97,7 @@ class TrueFalseResponseTest(ResponseTest): def test_named_true_false_grade(self): problem = self.build_problem(choices=[False, True, True], - choice_names=['foil_1','foil_2','foil_3']) + choice_names=['foil_1', 'foil_2', 'foil_3']) # Check the results # Mark correct if and only if ALL (and only) correct chocies selected @@ -107,6 +113,7 @@ class TrueFalseResponseTest(ResponseTest): self.assert_grade(problem, 'choice_foil_4', 'incorrect') self.assert_grade(problem, 'not_a_choice', 'incorrect') + class ImageResponseTest(ResponseTest): from response_xml_factory import ImageResponseXMLFactory xml_factory_class = ImageResponseXMLFactory @@ -118,7 +125,7 @@ class ImageResponseTest(ResponseTest): # Anything inside the rectangle (and along the borders) is correct # Everything else is incorrect correct_inputs = ["[12,19]", "[10,10]", "[20,20]", - "[10,15]", "[20,15]", "[15,10]", "[15,20]"] + "[10,15]", "[20,15]", "[15,10]", "[15,20]"] incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]"] self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) @@ -145,7 +152,7 @@ class ImageResponseTest(ResponseTest): def test_multiple_regions_grade(self): # Define multiple regions that the user can select - region_str="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]" + region_str = "[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]" # Expect that only points inside the regions are marked correct problem = self.build_problem(regions=region_str) @@ -155,7 +162,7 @@ class ImageResponseTest(ResponseTest): def test_region_and_rectangle_grade(self): rectangle_str = "(100,100)-(200,200)" - region_str="[[10,10], [20,10], [20, 30]]" + region_str = "[[10,10], [20,10], [20, 30]]" # Expect that only points inside the rectangle or region are marked correct problem = self.build_problem(regions=region_str, rectangle=rectangle_str) @@ -163,6 +170,13 @@ class ImageResponseTest(ResponseTest): incorrect_inputs = ["[0,0]", "[600,300]"] self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + def test_show_answer(self): + rectangle_str = "(100,100)-(200,200)" + region_str = "[[10,10], [20,10], [20, 30]]" + + problem = self.build_problem(regions=region_str, rectangle=rectangle_str) + self.assert_answer_format(problem) + class SymbolicResponseTest(unittest.TestCase): def test_sr_grade(self): @@ -171,85 +185,85 @@ class SymbolicResponseTest(unittest.TestCase): test_lcp = lcp.LoncapaProblem(open(symbolicresponse_file).read(), '1', system=test_system) correct_answers = {'1_2_1': 'cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]', '1_2_1_dynamath': ''' - - - - cos - - ( - θ - ) - - - - - [ - - - - 1 - - - 0 - - - - - 0 - - - 1 - - - - ] - - + - i - - - sin - - ( - θ - ) - - - - - [ - - - - 0 - - - 1 - - - - - 1 - - - 0 - - - - ] - - - -''', + + + + cos + + ( + θ + ) + + + + + [ + + + + 1 + + + 0 + + + + + 0 + + + 1 + + + + ] + + + + i + + + sin + + ( + θ + ) + + + + + [ + + + + 0 + + + 1 + + + + + 1 + + + 0 + + + + ] + + + + ''', } wrong_answers = {'1_2_1': '2', '1_2_1_dynamath': ''' - - 2 - -''', - } + + 2 + + ''', + } self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') @@ -260,7 +274,7 @@ class OptionResponseTest(ResponseTest): def test_grade(self): problem = self.build_problem(options=["first", "second", "third"], - correct_option="second") + correct_option="second") # Assert that we get the expected grades self.assert_grade(problem, "first", "incorrect") @@ -281,9 +295,9 @@ class FormulaResponseTest(ResponseTest): # The expected solution is numerically equivalent to x+2y problem = self.build_problem(sample_dict=sample_dict, - num_samples=10, - tolerance=0.01, - answer="x+2*y") + num_samples=10, + tolerance=0.01, + answer="x+2*y") # Expect an equivalent formula to be marked correct # 2x - x + y + y = x + 2y @@ -297,33 +311,31 @@ class FormulaResponseTest(ResponseTest): def test_hint(self): # Sample variables x and y in the range [-10, 10] - sample_dict = {'x': (-10, 10), 'y': (-10,10) } + sample_dict = {'x': (-10, 10), 'y': (-10, 10)} # Give a hint if the user leaves off the coefficient # or leaves out x hints = [('x + 3*y', 'y_coefficient', 'Check the coefficient of y'), - ('2*y', 'missing_x', 'Try including the variable x')] - + ('2*y', 'missing_x', 'Try including the variable x')] # The expected solution is numerically equivalent to x+2y problem = self.build_problem(sample_dict=sample_dict, - num_samples=10, - tolerance=0.01, - answer="x+2*y", - hints=hints) + num_samples=10, + tolerance=0.01, + answer="x+2*y", + hints=hints) # Expect to receive a hint if we add an extra y input_dict = {'1_2_1': "x + 2*y + y"} correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), - 'Check the coefficient of y') + 'Check the coefficient of y') # Expect to receive a hint if we leave out x input_dict = {'1_2_1': "2*y"} correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), - 'Try including the variable x') - + 'Try including the variable x') def test_script(self): # Calculate the answer using a script @@ -334,10 +346,10 @@ class FormulaResponseTest(ResponseTest): # The expected solution is numerically equivalent to 2*x problem = self.build_problem(sample_dict=sample_dict, - num_samples=10, - tolerance=0.01, - answer="$calculated_ans", - script=script) + num_samples=10, + tolerance=0.01, + answer="$calculated_ans", + script=script) # Expect that the inputs are graded correctly self.assert_grade(problem, '2*x', 'correct') @@ -348,7 +360,6 @@ class StringResponseTest(ResponseTest): from response_xml_factory import StringResponseXMLFactory xml_factory_class = StringResponseXMLFactory - def test_case_sensitive(self): problem = self.build_problem(answer="Second", case_sensitive=True) @@ -372,23 +383,23 @@ class StringResponseTest(ResponseTest): def test_hints(self): hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), - ("minnesota", "minn", "The state capital of Minnesota is St. Paul")] + ("minnesota", "minn", "The state capital of Minnesota is St. Paul")] problem = self.build_problem(answer="Michigan", - case_sensitive=False, - hints=hints) + case_sensitive=False, + hints=hints) # We should get a hint for Wisconsin input_dict = {'1_2_1': 'Wisconsin'} correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), - "The state capital of Wisconsin is Madison") + "The state capital of Wisconsin is Madison") # We should get a hint for Minnesota input_dict = {'1_2_1': 'Minnesota'} correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), - "The state capital of Minnesota is St. Paul") + "The state capital of Minnesota is St. Paul") # We should NOT get a hint for Michigan (the correct answer) input_dict = {'1_2_1': 'Michigan'} @@ -400,6 +411,7 @@ class StringResponseTest(ResponseTest): correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), "") + class CodeResponseTest(ResponseTest): from response_xml_factory import CodeResponseXMLFactory xml_factory_class = CodeResponseXMLFactory @@ -409,9 +421,9 @@ class CodeResponseTest(ResponseTest): grader_payload = json.dumps({"grader": "ps04/grade_square.py"}) self.problem = self.build_problem(initial_display="def square(x):", - answer_display="answer", - grader_payload=grader_payload, - num_responses=2) + answer_display="answer", + grader_payload=grader_payload, + num_responses=2) @staticmethod def make_queuestate(key, time): @@ -442,7 +454,6 @@ class CodeResponseTest(ResponseTest): self.assertEquals(self.problem.is_queued(), True) - def test_update_score(self): ''' Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem @@ -495,7 +506,6 @@ class CodeResponseTest(ResponseTest): else: self.assertTrue(self.problem.correct_map.is_queued(test_id)) # Should be queued, message undelivered - def test_recentmost_queuetime(self): ''' Test whether the LoncapaProblem knows about the time of queue requests @@ -538,13 +548,14 @@ class CodeResponseTest(ResponseTest): self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) + class ChoiceResponseTest(ResponseTest): from response_xml_factory import ChoiceResponseXMLFactory xml_factory_class = ChoiceResponseXMLFactory def test_radio_group_grade(self): problem = self.build_problem(choice_type='radio', - choices=[False, True, False]) + choices=[False, True, False]) # Check that we get the expected results self.assert_grade(problem, 'choice_0', 'incorrect') @@ -554,10 +565,9 @@ class ChoiceResponseTest(ResponseTest): # No choice 3 exists --> mark incorrect self.assert_grade(problem, 'choice_3', 'incorrect') - def test_checkbox_group_grade(self): problem = self.build_problem(choice_type='checkbox', - choices=[False, True, True]) + choices=[False, True, True]) # Check that we get the expected results # (correct if and only if BOTH correct choices chosen) @@ -581,14 +591,15 @@ class JavascriptResponseTest(ResponseTest): os.system("coffee -c %s" % (coffee_file_path)) problem = self.build_problem(generator_src="test_problem_generator.js", - grader_src="test_problem_grader.js", - display_class="TestProblemDisplay", - display_src="test_problem_display.js", - param_dict={'value': '4'}) + grader_src="test_problem_grader.js", + display_class="TestProblemDisplay", + display_src="test_problem_display.js", + param_dict={'value': '4'}) # Test that we get graded correctly - self.assert_grade(problem, json.dumps({0:4}), "correct") - self.assert_grade(problem, json.dumps({0:5}), "incorrect") + self.assert_grade(problem, json.dumps({0: 4}), "correct") + self.assert_grade(problem, json.dumps({0: 5}), "incorrect") + class NumericalResponseTest(ResponseTest): from response_xml_factory import NumericalResponseXMLFactory @@ -596,27 +607,26 @@ class NumericalResponseTest(ResponseTest): def test_grade_exact(self): problem = self.build_problem(question_text="What is 2 + 2?", - explanation="The answer is 4", - answer=4) + explanation="The answer is 4", + answer=4) correct_responses = ["4", "4.0", "4.00"] incorrect_responses = ["", "3.9", "4.1", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) - def test_grade_decimal_tolerance(self): problem = self.build_problem(question_text="What is 2 + 2 approximately?", - explanation="The answer is 4", - answer=4, - tolerance=0.1) + explanation="The answer is 4", + answer=4, + tolerance=0.1) correct_responses = ["4.0", "4.00", "4.09", "3.91"] incorrect_responses = ["", "4.11", "3.89", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) def test_grade_percent_tolerance(self): problem = self.build_problem(question_text="What is 2 + 2 approximately?", - explanation="The answer is 4", - answer=4, - tolerance="10%") + explanation="The answer is 4", + answer=4, + tolerance="10%") correct_responses = ["4.0", "4.3", "3.7", "4.30", "3.70"] incorrect_responses = ["", "4.5", "3.5", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) @@ -624,9 +634,9 @@ class NumericalResponseTest(ResponseTest): def test_grade_with_script(self): script_text = "computed_response = math.sqrt(4)" problem = self.build_problem(question_text="What is sqrt(4)?", - explanation="The answer is 2", - answer="$computed_response", - script=script_text) + explanation="The answer is 2", + answer="$computed_response", + script=script_text) correct_responses = ["2", "2.0"] incorrect_responses = ["", "2.01", "1.99", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) @@ -634,10 +644,10 @@ class NumericalResponseTest(ResponseTest): def test_grade_with_script_and_tolerance(self): script_text = "computed_response = math.sqrt(4)" problem = self.build_problem(question_text="What is sqrt(4)?", - explanation="The answer is 2", - answer="$computed_response", - tolerance="0.1", - script=script_text) + explanation="The answer is 2", + answer="$computed_response", + tolerance="0.1", + script=script_text) correct_responses = ["2", "2.0", "2.05", "1.95"] incorrect_responses = ["", "2.11", "1.89", "0"] self.assert_multiple_grade(problem, correct_responses, incorrect_responses) @@ -651,7 +661,6 @@ class NumericalResponseTest(ResponseTest): self.assert_multiple_grade(problem, correct_responses, incorrect_responses) - class CustomResponseTest(ResponseTest): from response_xml_factory import CustomResponseXMLFactory xml_factory_class = CustomResponseXMLFactory @@ -692,7 +701,6 @@ class CustomResponseTest(ResponseTest): overall_msg = correctmap.get_overall_message() self.assertEqual(overall_msg, "Overall message") - def test_function_code_single_input(self): # For function code, we pass in these arguments: @@ -746,7 +754,7 @@ class CustomResponseTest(ResponseTest): """) problem = self.build_problem(script=script, cfn="check_func", - expect="42", num_inputs=2) + expect="42", num_inputs=2) # Correct answer -- expect both inputs marked correct input_dict = {'1_2_1': '42', '1_2_2': '42'} @@ -768,7 +776,6 @@ class CustomResponseTest(ResponseTest): correctness = correct_map.get_correctness('1_2_2') self.assertEqual(correctness, 'incorrect') - def test_function_code_multiple_inputs(self): # If the has multiple inputs associated with it, @@ -794,10 +801,10 @@ class CustomResponseTest(ResponseTest): """) problem = self.build_problem(script=script, - cfn="check_func", num_inputs=3) + cfn="check_func", num_inputs=3) # Grade the inputs (one input incorrect) - input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' } + input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3'} correct_map = problem.grade_answers(input_dict) # Expect that we receive the overall message (for the whole response) @@ -813,7 +820,6 @@ class CustomResponseTest(ResponseTest): self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2') self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3') - def test_multiple_inputs_return_one_status(self): # When given multiple inputs, the 'answer_given' argument # to the check_func() is a list of inputs @@ -835,10 +841,10 @@ class CustomResponseTest(ResponseTest): """) problem = self.build_problem(script=script, - cfn="check_func", num_inputs=3) + cfn="check_func", num_inputs=3) # Grade the inputs (one input incorrect) - input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' } + input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3'} correct_map = problem.grade_answers(input_dict) # Everything marked incorrect @@ -847,7 +853,7 @@ class CustomResponseTest(ResponseTest): self.assertEqual(correct_map.get_correctness('1_2_3'), 'incorrect') # Grade the inputs (everything correct) - input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3' } + input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3'} correct_map = problem.grade_answers(input_dict) # Everything marked incorrect @@ -902,13 +908,13 @@ class SchematicResponseTest(ResponseTest): # To test that the context is set up correctly, # we create a script that sets *correct* to true # if and only if we find the *submission* (list) - script="correct = ['correct' if 'test' in submission[0] else 'incorrect']" + script = "correct = ['correct' if 'test' in submission[0] else 'incorrect']" problem = self.build_problem(answer=script) # The actual dictionary would contain schematic information # sent from the JavaScript simulation submission_dict = {'test': 'test'} - input_dict = { '1_2_1': json.dumps(submission_dict) } + input_dict = {'1_2_1': json.dumps(submission_dict)} correct_map = problem.grade_answers(input_dict) # Expect that the problem is graded as true @@ -916,6 +922,7 @@ class SchematicResponseTest(ResponseTest): # is what we expect) self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + class AnnotationResponseTest(ResponseTest): from response_xml_factory import AnnotationResponseXMLFactory xml_factory_class = AnnotationResponseXMLFactory @@ -924,18 +931,18 @@ class AnnotationResponseTest(ResponseTest): (correct, partially, incorrect) = ('correct', 'partially-correct', 'incorrect') answer_id = '1_2_1' - options = (('x', correct),('y', partially),('z', incorrect)) - make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids })} + options = (('x', correct), ('y', partially), ('z', incorrect)) + make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids})} tests = [ - {'correctness': correct, 'points': 2,'answers': make_answer([0]) }, - {'correctness': partially, 'points': 1, 'answers': make_answer([1]) }, - {'correctness': incorrect, 'points': 0, 'answers': make_answer([2]) }, - {'correctness': incorrect, 'points': 0, 'answers': make_answer([0,1,2]) }, - {'correctness': incorrect, 'points': 0, 'answers': make_answer([]) }, - {'correctness': incorrect, 'points': 0, 'answers': make_answer('') }, - {'correctness': incorrect, 'points': 0, 'answers': make_answer(None) }, - {'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null' } }, + {'correctness': correct, 'points': 2, 'answers': make_answer([0])}, + {'correctness': partially, 'points': 1, 'answers': make_answer([1])}, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([2])}, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([0, 1, 2])}, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([])}, + {'correctness': incorrect, 'points': 0, 'answers': make_answer('')}, + {'correctness': incorrect, 'points': 0, 'answers': make_answer(None)}, + {'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null'}}, ] for (index, test) in enumerate(tests): diff --git a/common/lib/capa/capa/util.py b/common/lib/capa/capa/util.py index a0f25c4947..9f3e8bd3a0 100644 --- a/common/lib/capa/capa/util.py +++ b/common/lib/capa/capa/util.py @@ -1,4 +1,4 @@ -from calc import evaluator, UndefinedVariable +from .calc import evaluator, UndefinedVariable #----------------------------------------------------------------------------- # diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/capa/capa/verifiers/draganddrop.py index 239ff2b9a4..cdfa163f33 100644 --- a/common/lib/capa/capa/verifiers/draganddrop.py +++ b/common/lib/capa/capa/verifiers/draganddrop.py @@ -27,6 +27,49 @@ values are (x,y) coordinates of centers of dragged images. import json +def flat_user_answer(user_answer): + """ + Convert nested `user_answer` to flat format. + + {'up': {'first': {'p': 'p_l'}}} + + to + + {'up': 'p_l[p][first]'} + """ + + def parse_user_answer(answer): + key = answer.keys()[0] + value = answer.values()[0] + if isinstance(value, dict): + + # Make complex value: + # Example: + # Create like 'p_l[p][first]' from {'first': {'p': 'p_l'} + complex_value_list = [] + v_value = value + while isinstance(v_value, dict): + v_key = v_value.keys()[0] + v_value = v_value.values()[0] + complex_value_list.append(v_key) + + complex_value = '{0}'.format(v_value) + for i in reversed(complex_value_list): + complex_value = '{0}[{1}]'.format(complex_value, i) + + res = {key: complex_value} + return res + else: + return answer + + result = [] + for answer in user_answer: + parse_answer = parse_user_answer(answer) + result.append(parse_answer) + + return result + + class PositionsCompare(list): """ Class for comparing positions. @@ -116,37 +159,36 @@ class DragAndDrop(object): # Number of draggables in user_groups may be differ that in # correct_groups, that is incorrect, except special case with 'number' - for groupname, draggable_ids in self.correct_groups.items(): - + for index, draggable_ids in enumerate(self.correct_groups): # 'number' rule special case # for reusable draggables we may get in self.user_groups # {'1': [u'2', u'2', u'2'], '0': [u'1', u'1'], '2': [u'3']} # if '+number' is in rule - do not remove duplicates and strip # '+number' from rule - current_rule = self.correct_positions[groupname].keys()[0] + current_rule = self.correct_positions[index].keys()[0] if 'number' in current_rule: - rule_values = self.correct_positions[groupname][current_rule] + rule_values = self.correct_positions[index][current_rule] # clean rule, do not do clean duplicate items - self.correct_positions[groupname].pop(current_rule, None) + self.correct_positions[index].pop(current_rule, None) parsed_rule = current_rule.replace('+', '').replace('number', '') - self.correct_positions[groupname][parsed_rule] = rule_values + self.correct_positions[index][parsed_rule] = rule_values else: # remove dublicates - self.user_groups[groupname] = list(set(self.user_groups[groupname])) + self.user_groups[index] = list(set(self.user_groups[index])) - if sorted(draggable_ids) != sorted(self.user_groups[groupname]): + if sorted(draggable_ids) != sorted(self.user_groups[index]): return False # Check that in every group, for rule of that group, user positions of # every element are equal with correct positions - for groupname in self.correct_groups: + for index, _ in enumerate(self.correct_groups): rules_executed = 0 for rule in ('exact', 'anyof', 'unordered_equal'): # every group has only one rule - if self.correct_positions[groupname].get(rule, None): + if self.correct_positions[index].get(rule, None): rules_executed += 1 if not self.compare_positions( - self.correct_positions[groupname][rule], - self.user_positions[groupname]['user'], flag=rule): + self.correct_positions[index][rule], + self.user_positions[index]['user'], flag=rule): return False if not rules_executed: # no correct rules for current group # probably xml content mistake - wrong rules names @@ -248,7 +290,7 @@ class DragAndDrop(object): correct_answer = {'name4': 't1', 'name_with_icon': 't1', '5': 't2', - '7':'t2'} + '7': 't2'} It is draggable_name: dragable_position mapping. @@ -284,24 +326,25 @@ class DragAndDrop(object): Args: user_answer: json - correct_answer: dict or list + correct_answer: dict or list """ - self.correct_groups = dict() # correct groups from xml - self.correct_positions = dict() # correct positions for comparing - self.user_groups = dict() # will be populated from user answer - self.user_positions = dict() # will be populated from user answer + self.correct_groups = [] # Correct groups from xml. + self.correct_positions = [] # Correct positions for comparing. + self.user_groups = [] # Will be populated from user answer. + self.user_positions = [] # Will be populated from user answer. - # convert from dict answer format to list format + # Convert from dict answer format to list format. if isinstance(correct_answer, dict): tmp = [] for key, value in correct_answer.items(): - tmp_dict = {'draggables': [], 'targets': [], 'rule': 'exact'} - tmp_dict['draggables'].append(key) - tmp_dict['targets'].append(value) - tmp.append(tmp_dict) + tmp.append({ + 'draggables': [key], + 'targets': [value], + 'rule': 'exact'}) correct_answer = tmp + # Convert string `user_answer` to object. user_answer = json.loads(user_answer) # This dictionary will hold a key for each draggable the user placed on @@ -309,27 +352,32 @@ class DragAndDrop(object): # correct_answer entries. If the draggable is mentioned in at least one # correct_answer entry, the value is False. # default to consider every user answer excess until proven otherwise. - self.excess_draggables = dict((users_draggable.keys()[0],True) - for users_draggable in user_answer['draggables']) + self.excess_draggables = dict((users_draggable.keys()[0],True) + for users_draggable in user_answer) - # create identical data structures from user answer and correct answer - for i in xrange(0, len(correct_answer)): - groupname = str(i) - self.correct_groups[groupname] = correct_answer[i]['draggables'] - self.correct_positions[groupname] = {correct_answer[i]['rule']: - correct_answer[i]['targets']} - self.user_groups[groupname] = [] - self.user_positions[groupname] = {'user': []} - for draggable_dict in user_answer['draggables']: - # draggable_dict is 1-to-1 {draggable_name: position} + # Convert nested `user_answer` to flat format. + user_answer = flat_user_answer(user_answer) + + # Create identical data structures from user answer and correct answer. + for answer in correct_answer: + user_groups_data = [] + user_positions_data = [] + for draggable_dict in user_answer: + # Draggable_dict is 1-to-1 {draggable_name: position}. draggable_name = draggable_dict.keys()[0] - if draggable_name in self.correct_groups[groupname]: - self.user_groups[groupname].append(draggable_name) - self.user_positions[groupname]['user'].append( + if draggable_name in answer['draggables']: + user_groups_data.append(draggable_name) + user_positions_data.append( draggable_dict[draggable_name]) # proved that this is not excess self.excess_draggables[draggable_name] = False + self.correct_groups.append(answer['draggables']) + self.correct_positions.append({answer['rule']: answer['targets']}) + self.user_groups.append(user_groups_data) + self.user_positions.append({'user': user_positions_data}) + + def grade(user_input, correct_answer): """ Creates DragAndDrop instance from user_input and correct_answer and calls DragAndDrop.grade for grading. diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/capa/capa/verifiers/tests_draganddrop.py index bcd024fa89..75a194cc6d 100644 --- a/common/lib/capa/capa/verifiers/tests_draganddrop.py +++ b/common/lib/capa/capa/verifiers/tests_draganddrop.py @@ -1,7 +1,8 @@ import unittest import draganddrop -from draganddrop import PositionsCompare +from .draganddrop import PositionsCompare +import json class Test_PositionsCompare(unittest.TestCase): @@ -40,90 +41,314 @@ class Test_PositionsCompare(unittest.TestCase): class Test_DragAndDrop_Grade(unittest.TestCase): + def test_targets_are_draggable_1(self): + user_input = json.dumps([ + {'p': 'p_l'}, + {'up': {'first': {'p': 'p_l'}}} + ]) + + correct_answer = [ + { + 'draggables': ['p'], + 'targets': [ + 'p_l', 'p_r' + ], + 'rule': 'anyof' + }, + { + 'draggables': ['up'], + 'targets': [ + 'p_l[p][first]' + ], + 'rule': 'anyof' + } + ] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_are_draggable_2(self): + user_input = json.dumps([ + {'p': 'p_l'}, + {'p': 'p_r'}, + {'s': 's_l'}, + {'s': 's_r'}, + {'up': {'1': {'p': 'p_l'}}}, + {'up': {'3': {'p': 'p_l'}}}, + {'up': {'1': {'p': 'p_r'}}}, + {'up': {'3': {'p': 'p_r'}}}, + {'up_and_down': {'1': {'s': 's_l'}}}, + {'up_and_down': {'1': {'s': 's_r'}}} + ]) + + correct_answer = [ + { + 'draggables': ['p'], + 'targets': ['p_l', 'p_r'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['s'], + 'targets': ['s_l', 's_r'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up_and_down'], + 'targets': [ + 's_l[s][1]', 's_r[s][1]' + ], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up'], + 'targets': [ + 'p_l[p][1]', 'p_l[p][3]', 'p_r[p][1]', 'p_r[p][3]' + ], + 'rule': 'unordered_equal' + } + ] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_are_draggable_2_manual_parsing(self): + user_input = json.dumps([ + {'up': 'p_l[p][1]'}, + {'p': 'p_l'}, + {'up': 'p_l[p][3]'}, + {'up': 'p_r[p][1]'}, + {'p': 'p_r'}, + {'up': 'p_r[p][3]'}, + {'up_and_down': 's_l[s][1]'}, + {'s': 's_l'}, + {'up_and_down': 's_r[s][1]'}, + {'s': 's_r'} + ]) + + correct_answer = [ + { + 'draggables': ['p'], + 'targets': ['p_l', 'p_r'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['s'], + 'targets': ['s_l', 's_r'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up_and_down'], + 'targets': [ + 's_l[s][1]', 's_r[s][1]' + ], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up'], + 'targets': [ + 'p_l[p][1]', 'p_l[p][3]', 'p_r[p][1]', 'p_r[p][3]' + ], + 'rule': 'unordered_equal' + } + ] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_are_draggable_3_nested(self): + user_input = json.dumps([ + {'molecule': 'left_side_tagret'}, + {'molecule': 'right_side_tagret'}, + {'p': {'p_target': {'molecule': 'left_side_tagret'}}}, + {'p': {'p_target': {'molecule': 'right_side_tagret'}}}, + {'s': {'s_target': {'molecule': 'left_side_tagret'}}}, + {'s': {'s_target': {'molecule': 'right_side_tagret'}}}, + {'up': {'1': {'p': {'p_target': {'molecule': 'left_side_tagret'}}}}}, + {'up': {'3': {'p': {'p_target': {'molecule': 'left_side_tagret'}}}}}, + {'up': {'1': {'p': {'p_target': {'molecule': 'right_side_tagret'}}}}}, + {'up': {'3': {'p': {'p_target': {'molecule': 'right_side_tagret'}}}}}, + {'up_and_down': {'1': {'s': {'s_target': {'molecule': 'left_side_tagret'}}}}}, + {'up_and_down': {'1': {'s': {'s_target': {'molecule': 'right_side_tagret'}}}}} + ]) + + correct_answer = [ + { + 'draggables': ['molecule'], + 'targets': ['left_side_tagret', 'right_side_tagret'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['p'], + 'targets': [ + 'left_side_tagret[molecule][p_target]', + 'right_side_tagret[molecule][p_target]' + ], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['s'], + 'targets': [ + 'left_side_tagret[molecule][s_target]', + 'right_side_tagret[molecule][s_target]' + ], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up_and_down'], + 'targets': [ + 'left_side_tagret[molecule][s_target][s][1]', + 'right_side_tagret[molecule][s_target][s][1]' + ], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up'], + 'targets': [ + 'left_side_tagret[molecule][p_target][p][1]', + 'left_side_tagret[molecule][p_target][p][3]', + 'right_side_tagret[molecule][p_target][p][1]', + 'right_side_tagret[molecule][p_target][p][3]' + ], + 'rule': 'unordered_equal' + } + ] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_are_draggable_4_real_example(self): + user_input = json.dumps([ + {'single_draggable': 's_l'}, + {'single_draggable': 's_r'}, + {'single_draggable': 'p_sigma'}, + {'single_draggable': 'p_sigma*'}, + {'single_draggable': 's_sigma'}, + {'single_draggable': 's_sigma*'}, + {'double_draggable': 'p_pi*'}, + {'double_draggable': 'p_pi'}, + {'triple_draggable': 'p_l'}, + {'triple_draggable': 'p_r'}, + {'up': {'1': {'triple_draggable': 'p_l'}}}, + {'up': {'2': {'triple_draggable': 'p_l'}}}, + {'up': {'2': {'triple_draggable': 'p_r'}}}, + {'up': {'3': {'triple_draggable': 'p_r'}}}, + {'up_and_down': {'1': {'single_draggable': 's_l'}}}, + {'up_and_down': {'1': {'single_draggable': 's_r'}}}, + {'up_and_down': {'1': {'single_draggable': 's_sigma'}}}, + {'up_and_down': {'1': {'single_draggable': 's_sigma*'}}}, + {'up_and_down': {'1': {'double_draggable': 'p_pi'}}}, + {'up_and_down': {'2': {'double_draggable': 'p_pi'}}} + ]) + + # 10 targets: + # s_l, s_r, p_l, p_r, s_sigma, s_sigma*, p_pi, p_sigma, p_pi*, p_sigma* + # + # 3 draggable objects, which have targets (internal target ids - 1, 2, 3): + # single_draggable, double_draggable, triple_draggable + # + # 2 draggable objects: + # up, up_and_down + correct_answer = [ + { + 'draggables': ['triple_draggable'], + 'targets': ['p_l', 'p_r'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['double_draggable'], + 'targets': ['p_pi', 'p_pi*'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['single_draggable'], + 'targets': ['s_l', 's_r', 's_sigma', 's_sigma*', 'p_sigma', 'p_sigma*'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up'], + 'targets': ['p_l[triple_draggable][1]', 'p_l[triple_draggable][2]', + 'p_r[triple_draggable][2]', 'p_r[triple_draggable][3]'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up_and_down'], + 'targets': ['s_l[single_draggable][1]', 's_r[single_draggable][1]', + 's_sigma[single_draggable][1]', 's_sigma*[single_draggable][1]', + 'p_pi[double_draggable][1]', 'p_pi[double_draggable][2]'], + 'rule': 'unordered_equal' + }, + + ] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + def test_targets_true(self): - user_input = '{"draggables": [{"1": "t1"}, \ - {"name_with_icon": "t2"}]}' - correct_answer = {'1': 't1', 'name_with_icon': 't2'} + user_input = '[{"1": "t1"}, \ + {"name_with_icon": "t2"}]' + correct_answer = {'1': 't1', 'name_with_icon': 't2'} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_expect_no_actions_wrong(self): - user_input = '{"draggables": [{"1": "t1"}, \ - {"name_with_icon": "t2"}]}' + user_input = '[{"1": "t1"}, \ + {"name_with_icon": "t2"}]' correct_answer = [] self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_expect_no_actions_right(self): - user_input = '{"draggables": []}' + user_input = '[]' correct_answer = [] self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_targets_false(self): - user_input = '{"draggables": [{"1": "t1"}, \ - {"name_with_icon": "t2"}]}' - correct_answer = {'1': 't3', 'name_with_icon': 't2'} + user_input = '[{"1": "t1"}, \ + {"name_with_icon": "t2"}]' + correct_answer = {'1': 't3', 'name_with_icon': 't2'} self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_multiple_images_per_target_true(self): - user_input = '{\ - "draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \ - {"2": "t1"}]}' - correct_answer = {'1': 't1', 'name_with_icon': 't2', + user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \ + {"2": "t1"}]' + correct_answer = {'1': 't1', 'name_with_icon': 't2', '2': 't1'} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_multiple_images_per_target_false(self): - user_input = '{\ - "draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \ - {"2": "t1"}]}' - correct_answer = {'1': 't2', 'name_with_icon': 't2', + user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \ + {"2": "t1"}]' + correct_answer = {'1': 't2', 'name_with_icon': 't2', '2': 't1'} self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_targets_and_positions(self): - user_input = '{"draggables": [{"1": [10,10]}, \ - {"name_with_icon": [[10,10],4]}]}' + user_input = '[{"1": [10,10]}, \ + {"name_with_icon": [[10,10],4]}]' correct_answer = {'1': [10, 10], 'name_with_icon': [[10, 10], 4]} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_position_and_targets(self): - user_input = '{"draggables": [{"1": "t1"}, {"name_with_icon": "t2"}]}' + user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}]' correct_answer = {'1': 't1', 'name_with_icon': 't2'} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_positions_exact(self): - user_input = '{"draggables": \ - [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]' correct_answer = {'1': [10, 10], 'name_with_icon': [20, 20]} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_positions_false(self): - user_input = '{"draggables": \ - [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]' correct_answer = {'1': [25, 25], 'name_with_icon': [20, 20]} self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_positions_true_in_radius(self): - user_input = '{"draggables": \ - [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]' correct_answer = {'1': [14, 14], 'name_with_icon': [20, 20]} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_positions_true_in_manual_radius(self): - user_input = '{"draggables": \ - [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]' correct_answer = {'1': [[40, 10], 30], 'name_with_icon': [20, 20]} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_positions_false_in_manual_radius(self): - user_input = '{"draggables": \ - [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]' correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]} self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_correct_answer_not_has_key_from_user_answer(self): - user_input = '{"draggables": [{"1": "t1"}, \ - {"name_with_icon": "t2"}]}' + user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}]' correct_answer = {'3': 't3', 'name_with_icon': 't2'} self.assertFalse(draganddrop.grade(user_input, correct_answer)) @@ -131,20 +356,20 @@ class Test_DragAndDrop_Grade(unittest.TestCase): """Draggables can be places anywhere on base image. Place grass in the middle of the image and ant in the right upper corner.""" - user_input = '{"draggables": \ - [{"ant":[610.5,57.449951171875]},{"grass":[322.5,199.449951171875]}]}' + user_input = '[{"ant":[610.5,57.449951171875]},\ + {"grass":[322.5,199.449951171875]}]' correct_answer = {'grass': [[300, 200], 200], 'ant': [[500, 0], 200]} self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_lcao_correct(self): """Describe carbon molecule in LCAO-MO""" - user_input = '{"draggables":[{"1":"s_left"}, \ + user_input = '[{"1":"s_left"}, \ {"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \ {"8":"p_left_2"},{"10":"p_right_1"},{"9":"p_right_2"}, \ {"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \ {"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \ - {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}' + {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]' correct_answer = [{ 'draggables': ['1', '2', '3', '4', '5', '6'], @@ -178,12 +403,12 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_lcao_extra_element_incorrect(self): """Describe carbon molecule in LCAO-MO""" - user_input = '{"draggables":[{"1":"s_left"}, \ + user_input = '[{"1":"s_left"}, \ {"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \ {"8":"p_left_2"},{"17":"p_left_3"},{"10":"p_right_1"},{"9":"p_right_2"}, \ {"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \ {"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \ - {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}' + {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]' correct_answer = [{ 'draggables': ['1', '2', '3', '4', '5', '6'], @@ -217,9 +442,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_reuse_draggable_no_mupliples(self): """Test reusable draggables (no mupltiple draggables per target)""" - user_input = '{"draggables":[{"1":"target1"}, \ + user_input = '[{"1":"target1"}, \ {"2":"target2"},{"1":"target3"},{"2":"target4"},{"2":"target5"}, \ - {"3":"target6"}]}' + {"3":"target6"}]' correct_answer = [ { 'draggables': ['1'], @@ -240,9 +465,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_reuse_draggable_with_mupliples(self): """Test reusable draggables with mupltiple draggables per target""" - user_input = '{"draggables":[{"1":"target1"}, \ + user_input = '[{"1":"target1"}, \ {"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \ - {"3":"target6"}]}' + {"3":"target6"}]' correct_answer = [ { 'draggables': ['1'], @@ -263,10 +488,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_reuse_many_draggable_with_mupliples(self): """Test reusable draggables with mupltiple draggables per target""" - user_input = '{"draggables":[{"1":"target1"}, \ + user_input = '[{"1":"target1"}, \ {"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \ {"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \ - {"5": "target5"}, {"6": "target2"}]}' + {"5": "target5"}, {"6": "target2"}]' correct_answer = [ { 'draggables': ['1', '4'], @@ -292,12 +517,12 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_reuse_many_draggable_with_mupliples_wrong(self): """Test reusable draggables with mupltiple draggables per target""" - user_input = '{"draggables":[{"1":"target1"}, \ + user_input = '[{"1":"target1"}, \ {"2":"target2"},{"1":"target1"}, \ {"2":"target3"}, \ {"2":"target4"}, \ {"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \ - {"5": "target5"}, {"6": "target2"}]}' + {"5": "target5"}, {"6": "target2"}]' correct_answer = [ { 'draggables': ['1', '4'], @@ -323,10 +548,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_label_10_targets_with_a_b_c_false(self): """Test reusable draggables (no mupltiple draggables per target)""" - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \ {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ - {"a":"target1"}]}' + {"a":"target1"}]' correct_answer = [ { 'draggables': ['a'], @@ -347,10 +572,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_label_10_targets_with_a_b_c_(self): """Test reusable draggables (no mupltiple draggables per target)""" - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \ {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ - {"a":"target10"}]}' + {"a":"target10"}]' correct_answer = [ { 'draggables': ['a'], @@ -371,10 +596,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_label_10_targets_with_a_b_c_multiple(self): """Test reusable draggables (mupltiple draggables per target)""" - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"},{"b":"target5"}, \ {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ - {"a":"target1"}]}' + {"a":"target1"}]' correct_answer = [ { 'draggables': ['a', 'a', 'a'], @@ -395,10 +620,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_label_10_targets_with_a_b_c_multiple_false(self): """Test reusable draggables (mupltiple draggables per target)""" - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \ {"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \ - {"a":"target1"}]}' + {"a":"target1"}]' correct_answer = [ { 'draggables': ['a', 'a', 'a'], @@ -419,10 +644,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_label_10_targets_with_a_b_c_reused(self): """Test a b c in 10 labels reused""" - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"},{"b":"target5"}, \ {"c":"target6"}, {"b":"target8"},{"c":"target9"}, \ - {"a":"target10"}]}' + {"a":"target10"}]' correct_answer = [ { 'draggables': ['a', 'a'], @@ -443,10 +668,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_label_10_targets_with_a_b_c_reused_false(self): """Test a b c in 10 labels reused false""" - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"},{"b":"target5"}, {"a":"target8"},\ {"c":"target6"}, {"b":"target8"},{"c":"target9"}, \ - {"a":"target10"}]}' + {"a":"target10"}]' correct_answer = [ { 'draggables': ['a', 'a'], @@ -467,9 +692,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_mixed_reuse_and_not_reuse(self): """Test reusable draggables """ - user_input = '{"draggables":[{"a":"target1"}, \ + user_input = '[{"a":"target1"}, \ {"b":"target2"},{"c":"target3"}, {"a":"target4"},\ - {"a":"target5"}]}' + {"a":"target5"}]' correct_answer = [ { 'draggables': ['a', 'b'], @@ -485,8 +710,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_mixed_reuse_and_not_reuse_number(self): """Test reusable draggables with number """ - user_input = '{"draggables":[{"a":"target1"}, \ - {"b":"target2"},{"c":"target3"}, {"a":"target4"}]}' + user_input = '[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"}, {"a":"target4"}]' correct_answer = [ { 'draggables': ['a', 'a', 'b'], @@ -502,8 +727,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase): def test_mixed_reuse_and_not_reuse_number_false(self): """Test reusable draggables with numbers, but wrong""" - user_input = '{"draggables":[{"a":"target1"}, \ - {"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]}' + user_input = '[{"a":"target1"}, \ + {"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]' correct_answer = [ { 'draggables': ['a', 'a', 'b'], @@ -518,9 +743,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase): self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_alternative_correct_answer(self): - user_input = '{"draggables":[{"name_with_icon":"t1"},\ + user_input = '[{"name_with_icon":"t1"},\ {"name_with_icon":"t1"},{"name_with_icon":"t1"},{"name4":"t1"}, \ - {"name4":"t1"}]}' + {"name4":"t1"}]' correct_answer = [ {'draggables': ['name4'], 'targets': ['t1', 't1'], 'rule': 'exact'}, {'draggables': ['name_with_icon'], 'targets': ['t1', 't1', 't1'], @@ -533,14 +758,13 @@ class Test_DragAndDrop_Populate(unittest.TestCase): def test_1(self): correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]} - user_input = '{"draggables": \ - [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}' + user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]' dnd = draganddrop.DragAndDrop(correct_answer, user_input) - correct_groups = {'1': ['name_with_icon'], '0': ['1']} - correct_positions = {'1': {'exact': [[20, 20]]}, '0': {'exact': [[[40, 10], 29]]}} - user_groups = {'1': [u'name_with_icon'], '0': [u'1']} - user_positions = {'1': {'user': [[20, 20]]}, '0': {'user': [[10, 10]]}} + correct_groups = [['1'], ['name_with_icon']] + correct_positions = [{'exact': [[[40, 10], 29]]}, {'exact': [[20, 20]]}] + user_groups = [['1'], ['name_with_icon']] + user_positions = [{'user': [[10, 10]]}, {'user': [[20, 20]]}] self.assertEqual(correct_groups, dnd.correct_groups) self.assertEqual(correct_positions, dnd.correct_positions) @@ -551,49 +775,49 @@ class Test_DragAndDrop_Populate(unittest.TestCase): class Test_DraAndDrop_Compare_Positions(unittest.TestCase): def test_1(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]], user=[[2, 3], [1, 1]], flag='anyof')) def test_2a(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]], user=[[2, 3], [1, 1]], flag='exact')) def test_2b(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertFalse(dnd.compare_positions(correct=[[1, 1], [2, 3]], user=[[2, 13], [1, 1]], flag='exact')) def test_3(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertFalse(dnd.compare_positions(correct=["a", "b"], user=["a", "b", "c"], flag='anyof')) def test_4(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"], user=["a", "b"], flag='anyof')) def test_5(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertFalse(dnd.compare_positions(correct=["a", "b", "c"], user=["a", "c", "b"], flag='exact')) def test_6(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"], user=["a", "c", "b"], flag='anyof')) def test_7(self): - dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}') + dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]') self.assertFalse(dnd.compare_positions(correct=["a", "b", "b"], user=["a", "c", "b"], flag='anyof')) diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index 8dbe2c84aa..5cf2488af0 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -7,7 +7,7 @@ import logging import requests -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) dateformat = '%Y%m%d%H%M%S' diff --git a/common/lib/capa/setup.py b/common/lib/capa/setup.py index 9c724dec8b..d9c813f55c 100644 --- a/common/lib/capa/setup.py +++ b/common/lib/capa/setup.py @@ -4,5 +4,5 @@ setup( name="capa", version="0.1", packages=find_packages(exclude=["tests"]), - install_requires=['distribute==0.6.34', 'pyparsing==1.5.6'], + install_requires=['distribute==0.6.30', 'pyparsing==1.5.6'], ) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 835085d8ea..85d42690b9 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -28,6 +28,7 @@ setup( "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", "error = xmodule.error_module:ErrorDescriptor", "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", + "poll_question = xmodule.poll_module:PollDescriptor", "problem = xmodule.capa_module:CapaDescriptor", "problemset = xmodule.seq_module:SequenceDescriptor", "randomize = xmodule.randomize_module:RandomizeDescriptor", @@ -45,6 +46,7 @@ setup( "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor", + "wrapper = xmodule.wrapper_module:WrapperDescriptor", "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", "foldit = xmodule.foldit_module:FolditDescriptor", diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 537d864127..0e1c66df8e 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -1,4 +1,3 @@ -import json import random import logging from lxml import etree @@ -7,6 +6,7 @@ from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.xml_module import XmlDescriptor from xmodule.exceptions import InvalidDefinitionError +from xblock.core import String, Scope, Object, BlockScope DEFAULT = "_DEFAULT_GROUP" @@ -31,29 +31,42 @@ def group_from_value(groups, v): return g -class ABTestModule(XModule): +class ABTestFields(object): + group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content) + group_assignments = Object(help="What group this user belongs to", scope=Scope.student_preferences, default={}) + group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) + experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content) + has_children = True + + +class ABTestModule(ABTestFields, XModule): """ Implements an A/B test with an aribtrary number of competing groups """ - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - - if shared_state is None: + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) + if self.group is None: self.group = group_from_value( - self.definition['data']['group_portions'].items(), + self.group_portions.items(), random.uniform(0, 1) ) - else: - shared_state = json.loads(shared_state) - self.group = shared_state['group'] - def get_shared_state(self): - return json.dumps({'group': self.group}) + @property + def group(self): + return self.group_assignments.get(self.experiment) + + @group.setter + def group(self, value): + self.group_assignments[self.experiment] = value + + @group.deleter + def group(self): + del self.group_assignments[self.experiment] def get_child_descriptors(self): - active_locations = set(self.definition['data']['group_content'][self.group]) + active_locations = set(self.group_content[self.group]) return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations] def displayable_items(self): @@ -64,43 +77,11 @@ class ABTestModule(XModule): # TODO (cpennington): Use Groups should be a first class object, rather than being # managed by ABTests -class ABTestDescriptor(RawDescriptor, XmlDescriptor): +class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor): module_class = ABTestModule template_dir_name = "abtest" - def __init__(self, system, definition=None, **kwargs): - """ - definition is a dictionary with the following layout: - {'data': { - 'experiment': 'the name of the experiment', - 'group_portions': { - 'group_a': 0.1, - 'group_b': 0.2 - }, - 'group_contents': { - 'group_a': [ - 'url://for/content/module/1', - 'url://for/content/module/2', - ], - 'group_b': [ - 'url://for/content/module/3', - ], - DEFAULT: [ - 'url://for/default/content/1' - ] - } - }, - 'children': [ - 'url://for/content/module/1', - 'url://for/content/module/2', - 'url://for/content/module/3', - 'url://for/default/content/1', - ]} - """ - kwargs['shared_state_key'] = definition['data']['experiment'] - RawDescriptor.__init__(self, system, definition, **kwargs) - @classmethod def definition_from_xml(cls, xml_object, system): """ @@ -118,19 +99,16 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): "ABTests must specify an experiment. Not found in:\n{xml}" .format(xml=etree.tostring(xml_object, pretty_print=True))) - definition = { - 'data': { - 'experiment': experiment, - 'group_portions': {}, - 'group_content': {DEFAULT: []}, - }, - 'children': []} + group_portions = {} + group_content = {} + children = [] + for group in xml_object: if group.tag == 'default': name = DEFAULT else: name = group.get('name') - definition['data']['group_portions'][name] = float(group.get('portion', 0)) + group_portions[name] = float(group.get('portion', 0)) child_content_urls = [] for child in group: @@ -140,29 +118,33 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): log.exception("Unable to load child when parsing ABTest. Continuing...") continue - definition['data']['group_content'][name] = child_content_urls - definition['children'].extend(child_content_urls) + group_content[name] = child_content_urls + children.extend(child_content_urls) default_portion = 1 - sum( - portion for (name, portion) in definition['data']['group_portions'].items()) + portion for (name, portion) in group_portions.items() + ) if default_portion < 0: raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1") - definition['data']['group_portions'][DEFAULT] = default_portion - definition['children'].sort() + group_portions[DEFAULT] = default_portion + children.sort() - return definition + return { + 'group_portions': group_portions, + 'group_content': group_content, + }, children def definition_to_xml(self, resource_fs): xml_object = etree.Element('abtest') - xml_object.set('experiment', self.definition['data']['experiment']) - for name, group in self.definition['data']['group_content'].items(): + xml_object.set('experiment', self.experiment) + for name, group in self.group_content.items(): if name == DEFAULT: group_elem = etree.SubElement(xml_object, 'default') else: group_elem = etree.SubElement(xml_object, 'group', attrib={ - 'portion': str(self.definition['data']['group_portions'][name]), + 'portion': str(self.group_portions[name]), 'name': name, }) @@ -172,6 +154,5 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): return xml_object - def has_dynamic_children(self): return True diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index f093b76f52..db2aa13cb7 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -5,13 +5,17 @@ from pkg_resources import resource_string, resource_listdir from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor -from xmodule.modulestore.mongo import MongoModuleStore -from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent +from xblock.core import Scope, String log = logging.getLogger(__name__) -class AnnotatableModule(XModule): + +class AnnotatableFields(object): + data = String(help="XML data for the annotation", scope=Scope.content) + + +class AnnotatableModule(AnnotatableFields, XModule): js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/html/display.coffee'), @@ -22,6 +26,17 @@ class AnnotatableModule(XModule): css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]} icon_class = 'annotatable' + + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) + + xmltree = etree.fromstring(self.data) + + self.instructions = self._extract_instructions(xmltree) + self.content = etree.tostring(xmltree, encoding='unicode') + self.element_id = self.location.html_id() + self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] + def _get_annotation_class_attr(self, index, el): """ Returns a dict with the CSS class attribute to set on the annotation and an XML key to delete from the element. @@ -103,7 +118,7 @@ class AnnotatableModule(XModule): def get_html(self): """ Renders parameters to template. """ context = { - 'display_name': self.display_name, + 'display_name': self.display_name_with_default, 'element_id': self.element_id, 'instructions_html': self.instructions, 'content_html': self._render_content() @@ -111,19 +126,8 @@ class AnnotatableModule(XModule): return self.system.render_template('annotatable.html', context) - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - xmltree = etree.fromstring(self.definition['data']) - - self.instructions = self._extract_instructions(xmltree) - self.content = etree.tostring(xmltree, encoding='unicode') - self.element_id = self.location.html_id() - self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] - -class AnnotatableDescriptor(RawDescriptor): +class AnnotatableDescriptor(AnnotatableFields, RawDescriptor): module_class = AnnotatableModule stores_state = True template_dir_name = "annotatable" diff --git a/common/lib/xmodule/xmodule/backcompat_module.py b/common/lib/xmodule/xmodule/backcompat_module.py index 40ffd46d1c..9e7b132e9e 100644 --- a/common/lib/xmodule/xmodule/backcompat_module.py +++ b/common/lib/xmodule/xmodule/backcompat_module.py @@ -1,7 +1,7 @@ """ These modules exist to translate old format XML into newer, semantic forms """ -from x_module import XModuleDescriptor +from .x_module import XModuleDescriptor from lxml import etree from functools import wraps import logging diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 2597690572..b437478ecc 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -6,25 +6,45 @@ import hashlib import json import logging import traceback -import re import sys -from datetime import timedelta from lxml import etree from pkg_resources import resource_string from capa.capa_problem import LoncapaProblem from capa.responsetypes import StudentInputError from capa.util import convert_files_to_filenames -from progress import Progress +from .progress import Progress from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.exceptions import NotFoundError +from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float +from .fields import Timedelta log = logging.getLogger("mitx.courseware") -#----------------------------------------------------------------------------- -TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') + +class StringyInteger(Integer): + """ + A model type that converts from strings to integers when reading from json + """ + def from_json(self, value): + try: + return int(value) + except: + return None + + +class StringyFloat(Float): + """ + A model type that converts from string to floats when reading from json + """ + def from_json(self, value): + try: + return float(value) + except: + return None + # Generated this many different variants of problems with rerandomize=per_student NUM_RANDOMIZATION_BINS = 20 @@ -45,41 +65,15 @@ def randomization_bin(seed, problem_id): return int(h.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS -def only_one(lst, default="", process=lambda x: x): - """ - If lst is empty, returns default +class Randomization(String): + def from_json(self, value): + if value in ("", "true"): + return "always" + elif value == "false": + return "per_student" + return value - If lst has a single element, applies process to that element and returns it. - - Otherwise, raises an exception. - """ - if len(lst) == 0: - return default - elif len(lst) == 1: - return process(lst[0]) - else: - raise Exception('Malformed XML: expected at most one element in list.') - - -def parse_timedelta(time_str): - """ - time_str: A string with the following components: - day[s] (optional) - hour[s] (optional) - minute[s] (optional) - second[s] (optional) - - Returns a datetime.timedelta parsed from the string - """ - parts = TIMEDELTA_REGEX.match(time_str) - if not parts: - return - parts = parts.groupdict() - time_params = {} - for (name, param) in parts.iteritems(): - if param: - time_params[name] = int(param) - return timedelta(**time_params) + to_json = from_json class ComplexEncoder(json.JSONEncoder): @@ -89,13 +83,33 @@ class ComplexEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, obj) -class CapaModule(XModule): +class CapaFields(object): + attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) + max_attempts = StringyInteger(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) + due = String(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") + 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) + 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.student_state, default={}) + input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.student_state) + student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state) + done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state) + display_name = String(help="Display name for this module", scope=Scope.settings) + seed = StringyInteger(help="Random seed for this student", scope=Scope.student_state) + weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings) + markdown = String(help="Markdown source of this module", scope=Scope.settings) + + +class CapaModule(CapaFields, XModule): ''' An XModule implementing LonCapa format problems, implemented by way of capa.capa_problem.LoncapaProblem ''' icon_class = 'problem' + js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/javascript_loader.coffee'), @@ -107,61 +121,25 @@ class CapaModule(XModule): js_module_name = "Problem" css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} - def __init__(self, system, location, definition, descriptor, instance_state=None, - shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, - shared_state, **kwargs) + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) - self.attempts = 0 - self.max_attempts = None - - dom2 = etree.fromstring(definition['data']) - - display_due_date_string = self.metadata.get('due', None) - if display_due_date_string is not None: - self.display_due_date = dateutil.parser.parse(display_due_date_string) - #log.debug("Parsed " + display_due_date_string + - # " to " + str(self.display_due_date)) + if self.due: + due_date = dateutil.parser.parse(self.due) else: - self.display_due_date = None + due_date = None - grace_period_string = self.metadata.get('graceperiod', None) - if grace_period_string is not None and self.display_due_date: - self.grace_period = parse_timedelta(grace_period_string) - self.close_date = self.display_due_date + self.grace_period - #log.debug("Then parsed " + grace_period_string + - # " to closing date" + str(self.close_date)) + if self.graceperiod is not None and due_date: + self.close_date = due_date + self.graceperiod else: - self.grace_period = None - self.close_date = self.display_due_date + self.close_date = due_date - max_attempts = self.metadata.get('attempts') - if max_attempts is not None and max_attempts != '': - self.max_attempts = int(max_attempts) - else: - self.max_attempts = None - - self.show_answer = self.metadata.get('showanswer', 'closed') - - self.force_save_button = self.metadata.get('force_save_button', 'false') - - if self.show_answer == "": - self.show_answer = "closed" - - if instance_state is not None: - instance_state = json.loads(instance_state) - if instance_state is not None and 'attempts' in instance_state: - self.attempts = instance_state['attempts'] - - self.name = only_one(dom2.xpath('/problem/@name')) - - if self.rerandomize == 'never': - self.seed = 1 - elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): - # see comment on randomization_bin - self.seed = randomization_bin(system.seed, self.location.url) - else: - self.seed = None + if self.seed is None: + if self.rerandomize == 'never': + self.seed = 1 + elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): + # see comment on randomization_bin + self.seed = randomization_bin(system.seed, self.location.url) # Need the problem location in openendedresponse to send out. Adding # it to the system here seems like the least clunky way to get it @@ -171,8 +149,7 @@ class CapaModule(XModule): try: # TODO (vshnayder): move as much as possible of this work and error # checking to descriptor load time - self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - instance_state, seed=self.seed, system=self.system) + self.lcp = self.new_lcp(self.get_state_for_lcp()) except Exception as err: msg = 'cannot create LoncapaProblem {loc}: {err}'.format( loc=self.location.url(), err=err) @@ -189,35 +166,40 @@ class CapaModule(XModule): problem_text = ('' 'Problem %s has an error:%s' % (self.location.url(), msg)) - self.lcp = LoncapaProblem( - problem_text, self.location.html_id(), - instance_state, seed=self.seed, system=self.system) + self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text) else: # add extra info and raise raise Exception(msg), None, sys.exc_info()[2] - @property - def rerandomize(self): - """ - Property accessor that returns self.metadata['rerandomize'] in a - canonical form - """ - rerandomize = self.metadata.get('rerandomize', 'always') - if rerandomize in ("", "always", "true"): - return "always" - elif rerandomize in ("false", "per_student"): - return "per_student" - elif rerandomize == "never": - return "never" - elif rerandomize == "onreset": - return "onreset" - else: - raise Exception("Invalid rerandomize attribute " + rerandomize) + self.set_state_from_lcp() - def get_instance_state(self): - state = self.lcp.get_state() - state['attempts'] = self.attempts - return json.dumps(state) + def new_lcp(self, state, text=None): + if text is None: + text = self.data + + return LoncapaProblem( + problem_text=text, + id=self.location.html_id(), + state=state, + system=self.system, + ) + + def get_state_for_lcp(self): + return { + 'done': self.done, + 'correct_map': self.correct_map, + 'student_answers': self.student_answers, + 'input_state': self.input_state, + 'seed': self.seed, + } + + def set_state_from_lcp(self): + lcp_state = self.lcp.get_state() + self.done = lcp_state['done'] + self.correct_map = lcp_state['correct_map'] + self.input_state = lcp_state['input_state'] + self.student_answers = lcp_state['student_answers'] + self.seed = lcp_state['seed'] def get_score(self): return self.lcp.get_score() @@ -234,7 +216,7 @@ class CapaModule(XModule): if total > 0: try: return Progress(score, total) - except Exception as err: + except Exception: log.exception("Got bad progress") return None return None @@ -291,7 +273,6 @@ class CapaModule(XModule): return False else: return True - # Only randomized problems need a "reset" button else: return False @@ -310,11 +291,26 @@ class CapaModule(XModule): is_survey_question = (self.max_attempts == 0) needs_reset = self.is_completed() and self.rerandomize == "always" + # If the student has unlimited attempts, and their answers + # are not randomized, then we do not need a save button + # because they can use the "Check" button without consequences. + # + # The consequences we want to avoid are: + # * Using up an attempt (if max_attempts is set) + # * Changing the current problem, and no longer being + # able to view it (if rerandomize is "always") + # + # In those cases. the if statement below is false, + # and the save button can still be displayed. + # + if self.max_attempts is None and self.rerandomize != "always": + return False + # If the problem is closed (and not a survey question with max_attempts==0), - # then do NOT show the reset button + # then do NOT show the save button # If we're waiting for the user to reset a randomized problem - # then do NOT show the reset button - if (self.closed() and not is_survey_question) or needs_reset: + # then do NOT show the save button + elif (self.closed() and not is_survey_question) or needs_reset: return False else: return True @@ -343,6 +339,8 @@ class CapaModule(XModule): # We're in non-debug mode, and possibly even in production. We want # to avoid bricking of problem as much as possible else: + # We're in non-debug mode, and possibly even in production. We want + # to avoid bricking of problem as much as possible # Presumably, student submission has corrupted LoncapaProblem HTML. # First, pull down all student answers @@ -359,9 +357,8 @@ class CapaModule(XModule): student_answers.pop(answer_id) # Next, generate a fresh LoncapaProblem - self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - state=None, # Tabula rasa - seed=self.seed, system=self.system) + self.lcp = self.new_lcp(None) + self.set_state_from_lcp() # Prepend a scary warning to the student warning = '

          '\ @@ -379,8 +376,8 @@ class CapaModule(XModule): html = warning try: html += self.lcp.get_html() - except Exception, err: # Couldn't do it. Give up - log.exception(err) + except Exception: # Couldn't do it. Give up + log.exception("Unable to generate html from LoncapaProblem") raise return html @@ -403,16 +400,15 @@ class CapaModule(XModule): # if we want to show a check button, and False otherwise # This works because non-empty strings evaluate to True if self.should_show_check_button(): - check_button = self.check_button_name() + check_button = self.check_button_name() else: check_button = False - content = {'name': self.display_name, + content = {'name': self.display_name_with_default, 'html': html, - 'weight': self.descriptor.weight, + 'weight': self.weight, } - context = {'problem': content, 'id': self.id, 'check_button': check_button, @@ -450,7 +446,8 @@ class CapaModule(XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, 'score_update': self.update_score, - 'input_ajax': self.lcp.handle_input_ajax + 'input_ajax': self.handle_input_ajax, + 'ungraded_response': self.handle_ungraded_response } if dispatch not in handlers: @@ -499,28 +496,28 @@ class CapaModule(XModule): ''' Is the user allowed to see an answer? ''' - if self.show_answer == '': + if self.showanswer == '': return False - elif self.show_answer == "never": + elif self.showanswer == "never": return False elif self.system.user_is_staff: # This is after the 'never' check because admins can see the answer # unless the problem explicitly prevents it return True - elif self.show_answer == 'attempted': + elif self.showanswer == 'attempted': return self.attempts > 0 - elif self.show_answer == 'answered': + elif self.showanswer == 'answered': # NOTE: this is slightly different from 'attempted' -- resetting the problems # makes lcp.done False, but leaves attempts unchanged. return self.lcp.done - elif self.show_answer == 'closed': + elif self.showanswer == 'closed': return self.closed() - elif self.show_answer == 'finished': + elif self.showanswer == 'finished': return self.closed() or self.is_correct() - elif self.show_answer == 'past_due': + elif self.showanswer == 'past_due': return self.is_past_due() - elif self.show_answer == 'always': + elif self.showanswer == 'always': return True return False @@ -539,9 +536,48 @@ class CapaModule(XModule): queuekey = get['queuekey'] score_msg = get['xqueue_body'] self.lcp.update_score(score_msg, queuekey) + self.set_state_from_lcp() + self.publish_grade() return dict() # No AJAX return is needed + def handle_ungraded_response(self, get): + ''' + Delivers a response from the XQueue to the capa problem + + The score of the problem will not be updated + + Args: + - get (dict) must contain keys: + queuekey - a key specific to this response + xqueue_body - the body of the response + Returns: + empty dictionary + + No ajax return is needed, so an empty dict is returned + ''' + queuekey = get['queuekey'] + score_msg = get['xqueue_body'] + # pass along the xqueue message to the problem + self.lcp.ungraded_response(score_msg, queuekey) + self.set_state_from_lcp() + return dict() + + def handle_input_ajax(self, get): + ''' + Handle ajax calls meant for a particular input in the problem + + Args: + - get (dict) - data that should be passed to the input + Returns: + - dict containing the response from the input + ''' + response = self.lcp.handle_input_ajax(get) + # save any state changes that may occur + self.set_state_from_lcp() + return response + + def get_answer(self, get): ''' For the "show answer" button. @@ -550,13 +586,14 @@ class CapaModule(XModule): ''' event_info = dict() event_info['problem_id'] = self.location.url() - self.system.track_function('show_answer', event_info) + self.system.track_function('showanswer', event_info) if not self.answer_available(): raise NotFoundError('Answer is not available') else: answers = self.lcp.get_question_answers() + self.set_state_from_lcp() - # answers (eg ) may have embedded images + # answers (eg ) may have embedded images # but be careful, some problems are using non-string answer dicts new_answers = dict() for answer_id in answers: @@ -606,7 +643,7 @@ class CapaModule(XModule): to 'input_1' in the returned dict) ''' answers = dict() - + for key in get: # e.g. input_resistor_1 ==> resistor_1 _, _, name = key.partition('_') @@ -639,6 +676,18 @@ class CapaModule(XModule): return answers + def publish_grade(self): + """ + Publishes the student's current grade to the system as an event + """ + score = self.lcp.get_score() + self.system.publish({ + 'event_name': 'grade', + 'value': score['score'], + 'max_value': score['total'], + }) + + def check_problem(self, get): ''' Checks whether answers to a problem are correct, and returns a map of correct/incorrect answers: @@ -652,7 +701,6 @@ class CapaModule(XModule): answers = self.make_dict_of_responses(get) event_info['answers'] = convert_files_to_filenames(answers) - # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' @@ -660,7 +708,7 @@ class CapaModule(XModule): raise NotFoundError('Problem is closed') # Problem submitted. Student should reset before checking again - if self.lcp.done and self.rerandomize == "always": + if self.done and self.rerandomize == "always": event_info['failure'] = 'unreset' self.system.track_function('save_problem_check_fail', event_info) raise NotFoundError('Problem must be reset before it can be checked again') @@ -672,12 +720,11 @@ class CapaModule(XModule): waittime_between_requests = self.system.xqueue['waittime'] if (current_time - prev_submit_time).total_seconds() < waittime_between_requests: msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests - return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback try: - old_state = self.lcp.get_state() - lcp_id = self.lcp.problem_id correct_map = self.lcp.grade_answers(answers) + self.set_state_from_lcp() except StudentInputError as inst: log.exception("StudentInputError in capa_module:problem_check") return {'success': inst.message} @@ -686,12 +733,14 @@ class CapaModule(XModule): msg = "Error checking problem: " + str(err) msg += '\nTraceback:\n' + traceback.format_exc() return {'success': msg} - log.exception("Error in capa_module problem checking") - raise Exception("error in capa_module") + raise self.attempts = self.attempts + 1 self.lcp.done = True + self.set_state_from_lcp() + self.publish_grade() + # success = correct if ALL questions in this problem are correct success = 'correct' for answer_id in correct_map: @@ -705,7 +754,7 @@ class CapaModule(XModule): event_info['attempts'] = self.attempts self.system.track_function('save_problem_check', event_info) - if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback + if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback self.system.psychometrics_handler(self.get_instance_state()) # render problem into HTML @@ -729,7 +778,7 @@ class CapaModule(XModule): event_info['answers'] = answers # Too late. Cannot submit - if self.closed() and not self.max_attempts==0: + if self.closed() and not self.max_attempts ==0: event_info['failure'] = 'closed' self.system.track_function('save_problem_fail', event_info) return {'success': False, @@ -737,7 +786,7 @@ class CapaModule(XModule): # Problem submitted. Student should reset before saving # again. - if self.lcp.done and self.rerandomize == "always": + if self.done and self.rerandomize == "always": event_info['failure'] = 'done' self.system.track_function('save_problem_fail', event_info) return {'success': False, @@ -745,9 +794,11 @@ class CapaModule(XModule): self.lcp.student_answers = answers + self.set_state_from_lcp() + self.system.track_function('save_problem_success', event_info) msg = "Your answers have been saved" - if not self.max_attempts==0: + if not self.max_attempts ==0: msg += " but not graded. Hit 'Check' to grade them." return {'success': True, 'msg': msg} @@ -773,31 +824,33 @@ class CapaModule(XModule): return {'success': False, 'error': "Problem is closed"} - if not self.lcp.done: + if not self.done: event_info['failure'] = 'not_done' self.system.track_function('reset_problem_fail', event_info) return {'success': False, 'error': "Refresh the page and make an attempt before resetting."} - self.lcp.do_reset() if self.rerandomize in ["always", "onreset"]: # reset random number generator seed (note the self.lcp.get_state() # in next line) - self.lcp.seed = None - + seed = None + else: + seed = self.lcp.seed - self.lcp = LoncapaProblem(self.definition['data'], - self.location.html_id(), self.lcp.get_state(), - system=self.system) + # Generate a new problem with either the previous seed or a new seed + self.lcp = self.new_lcp({'seed': seed}) + + # Pull in the new problem seed + self.set_state_from_lcp() event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) - return { 'success': True, + return {'success': True, 'html': self.get_problem_html(encapsulate=False)} -class CapaDescriptor(RawDescriptor): +class CapaDescriptor(CapaFields, RawDescriptor): """ Module implementing problems in the LON-CAPA format, as implemented by capa.capa_problem @@ -818,20 +871,27 @@ class CapaDescriptor(RawDescriptor): # actually use type and points? metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points') + # The capa format specifies that what we call max_attempts in the code + # is the attribute `attempts`. This will do that conversion + metadata_translations = dict(RawDescriptor.metadata_translations) + metadata_translations['attempts'] = 'max_attempts' + def get_context(self): _context = RawDescriptor.get_context(self) - _context.update({'markdown': self.metadata.get('markdown', ''), - 'enable_markdown' : 'markdown' in self.metadata}) + _context.update({'markdown': self.markdown, + 'enable_markdown': self.markdown is not None}) return _context @property def editable_metadata_fields(self): - """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user.""" - subset = [field for field in super(CapaDescriptor,self).editable_metadata_fields - if field not in ['markdown', 'empty']] + """Remove metadata from the editable fields since it has its own editor""" + subset = super(CapaDescriptor, self).editable_metadata_fields + if 'markdown' in subset: + del subset['markdown'] + if 'empty' in subset: + del subset['empty'] return subset - # VS[compat] # TODO (cpennington): Delete this method once all fall 2012 course are being # edited in the cms @@ -841,12 +901,3 @@ class CapaDescriptor(RawDescriptor): 'problems/' + path[8:], path[8:], ] - - def __init__(self, *args, **kwargs): - super(CapaDescriptor, self).__init__(*args, **kwargs) - - weight_string = self.metadata.get('weight', None) - if weight_string: - self.weight = float(weight_string) - else: - self.weight = None diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 0cc69a4a24..48fbfcced1 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -6,19 +6,72 @@ from pkg_resources import resource_string from xmodule.raw_module import RawDescriptor from .x_module import XModule +from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float, List from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor +from collections import namedtuple log = logging.getLogger("mitx.courseware") -VERSION_TUPLES = ( - ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module), -) +V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload", + "skip_spelling_checks", "due", "graceperiod", "max_score"] + +V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", + "student_attempts", "ready_to_reset"] + +V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES + +VersionTuple = namedtuple('VersionTuple', ['descriptor', 'module', 'settings_attributes', 'student_attributes']) +VERSION_TUPLES = { + 1: VersionTuple(CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES, + V1_STUDENT_ATTRIBUTES), +} DEFAULT_VERSION = 1 -DEFAULT_VERSION = str(DEFAULT_VERSION) -class CombinedOpenEndedModule(XModule): +class VersionInteger(Integer): + """ + A model type that converts from strings to integers when reading from json. + Also does error checking to see if version is correct or not. + """ + + def from_json(self, value): + try: + value = int(value) + if value not in VERSION_TUPLES: + version_error_string = "Could not find version {0}, using version {1} instead" + log.error(version_error_string.format(value, DEFAULT_VERSION)) + value = DEFAULT_VERSION + except: + value = DEFAULT_VERSION + return value + + +class CombinedOpenEndedFields(object): + display_name = String(help="Display name for this module", default="Open Ended Grading", scope=Scope.settings) + current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.student_state) + task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.student_state) + state = String(help="Which step within the current task that the student is on.", default="initial", + scope=Scope.student_state) + student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, + scope=Scope.student_state) + ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False, + scope=Scope.student_state) + attempts = Integer(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings) + is_graded = Boolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings) + accept_file_upload = Boolean(help="Whether or not the problem accepts file uploads.", default=False, + scope=Scope.settings) + skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True, + scope=Scope.settings) + due = String(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) + max_score = Integer(help="Maximum score for the problem.", default=1, 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) + + +class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): """ This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). It transitions between problems, and support arbitrary ordering. @@ -49,6 +102,8 @@ class CombinedOpenEndedModule(XModule): INTERMEDIATE_DONE = 'intermediate_done' DONE = 'done' + icon_class = 'problem' + js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/javascript_loader.coffee'), @@ -57,11 +112,8 @@ class CombinedOpenEndedModule(XModule): css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) """ Definition file should have one or many task blocks, a rubric block, and a prompt block: @@ -100,50 +152,37 @@ class CombinedOpenEndedModule(XModule): self.system = system self.system.set('location', location) - # Load instance state - if instance_state is not None: - instance_state = json.loads(instance_state) - else: - instance_state = {} + if self.task_states is None: + self.task_states = [] - self.version = self.metadata.get('version', DEFAULT_VERSION) - version_error_string = "Version of combined open ended module {0} is not correct. Going with version {1}" - if not isinstance(self.version, basestring): - try: - self.version = str(self.version) - except: - #This is a dev_facing_error - log.info(version_error_string.format(self.version, DEFAULT_VERSION)) - self.version = DEFAULT_VERSION + version_tuple = VERSION_TUPLES[self.version] - versions = [i[0] for i in VERSION_TUPLES] - descriptors = [i[1] for i in VERSION_TUPLES] - modules = [i[2] for i in VERSION_TUPLES] + self.student_attributes = version_tuple.student_attributes + self.settings_attributes = version_tuple.settings_attributes - try: - version_index = versions.index(self.version) - except: - #This is a dev_facing_error - log.error(version_error_string.format(self.version, DEFAULT_VERSION)) - self.version = DEFAULT_VERSION - version_index = versions.index(self.version) + attributes = self.student_attributes + self.settings_attributes static_data = { 'rewrite_content_links': self.rewrite_content_links, } - - self.child_descriptor = descriptors[version_index](self.system) - self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']), - self.system) - self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor, - instance_state=json.dumps(instance_state), metadata=self.metadata, - static_data=static_data) + instance_state = {k: getattr(self, k) for k in attributes} + self.child_descriptor = version_tuple.descriptor(self.system) + self.child_definition = version_tuple.descriptor.definition_from_xml(etree.fromstring(self.data), self.system) + self.child_module = version_tuple.module(self.system, location, self.child_definition, self.child_descriptor, + instance_state=instance_state, static_data=static_data, + attributes=attributes) + self.save_instance_data() def get_html(self): - return self.child_module.get_html() + self.save_instance_data() + return_value = self.child_module.get_html() + return return_value def handle_ajax(self, dispatch, get): - return self.child_module.handle_ajax(dispatch, get) + self.save_instance_data() + return_value = self.child_module.handle_ajax(dispatch, get) + self.save_instance_data() + return return_value def get_instance_state(self): return self.child_module.get_instance_state() @@ -151,8 +190,8 @@ class CombinedOpenEndedModule(XModule): def get_score(self): return self.child_module.get_score() - def max_score(self): - return self.child_module.max_score() + #def max_score(self): + # return self.child_module.max_score() def get_progress(self): return self.child_module.get_progress() @@ -161,12 +200,14 @@ class CombinedOpenEndedModule(XModule): def due_date(self): return self.child_module.due_date - @property - def display_name(self): - return self.child_module.display_name + def save_instance_data(self): + for attribute in self.student_attributes: + child_attr = getattr(self.child_module, attribute) + if child_attr != getattr(self, attribute): + setattr(self, attribute, getattr(self.child_module, attribute)) -class CombinedOpenEndedDescriptor(RawDescriptor): +class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor): """ Module for adding combined open ended questions """ diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index 787d355c4a..b3e0e0e06b 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -1,126 +1,160 @@ +"""Conditional module is the xmodule, which you can use for disabling +some xmodules by conditions. +""" + import json import logging +from lxml import etree +from pkg_resources import resource_string from xmodule.x_module import XModule from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor +from xblock.core import String, Scope, List +from xmodule.modulestore.exceptions import ItemNotFoundError -from pkg_resources import resource_string log = logging.getLogger('mitx.' + __name__) -class ConditionalModule(XModule): - ''' +class ConditionalFields(object): + show_tag_list = List(help="Poll answers", scope=Scope.content) + + +class ConditionalModule(ConditionalFields, XModule): + """ Blocks child module from showing unless certain conditions are met. Example: - + + - - + tag attributes: + sources - location id of required modules, separated by ';' - ''' + completed - map to `is_completed` module method + attempted - map to `is_attempted` module method + poll_answer - map to `poll_answer` module attribute + voted - map to `voted` module attribute - js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'), + tag attributes: + sources - location id of required modules, separated by ';' + + You can add you own rules for tag, like + "completed", "attempted" etc. To do that yo must extend + `ConditionalModule.conditions_map` variable and add pair: + my_attr: my_property/my_method + + After that you can use it: + + ... + + + And my_property/my_method will be called for required modules. + + """ + + js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), + resource_string(__name__, 'js/src/conditional/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), - resource_string(__name__, 'js/src/javascript_loader.coffee'), + ]} js_module_name = "Conditional" css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} + # Map + # key: + # value: + conditions_map = { + 'poll_answer': 'poll_answer', # poll_question attr + 'completed': 'is_completed', # capa_problem attr + 'attempted': 'is_attempted', # capa_problem attr + 'voted': 'voted' # poll_question attr + } - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - """ - In addition to the normal XModule init, provide: - - self.condition = string describing condition required - - """ - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - self.contents = None - self.condition = self.metadata.get('condition', '') - self._get_required_modules() - children = self.get_display_items() - if children: - self.icon_class = children[0].get_icon_class() - #log.debug('conditional module required=%s' % self.required_modules_list) - - def _get_required_modules(self): - self.required_modules = [] - for descriptor in self.descriptor.get_required_module_descriptors(): - module = self.system.get_module(descriptor) - self.required_modules.append(module) - #log.debug('required_modules=%s' % (self.required_modules)) + def _get_condition(self): + # Get first valid condition. + for xml_attr, attr_name in self.conditions_map.iteritems(): + xml_value = self.descriptor.xml_attributes.get(xml_attr) + if xml_value: + return xml_value, attr_name + raise Exception('Error in conditional module: unknown condition "%s"' + % xml_attr) def is_condition_satisfied(self): - self._get_required_modules() + self.required_modules = [self.system.get_module(descriptor) for + descriptor in self.descriptor.get_required_module_descriptors()] - if self.condition == 'require_completed': - # all required modules must be completed, as determined by - # the modules .is_completed() method - for module in self.required_modules: - #log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers) - #log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state) - if not hasattr(module, 'is_completed'): - raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module) - if not module.is_completed(): - log.debug('conditional module: %s not completed' % module) - return False - else: - log.debug('conditional module: %s IS completed' % module) - return True - elif self.condition == 'require_attempted': - # all required modules must be attempted, as determined by - # the modules .is_attempted() method - for module in self.required_modules: - if not hasattr(module, 'is_attempted'): - raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module) - if not module.is_attempted(): - log.debug('conditional module: %s not attempted' % module) - return False - else: - log.debug('conditional module: %s IS attempted' % module) - return True - else: - raise Exception('Error in conditional module: unknown condition "%s"' % self.condition) + xml_value, attr_name = self._get_condition() - return True + if xml_value and self.required_modules: + for module in self.required_modules: + if not hasattr(module, attr_name): + raise Exception('Error in conditional module: \ + required module {module} has no {module_attr}'.format( + module=module, module_attr=attr_name)) + + attr = getattr(module, attr_name) + if callable(attr): + attr = attr() + + if xml_value != str(attr): + break + else: + return True + return False def get_html(self): - self.is_condition_satisfied() + # Calculate html ids of dependencies + self.required_html_ids = [descriptor.location.html_id() for + descriptor in self.descriptor.get_required_module_descriptors()] + return self.system.render_template('conditional_ajax.html', { 'element_id': self.location.html_id(), 'id': self.id, 'ajax_url': self.system.ajax_url, + 'depends': ';'.join(self.required_html_ids) }) def handle_ajax(self, dispatch, post): - ''' - This is called by courseware.module_render, to handle an AJAX call. - ''' - #log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch) - + """This is called by courseware.moduleodule_render, to handle + an AJAX call. + """ if not self.is_condition_satisfied(): - context = {'module': self} - html = self.system.render_template('conditional_module.html', context) - return json.dumps({'html': html}) + message = self.descriptor.xml_attributes.get('message') + context = {'module': self, + 'message': message} + html = self.system.render_template('conditional_module.html', + context) + return json.dumps({'html': [html], 'message': bool(message)}) - if self.contents is None: - self.contents = [child.get_html() for child in self.get_display_items()] - - # for now, just deal with one child - html = self.contents[0] + html = [child.get_html() for child in self.get_display_items()] return json.dumps({'html': html}) + def get_icon_class(self): + new_class = 'other' + if self.is_condition_satisfied(): + # HACK: This shouldn't be hard-coded to two types + # OBSOLETE: This obsoletes 'type' + class_priority = ['video', 'problem'] + + child_classes = [self.system.get_module(child_descriptor).get_icon_class() + for child_descriptor in self.descriptor.get_children()] + for c in class_priority: + if c in child_classes: + new_class = c + return new_class + + +class ConditionalDescriptor(ConditionalFields, SequenceDescriptor): + """Descriptor for conditional xmodule.""" + _tag_name = 'conditional' -class ConditionalDescriptor(SequenceDescriptor): module_class = ConditionalModule filename_extension = "xml" @@ -128,26 +162,68 @@ class ConditionalDescriptor(SequenceDescriptor): stores_state = True has_score = False - def __init__(self, *args, **kwargs): - super(ConditionalDescriptor, self).__init__(*args, **kwargs) - required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')] - self.required_module_locations = [] - for rm in required_module_list: - try: - (tag, name) = rm - except Exception as err: - msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required') - log.warning(msg) - self.system.error_tracker(msg) - continue - loc = self.location.dict() - loc['category'] = tag - loc['name'] = name - self.required_module_locations.append(Location(loc)) - log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations) + @staticmethod + def parse_sources(xml_element, system, return_descriptor=False): + """Parse xml_element 'sources' attr and: + if return_descriptor=True - return list of descriptors + if return_descriptor=False - return list of locations + """ + result = [] + sources = xml_element.get('sources') + if sources: + locations = [location.strip() for location in sources.split(';')] + for location in locations: + if Location.is_valid(location): # Check valid location url. + try: + if return_descriptor: + descriptor = system.load_item(location) + result.append(descriptor) + else: + result.append(location) + except ItemNotFoundError: + msg = "Invalid module by location." + log.exception(msg) + system.error_tracker(msg) + return result def get_required_module_descriptors(self): - """Returns a list of XModuleDescritpor instances upon which this module depends, but are - not children of this module""" - return [self.system.load_item(loc) for loc in self.required_module_locations] + """Returns a list of XModuleDescritpor instances upon + which this module depends. + """ + return ConditionalDescriptor.parse_sources( + self.xml_attributes, self.system, True) + + @classmethod + def definition_from_xml(cls, xml_object, system): + children = [] + show_tag_list = [] + for child in xml_object: + if child.tag == 'show': + location = ConditionalDescriptor.parse_sources( + child, system) + children.extend(location) + show_tag_list.extend(location) + else: + try: + descriptor = system.process_xml(etree.tostring(child)) + module_url = descriptor.location.url() + children.append(module_url) + except: + msg = "Unable to load child when parsing Conditional." + log.exception(msg) + system.error_tracker(msg) + return {'show_tag_list': show_tag_list}, children + + def definition_to_xml(self, resource_fs): + xml_object = etree.Element(self._tag_name) + for child in self.get_children(): + location = str(child.location) + if location in self.show_tag_list: + show_str = '<{tag_name} sources="{sources}" />'.format( + tag_name='show', sources=location) + xml_object.append(etree.fromstring(show_str)) + else: + xml_object.append( + etree.fromstring(child.export_to_xml(resource_fs))) + return xml_object diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 89c52bb742..6f3b8e94c9 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -1,125 +1,228 @@ import logging from cStringIO import StringIO -from math import exp, erf +from math import exp from lxml import etree from path import path # NOTE (THK): Only used for detecting presence of syllabus import requests import time from datetime import datetime +import dateutil.parser + from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor, SequenceModule -from xmodule.timeparse import parse_time, stringify_time +from xmodule.timeparse import parse_time from xmodule.util.decorators import lazyproperty from xmodule.graders import grader_from_conf -from datetime import datetime import json -import logging -import requests -import time -import copy + +from xblock.core import Scope, List, String, Object, Boolean +from .fields import Date log = logging.getLogger(__name__) +class StringOrDate(Date): + def from_json(self, value): + """ + Parse an optional metadata key containing a time or a string: + if present, assume it's a string if it doesn't parse. + """ + try: + result = super(StringOrDate, self).from_json(value) + except ValueError: + return value + if result is None: + return value + else: + return result + + def to_json(self, value): + """ + Convert a time struct or string to a string. + """ + try: + result = super(StringOrDate, self).to_json(value) + except: + return value + if result is None: + return value + else: + return result + + edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_comments=True, remove_blank_text=True) _cached_toc = {} -class CourseDescriptor(SequenceDescriptor): - module_class = SequenceModule +class Textbook(object): + def __init__(self, title, book_url): + self.title = title + self.book_url = book_url + self.start_page = int(self.table_of_contents[0].attrib['page']) - template_dir_name = 'course' + # The last page should be the last element in the table of contents, + # but it may be nested. So recurse all the way down the last element + last_el = self.table_of_contents[-1] + while last_el.getchildren(): + last_el = last_el[-1] - class Textbook: - def __init__(self, title, book_url): - self.title = title - self.book_url = book_url - self.table_of_contents = self._get_toc_from_s3() - self.start_page = int(self.table_of_contents[0].attrib['page']) + self.end_page = int(last_el.attrib['page']) - # The last page should be the last element in the table of contents, - # but it may be nested. So recurse all the way down the last element - last_el = self.table_of_contents[-1] - while last_el.getchildren(): - last_el = last_el[-1] + @lazyproperty + def table_of_contents(self): + """ + Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url - self.end_page = int(last_el.attrib['page']) + Returns XML tree representation of the table of contents + """ + toc_url = self.book_url + 'toc.xml' - @property - def table_of_contents(self): - return self.table_of_contents + # cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores) + # course modules have a very short lifespan and are constantly being created and torn down. + # Since this module in the __init__() method does a synchronous call to AWS to get the TOC + # this is causing a big performance problem. So let's be a bit smarter about this and cache + # each fetch and store in-mem for 10 minutes. + # NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and + # rewrite to use the traditional Django in-memory cache. + try: + # see if we already fetched this + if toc_url in _cached_toc: + (table_of_contents, timestamp) = _cached_toc[toc_url] + age = datetime.now() - timestamp + # expire every 10 minutes + if age.seconds < 600: + return table_of_contents + except Exception as err: + pass - def _get_toc_from_s3(self): - """ - Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url + # Get the table of contents from S3 + log.info("Retrieving textbook table of contents from %s" % toc_url) + try: + r = requests.get(toc_url) + except Exception as err: + msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url) + log.error(msg) + raise Exception(msg) - Returns XML tree representation of the table of contents - """ - toc_url = self.book_url + 'toc.xml' + # TOC is XML. Parse it + try: + table_of_contents = etree.fromstring(r.text) + except Exception as err: + msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url) + log.error(msg) + raise Exception(msg) - # cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores) - # course modules have a very short lifespan and are constantly being created and torn down. - # Since this module in the __init__() method does a synchronous call to AWS to get the TOC - # this is causing a big performance problem. So let's be a bit smarter about this and cache - # each fetch and store in-mem for 10 minutes. - # NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and - # rewrite to use the traditional Django in-memory cache. + return table_of_contents + + +class TextbookList(List): + def from_json(self, values): + textbooks = [] + for title, book_url in values: try: - # see if we already fetched this - if toc_url in _cached_toc: - (table_of_contents, timestamp) = _cached_toc[toc_url] - age = datetime.now() - timestamp - # expire every 10 minutes - if age.seconds < 600: - return table_of_contents - except Exception as err: - pass - - # Get the table of contents from S3 - log.info("Retrieving textbook table of contents from %s" % toc_url) - try: - r = requests.get(toc_url) - except Exception as err: - msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url) - log.error(msg) - raise Exception(msg) - - # TOC is XML. Parse it - try: - table_of_contents = etree.fromstring(r.text) - _cached_toc[toc_url] = (table_of_contents, datetime.now()) - except Exception as err: - msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url) - log.error(msg) - raise Exception(msg) - - return table_of_contents - - def __init__(self, system, definition=None, **kwargs): - super(CourseDescriptor, self).__init__(system, definition, **kwargs) - self.textbooks = [] - for title, book_url in self.definition['data']['textbooks']: - try: - self.textbooks.append(self.Textbook(title, book_url)) + textbooks.append(Textbook(title, book_url)) except: # If we can't get to S3 (e.g. on a train with no internet), don't break # the rest of the courseware. log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url)) continue - self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course + return textbooks + + def to_json(self, values): + json_data = [] + for val in values: + if isinstance(val, Textbook): + json_data.append((val.title, val.book_url)) + elif isinstance(val, tuple): + json_data.append(val) + else: + continue + return json_data + + +class CourseFields(object): + textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", scope=Scope.content) + wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) + enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) + enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) + start = Date(help="Start time when this module is visible", scope=Scope.settings) + end = Date(help="Date that this class ends", scope=Scope.settings) + advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) + grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content) + show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) + display_name = String(help="Display name for this module", scope=Scope.settings) + tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) + end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) + discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) + discussion_topics = Object( + help="Map of topics names to ids", + scope=Scope.settings, + computed_default=lambda c: {'General': {'id': c.location.html_id()}}, + ) + testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings) + announcement = Date(help="Date this course is announced", scope=Scope.settings) + cohort_config = Object(help="Dictionary defining cohort configuration", scope=Scope.settings) + is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings) + no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings) + disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings) + pdf_textbooks = List(help="List of dictionaries containing pdf_textbook configuration", scope=Scope.settings) + html_textbooks = List(help="List of dictionaries containing html_textbook configuration", scope=Scope.settings) + remote_gradebook = Object(scope=Scope.settings) + allow_anonymous = Boolean(scope=Scope.settings, default=True) + allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False) + advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings) + has_children = True + checklists = List(scope=Scope.settings) + info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') + + # An extra property is used rather than the wiki_slug/number because + # there are courses that change the number for different runs. This allows + # courses to share the same css_class across runs even if they have + # different numbers. + # + # TODO get rid of this as soon as possible or potentially build in a robust + # way to add in course-specific styling. There needs to be a discussion + # about the right way to do this, but arjun will address this ASAP. Also + # note that the courseware template needs to change when this is removed. + css_class = String(help="DO NOT USE THIS", scope=Scope.settings) + + # TODO: This is a quick kludge to allow CS50 (and other courses) to + # specify their own discussion forums as external links by specifying a + # "discussion_link" in their policy JSON file. This should later get + # folded in with Syllabus, Course Info, and additional Custom tabs in a + # more sensible framework later. + discussion_link = String(help="DO NOT USE THIS", scope=Scope.settings) + + # TODO: same as above, intended to let internal CS50 hide the progress tab + # until we get grade integration set up. + # Explicit comparison to True because we always want to return a bool. + hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings) + + +class CourseDescriptor(CourseFields, SequenceDescriptor): + module_class = SequenceModule + + template_dir_name = 'course' + + + def __init__(self, *args, **kwargs): + super(CourseDescriptor, self).__init__(*args, **kwargs) + + if self.wiki_slug is None: + self.wiki_slug = self.location.course msg = None if self.start is None: msg = "Course loaded without a valid start date. id = %s" % self.id # hack it -- start in 1970 - self.metadata['start'] = stringify_time(time.gmtime(0)) + self.start = time.gmtime(0) log.critical(msg) - system.error_tracker(msg) + self.system.error_tracker(msg) # NOTE: relies on the modulestore to call set_grading_policy() right after # init. (Modulestore is in charge of figuring out where to load the policy from) @@ -128,10 +231,10 @@ class CourseDescriptor(SequenceDescriptor): # disable the syllabus content for courses that do not provide a syllabus self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) self._grading_policy = {} - self.set_grading_policy(self.definition['data'].get('grading_policy', None)) + self.set_grading_policy(self.grading_policy) self.test_center_exams = [] - test_center_info = self.metadata.get('testcenter_info') + test_center_info = self.testcenter_info if test_center_info is not None: for exam_name in test_center_info: try: @@ -144,11 +247,11 @@ class CourseDescriptor(SequenceDescriptor): log.error(msg) continue - def defaut_grading_policy(self): + def default_grading_policy(self): """ Return a dict which is a copy of the default grading policy """ - default = {"GRADER": [ + return {"GRADER": [ { "type": "Homework", "min_count": 12, @@ -180,7 +283,6 @@ class CourseDescriptor(SequenceDescriptor): "GRADE_CUTOFFS": { "Pass": 0.5 }} - return copy.deepcopy(default) def set_grading_policy(self, course_policy): """ @@ -191,7 +293,7 @@ class CourseDescriptor(SequenceDescriptor): course_policy = {} # Load the global settings as a dictionary - grading_policy = self.defaut_grading_policy() + grading_policy = self.default_grading_policy() # Override any global settings with the course settings grading_policy.update(course_policy) @@ -222,7 +324,6 @@ class CourseDescriptor(SequenceDescriptor): return policy_str - @classmethod def from_xml(cls, xml_data, system, org=None, course=None): instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course) @@ -250,14 +351,13 @@ class CourseDescriptor(SequenceDescriptor): # cdodge: import the grading policy information that is on disk and put into the # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB - instance.definition['data']['grading_policy'] = policy + instance.grading_policy = policy # now set the current instance. set_grading_policy() will apply some inheritance rules instance.set_grading_policy(policy) return instance - @classmethod def definition_from_xml(cls, xml_object, system): textbooks = [] @@ -265,19 +365,19 @@ class CourseDescriptor(SequenceDescriptor): textbooks.append((textbook.get('title'), textbook.get('book_url'))) xml_object.remove(textbook) - #Load the wiki tag if it exists + # Load the wiki tag if it exists wiki_slug = None wiki_tag = xml_object.find("wiki") if wiki_tag is not None: wiki_slug = wiki_tag.attrib.get("slug", default=None) xml_object.remove(wiki_tag) - definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system) + definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system) - definition.setdefault('data', {})['textbooks'] = textbooks - definition['data']['wiki_slug'] = wiki_slug + definition['textbooks'] = textbooks + definition['wiki_slug'] = wiki_slug - return definition + return definition, children def has_ended(self): """ @@ -292,30 +392,6 @@ class CourseDescriptor(SequenceDescriptor): def has_started(self): return time.gmtime() > self.start - @property - def end(self): - return self._try_parse_time("end") - @end.setter - def end(self, value): - if isinstance(value, time.struct_time): - self.metadata['end'] = stringify_time(value) - @property - def enrollment_start(self): - return self._try_parse_time("enrollment_start") - - @enrollment_start.setter - def enrollment_start(self, value): - if isinstance(value, time.struct_time): - self.metadata['enrollment_start'] = stringify_time(value) - @property - def enrollment_end(self): - return self._try_parse_time("enrollment_end") - - @enrollment_end.setter - def enrollment_end(self, value): - if isinstance(value, time.struct_time): - self.metadata['enrollment_end'] = stringify_time(value) - @property def grader(self): return grader_from_conf(self.raw_grader) @@ -328,7 +404,7 @@ class CourseDescriptor(SequenceDescriptor): def raw_grader(self, value): # NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf self._grading_policy['RAW_GRADER'] = value - self.definition['data'].setdefault('grading_policy', {})['GRADER'] = value + self.grading_policy['GRADER'] = value @property def grade_cutoffs(self): @@ -337,48 +413,23 @@ class CourseDescriptor(SequenceDescriptor): @grade_cutoffs.setter def grade_cutoffs(self, value): self._grading_policy['GRADE_CUTOFFS'] = value - self.definition['data'].setdefault('grading_policy', {})['GRADE_CUTOFFS'] = value + + # XBlock fields don't update after mutation + policy = self.grading_policy + policy['GRADE_CUTOFFS'] = value + self.grading_policy = policy @property def lowest_passing_grade(self): return min(self._grading_policy['GRADE_CUTOFFS'].values()) - @property - def tabs(self): - """ - Return the tabs config, as a python object, or None if not specified. - """ - return self.metadata.get('tabs') - - @property - def pdf_textbooks(self): - """ - Return the pdf_textbooks config, as a python object, or None if not specified. - """ - return self.metadata.get('pdf_textbooks', []) - - @property - def html_textbooks(self): - """ - Return the html_textbooks config, as a python object, or None if not specified. - """ - return self.metadata.get('html_textbooks', []) - - @tabs.setter - def tabs(self, value): - self.metadata['tabs'] = value - - @property - def show_calculator(self): - return self.metadata.get("show_calculator", None) == "Yes" - @property def is_cohorted(self): """ Return whether the course is cohorted. """ - config = self.metadata.get("cohort_config") + config = self.cohort_config if config is None: return False @@ -392,7 +443,7 @@ class CourseDescriptor(SequenceDescriptor): if not self.is_cohorted: return False - return bool(self.metadata.get("cohort_config", {}).get( + return bool(self.cohort_config.get( "auto_cohort", False)) @property @@ -402,8 +453,10 @@ class CourseDescriptor(SequenceDescriptor): specified. Returns specified list even if is_cohorted and/or auto_cohort are false. """ - return self.metadata.get("cohort_config", {}).get( - "auto_cohort_groups", []) + if self.cohort_config is None: + return [] + else: + return self.cohort_config.get("auto_cohort_groups", []) @property @@ -411,7 +464,7 @@ class CourseDescriptor(SequenceDescriptor): """ Return list of topic ids defined in course policy. """ - topics = self.metadata.get("discussion_topics", {}) + topics = self.discussion_topics return [d["id"] for d in topics.values()] @@ -422,7 +475,7 @@ class CourseDescriptor(SequenceDescriptor): the empty set. Note that all inline discussions are automatically cohorted based on the course's is_cohorted setting. """ - config = self.metadata.get("cohort_config") + config = self.cohort_config if config is None: return set() @@ -431,13 +484,13 @@ class CourseDescriptor(SequenceDescriptor): @property - def is_new(self): + def is_newish(self): """ - Returns if the course has been flagged as new in the metadata. If + Returns if the course has been flagged as new. If there is no flag, return a heuristic value considering the announcement and the start dates. """ - flag = self.metadata.get('is_new', None) + flag = self.is_new if flag is None: # Use a heuristic if the course has not been flagged announcement, start, now = self._sorting_dates() @@ -457,8 +510,8 @@ class CourseDescriptor(SequenceDescriptor): @property def sorting_score(self): """ - Returns a number that can be used to sort the courses according - the how "new"" they are. The "newness"" score is computed using a + Returns a tuple that can be used to sort the courses according + the how "new" they are. The "newness" score is computed using a heuristic that takes into account the announcement and (advertized) start dates of the course if available. @@ -483,12 +536,15 @@ class CourseDescriptor(SequenceDescriptor): def to_datetime(timestamp): return datetime(*timestamp[:6]) - def get_date(field): - timetuple = self._try_parse_time(field) - return to_datetime(timetuple) if timetuple else None + announcement = self.announcement + if announcement is not None: + announcement = to_datetime(announcement) + + try: + start = dateutil.parser.parse(self.advertised_start) + except (ValueError, AttributeError): + start = to_datetime(self.start) - announcement = get_date('announcement') - start = get_date('advertised_start') or to_datetime(self.start) now = to_datetime(time.gmtime()) return announcement, start, now @@ -513,7 +569,7 @@ class CourseDescriptor(SequenceDescriptor): all_descriptors - This contains a list of all xmodules that can effect grading a student. This is used to efficiently fetch - all the xmodule state for a StudentModuleCache without walking + all the xmodule state for a ModelDataCache without walking the descriptor tree again. @@ -531,14 +587,14 @@ class CourseDescriptor(SequenceDescriptor): for c in self.get_children(): sections = [] for s in c.get_children(): - if s.metadata.get('graded', False): + if s.lms.graded: xmoduledescriptors = list(yield_descriptor_descendents(s)) xmoduledescriptors.append(s) # The xmoduledescriptors included here are only the ones that have scores. section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)} - section_format = s.metadata.get('format', "") + section_format = s.lms.format if s.lms.format is not None else '' graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description] all_descriptors.extend(xmoduledescriptors) @@ -579,58 +635,32 @@ class CourseDescriptor(SequenceDescriptor): @property def start_date_text(self): - parsed_advertised_start = self._try_parse_time('advertised_start') + def try_parse_iso_8601(text): + try: + result = datetime.strptime(text, "%Y-%m-%dT%H:%M") + result = result.strftime("%b %d, %Y") + except ValueError: + result = text.title() - # If the advertised start isn't a real date string, we assume it's free - # form text... - if parsed_advertised_start is None and \ - ('advertised_start' in self.metadata): - return self.metadata['advertised_start'] + return result - displayed_start = parsed_advertised_start or self.start - - # If we have neither an advertised start or a real start, just return TBD - if not displayed_start: - return "TBD" - - return time.strftime("%b %d, %Y", displayed_start) + if isinstance(self.advertised_start, basestring): + return try_parse_iso_8601(self.advertised_start) + elif self.advertised_start is None and self.start is None: + return 'TBD' + else: + return time.strftime("%b %d, %Y", self.advertised_start or self.start) @property def end_date_text(self): return time.strftime("%b %d, %Y", self.end) - # An extra property is used rather than the wiki_slug/number because - # there are courses that change the number for different runs. This allows - # courses to share the same css_class across runs even if they have - # different numbers. - # - # TODO get rid of this as soon as possible or potentially build in a robust - # way to add in course-specific styling. There needs to be a discussion - # about the right way to do this, but arjun will address this ASAP. Also - # note that the courseware template needs to change when this is removed. - @property - def css_class(self): - return self.metadata.get('css_class', '') - - @property - def info_sidebar_name(self): - return self.metadata.get('info_sidebar_name', 'Course Handouts') - - @property - def discussion_link(self): - """TODO: This is a quick kludge to allow CS50 (and other courses) to - specify their own discussion forums as external links by specifying a - "discussion_link" in their policy JSON file. This should later get - folded in with Syllabus, Course Info, and additional Custom tabs in a - more sensible framework later.""" - return self.metadata.get('discussion_link', None) - @property def forum_posts_allowed(self): try: blackout_periods = [(parse_time(start), parse_time(end)) for start, end - in self.metadata.get('discussion_blackouts', [])] + in self.discussion_blackouts] now = time.gmtime() for start, end in blackout_periods: if start <= now <= end: @@ -640,23 +670,6 @@ class CourseDescriptor(SequenceDescriptor): return True - @property - def hide_progress_tab(self): - """TODO: same as above, intended to let internal CS50 hide the progress tab - until we get grade integration set up.""" - # Explicit comparison to True because we always want to return a bool. - return self.metadata.get('hide_progress_tab') == True - - @property - def end_of_course_survey_url(self): - """ - Pull from policy. Once we have our own survey module set up, can change this to point to an automatically - created survey for each class. - - Returns None if no url specified. - """ - return self.metadata.get('end_of_course_survey_url') - class TestCenterExam(object): def __init__(self, course_id, exam_name, exam_info): self.course_id = course_id @@ -671,7 +684,7 @@ class CourseDescriptor(SequenceDescriptor): # *end* of the same day, not the same time. It's going to be used as the # end of the exam overall, so we don't want the exam to disappear too soon. # It's also used optionally as the registration end date, so time matters there too. - self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date + self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date if self.last_eligible_appointment_date is None: raise ValueError("Last appointment date must be specified") self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0) @@ -743,10 +756,6 @@ class CourseDescriptor(SequenceDescriptor): exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] return exams[0] if len(exams) == 1 else None - @property - def title(self): - return self.display_name - @property def number(self): return self.location.course diff --git a/common/lib/xmodule/xmodule/css/poll/display.scss b/common/lib/xmodule/xmodule/css/poll/display.scss new file mode 100644 index 0000000000..82c018a3a0 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/poll/display.scss @@ -0,0 +1,222 @@ +section.poll_question { + @media print { + display: block; + width: auto; + padding: 0; + + canvas, img { + page-break-inside: avoid; + } + } + + .inline { + display: inline; + } + + h3 { + margin-top: 0; + margin-bottom: 15px; + color: #fe57a1; + font-size: 1.9em; + + &.problem-header { + section.staff { + margin-top: 30px; + font-size: 80%; + } + } + + @media print { + display: block; + width: auto; + border-right: 0; + } + } + + p { + text-align: justify; + font-weight: bold; + } + + .poll_answer { + margin-bottom: 20px; + + &.short { + clear: both; + } + + .question { + height: auto; + clear: both; + min-height: 30px; + + &.short { + clear: none; + width: 30%; + display: inline; + float: left; + } + + .button { + -webkit-appearance: none; + -webkit-background-clip: padding-box; + -webkit-border-image: none; + -webkit-box-align: center; + -webkit-box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset; + -webkit-font-smoothing: antialiased; + -webkit-rtl-ordering: logical; + -webkit-user-select: text; + -webkit-writing-mode: horizontal-tb; + background-clip: padding-box; + background-color: rgb(238, 238, 238); + background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(210, 210, 210)); + border-bottom-color: rgb(202, 202, 202); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-style: solid; + border-bottom-width: 1px; + border-left-color: rgb(202, 202, 202); + border-left-style: solid; + border-left-width: 1px; + border-right-color: rgb(202, 202, 202); + border-right-style: solid; + border-right-width: 1px; + border-top-color: rgb(202, 202, 202); + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-style: solid; + border-top-width: 1px; + box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset; + box-sizing: border-box; + color: rgb(51, 51, 51); + cursor: pointer; + + /* display: inline-block; */ + display: inline; + float: left; + + font-family: 'Open Sans', Verdana, Geneva, sans-serif; + font-size: 13px; + font-style: normal; + font-variant: normal; + font-weight: bold; + + letter-spacing: normal; + line-height: 25.59375px; + margin-bottom: 15px; + margin: 0px; + padding: 0px; + text-align: center; + text-decoration: none; + text-indent: 0px; + text-shadow: rgb(248, 248, 248) 0px 1px 0px; + text-transform: none; + vertical-align: top; + white-space: pre-line; + + width: 25px; + height: 25px; + + word-spacing: 0px; + writing-mode: lr-tb; + } + .button.answered { + -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + background-color: rgb(29, 157, 217); + background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176)); + border-bottom-color: rgb(13, 114, 162); + border-left-color: rgb(13, 114, 162); + border-right-color: rgb(13, 114, 162); + border-top-color: rgb(13, 114, 162); + box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + color: rgb(255, 255, 255); + text-shadow: rgb(7, 103, 148) 0px 1px 0px; + background-image: none; + } + + .text { + display: inline; + float: left; + width: 80%; + text-align: left; + min-height: 30px; + margin-left: 20px; + height: auto; + margin-bottom: 20px; + cursor: pointer; + + &.short { + width: 100px; + } + } + } + + .stats { + min-height: 40px; + margin-top: 20px; + clear: both; + + &.short { + margin-top: 0; + clear: none; + display: inline; + float: right; + width: 70%; + } + + .bar { + width: 75%; + height: 20px; + border: 1px solid black; + display: inline; + float: left; + margin-right: 10px; + + &.short { + width: 65%; + height: 20px; + margin-top: 3px; + } + + .percent { + background-color: gray; + width: 0px; + height: 20px; + + &.short { } + } + } + + .number { + width: 80px; + display: inline; + float: right; + height: 28px; + text-align: right; + + &.short { + width: 120px; + height: auto; + } + } + } + } + + .poll_answer.answered { + -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + background-color: rgb(29, 157, 217); + background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176)); + border-bottom-color: rgb(13, 114, 162); + border-left-color: rgb(13, 114, 162); + border-right-color: rgb(13, 114, 162); + border-top-color: rgb(13, 114, 162); + box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + color: rgb(255, 255, 255); + text-shadow: rgb(7, 103, 148) 0px 1px 0px; + } + + .button.reset-button { + clear: both; + float: right; + } +} diff --git a/common/lib/xmodule/xmodule/css/wrapper/display.scss b/common/lib/xmodule/xmodule/css/wrapper/display.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index 6ddfcbe6c0..7725a88e77 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -3,35 +3,38 @@ from pkg_resources import resource_string, resource_listdir from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor - -import json +from xblock.core import String, Scope -class DiscussionModule(XModule): +class DiscussionFields(object): + discussion_id = String(scope=Scope.settings) + discussion_category = String(scope=Scope.settings) + discussion_target = String(scope=Scope.settings) + sort_key = String(scope=Scope.settings) + + +class DiscussionModule(DiscussionFields, XModule): js = {'coffee': [resource_string(__name__, 'js/src/time.coffee'), resource_string(__name__, 'js/src/discussion/display.coffee')] } js_module_name = "InlineDiscussion" + + def get_html(self): context = { 'discussion_id': self.discussion_id, } return self.system.render_template('discussion/_discussion_module.html', context) - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - if isinstance(instance_state, str): - instance_state = json.loads(instance_state) - xml_data = etree.fromstring(definition['data']) - self.discussion_id = xml_data.attrib['id'] - self.title = xml_data.attrib['for'] - self.discussion_category = xml_data.attrib['discussion_category'] - - -class DiscussionDescriptor(RawDescriptor): +class DiscussionDescriptor(DiscussionFields, RawDescriptor): module_class = DiscussionModule template_dir_name = "discussion" + + # The discussion XML format uses `id` and `for` attributes, + # but these would overload other module attributes, so we prefix them + # for actual use in the code + metadata_translations = dict(RawDescriptor.metadata_translations) + metadata_translations['id'] = 'discussion_id' + metadata_translations['for'] = 'discussion_target' diff --git a/common/lib/xmodule/xmodule/editing_module.py b/common/lib/xmodule/xmodule/editing_module.py index e025179b63..b93727a96b 100644 --- a/common/lib/xmodule/xmodule/editing_module.py +++ b/common/lib/xmodule/xmodule/editing_module.py @@ -1,11 +1,16 @@ from pkg_resources import resource_string from xmodule.mako_module import MakoModuleDescriptor +from xblock.core import Scope, String import logging log = logging.getLogger(__name__) -class EditingDescriptor(MakoModuleDescriptor): +class EditingFields(object): + data = String(scope=Scope.content, default='') + + +class EditingDescriptor(EditingFields, MakoModuleDescriptor): """ Module that provides a raw editing view of its data and children. It does not perform any validation on its definition---just passes it along to the browser. @@ -20,7 +25,7 @@ class EditingDescriptor(MakoModuleDescriptor): def get_context(self): _context = MakoModuleDescriptor.get_context(self) # Add our specific template information (the raw data body) - _context.update({'data': self.definition.get('data', '')}) + _context.update({'data': self.data}) return _context diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 2df47e05e6..d2135302da 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -8,6 +8,7 @@ from xmodule.x_module import XModule from xmodule.editing_module import JSONEditingDescriptor from xmodule.errortracker import exc_info_to_str from xmodule.modulestore import Location +from xblock.core import String, Scope log = logging.getLogger(__name__) @@ -20,7 +21,14 @@ log = logging.getLogger(__name__) # decides whether to create a staff or not-staff module. -class ErrorModule(XModule): +class ErrorFields(object): + contents = String(scope=Scope.content) + error_msg = String(scope=Scope.content) + display_name = String(scope=Scope.settings) + + +class ErrorModule(ErrorFields, XModule): + def get_html(self): '''Show an error to staff. TODO (vshnayder): proper style, divs, etc. @@ -28,12 +36,12 @@ class ErrorModule(XModule): # staff get to see all the details return self.system.render_template('module-error.html', { 'staff_access': True, - 'data': self.definition['data']['contents'], - 'error': self.definition['data']['error_msg'], + 'data': self.contents, + 'error': self.error_msg, }) -class NonStaffErrorModule(XModule): +class NonStaffErrorModule(ErrorFields, XModule): def get_html(self): '''Show an error to a student. TODO (vshnayder): proper style, divs, etc. @@ -46,7 +54,7 @@ class NonStaffErrorModule(XModule): }) -class ErrorDescriptor(JSONEditingDescriptor): +class ErrorDescriptor(ErrorFields, JSONEditingDescriptor): """ Module that provides a raw editing view of broken xml. """ @@ -66,26 +74,22 @@ class ErrorDescriptor(JSONEditingDescriptor): name=hashlib.sha1(contents).hexdigest() ) - definition = { - 'data': { - 'error_msg': str(error_msg), - 'contents': contents, - } - } - # real metadata stays in the content, but add a display name - metadata = {'display_name': 'Error: ' + location.name} + model_data = { + 'error_msg': str(error_msg), + 'contents': contents, + 'display_name': 'Error: ' + location.name + } return ErrorDescriptor( system, - definition, - location=location, - metadata=metadata + location, + model_data, ) def get_context(self): return { 'module': self, - 'data': self.definition['data']['contents'], + 'data': self.contents, } @classmethod @@ -101,10 +105,7 @@ class ErrorDescriptor(JSONEditingDescriptor): def from_descriptor(cls, descriptor, error_msg='Error not available'): return cls._construct( descriptor.system, - json.dumps({ - 'definition': descriptor.definition, - 'metadata': descriptor.metadata, - }, indent=4), + descriptor._model_data, error_msg, location=descriptor.location, ) @@ -148,14 +149,14 @@ class ErrorDescriptor(JSONEditingDescriptor): files, etc. That would just get re-wrapped on import. ''' try: - xml = etree.fromstring(self.definition['data']['contents']) + xml = etree.fromstring(self.contents) return etree.tostring(xml, encoding='unicode') except etree.XMLSyntaxError: # still not valid. root = etree.Element('error') - root.text = self.definition['data']['contents'] + root.text = self.contents err_node = etree.SubElement(root, 'error_msg') - err_node.text = self.definition['data']['error_msg'] + err_node.text = self.error_msg return etree.tostring(root, encoding='unicode') diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py new file mode 100644 index 0000000000..ea857933fc --- /dev/null +++ b/common/lib/xmodule/xmodule/fields.py @@ -0,0 +1,81 @@ +import time +import logging +import re + +from datetime import timedelta +from xblock.core import ModelType +import datetime +import dateutil.parser + +log = logging.getLogger(__name__) + + +class Date(ModelType): + ''' + Date fields know how to parse and produce json (iso) compatible formats. + ''' + def from_json(self, field): + """ + Parse an optional metadata key containing a time: if present, complain + if it doesn't parse. + Return None if not present or invalid. + """ + if field is None: + return field + elif field is "": + return None + elif isinstance(field, basestring): + d = dateutil.parser.parse(field) + return d.utctimetuple() + elif isinstance(field, (int, long, float)): + return time.gmtime(field / 1000) + elif isinstance(field, time.struct_time): + return field + else: + msg = "Field {0} has bad value '{1}'".format( + self._name, field) + log.warning(msg) + return None + + def to_json(self, value): + """ + Convert a time struct to a string + """ + if value is None: + return None + if isinstance(value, time.struct_time): + # struct_times are always utc + return time.strftime('%Y-%m-%dT%H:%M:%SZ', value) + elif isinstance(value, datetime.datetime): + return value.isoformat() + 'Z' + + +TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') +class Timedelta(ModelType): + def from_json(self, time_str): + """ + time_str: A string with the following components: + day[s] (optional) + hour[s] (optional) + minute[s] (optional) + second[s] (optional) + + Returns a datetime.timedelta parsed from the string + """ + parts = TIMEDELTA_REGEX.match(time_str) + if not parts: + return + parts = parts.groupdict() + time_params = {} + for (name, param) in parts.iteritems(): + if param: + time_params[name] = int(param) + return timedelta(**time_params) + + def to_json(self, value): + values = [] + for attr in ('days', 'hours', 'minutes', 'seconds'): + cur_value = getattr(value, attr, 0) + if cur_value > 0: + values.append("%d %s" % (cur_value, attr)) + return ' '.join(values) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 88e29b4203..884f9e2df2 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -7,17 +7,27 @@ from pkg_resources import resource_string from xmodule.editing_module import EditingDescriptor from xmodule.x_module import XModule from xmodule.xml_module import XmlDescriptor +from xblock.core import Scope, Integer, String log = logging.getLogger(__name__) -class FolditModule(XModule): + +class FolditFields(object): + # default to what Spring_7012x uses + required_level = Integer(default=4, scope=Scope.settings) + required_sublevel = Integer(default=5, scope=Scope.settings) + due = String(help="Date that this problem is due by", scope=Scope.settings, default='') + + show_basic_score = String(scope=Scope.settings, default='false') + show_leaderboard = String(scope=Scope.settings, default='false') + + +class FolditModule(FolditFields, XModule): css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]} - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) """ Example: @@ -26,25 +36,17 @@ class FolditModule(XModule): required_sublevel="3" show_leaderboard="false"/> """ - req_level = self.metadata.get("required_level") - req_sublevel = self.metadata.get("required_sublevel") - - # default to what Spring_7012x uses - self.required_level = req_level if req_level else 4 - self.required_sublevel = req_sublevel if req_sublevel else 5 - def parse_due_date(): """ Pull out the date, or None """ - s = self.metadata.get("due") + s = self.due if s: return parser.parse(s) else: return None - self.due_str = self.metadata.get("due", "None") - self.due = parse_due_date() + self.due_time = parse_due_date() def is_complete(self): """ @@ -59,7 +61,7 @@ class FolditModule(XModule): self.system.anonymous_student_id, self.required_level, self.required_sublevel, - self.due) + self.due_time) return complete def completed_puzzles(self): @@ -99,11 +101,11 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) - showbasic = (self.metadata.get("show_basic_score", "").lower() == "true") - showleader = (self.metadata.get("show_leaderboard", "").lower() == "true") + showbasic = (self.show_basic_score.lower() == "true") + showleader = (self.show_leaderboard.lower() == "true") context = { - 'due': self.due_str, + 'due': self.due, 'success': self.is_complete(), 'goal_level': goal_level, 'completed': self.completed_puzzles(), @@ -125,7 +127,7 @@ class FolditModule(XModule): self.required_sublevel) context = { - 'due': self.due_str, + 'due': self.due, 'success': self.is_complete(), 'goal_level': goal_level, 'completed': self.completed_puzzles(), @@ -155,7 +157,7 @@ class FolditModule(XModule): -class FolditDescriptor(XmlDescriptor, EditingDescriptor): +class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor): """ Module for adding Foldit problems to courses """ @@ -176,7 +178,8 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor): @classmethod def definition_from_xml(cls, xml_object, system): - """ - Get the xml_object's attributes. - """ - return {'metadata': xml_object.attrib} + return ({}, []) + + def definition_to_xml(self): + xml_object = etree.Element('foldit') + return xml_object diff --git a/common/lib/xmodule/xmodule/gst_module.py b/common/lib/xmodule/xmodule/gst_module.py index ef1be96c84..00e8cf1f10 100644 --- a/common/lib/xmodule/xmodule/gst_module.py +++ b/common/lib/xmodule/xmodule/gst_module.py @@ -14,12 +14,18 @@ from xmodule.xml_module import XmlDescriptor from xmodule.x_module import XModule from xmodule.stringify import stringify_children from pkg_resources import resource_string +from xblock.core import String, Scope log = logging.getLogger(__name__) -class GraphicalSliderToolModule(XModule): +class GraphicalSliderToolFields(object): + render = String(scope=Scope.content) + configuration = String(scope=Scope.content) + + +class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule): ''' Graphical-Slider-Tool Module ''' @@ -43,15 +49,6 @@ class GraphicalSliderToolModule(XModule): } js_module_name = "GraphicalSliderTool" - def __init__(self, system, location, definition, descriptor, instance_state=None, - shared_state=None, **kwargs): - """ - For XML file format please look at documentation. TODO - receive - information where to store XML documentation. - """ - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - def get_html(self): """ Renders parameters to template. """ @@ -60,14 +57,14 @@ class GraphicalSliderToolModule(XModule): self.html_class = self.location.category self.configuration_json = self.build_configuration_json() params = { - 'gst_html': self.substitute_controls(self.definition['render']), + 'gst_html': self.substitute_controls(self.render), 'element_id': self.html_id, 'element_class': self.html_class, 'configuration_json': self.configuration_json } - self.content = self.system.render_template( + content = self.system.render_template( 'graphical_slider_tool.html', params) - return self.content + return content def substitute_controls(self, html_string): """ Substitutes control elements (slider, textbox and plot) in @@ -139,10 +136,10 @@ class GraphicalSliderToolModule(XModule): # added for interface compatibility with xmltodict.parse # class added for javascript's part purposes return json.dumps(xmltodict.parse('' + self.definition['configuration'] + '')) + '">' + self.configuration + '')) -class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor): +class GraphicalSliderToolDescriptor(GraphicalSliderToolFields, MakoModuleDescriptor, XmlDescriptor): module_class = GraphicalSliderToolModule template_dir_name = 'graphical_slider_tool' @@ -177,14 +174,14 @@ class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor): return { 'render': parse('render'), 'configuration': parse('configuration') - } + }, [] def definition_to_xml(self, resource_fs): '''Return an xml element representing this definition.''' xml_object = etree.Element('graphical_slider_tool') def add_child(k): - child_str = '<{tag}>{body}'.format(tag=k, body=self.definition[k]) + child_str = '<{tag}>{body}'.format(tag=k, body=getattr(self, k)) child_node = etree.fromstring(child_str) xml_object.append(child_node) diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 456ea3cf10..e9cec32e3e 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -7,10 +7,9 @@ from lxml import etree from path import path from pkg_resources import resource_string -from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent +from xblock.core import Scope, String from xmodule.editing_module import EditingDescriptor from xmodule.html_checker import check_html -from xmodule.modulestore import Location from xmodule.stringify import stringify_children from xmodule.x_module import XModule from xmodule.xml_module import XmlDescriptor, name_to_pathname @@ -18,7 +17,11 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname log = logging.getLogger("mitx.courseware") -class HtmlModule(XModule): +class HtmlFields(object): + data = String(help="Html contents to display for this module", scope=Scope.content) + + +class HtmlModule(HtmlFields, XModule): js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/html/display.coffee') @@ -28,17 +31,10 @@ class HtmlModule(XModule): css = {'scss': [resource_string(__name__, 'css/html/display.scss')]} def get_html(self): - return self.html - - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - self.html = self.definition['data'] + return self.data - -class HtmlDescriptor(XmlDescriptor, EditingDescriptor): +class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): """ Module for putting raw html in a course """ @@ -91,7 +87,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): if filename is None: definition_xml = copy.deepcopy(xml_object) cls.clean_metadata_from_xml(definition_xml) - return {'data': stringify_children(definition_xml)} + return {'data': stringify_children(definition_xml)}, [] else: # html is special. cls.filename_extension is 'xml', but # if 'filename' is in the definition, that means to load @@ -105,8 +101,6 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): filepath = "{base}/{name}.html".format(base=base, name=filename) #log.debug("looking for html file for {0} at {1}".format(location, filepath)) - - # VS[compat] # TODO (cpennington): If the file doesn't exist at the right path, # give the class a chance to fix it up. The file will be written out @@ -135,7 +129,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): # for Fall 2012 LMS migration: keep filename (and unmangled filename) definition['filename'] = [filepath, filename] - return definition + return definition, [] except (ResourceNotFoundError) as err: msg = 'Unable to load file contents at path {0}: {1} '.format( @@ -151,19 +145,18 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): string to filename.html. ''' try: - return etree.fromstring(self.definition['data']) + return etree.fromstring(self.data) except etree.XMLSyntaxError: pass # Not proper format. Write html to file, return an empty tag pathname = name_to_pathname(self.url_name) - pathdir = path(pathname).dirname() filepath = u'{category}/{pathname}.html'.format(category=self.category, pathname=pathname) resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True) with resource_fs.open(filepath, 'w') as file: - file.write(self.definition['data'].encode('utf-8')) + file.write(self.data.encode('utf-8')) # write out the relative name relname = path(pathname).basename() @@ -175,8 +168,11 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): @property def editable_metadata_fields(self): """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user.""" - subset = [field for field in super(HtmlDescriptor,self).editable_metadata_fields - if field not in ['empty']] + subset = super(HtmlDescriptor, self).editable_metadata_fields + + if 'empty' in subset: + del subset['empty'] + return subset diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 158c2b98d0..70704ab247 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -41,6 +41,11 @@ class @Problem @el.attr progress: response.progress_status @el.trigger('progressChanged') + forceUpdate: (response) => + @el.attr progress: response.progress_status + @el.trigger('progressChanged') + + queueing: => @queued_items = @$(".xqueue") @num_queued_items = @queued_items.length @@ -71,6 +76,7 @@ class @Problem @num_queued_items = @new_queued_items.length if @num_queued_items == 0 + @forceUpdate response delete window.queuePollerID else # TODO: Some logic to dynamically adjust polling rate based on queuelen diff --git a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee index 33dcb29079..857424c1dc 100644 --- a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee @@ -1,26 +1,35 @@ class @Conditional - constructor: (element) -> + constructor: (element, callerElId) -> @el = $(element).find('.conditional-wrapper') - @id = @el.data('problem-id') - @element_id = @el.attr('id') + + @callerElId = callerElId + + if callerElId isnt undefined + dependencies = @el.data('depends') + if (typeof dependencies is 'string') and (dependencies.length > 0) and (dependencies.indexOf(callerElId) is -1) + return + @url = @el.data('url') - @render() + @render(element) - $: (selector) -> - $(selector, @el) - - updateProgress: (response) => - if response.progress_changed - @el.attr progress: response.progress_status - @el.trigger('progressChanged') - - render: (content) -> - if content - @el.html(content) - XModule.loadModules(@el) - else + render: (element) -> $.postWithPrefix "#{@url}/conditional_get", (response) => - @el.html(response.html) - XModule.loadModules(@el) + @el.html '' + @el.append(i) for i in response.html + parentEl = $(element).parent() + parentId = parentEl.attr 'id' + + if response.message is false + if parentId.indexOf('vert') is 0 + parentEl.hide() + else + $(element).hide() + else + if parentId.indexOf('vert') is 0 + parentEl.show() + else + $(element).show() + + XModule.loadModules @el diff --git a/common/lib/xmodule/xmodule/js/src/poll/logme.js b/common/lib/xmodule/xmodule/js/src/poll/logme.js new file mode 100644 index 0000000000..c045757044 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/logme.js @@ -0,0 +1,54 @@ +// Wrapper for RequireJS. It will make the standard requirejs(), require(), and +// define() functions from Require JS available inside the anonymous function. +(function (requirejs, require, define) { + +define('logme', [], function () { + var debugMode; + + // debugMode can be one of the following: + // + // true - All messages passed to logme will be written to the internal + // browser console. + // false - Suppress all output to the internal browser console. + // + // Obviously, if anywhere there is a direct console.log() call, we can't do + // anything about it. That's why use logme() - it will allow to turn off + // the output of debug information with a single change to a variable. + debugMode = true; + + return logme; + + /* + * function: logme + * + * A helper function that provides logging facilities. We don't want + * to call console.log() directly, because sometimes it is not supported + * by the browser. Also when everything is routed through this function. + * the logging output can be easily turned off. + * + * logme() supports multiple parameters. Each parameter will be passed to + * console.log() function separately. + * + */ + function logme() { + var i; + + if ( + (typeof debugMode === 'undefined') || + (debugMode !== true) || + (typeof window.console === 'undefined') + ) { + return; + } + + for (i = 0; i < arguments.length; i++) { + window.console.log(arguments[i]); + } + } // End-of: function logme +}); + +// End of wrapper for RequireJS. As you can see, we are passing +// namespaced Require JS variables to an anonymous function. Within +// it, you can use the standard requirejs(), require(), and define() +// functions as if they were in the global namespace. +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) diff --git a/common/lib/xmodule/xmodule/js/src/poll/poll.js b/common/lib/xmodule/xmodule/js/src/poll/poll.js new file mode 100644 index 0000000000..a2ccbc7c03 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/poll.js @@ -0,0 +1,5 @@ +window.Poll = function (el) { + RequireJS.require(['PollMain'], function (PollMain) { + new PollMain(el); + }); +}; diff --git a/common/lib/xmodule/xmodule/js/src/poll/poll_main.js b/common/lib/xmodule/xmodule/js/src/poll/poll_main.js new file mode 100644 index 0000000000..74f2a488d7 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/poll_main.js @@ -0,0 +1,323 @@ +(function (requirejs, require, define) { +define('PollMain', ['logme'], function (logme) { + +PollMain.prototype = { + +'showAnswerGraph': function (poll_answers, total) { + var _this, totalValue; + + totalValue = parseFloat(total); + if (isFinite(totalValue) === false) { + return; + } + + _this = this; + + $.each(poll_answers, function (index, value) { + var numValue, percentValue; + + numValue = parseFloat(value); + if (isFinite(numValue) === false) { + return; + } + + percentValue = (numValue / totalValue) * 100.0; + + _this.answersObj[index].statsEl.show(); + _this.answersObj[index].numberEl.html('' + value + ' (' + percentValue.toFixed(1) + '%)'); + _this.answersObj[index].percentEl.css({ + 'width': '' + percentValue.toFixed(1) + '%' + }); + }); +}, + +'submitAnswer': function (answer, answerObj) { + var _this; + + // Make sure that the user can answer a question only once. + if (this.questionAnswered === true) { + return; + } + this.questionAnswered = true; + + _this = this; + + console.log('submit answer'); + + answerObj.buttonEl.addClass('answered'); + + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.postWithPrefix( + _this.ajax_url + '/' + answer, {}, + function (response) { + console.log('success! response = '); + console.log(response); + + _this.showAnswerGraph(response.poll_answers, response.total); + + if (_this.canReset === true) { + _this.resetButton.show(); + } + + // Initialize Conditional constructors. + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) { + new window.Conditional(value, _this.id.replace(/^poll_/, '')); + }); + } + } + ); + +}, // End-of: 'submitAnswer': function (answer, answerEl) { + + +'submitReset': function () { + var _this; + + _this = this; + + console.log('submit reset'); + + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.postWithPrefix( + this.ajax_url + '/' + 'reset_poll', + {}, + function (response) { + console.log('success! response = '); + console.log(response); + + if ( + (response.hasOwnProperty('status') !== true) || + (typeof response.status !== 'string') || + (response.status.toLowerCase() !== 'success')) { + return; + } + + _this.questionAnswered = false; + _this.questionEl.find('.button.answered').removeClass('answered'); + _this.questionEl.find('.stats').hide(); + _this.resetButton.hide(); + + // Initialize Conditional constructors. We will specify the third parameter as 'true' + // notifying the constructor that this is a reset operation. + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) { + new window.Conditional(value, _this.id.replace(/^poll_/, '')); + }); + } + } + ); +}, // End-of: 'submitAnswer': function (answer, answerEl) { + +'postInit': function () { + var _this; + + // Access this object inside inner functions. + _this = this; + + if ( + (this.jsonConfig.poll_answer.length > 0) && + (this.jsonConfig.answers.hasOwnProperty(this.jsonConfig.poll_answer) === false) + ) { + this.questionEl.append( + '

          Error!

          ' + + '

          XML data format changed. List of answers was modified, but poll data was not updated.

          ' + ); + + return; + } + + // Get the DOM id of the question. + this.id = this.questionEl.attr('id'); + + // Get the URL to which we will post the users answer to the question. + this.ajax_url = this.questionEl.data('ajax-url'); + + this.questionHtmlMarkup = $('
          ').html(this.jsonConfig.question).text(); + this.questionEl.append(this.questionHtmlMarkup); + + // When the user selects and answer, we will set this flag to true. + this.questionAnswered = false; + + this.answersObj = {}; + this.shortVersion = true; + + $.each(this.jsonConfig.answers, function (index, value) { + if (value.length >= 18) { + _this.shortVersion = false; + } + }); + + $.each(this.jsonConfig.answers, function (index, value) { + var answer; + + answer = {}; + + _this.answersObj[index] = answer; + + answer.el = $('
          '); + + answer.questionEl = $('
          '); + answer.buttonEl = $('
          '); + answer.textEl = $('
          '); + answer.questionEl.append(answer.buttonEl); + answer.questionEl.append(answer.textEl); + + answer.el.append(answer.questionEl); + + answer.statsEl = $('
          '); + answer.barEl = $('
          '); + answer.percentEl = $('
          '); + answer.barEl.append(answer.percentEl); + answer.numberEl = $('
          '); + answer.statsEl.append(answer.barEl); + answer.statsEl.append(answer.numberEl); + + answer.statsEl.hide(); + + answer.el.append(answer.statsEl); + + answer.textEl.html(value); + + if (_this.shortVersion === true) { + $.each(answer, function (index, value) { + if (value instanceof jQuery) { + value.addClass('short'); + } + }); + } + + answer.el.appendTo(_this.questionEl); + + answer.textEl.on('click', function () { + _this.submitAnswer(index, answer); + }); + + answer.buttonEl.on('click', function () { + _this.submitAnswer(index, answer); + }); + + if (index === _this.jsonConfig.poll_answer) { + answer.buttonEl.addClass('answered'); + _this.questionAnswered = true; + } + }); + + console.log(this.jsonConfig.reset); + + if ((typeof this.jsonConfig.reset === 'string') && (this.jsonConfig.reset.toLowerCase() === 'true')) { + this.canReset = true; + + this.resetButton = $('
          Change your vote
          '); + + if (this.questionAnswered === false) { + this.resetButton.hide(); + } + + this.resetButton.appendTo(this.questionEl); + + this.resetButton.on('click', function () { + _this.submitReset(); + }); + } else { + this.canReset = false; + } + + // If it turns out that the user already answered the question, show the answers graph. + if (this.questionAnswered === true) { + this.showAnswerGraph(this.jsonConfig.poll_answers, this.jsonConfig.total); + } +} // End-of: 'postInit': function () { +}; // End-of: PollMain.prototype = { + +return PollMain; + +function PollMain(el) { + var _this; + + this.questionEl = $(el).find('.poll_question'); + if (this.questionEl.length !== 1) { + // We require one question DOM element. + logme('ERROR: PollMain constructor requires one question DOM element.'); + + return; + } + + // Just a safety precussion. If we run this code more than once, multiple 'click' callback handlers will be + // attached to the same DOM elements. We don't want this to happen. + if (this.questionEl.attr('poll_main_processed') === 'true') { + logme( + 'ERROR: PolMain JS constructor was called on a DOM element that has already been processed once.' + ); + + return; + } + + // This element was not processed earlier. + // Make sure that next time we will not process this element a second time. + this.questionEl.attr('poll_main_processed', 'true'); + + // Access this object inside inner functions. + _this = this; + + // DOM element which contains the current poll along with any conditionals. By default we assume that such + // element is not present. We will try to find it. + this.wrapperSectionEl = null; + + (function (tempEl, c1) { + while (tempEl.tagName.toLowerCase() !== 'body') { + tempEl = $(tempEl).parent()[0]; + c1 += 1; + + if ( + (tempEl.tagName.toLowerCase() === 'section') && + ($(tempEl).hasClass('xmodule_WrapperModule') === true) + ) { + _this.wrapperSectionEl = tempEl; + + break; + } else if (c1 > 50) { + // In case something breaks, and we enter an endless loop, a sane + // limit for loop iterations. + + break; + } + } + }($(el)[0], 0)); + + try { + this.jsonConfig = JSON.parse(this.questionEl.children('.poll_question_div').html()); + + $.postWithPrefix( + '' + this.questionEl.data('ajax-url') + '/' + 'get_state', {}, + function (response) { + _this.jsonConfig.poll_answer = response.poll_answer; + _this.jsonConfig.total = response.total; + + $.each(response.poll_answers, function (index, value) { + _this.jsonConfig.poll_answers[index] = value; + }); + + _this.questionEl.children('.poll_question_div').html(JSON.stringify(_this.jsonConfig)); + + _this.postInit(); + } + ); + + return; + } catch (err) { + logme( + 'ERROR: Invalid JSON config for poll ID "' + this.id + '".', + 'Error messsage: "' + err.message + '".' + ); + + return; + } +} // End-of: function PollMain(el) { + +}); // End-of: define('PollMain', ['logme'], function (logme) { + +// End-of: (function (requirejs, require, define) { +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 793e7f4f3c..0e4c9788ba 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -56,7 +56,7 @@ class @Sequence element.removeClass('progress-none') .removeClass('progress-some') .removeClass('progress-done') - + switch progress when 'none' then element.addClass('progress-none') when 'in_progress' then element.addClass('progress-some') @@ -65,6 +65,11 @@ class @Sequence toggleArrows: => @$('.sequence-nav-buttons a').unbind('click') + if @contents.length == 0 + @$('.sequence-nav-buttons .prev a').addClass('disabled') + @$('.sequence-nav-buttons .next a').addClass('disabled') + return + if @position == 1 @$('.sequence-nav-buttons .prev a').addClass('disabled') else @@ -105,8 +110,8 @@ class @Sequence if (1 <= new_position) and (new_position <= @num_contents) Logger.log "seq_goto", old: @position, new: new_position, id: @id - - # On Sequence chage, destroy any existing polling thread + + # On Sequence chage, destroy any existing polling thread # for queued submissions, see ../capa/display.coffee if window.queuePollerID window.clearTimeout(window.queuePollerID) diff --git a/common/lib/xmodule/xmodule/js/src/video/display.coffee b/common/lib/xmodule/xmodule/js/src/video/display.coffee index 1876330340..aadafbc8d0 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display.coffee @@ -4,7 +4,6 @@ class @Video @id = @el.attr('id').replace(/video_/, '') @start = @el.data('start') @end = @el.data('end') - @caption_data_dir = @el.data('caption-data-dir') @caption_asset_path = @el.data('caption-asset-path') @show_captions = @el.data('show-captions') == "true" window.player = null diff --git a/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee b/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee new file mode 100644 index 0000000000..a13c5a8bc7 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee @@ -0,0 +1,10 @@ +class @WrapperDescriptor extends XModule.Descriptor + constructor: (@element) -> + console.log 'WrapperDescriptor' + @$items = $(@element).find(".vert-mod") + @$items.sortable( + update: (event, ui) => @update() + ) + + save: -> + children: $('.vert-mod li', @element).map((idx, el) -> $(el).data('id')).toArray() diff --git a/common/lib/xmodule/xmodule/mako_module.py b/common/lib/xmodule/xmodule/mako_module.py index da96bfa212..84db6ad779 100644 --- a/common/lib/xmodule/xmodule/mako_module.py +++ b/common/lib/xmodule/xmodule/mako_module.py @@ -1,5 +1,5 @@ -from x_module import XModuleDescriptor, DescriptorSystem -import logging +from .x_module import XModuleDescriptor, DescriptorSystem +from .modulestore.inheritance import own_metadata class MakoDescriptorSystem(DescriptorSystem): @@ -21,21 +21,21 @@ class MakoModuleDescriptor(XModuleDescriptor): the descriptor as the `module` parameter to that template """ - def __init__(self, system, definition=None, **kwargs): + def __init__(self, system, location, model_data): if getattr(system, 'render_template', None) is None: raise TypeError('{system} must have a render_template function' ' in order to use a MakoDescriptor'.format( system=system)) - super(MakoModuleDescriptor, self).__init__(system, definition, **kwargs) + super(MakoModuleDescriptor, self).__init__(system, location, model_data) def get_context(self): """ Return the context to render the mako template with """ - return {'module': self, - 'metadata': self.metadata, - 'editable_metadata_fields': self.editable_metadata_fields - } + return { + 'module': self, + 'editable_metadata_fields': self.editable_metadata_fields, + } def get_html(self): return self.system.render_template( @@ -44,6 +44,10 @@ class MakoModuleDescriptor(XModuleDescriptor): # cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata) @property def editable_metadata_fields(self): - subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields and - name not in self._inherited_metadata] - return subset + fields = {} + for field, value in own_metadata(self).items(): + if field in self.system_metadata_fields: + continue + + fields[field] = value + return fields diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 525527c93f..022e016a58 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -423,6 +423,7 @@ class ModuleStoreBase(ModuleStore): Set up the error-tracking logic. ''' self._location_errors = {} # location -> ErrorLog + self.metadata_inheritance_cache = None def _get_errorlog(self, location): """ diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index 0b86c2fea4..b0a65273c7 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -33,11 +33,12 @@ def modulestore(name='default'): class_ = load_function(settings.MODULESTORE[name]['ENGINE']) options = {} + options.update(settings.MODULESTORE[name]['OPTIONS']) for key in FUNCTION_KEYS: if key in options: options[key] = load_function(options[key]) - + _MODULESTORES[name] = class_( **options ) diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 81f4da2780..71922c08df 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -15,11 +15,11 @@ def as_draft(location): def wrap_draft(item): """ - Sets `item.metadata['is_draft']` to `True` if the item is a - draft, and false otherwise. Sets the item's location to the + Sets `item.cms.is_draft` to `True` if the item is a + draft, and `False` otherwise. Sets the item's location to the non-draft location in either case """ - item.metadata['is_draft'] = item.location.revision == DRAFT + item.cms.is_draft = item.location.revision == DRAFT item.location = item.location._replace(revision=None) return item @@ -118,7 +118,7 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.metadata['is_draft']: + if not draft_item.cms.is_draft: self.clone_item(location, draft_loc) return super(DraftModuleStore, self).update_item(draft_loc, data) @@ -133,7 +133,7 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.metadata['is_draft']: + if not draft_item.cms.is_draft: self.clone_item(location, draft_loc) return super(DraftModuleStore, self).update_children(draft_loc, children) @@ -149,7 +149,7 @@ class DraftModuleStore(ModuleStoreBase): draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.metadata['is_draft']: + if not draft_item.cms.is_draft: self.clone_item(location, draft_loc) if 'is_draft' in metadata: @@ -179,13 +179,11 @@ class DraftModuleStore(ModuleStoreBase): Save a current draft to the underlying modulestore """ draft = self.get_item(location) - metadata = {} - metadata.update(draft.metadata) - metadata['published_date'] = tuple(datetime.utcnow().timetuple()) - metadata['published_by'] = published_by_id - super(DraftModuleStore, self).update_item(location, draft.definition.get('data', {})) - super(DraftModuleStore, self).update_children(location, draft.definition.get('children', [])) - super(DraftModuleStore, self).update_metadata(location, metadata) + draft.cms.published_date = datetime.utcnow() + draft.cms.published_by = published_by_id + super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data) + super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children) + super(DraftModuleStore, self).update_metadata(location, draft._model_data._kvs._metadata) self.delete_item(location) def unpublish(self, location): diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py new file mode 100644 index 0000000000..d819abe367 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -0,0 +1,67 @@ +from xblock.core import Scope + +# A list of metadata that this module can inherit from its parent module +INHERITABLE_METADATA = ( + 'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize', + # TODO (ichuang): used for Fall 2012 xqa server access + 'xqa_key', + # How many days early to show a course element to beta testers (float) + # intended to be set per-course, but can be overridden in for specific + # elements. Can be a float. + 'days_early_for_beta' +) + +def compute_inherited_metadata(descriptor): + """Given a descriptor, traverse all of its descendants and do metadata + inheritance. Should be called on a CourseDescriptor after importing a + course. + + NOTE: This means that there is no such thing as lazy loading at the + moment--this accesses all the children.""" + for child in descriptor.get_children(): + inherit_metadata(child, descriptor._model_data) + compute_inherited_metadata(child) + + +def inherit_metadata(descriptor, model_data): + """ + Updates this module with metadata inherited from a containing module. + Only metadata specified in self.inheritable_metadata will + be inherited + """ + if not hasattr(descriptor, '_inherited_metadata'): + setattr(descriptor, '_inherited_metadata', {}) + + # Set all inheritable metadata from kwargs that are + # in self.inheritable_metadata and aren't already set in metadata + for attr in INHERITABLE_METADATA: + if attr not in descriptor._model_data and attr in model_data: + descriptor._inherited_metadata[attr] = model_data[attr] + descriptor._model_data[attr] = model_data[attr] + + +def own_metadata(module): + """ + Return a dictionary that contains only non-inherited field keys, + mapped to their values + """ + inherited_metadata = getattr(module, '_inherited_metadata', {}) + metadata = {} + for field in module.fields + module.lms.fields: + # Only save metadata that wasn't inherited + if field.scope != Scope.settings: + continue + + if field.name in inherited_metadata and module._model_data.get(field.name) == inherited_metadata.get(field.name): + continue + + if field.name not in module._model_data: + continue + + try: + metadata[field.name] = module._model_data[field.name] + except KeyError: + # Ignore any missing keys in _model_data + pass + + return metadata diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index e2a4524188..47e35cda93 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -4,27 +4,103 @@ import logging import copy from bson.son import SON +from collections import namedtuple from fs.osfs import OSFS from itertools import repeat from path import path -from datetime import datetime, timedelta +from datetime import datetime +from operator import attrgetter from importlib import import_module from xmodule.errortracker import null_error_tracker, exc_info_to_str -from xmodule.x_module import XModuleDescriptor from xmodule.mako_module import MakoDescriptorSystem +from xmodule.x_module import XModuleDescriptor from xmodule.error_module import ErrorDescriptor +from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError +from xblock.core import Scope from . import ModuleStoreBase, Location from .draft import DraftModuleStore from .exceptions import (ItemNotFoundError, DuplicateItemError) +from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata + +log = logging.getLogger(__name__) # TODO (cpennington): This code currently operates under the assumption that # there is only one revision for each item. Once we start versioning inside the CMS, # that assumption will have to change +class MongoKeyValueStore(KeyValueStore): + """ + A KeyValueStore that maps keyed data access to one of the 3 data areas + known to the MongoModuleStore (data, children, and metadata) + """ + def __init__(self, data, children, metadata): + self._data = data + self._children = children + self._metadata = metadata + + def get(self, key): + if key.scope == Scope.children: + return self._children + elif key.scope == Scope.parent: + return None + elif key.scope == Scope.settings: + return self._metadata[key.field_name] + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + return self._data + else: + return self._data[key.field_name] + else: + raise InvalidScopeError(key.scope) + + def set(self, key, value): + if key.scope == Scope.children: + self._children = value + elif key.scope == Scope.settings: + self._metadata[key.field_name] = value + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + self._data = value + else: + self._data[key.field_name] = value + else: + raise InvalidScopeError(key.scope) + + def delete(self, key): + if key.scope == Scope.children: + self._children = [] + elif key.scope == Scope.settings: + if key.field_name in self._metadata: + del self._metadata[key.field_name] + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + self._data = None + else: + del self._data[key.field_name] + else: + raise InvalidScopeError(key.scope) + + def has(self, key): + if key.scope in (Scope.children, Scope.parent): + return True + elif key.scope == Scope.settings: + return key.field_name in self._metadata + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + return True + else: + return key.field_name in self._data + else: + return False + + +MongoUsage = namedtuple('MongoUsage', 'id, def_id') + + class CachingDescriptorSystem(MakoDescriptorSystem): """ A system that has a cache of module json that it will use to load modules @@ -33,7 +109,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): references to metadata_inheritance_tree """ def __init__(self, modulestore, module_data, default_class, resources_fs, - error_tracker, render_template, metadata_inheritance_tree = None): + error_tracker, render_template, cached_metadata=None): """ modulestore: the module store that can be used to retrieve additional modules @@ -58,9 +134,13 @@ class CachingDescriptorSystem(MakoDescriptorSystem): # cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's # define an attribute here as well, even though it's None self.course_id = None - self.metadata_inheritance_tree = metadata_inheritance_tree + self.cached_metadata = cached_metadata + def load_item(self, location): + """ + Return an XModule instance for the specified location + """ location = Location(location) json_data = self.module_data.get(location) if json_data is None: @@ -72,12 +152,31 @@ class CachingDescriptorSystem(MakoDescriptorSystem): else: # load the module and apply the inherited metadata try: - module = XModuleDescriptor.load_from_json(json_data, self, self.default_class) - if self.metadata_inheritance_tree is not None: - metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(),{}) - module.inherit_metadata(metadata_to_inherit) + class_ = XModuleDescriptor.load_class( + json_data['location']['category'], + self.default_class + ) + definition = json_data.get('definition', {}) + metadata = json_data.get('metadata', {}) + for old_name, new_name in class_.metadata_translations.items(): + if old_name in metadata: + metadata[new_name] = metadata[old_name] + del metadata[old_name] + + kvs = MongoKeyValueStore( + definition.get('data', {}), + definition.get('children', []), + metadata, + ) + + model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location)) + module = class_(self, location, model_data) + if self.cached_metadata is not None: + metadata_to_inherit = self.cached_metadata.get(location.url(), {}) + inherit_metadata(module, metadata_to_inherit) return module except: + log.warning("Failed to load descriptor", exc_info=True) return ErrorDescriptor.from_json( json_data, self, @@ -103,16 +202,19 @@ def location_to_query(location, wildcard=True): return query -def namedtuple_to_son(namedtuple, prefix=''): +def namedtuple_to_son(ntuple, prefix=''): """ Converts a namedtuple into a SON object with the same key order """ son = SON() - for idx, field_name in enumerate(namedtuple._fields): - son[prefix + field_name] = namedtuple[idx] + for idx, field_name in enumerate(ntuple._fields): + son[prefix + field_name] = ntuple[idx] return son +metadata_cache_key = attrgetter('org', 'course') + + class MongoModuleStore(ModuleStoreBase): """ A Mongodb backed ModuleStore @@ -122,7 +224,8 @@ class MongoModuleStore(ModuleStoreBase): def __init__(self, host, db, collection, fs_root, render_template, port=27017, default_class=None, error_tracker=null_error_tracker, - user=None, password=None, **kwargs): + user=None, password=None, request_cache=None, + metadata_inheritance_cache_subsystem=None, **kwargs): ModuleStoreBase.__init__(self) @@ -135,7 +238,6 @@ class MongoModuleStore(ModuleStoreBase): if user is not None and password is not None: self.collection.database.authenticate(user, password) - # Force mongo to report errors, at the expense of performance self.collection.safe = True @@ -153,26 +255,29 @@ class MongoModuleStore(ModuleStoreBase): self.fs_root = path(fs_root) self.error_tracker = error_tracker self.render_template = render_template - self.metadata_inheritance_cache = {} + self.ignore_write_events_on_courses = [] + self.request_cache = request_cache + self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem - def get_metadata_inheritance_tree(self, location): + def compute_metadata_inheritance_tree(self, location): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' - + # get all collections in the course, this query should not return any leaf nodes - query = { + # note this is a bit ugly as when we add new categories of containers, we have to add it here + query = { '_id.org': location.org, '_id.course': location.course, - '$or': [ - {"_id.category":"course"}, - {"_id.category":"chapter"}, - {"_id.category":"sequential"}, - {"_id.category":"vertical"} - ] + '_id.category': {'$in': ['course', 'chapter', 'sequential', 'vertical']} } - # we just want the Location, children, and metadata - record_filter = {'_id':1,'definition.children':1,'metadata':1} + # we just want the Location, children, and inheritable metadata + record_filter = {'_id': 1, 'definition.children': 1} + + # just get the inheritable metadata since that is all we need for the computation + # this minimizes both data pushed over the wire + for attr in INHERITABLE_METADATA: + record_filter['metadata.{0}'.format(attr)] = 1 # call out to the DB resultset = self.collection.find(query, record_filter) @@ -189,51 +294,87 @@ class MongoModuleStore(ModuleStoreBase): # now traverse the tree and compute down the inherited metadata metadata_to_inherit = {} + def _compute_inherited_metadata(url): - my_metadata = results_by_url[url]['metadata'] + """ + Helper method for computing inherited metadata for a specific location url + """ + my_metadata = {} + # check for presence of metadata key. Note that a given module may not yet be fully formed. + # example: update_item -> update_children -> update_metadata sequence on new item create + # if we get called here without update_metadata called first then 'metadata' hasn't been set + # as we're not fully transactional at the DB layer. Same comment applies to below key name + # check + my_metadata = results_by_url[url].get('metadata', {}) for key in my_metadata.keys(): - if key not in XModuleDescriptor.inheritable_metadata: + if key not in INHERITABLE_METADATA: del my_metadata[key] results_by_url[url]['metadata'] = my_metadata # go through all the children and recurse, but only if we have # in the result set. Remember results will not contain leaf nodes - for child in results_by_url[url].get('definition',{}).get('children',[]): + for child in results_by_url[url].get('definition', {}).get('children', []): if child in results_by_url: new_child_metadata = copy.deepcopy(my_metadata) - new_child_metadata.update(results_by_url[child]['metadata']) + new_child_metadata.update(results_by_url[child].get('metadata', {})) results_by_url[child]['metadata'] = new_child_metadata metadata_to_inherit[child] = new_child_metadata _compute_inherited_metadata(child) else: # this is likely a leaf node, so let's record what metadata we need to inherit metadata_to_inherit[child] = my_metadata - + if root is not None: _compute_inherited_metadata(root) - cache = {'parent_metadata': metadata_to_inherit, - 'timestamp' : datetime.now()} + return metadata_to_inherit - return cache - - def get_cached_metadata_inheritance_tree(self, location, max_age_allowed): + def get_cached_metadata_inheritance_tree(self, location, force_refresh=False): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' - cache_name = '{0}/{1}'.format(location.org, location.course) - cache = self.metadata_inheritance_cache.get(cache_name,{'parent_metadata': {}, - 'timestamp': datetime.now() - timedelta(hours=1)}) - age = (datetime.now() - cache['timestamp']) + key = metadata_cache_key(location) + tree = {} + + if not force_refresh: + # see if we are first in the request cache (if present) + if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}): + return self.request_cache.data['metadata_inheritance'][key] - if age.seconds >= max_age_allowed: - logging.debug('loading entire inheritance tree for {0}'.format(cache_name)) - cache = self.get_metadata_inheritance_tree(location) - self.metadata_inheritance_cache[cache_name] = cache + # then look in any caching subsystem (e.g. memcached) + if self.metadata_inheritance_cache_subsystem is not None: + tree = self.metadata_inheritance_cache_subsystem.get(key, {}) + else: + logging.warning('Running MongoModuleStore without a metadata_inheritance_cache_subsystem. This is OK in localdev and testing environment. Not OK in production.') - return cache + if not tree: + # if not in subsystem, or we are on force refresh, then we have to compute + tree = self.compute_metadata_inheritance_tree(location) + + # now write out computed tree to caching subsystem (e.g. memcached), if available + if self.metadata_inheritance_cache_subsystem is not None: + self.metadata_inheritance_cache_subsystem.set(key, tree) + # now populate a request_cache, if available. NOTE, we are outside of the + # scope of the above if: statement so that after a memcache hit, it'll get + # put into the request_cache + if self.request_cache is not None: + # we can't assume the 'metadatat_inheritance' part of the request cache dict has been + # defined + if 'metadata_inheritance' not in self.request_cache.data: + self.request_cache.data['metadata_inheritance'] = {} + self.request_cache.data['metadata_inheritance'][key] = tree + return tree + + def refresh_cached_metadata_inheritance_tree(self, location): + """ + Refresh the cached metadata inheritance tree for the org/course combination + for location + """ + pseudo_course_id = '/'.join([location.org, location.course]) + if pseudo_course_id not in self.ignore_write_events_on_courses: + self.get_cached_metadata_inheritance_tree(location, force_refresh=True) def _clean_item_data(self, item): """ @@ -260,6 +401,9 @@ class MongoModuleStore(ModuleStoreBase): children.extend(item.get('definition', {}).get('children', [])) data[Location(item['location'])] = item + if depth == 0: + break + # Load all children by id. See # http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24or # for or-query syntax @@ -276,11 +420,11 @@ class MongoModuleStore(ModuleStoreBase): return data - def _load_item(self, item, data_cache): + def _load_item(self, item, data_cache, apply_cached_metadata=True): """ Load an XModuleDescriptor from item, using the children stored in data_cache """ - data_dir = item.get('metadata', {}).get('data_dir', item['location']['course']) + data_dir = getattr(item, 'data_dir', item['location']['course']) root = self.fs_root / data_dir if not root.isdir(): @@ -288,12 +432,9 @@ class MongoModuleStore(ModuleStoreBase): resource_fs = OSFS(root) - metadata_inheritance_tree = None - - # if we are loading a course object, there is no parent to inherit the metadata from - # so don't bother getting it - if item['location']['category'] != 'course': - metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']), 300) + cached_metadata = {} + if apply_cached_metadata: + cached_metadata = self.get_cached_metadata_inheritance_tree(Location(item['location'])) # TODO (cdodge): When the 'split module store' work has been completed, we should remove # the 'metadata_inheritance_tree' parameter @@ -304,7 +445,7 @@ class MongoModuleStore(ModuleStoreBase): resource_fs, self.error_tracker, self.render_template, - metadata_inheritance_tree = metadata_inheritance_tree + cached_metadata, ) return system.load_item(item['location']) @@ -315,7 +456,10 @@ class MongoModuleStore(ModuleStoreBase): """ data_cache = self._cache_children(items, depth) - return [self._load_item(item, data_cache) for item in items] + # if we are loading a course object, if we're not prefetching children (depth != 0) then don't + # bother with the metadata inheritance + return [self._load_item(item, data_cache, + apply_cached_metadata=(item['location']['category']!='course' or depth !=0)) for item in items] def get_courses(self): ''' @@ -398,7 +542,12 @@ class MongoModuleStore(ModuleStoreBase): try: source_item = self.collection.find_one(location_to_query(source)) source_item['_id'] = Location(location).dict() - self.collection.insert(source_item) + self.collection.insert( + source_item, + # Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False") + # from overriding our default value set in the init method. + safe=self.collection.safe + ) item = self._load_items([source_item])[0] # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so @@ -407,14 +556,20 @@ class MongoModuleStore(ModuleStoreBase): if location.category == 'static_tab': course = self.get_course_for_item(item.location) existing_tabs = course.tabs or [] - existing_tabs.append({'type': 'static_tab', 'name': item.metadata.get('display_name'), 'url_slug': item.location.name}) + existing_tabs.append({ + 'type': 'static_tab', + 'name': item.display_name, + 'url_slug': item.location.name + }) course.tabs = existing_tabs - self.update_metadata(course.location, course.metadata) + self.update_metadata(course.location, course._model_data._kvs._metadata) return item except pymongo.errors.DuplicateKeyError: raise DuplicateItemError(location) + # recompute (and update) the metadata inheritance tree which is cached + self.refresh_cached_metadata_inheritance_tree(Location(location)) def get_course_for_item(self, location, depth=0): ''' @@ -435,10 +590,11 @@ class MongoModuleStore(ModuleStoreBase): # make sure we found exactly one match on this above course search found_cnt = len(courses) if found_cnt == 0: - raise BaseException('Could not find course at {0}'.format(course_search_location)) + raise Exception('Could not find course at {0}'.format(course_search_location)) if found_cnt > 1: - raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + raise Exception('Found more than one course at {0}. There should only be one!!! ' + 'Dump = {1}'.format(course_search_location, courses)) return courses[0] @@ -455,6 +611,9 @@ class MongoModuleStore(ModuleStoreBase): {'$set': update}, multi=False, upsert=True, + # Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False") + # from overriding our default value set in the init method. + safe=self.collection.safe ) if result['n'] == 0: raise ItemNotFoundError(location) @@ -480,6 +639,8 @@ class MongoModuleStore(ModuleStoreBase): """ self._update_single_item(location, {'definition.children': children}) + # recompute (and update) the metadata inheritance tree which is cached + self.refresh_cached_metadata_inheritance_tree(Location(location)) def update_metadata(self, location, metadata): """ @@ -501,10 +662,11 @@ class MongoModuleStore(ModuleStoreBase): tab['name'] = metadata.get('display_name') break course.tabs = existing_tabs - self.update_metadata(course.location, course.metadata) + self.update_metadata(course.location, own_metadata(course)) self._update_single_item(location, {'metadata': metadata}) - + # recompute (and update) the metadata inheritance tree which is cached + self.refresh_cached_metadata_inheritance_tree(loc) def delete_item(self, location): """ @@ -520,10 +682,14 @@ class MongoModuleStore(ModuleStoreBase): course = self.get_course_for_item(item.location) existing_tabs = course.tabs or [] course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name] - self.update_metadata(course.location, course.metadata) - - self.collection.remove({'_id': Location(location).dict()}) + self.update_metadata(course.location, own_metadata(course)) + self.collection.remove({'_id': Location(location).dict()}, + # Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False") + # from overriding our default value set in the init method. + safe=self.collection.safe) + # recompute (and update) the metadata inheritance tree which is cached + self.refresh_cached_metadata_inheritance_tree(Location(location)) def get_parent_locations(self, location, course_id): '''Find all locations that are the parents of this location in this @@ -544,4 +710,10 @@ class MongoModuleStore(ModuleStoreBase): # DraftModuleStore is first, because it needs to intercept calls to MongoModuleStore class DraftMongoModuleStore(DraftModuleStore, MongoModuleStore): + """ + Version of MongoModuleStore with draft capability mixed in + """ + """ + Version of MongoModuleStore with draft capability mixed in + """ pass diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index 5146ac18c8..cb3cd375a7 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -41,22 +41,24 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele print "Cloning module {0} to {1}....".format(original_loc, module.location) - if 'data' in module.definition: - modulestore.update_item(module.location, module.definition['data']) + modulestore.update_item(module.location, module._model_data._kvs._data) # repoint children - if 'children' in module.definition: + if module.has_children: new_children = [] - for child_loc_url in module.definition['children']: + for child_loc_url in module.children: child_loc = Location(child_loc_url) - child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org, - course=dest_location.course) - new_children = new_children + [child_loc.url()] + child_loc = child_loc._replace( + tag=dest_location.tag, + org=dest_location.org, + course=dest_location.course + ) + new_children.append(child_loc.url()) modulestore.update_children(module.location, new_children) # save metadata - modulestore.update_metadata(module.location, module.metadata) + modulestore.update_metadata(module.location, module._model_data._kvs._metadata) # now iterate through all of the assets and clone them # first the thumbnails diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index f2a291d680..1a82e1b708 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -4,6 +4,7 @@ from uuid import uuid4 from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.timeparse import stringify_time +from xmodule.modulestore.inheritance import own_metadata def XMODULE_COURSE_CREATION(class_to_create, **kwargs): @@ -24,8 +25,7 @@ class XModuleCourseFactory(Factory): @classmethod def _create(cls, target_class, *args, **kwargs): - # This logic was taken from the create_new_course method in - # cms/djangoapps/contentstore/views.py + template = Location('i4x', 'edx', 'templates', 'course', 'Empty') org = kwargs.get('org') number = kwargs.get('number') @@ -40,11 +40,9 @@ class XModuleCourseFactory(Factory): # This metadata code was copied from cms/djangoapps/contentstore/views.py if display_name is not None: - new_course.metadata['display_name'] = display_name - - new_course.metadata['data_dir'] = uuid4().hex - new_course.metadata['start'] = stringify_time(gmtime()) + new_course.display_name = display_name + new_course.lms.start = gmtime() new_course.tabs = [{"type": "courseware"}, {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, @@ -52,7 +50,7 @@ class XModuleCourseFactory(Factory): {"type": "progress", "name": "Progress"}] # Update the data in the mongo datastore - store.update_metadata(new_course.location.url(), new_course.own_metadata) + store.update_metadata(new_course.location.url(), own_metadata(new_course)) return new_course @@ -81,35 +79,59 @@ class XModuleItemFactory(Factory): @classmethod def _create(cls, target_class, *args, **kwargs): """ - kwargs must include parent_location, template. Can contain display_name - target_class is ignored + Uses *kwargs*: + + *parent_location* (required): the location of the parent module + (e.g. the parent course or section) + + *template* (required): the template to create the item from + (e.g. i4x://templates/section/Empty) + + *data* (optional): the data for the item + (e.g. XML problem definition for a problem item) + + *display_name* (optional): the display name of the item + + *metadata* (optional): dictionary of metadata attributes + + *target_class* is ignored """ DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] parent_location = Location(kwargs.get('parent_location')) template = Location(kwargs.get('template')) + data = kwargs.get('data') display_name = kwargs.get('display_name') + metadata = kwargs.get('metadata', {}) store = modulestore('direct') # This code was based off that in cms/djangoapps/contentstore/views.py parent = store.get_item(parent_location) - dest_location = parent_location._replace(category=template.category, name=uuid4().hex) + + # If a display name is set, use that + dest_name = display_name.replace(" ", "_") if display_name is not None else uuid4().hex + dest_location = parent_location._replace(category=template.category, + name=dest_name) new_item = store.clone_item(template, dest_location) - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - # replace the display name with an optional parameter passed in from the caller if display_name is not None: - new_item.metadata['display_name'] = display_name + new_item.display_name = display_name - store.update_metadata(new_item.location.url(), new_item.own_metadata) + # Add additional metadata or override current metadata + item_metadata = own_metadata(new_item) + item_metadata.update(metadata) + store.update_metadata(new_item.location.url(), item_metadata) + + # replace the data with the optional *data* parameter + if data is not None: + store.update_item(new_item.location, data) if new_item.location.category not in DETACHED_CATEGORIES: - store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + store.update_children(parent_location, parent.children + [new_item.location.url()]) return new_item diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 6f6f47ba85..061d70d09f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -1,6 +1,7 @@ import pymongo -from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup +from mock import Mock +from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup, assert_false from pprint import pprint from xmodule.modulestore import Location diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 1bd27189e9..677f8b7d6a 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -23,13 +23,14 @@ from xmodule.html_module import HtmlDescriptor from . import ModuleStoreBase, Location from .exceptions import ItemNotFoundError +from .inheritance import compute_inherited_metadata edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_comments=True, remove_blank_text=True) etree.set_default_parser(edx_xml_parser) -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) # VS[compat] @@ -73,7 +74,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check) # tags that really need unique names--they store (or should store) state. - need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'timelimit') + need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', + 'videosequence', 'poll_question', 'timelimit') attr = xml_data.attrib tag = xml_data.tag @@ -161,7 +163,6 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): etree.tostring(xml_data, encoding='unicode'), self, self.org, self.course, xmlstore.default_class) except Exception as err: - print err, self.load_error_modules if not self.load_error_modules: raise @@ -174,7 +175,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # Normally, we don't want lots of exception traces in our logs from common # content problems. But if you're debugging the xml loading code itself, # uncomment the next line. - # log.exception(msg) + log.exception(msg) self.error_tracker(msg) err_msg = msg + "\n" + exc_info_to_str(sys.exc_info()) @@ -186,12 +187,13 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): err_msg ) - descriptor.metadata['data_dir'] = course_dir + setattr(descriptor, 'data_dir', course_dir) xmlstore.modules[course_id][descriptor.location] = descriptor - for child in descriptor.get_children(): - parent_tracker.add_parent(child.location, descriptor.location) + if hasattr(descriptor, 'children'): + for child in descriptor.get_children(): + parent_tracker.add_parent(child.location, descriptor.location) return descriptor render_template = lambda: '' @@ -318,8 +320,6 @@ class XMLModuleStore(ModuleStoreBase): # Didn't load course. Instead, save the errors elsewhere. self.errored_courses[course_dir] = errorlog - - def __unicode__(self): ''' String representation - for debugging @@ -345,8 +345,6 @@ class XMLModuleStore(ModuleStoreBase): log.warning(msg + " " + str(err)) return {} - - def load_course(self, course_dir, tracker): """ Load a course into this module store @@ -430,7 +428,7 @@ class XMLModuleStore(ModuleStoreBase): # breaks metadata inheritance via get_children(). Instead # (actually, in addition to, for now), we do a final inheritance pass # after we have the course descriptor. - XModuleDescriptor.compute_inherited_metadata(course_descriptor) + compute_inherited_metadata(course_descriptor) # now import all pieces of course_info which is expected to be stored # in /info or /info/ @@ -449,7 +447,6 @@ class XMLModuleStore(ModuleStoreBase): def load_extra_content(self, system, course_descriptor, category, base_dir, course_dir, url_name): - self._load_extra_content(system, course_descriptor, category, base_dir, course_dir) # then look in a override folder based on the course run @@ -460,26 +457,29 @@ class XMLModuleStore(ModuleStoreBase): def _load_extra_content(self, system, course_descriptor, category, path, course_dir): for filepath in glob.glob(path / '*'): - if not os.path.isdir(filepath): - with open(filepath) as f: - try: - html = f.read().decode('utf-8') - # tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix - slug = os.path.splitext(os.path.basename(filepath))[0] - loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug) - module = HtmlDescriptor(system, definition={'data': html}, **{'location': loc}) - # VS[compat]: - # Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them) - # from the course policy - if category == "static_tab": - for tab in course_descriptor.tabs or []: - if tab.get('url_slug') == slug: - module.metadata['display_name'] = tab['name'] - module.metadata['data_dir'] = course_dir - self.modules[course_descriptor.id][module.location] = module - except Exception, e: - logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e))) - system.error_tracker("ERROR: " + str(e)) + if not os.path.isfile(filepath): + continue + + with open(filepath) as f: + try: + html = f.read().decode('utf-8') + # tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix + slug = os.path.splitext(os.path.basename(filepath))[0] + loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug) + module = HtmlDescriptor(system, loc, {'data': html}) + # VS[compat]: + # Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them) + # from the course policy + if category == "static_tab": + for tab in course_descriptor.tabs or []: + if tab.get('url_slug') == slug: + module.display_name = tab['name'] + module.data_dir = course_dir + self.modules[course_descriptor.id][module.location] = module + except Exception, e: + logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e))) + system.error_tracker("ERROR: " + str(e)) + def get_instance(self, course_id, location, depth=0): """ diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index edf4708687..0724211ed3 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -1,6 +1,7 @@ import logging from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore +from xmodule.modulestore.inheritance import own_metadata from fs.osfs import OSFS from json import dumps @@ -31,14 +32,12 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d # export the grading policy policies_dir = export_fs.makeopendir('policies') course_run_policy_dir = policies_dir.makeopendir(course.location.name) - if 'grading_policy' in course.definition['data']: - with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy: - grading_policy.write(dumps(course.definition['data']['grading_policy'])) + with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy: + grading_policy.write(dumps(course.grading_policy)) # export all of the course metadata in policy.json with course_run_policy_dir.open('policy.json', 'w') as course_policy: - policy = {} - policy = {'course/' + course.location.name: course.metadata} + policy = {'course/' + course.location.name: own_metadata(course)} course_policy.write(dumps(policy)) @@ -50,4 +49,4 @@ def export_extra_content(export_fs, modulestore, course_location, category_type, item_dir = export_fs.makeopendir(dirname) for item in items: with item_dir.open(item.location.name + file_suffix, 'w') as item_file: - item_file.write(item.definition['data'].encode('utf8')) + item_file.write(item.data.encode('utf8')) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 0b77900ae9..6a4ce5131b 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -4,10 +4,13 @@ import mimetypes from lxml.html import rewrite_links as lxml_rewrite_links from path import path +from xblock.core import Scope + from .xml import XMLModuleStore from .exceptions import DuplicateItemError from xmodule.modulestore import Location from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX +from .inheritance import own_metadata log = logging.getLogger(__name__) @@ -20,6 +23,8 @@ def import_static_content(modules, course_loc, course_data_path, static_content_ # now import all static assets static_dir = course_data_path / subpath + verbose = True + for dirname, dirnames, filenames in os.walk(static_dir): for filename in filenames: @@ -95,6 +100,79 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic return link +def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False): + # remap module to the new namespace + if target_location_namespace is not None: + # This looks a bit wonky as we need to also change the 'name' of the imported course to be what + # the caller passed in + if module.location.category != 'course': + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + else: + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course, name=target_location_namespace.name) + + # then remap children pointers since they too will be re-namespaced + if module.has_children: + children_locs = module.children + new_locs = [] + for child in children_locs: + child_loc = Location(child) + new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + + new_locs.append(new_child_loc.url()) + + module.children = new_locs + + if hasattr(module, 'data'): + # cdodge: now go through any link references to '/static/' and make sure we've imported + # it as a StaticContent asset + try: + remap_dict = {} + + # use the rewrite_links as a utility means to enumerate through all links + # in the module data. We use that to load that reference into our asset store + # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to + # do the rewrites natively in that code. + # For example, what I'm seeing is -> + # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's + # no good, so we have to do this kludge + if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code + lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, + static_content_store, link, remap_dict)) + + for key in remap_dict.keys(): + module.data = module.data.replace(key, remap_dict[key]) + + except Exception: + logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) + + modulestore.update_item(module.location, module.data) + + if module.has_children: + modulestore.update_children(module.location, module.children) + + modulestore.update_metadata(module.location, own_metadata(module)) + + +def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False): + # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which + # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, + # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - + # if there is *any* tabs - then there at least needs to be some predefined ones + if module.tabs is None or len(module.tabs) == 0: + module.tabs = [{"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge + + # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg + # so let's make sure we import in case there are no other references to it in the modules + verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg') + import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose) + + def import_from_xml(store, data_dir, course_dirs=None, default_class='xmodule.raw_module.RawDescriptor', load_error_modules=True, static_content_store=None, target_location_namespace=None, verbose=False): @@ -125,104 +203,130 @@ def import_from_xml(store, data_dir, course_dirs=None, course_items = [] for course_id in module_store.modules.keys(): - course_data_path = None - course_location = None + if target_location_namespace is not None: + pseudo_course_id = '/'.join([target_location_namespace.org, target_location_namespace.course]) + else: + course_id_components = course_id.split('/') + pseudo_course_id = '/'.join([course_id_components[0], course_id_components[1]]) - if verbose: - log.debug("Scanning {0} for course module...".format(course_id)) + try: + # turn off all write signalling while importing as this is a high volume operation + if pseudo_course_id not in store.ignore_write_events_on_courses: + store.ignore_write_events_on_courses.append(pseudo_course_id) - # Quick scan to get course module as we need some info from there. Also we need to make sure that the - # course module is committed first into the store - for module in module_store.modules[course_id].itervalues(): - if module.category == 'course': - course_data_path = path(data_dir) / module.metadata['data_dir'] - course_location = module.location - - module = remap_namespace(module, target_location_namespace) - - # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which - # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, - # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - - # if there is *any* tabs - then there at least needs to be some predefined ones - if module.tabs is None or len(module.tabs) == 0: - module.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge - - - store.update_item(module.location, module.definition['data']) - if 'children' in module.definition: - store.update_children(module.location, module.definition['children']) - store.update_metadata(module.location, dict(module.own_metadata)) - - # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg - # so let's make sure we import in case there are no other references to it in the modules - verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg') - - course_items.append(module) - - - # then import all the static content - if static_content_store is not None: - _namespace_rename = target_location_namespace if target_location_namespace is not None else course_location - - # first pass to find everything in /static/ - import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store, - _namespace_rename, subpath='static', verbose=verbose) - - # finally loop through all the modules - for module in module_store.modules[course_id].itervalues(): - - if module.category == 'course': - # we've already saved the course module up at the top of the loop - # so just skip over it in the inner loop - continue - - # remap module to the new namespace - if target_location_namespace is not None: - module = remap_namespace(module, target_location_namespace) + course_data_path = None + course_location = None if verbose: - log.debug('importing module location {0}'.format(module.location)) + log.debug("Scanning {0} for course module...".format(course_id)) - if 'data' in module.definition: - module_data = module.definition['data'] + # Quick scan to get course module as we need some info from there. Also we need to make sure that the + # course module is committed first into the store + for module in module_store.modules[course_id].itervalues(): + if module.category == 'course': + course_data_path = path(data_dir) / module.data_dir + course_location = module.location - # cdodge: now go through any link references to '/static/' and make sure we've imported - # it as a StaticContent asset - try: - remap_dict = {} + module = remap_namespace(module, target_location_namespace) - # use the rewrite_links as a utility means to enumerate through all links - # in the module data. We use that to load that reference into our asset store - # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to - # do the rewrites natively in that code. - # For example, what I'm seeing is -> - # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's - # no good, so we have to do this kludge - if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code - lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path, - static_content_store, link, remap_dict)) + # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which + # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, + # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - + # if there is *any* tabs - then there at least needs to be some predefined ones + if module.tabs is None or len(module.tabs) == 0: + module.tabs = [{"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge - for key in remap_dict.keys(): - module_data = module_data.replace(key, remap_dict[key]) - except Exception, e: - logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) + if hasattr(module, 'data'): + store.update_item(module.location, module.data) + store.update_children(module.location, module.children) + store.update_metadata(module.location, dict(own_metadata(module))) - store.update_item(module.location, module_data) + # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg + # so let's make sure we import in case there are no other references to it in the modules + verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg') - if 'children' in module.definition: - store.update_children(module.location, module.definition['children']) + course_items.append(module) - # NOTE: It's important to use own_metadata here to avoid writing - # inherited metadata everywhere. - store.update_metadata(module.location, dict(module.own_metadata)) + + # then import all the static content + if static_content_store is not None: + _namespace_rename = target_location_namespace if target_location_namespace is not None else course_location + + # first pass to find everything in /static/ + import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store, + _namespace_rename, subpath='static', verbose=verbose) + + # finally loop through all the modules + for module in module_store.modules[course_id].itervalues(): + + if module.category == 'course': + # we've already saved the course module up at the top of the loop + # so just skip over it in the inner loop + continue + + # remap module to the new namespace + if target_location_namespace is not None: + module = remap_namespace(module, target_location_namespace) + + if verbose: + log.debug('importing module location {0}'.format(module.location)) + + content = {} + for field in module.fields: + if field.scope != Scope.content: + continue + try: + content[field.name] = module._model_data[field.name] + except KeyError: + # Ignore any missing keys in _model_data + pass + + if 'data' in content: + module_data = content['data'] + + # cdodge: now go through any link references to '/static/' and make sure we've imported + # it as a StaticContent asset + try: + remap_dict = {} + + # use the rewrite_links as a utility means to enumerate through all links + # in the module data. We use that to load that reference into our asset store + # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to + # do the rewrites natively in that code. + # For example, what I'm seeing is -> + # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's + # no good, so we have to do this kludge + if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code + lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path, + static_content_store, link, remap_dict)) + + for key in remap_dict.keys(): + module_data = module_data.replace(key, remap_dict[key]) + + except Exception, e: + logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) + + store.update_item(module.location, content) + + if hasattr(module, 'children') and module.children != []: + store.update_children(module.location, module.children) + + # NOTE: It's important to use own_metadata here to avoid writing + # inherited metadata everywhere. + store.update_metadata(module.location, dict(own_metadata(module))) + finally: + # turn back on all write signalling + if pseudo_course_id in store.ignore_write_events_on_courses: + store.ignore_write_events_on_courses.remove(pseudo_course_id) + store.refresh_cached_metadata_inheritance_tree(target_location_namespace if + target_location_namespace is not None else course_location) return module_store, course_items - def remap_namespace(module, target_location_namespace): if target_location_namespace is None: return module @@ -237,21 +341,21 @@ def remap_namespace(module, target_location_namespace): course=target_location_namespace.course, name=target_location_namespace.name) # then remap children pointers since they too will be re-namespaced - children_locs = module.definition.get('children') - if children_locs is not None: - new_locs = [] - for child in children_locs: - child_loc = Location(child) - new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course) + if hasattr(module,'children'): + children_locs = module.children + if children_locs is not None and children_locs != []: + new_locs = [] + for child in children_locs: + child_loc = Location(child) + new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) - new_locs.append(new_child_loc.url()) + new_locs.append(new_child_loc.url()) - module.definition['children'] = new_locs + module.children = new_locs return module - def validate_category_hierarchy(module_store, course_id, parent_category, expected_child_category): err_cnt = 0 @@ -262,7 +366,7 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect parents.append(module) for parent in parents: - for child_loc in [Location(child) for child in parent.definition.get('children', [])]: + for child_loc in [Location(child) for child in parent.children]: if child_loc.category != expected_child_category: err_cnt += 1 print 'ERROR: child {0} of parent {1} was expected to be category of {2} but was {3}'.format( @@ -274,7 +378,7 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect def validate_data_source_path_existence(path, is_err=True, extra_msg=None): _cnt = 0 if not os.path.exists(path): - print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if + print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if extra_msg is not None else '')) _cnt = 1 return _cnt diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 20cedaab75..6fe37b9525 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -3,16 +3,14 @@ import logging from lxml import etree from lxml.html import rewrite_links from xmodule.timeinfo import TimeInfo -from xmodule.capa_module import only_one, ComplexEncoder +from xmodule.capa_module import ComplexEncoder from xmodule.editing_module import EditingDescriptor -from xmodule.html_checker import check_html from xmodule.progress import Progress from xmodule.stringify import stringify_children -from xmodule.x_module import XModule from xmodule.xml_module import XmlDescriptor import self_assessment_module import open_ended_module -from combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST +from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST log = logging.getLogger("mitx.courseware") @@ -26,7 +24,7 @@ MAX_ATTEMPTS = 1 MAX_SCORE = 1 #The highest score allowed for the overall xmodule and for each rubric point -MAX_SCORE_ALLOWED = 3 +MAX_SCORE_ALLOWED = 50 #If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress #Metadata overrides this. @@ -121,17 +119,10 @@ class CombinedOpenEndedV1Module(): """ - self.metadata = metadata - self.display_name = metadata.get('display_name', "Open Ended") + self.instance_state = instance_state + self.display_name = instance_state.get('display_name', "Open Ended") self.rewrite_content_links = static_data.get('rewrite_content_links', "") - - # Load instance state - if instance_state is not None: - instance_state = json.loads(instance_state) - else: - instance_state = {} - #We need to set the location here so the child modules can use it system.set('location', location) self.system = system @@ -143,18 +134,18 @@ class CombinedOpenEndedV1Module(): #Overall state of the combined open ended module self.state = instance_state.get('state', self.INITIAL) - self.attempts = instance_state.get('attempts', 0) + self.student_attempts = instance_state.get('student_attempts', 0) #Allow reset is true if student has failed the criteria to move to the next child task - self.allow_reset = instance_state.get('ready_to_reset', False) - self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) - self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT - self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT - self.skip_basic_checks = self.metadata.get('skip_spelling_checks', SKIP_BASIC_CHECKS) + self.ready_to_reset = instance_state.get('ready_to_reset', False) + self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS) + self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT + self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT + self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT - display_due_date_string = self.metadata.get('due', None) + display_due_date_string = self.instance_state.get('due', None) - grace_period_string = self.metadata.get('graceperiod', None) + grace_period_string = self.instance_state.get('graceperiod', None) try: self.timeinfo = TimeInfo(display_due_date_string, grace_period_string) except: @@ -164,7 +155,7 @@ class CombinedOpenEndedV1Module(): # Used for progress / grading. Currently get credit just for # completion (doesn't matter if you self-assessed correct/incorrect). - self._max_score = int(self.metadata.get('max_score', MAX_SCORE)) + self._max_score = self.instance_state.get('max_score', MAX_SCORE) self.rubric_renderer = CombinedOpenEndedRubric(system, True) rubric_string = stringify_children(definition['rubric']) @@ -173,7 +164,7 @@ class CombinedOpenEndedV1Module(): #Static data is passed to the child modules to render self.static_data = { 'max_score': self._max_score, - 'max_attempts': self.max_attempts, + 'max_attempts': self.attempts, 'prompt': definition['prompt'], 'rubric': definition['rubric'], 'display_name': self.display_name, @@ -207,10 +198,10 @@ class CombinedOpenEndedV1Module(): last_response = last_response_data['response'] loaded_task_state = json.loads(current_task_state) - if loaded_task_state['state'] == self.INITIAL: - loaded_task_state['state'] = self.ASSESSING - loaded_task_state['created'] = True - loaded_task_state['history'].append({'answer': last_response}) + if loaded_task_state['child_state'] == self.INITIAL: + loaded_task_state['child_state'] = self.ASSESSING + loaded_task_state['child_created'] = True + loaded_task_state['child_history'].append({'answer': last_response}) current_task_state = json.dumps(loaded_task_state) return current_task_state @@ -249,8 +240,8 @@ class CombinedOpenEndedV1Module(): self.current_task_xml = self.task_xml[self.current_task_number] if self.current_task_number > 0: - self.allow_reset = self.check_allow_reset() - if self.allow_reset: + self.ready_to_reset = self.check_allow_reset() + if self.ready_to_reset: self.current_task_number = self.current_task_number - 1 current_task_type = self.get_tag_name(self.current_task_xml) @@ -276,12 +267,12 @@ class CombinedOpenEndedV1Module(): last_response_data = self.get_last_response(self.current_task_number - 1) last_response = last_response_data['response'] current_task_state = json.dumps({ - 'state': self.ASSESSING, + 'child_state': self.ASSESSING, 'version': self.STATE_VERSION, 'max_score': self._max_score, - 'attempts': 0, - 'created': True, - 'history': [{'answer': last_response}], + 'child_attempts': 0, + 'child_created': True, + 'child_history': [{'answer': last_response}], }) self.current_task = child_task_module(self.system, self.location, self.current_task_parsed_xml, self.current_task_descriptor, @@ -306,7 +297,7 @@ class CombinedOpenEndedV1Module(): Input: None Output: the allow_reset attribute of the current module. """ - if not self.allow_reset: + if not self.ready_to_reset: if self.current_task_number > 0: last_response_data = self.get_last_response(self.current_task_number - 1) current_response_data = self.get_current_attributes(self.current_task_number) @@ -314,9 +305,9 @@ class CombinedOpenEndedV1Module(): if (current_response_data['min_score_to_attempt'] > last_response_data['score'] or current_response_data['max_score_to_attempt'] < last_response_data['score']): self.state = self.DONE - self.allow_reset = True + self.ready_to_reset = True - return self.allow_reset + return self.ready_to_reset def get_context(self): """ @@ -330,7 +321,7 @@ class CombinedOpenEndedV1Module(): context = { 'items': [{'content': task_html}], 'ajax_url': self.system.ajax_url, - 'allow_reset': self.allow_reset, + 'allow_reset': self.ready_to_reset, 'state': self.state, 'task_count': len(self.task_xml), 'task_number': self.current_task_number + 1, @@ -372,7 +363,15 @@ class CombinedOpenEndedV1Module(): """ self.update_task_states() html = self.current_task.get_html(self.system) - return_html = rewrite_links(html, self.rewrite_content_links) + return_html = html + try: + #Without try except block, get this error: + # File "/home/vik/mitx_all/mitx/common/lib/xmodule/xmodule/x_module.py", line 263, in rewrite_content_links + # if link.startswith(XASSET_SRCREF_PREFIX): + # Placing try except so that if the error is fixed, this code will start working again. + return_html = rewrite_links(html, self.rewrite_content_links) + except: + pass return return_html def get_current_attributes(self, task_number): @@ -426,7 +425,7 @@ class CombinedOpenEndedV1Module(): else: last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment) last_post_assessment = last_post_evaluation - rubric_data = task._parse_score_msg(task.history[-1].get('post_assessment', ""), self.system) + rubric_data = task._parse_score_msg(task.child_history[-1].get('post_assessment', ""), self.system) rubric_scores = rubric_data['rubric_scores'] grader_types = rubric_data['grader_types'] feedback_items = rubric_data['feedback_items'] @@ -440,7 +439,7 @@ class CombinedOpenEndedV1Module(): last_post_assessment = "" last_correctness = task.is_last_response_correct() max_score = task.max_score() - state = task.state + state = task.child_state if task_type in HUMAN_TASK_TYPE: human_task_name = HUMAN_TASK_TYPE[task_type] else: @@ -490,10 +489,10 @@ class CombinedOpenEndedV1Module(): Output: boolean indicating whether or not the task state changed. """ changed = False - if not self.allow_reset: + if not self.ready_to_reset: self.task_states[self.current_task_number] = self.current_task.get_instance_state() current_task_state = json.loads(self.task_states[self.current_task_number]) - if current_task_state['state'] == self.DONE: + if current_task_state['child_state'] == self.DONE: self.current_task_number += 1 if self.current_task_number >= (len(self.task_xml)): self.state = self.DONE @@ -647,7 +646,7 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered """ self.update_task_states() - return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset} + return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.ready_to_reset} def reset(self, get): """ @@ -656,26 +655,26 @@ class CombinedOpenEndedV1Module(): Output: AJAX dictionary to tbe rendered """ if self.state != self.DONE: - if not self.allow_reset: + if not self.ready_to_reset: return self.out_of_sync_error(get) - if self.attempts > self.max_attempts: + if self.student_attempts > self.attempts: return { 'success': False, #This is a student_facing_error 'error': ('You have attempted this question {0} times. ' 'You are only allowed to attempt it {1} times.').format( - self.attempts, self.max_attempts) + self.student_attempts, self.attempts) } self.state = self.INITIAL - self.allow_reset = False + self.ready_to_reset = False for i in xrange(0, len(self.task_xml)): self.current_task_number = i self.setup_next_task(reset=True) self.current_task.reset(self.system) self.task_states[self.current_task_number] = self.current_task.get_instance_state() self.current_task_number = 0 - self.allow_reset = False + self.ready_to_reset = False self.setup_next_task() return {'success': True, 'html': self.get_html_nonsystem()} @@ -691,8 +690,8 @@ class CombinedOpenEndedV1Module(): 'current_task_number': self.current_task_number, 'state': self.state, 'task_states': self.task_states, - 'attempts': self.attempts, - 'ready_to_reset': self.allow_reset, + 'student_attempts': self.student_attempts, + 'ready_to_reset': self.ready_to_reset, } return json.dumps(state) @@ -727,7 +726,7 @@ class CombinedOpenEndedV1Module(): entirely, in which case they will be in the self.DONE state), and if it is scored or not. @return: Boolean corresponding to the above. """ - return (self.state == self.DONE or self.allow_reset) and self.is_scored + return (self.state == self.DONE or self.ready_to_reset) and self.is_scored def get_score(self): """ @@ -778,7 +777,7 @@ class CombinedOpenEndedV1Module(): return progress_object -class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): +class CombinedOpenEndedV1Descriptor(): """ Module for adding combined open ended questions """ @@ -790,6 +789,9 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): has_score = True template_dir_name = "combinedopenended" + def __init__(self, system): + self.system = system + @classmethod def definition_from_xml(cls, xml_object, system): """ diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py index 287aeb5c24..bceb12e444 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py @@ -101,7 +101,7 @@ class CombinedOpenEndedRubric(object): log.error(error_message) raise RubricParsingError(error_message) - if total != max_score: + if int(total) != int(max_score): #This is a staff_facing_error error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format( max_score, location, total) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py index 21715c8e57..08f2a95387 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py @@ -1,5 +1,5 @@ import logging -from grading_service_module import GradingService +from .grading_service_module import GradingService log = logging.getLogger(__name__) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py index 0f961794d5..f3f6568b1e 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py @@ -5,7 +5,7 @@ import requests from requests.exceptions import RequestException, ConnectionError, HTTPError import sys -from combined_open_ended_rubric import CombinedOpenEndedRubric +from .combined_open_ended_rubric import CombinedOpenEndedRubric from lxml import etree log = logging.getLogger(__name__) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py index 6956f336a5..2eb9502269 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py @@ -36,7 +36,7 @@ ALLOWABLE_IMAGE_SUFFIXES = [ ] #Maximum allowed dimensions (x and y) for an uploaded image -MAX_ALLOWED_IMAGE_DIM = 1500 +MAX_ALLOWED_IMAGE_DIM = 2000 #Dimensions to which image is resized before it is evaluated for color count, etc MAX_IMAGE_DIM = 150 @@ -178,7 +178,7 @@ class URLProperties(object): Runs all available url tests @return: True if URL passes tests, false if not. """ - url_is_okay = self.check_suffix() and self.check_if_parses() and self.check_domain() + url_is_okay = self.check_suffix() and self.check_if_parses() return url_is_okay def check_domain(self): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index fc53a62c06..8373700837 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -22,7 +22,7 @@ from numpy import median from datetime import datetime -from combined_open_ended_rubric import CombinedOpenEndedRubric +from .combined_open_ended_rubric import CombinedOpenEndedRubric log = logging.getLogger("mitx.courseware") @@ -65,17 +65,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if oeparam is None: #This is a staff_facing_error raise ValueError(error_message.format('oeparam')) - if self.prompt is None: + if self.child_prompt is None: raise ValueError(error_message.format('prompt')) - if self.rubric is None: + if self.child_rubric is None: raise ValueError(error_message.format('rubric')) - self._parse(oeparam, self.prompt, self.rubric, system) + self._parse(oeparam, self.child_prompt, self.child_rubric, system) - if self.created == True and self.state == self.ASSESSING: - self.created = False + if self.child_created == True and self.child_state == self.ASSESSING: + self.child_created = False self.send_to_grader(self.latest_answer(), system) - self.created = False + self.child_created = False def _parse(self, oeparam, prompt, rubric, system): ''' @@ -89,8 +89,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # Note that OpenEndedResponse is agnostic to the specific contents of grader_payload prompt_string = stringify_children(prompt) rubric_string = stringify_children(rubric) - self.prompt = prompt_string - self.rubric = rubric_string + self.child_prompt = prompt_string + self.child_rubric = rubric_string grader_payload = oeparam.find('grader_payload') grader_payload = grader_payload.text if grader_payload is not None else '' @@ -131,7 +131,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): @param system: ModuleSystem @return: Success indicator """ - self.state = self.DONE + self.child_state = self.DONE return {'success': True} def message_post(self, get, system): @@ -171,10 +171,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild): anonymous_student_id = system.anonymous_student_id queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime + anonymous_student_id + - str(len(self.history))) + str(len(self.child_history))) xheader = xqueue_interface.make_xheader( - lms_callback_url=system.xqueue['callback_url'], + lms_callback_url=system.xqueue['construct_callback'](), lms_key=queuekey, queue_name=self.message_queue_name ) @@ -198,7 +198,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if error: success = False - self.state = self.DONE + self.child_state = self.DONE #This is a student_facing_message return {'success': success, 'msg': "Successfully submitted your feedback."} @@ -222,9 +222,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # Generate header queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime + anonymous_student_id + - str(len(self.history))) + str(len(self.child_history))) - xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'], + xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['construct_callback'](), lms_key=queuekey, queue_name=self.queue_name) @@ -265,7 +265,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): self.record_latest_score(new_score_msg['score']) self.record_latest_post_assessment(score_msg) - self.state = self.POST_ASSESSMENT + self.child_state = self.POST_ASSESSMENT return True @@ -542,16 +542,16 @@ class OpenEndedModule(openendedchild.OpenEndedChild): @param short_feedback: If the long feedback is wanted or not @return: Returns formatted feedback """ - if not self.history: + if not self.child_history: return "" - feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system, + feedback_dict = self._parse_score_msg(self.child_history[-1].get('post_assessment', ""), system, join_feedback=join_feedback) if not short_feedback: return feedback_dict['feedback'] if feedback_dict['valid'] else '' if feedback_dict['valid']: short_feedback = self._convert_longform_feedback_to_html( - json.loads(self.history[-1].get('post_assessment', ""))) + json.loads(self.child_history[-1].get('post_assessment', ""))) return short_feedback if feedback_dict['valid'] else '' def format_feedback_with_evaluation(self, system, feedback): @@ -604,7 +604,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): @param system: Modulesystem (needed to align with other ajax functions) @return: Returns the current state """ - state = self.state + state = self.child_state return {'state': state} def save_answer(self, get, system): @@ -620,7 +620,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if closed: return msg - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: return self.out_of_sync_error(get) # add new history element with answer and empty score and hint. @@ -667,13 +667,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild): """ #set context variables and render template eta_string = None - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: latest = self.latest_answer() previous_answer = latest if latest is not None else self.initial_display post_assessment = self.latest_post_assessment(system) score = self.latest_score() correct = 'correct' if self.is_submission_correct(score) else 'incorrect' - if self.state == self.ASSESSING: + if self.child_state == self.ASSESSING: eta_string = self.get_eta() else: post_assessment = "" @@ -681,9 +681,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild): previous_answer = self.initial_display context = { - 'prompt': self.prompt, + 'prompt': self.child_prompt, 'previous_answer': previous_answer, - 'state': self.state, + 'state': self.child_state, 'allow_reset': self._allow_reset(), 'rows': 30, 'cols': 80, @@ -698,7 +698,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return html -class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor): +class OpenEndedDescriptor(): """ Module for adding open ended response questions to courses """ @@ -710,6 +710,9 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor): has_score = True template_dir_name = "openended" + def __init__(self, system): + self.system =system + @classmethod def definition_from_xml(cls, xml_object, system): """ @@ -731,7 +734,7 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor): """Assumes that xml_object has child k""" return xml_object.xpath(k)[0] - return {'oeparam': parse('openendedparam'), } + return {'oeparam': parse('openendedparam')} def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 922a4f9b77..b9341f0cbe 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -1,19 +1,9 @@ -import copy -from fs.errors import ResourceNotFoundError -import itertools import json import logging -from lxml import etree -from lxml.html import rewrite_links from lxml.html.clean import Cleaner, autolink_html -from path import path -import os -import sys -import hashlib -import capa.xqueue_interface as xqueue_interface import re -from xmodule.capa_module import only_one, ComplexEncoder +from xmodule.capa_module import ComplexEncoder import open_ended_image_submission from xmodule.editing_module import EditingDescriptor from xmodule.html_checker import check_html @@ -22,7 +12,7 @@ from xmodule.stringify import stringify_children from xmodule.xml_module import XmlDescriptor from xmodule.modulestore import Location from capa.util import * -from peer_grading_service import PeerGradingService, MockPeerGradingService +from .peer_grading_service import PeerGradingService, MockPeerGradingService import controller_query_service from datetime import datetime @@ -77,8 +67,12 @@ class OpenEndedChild(object): def __init__(self, system, location, definition, descriptor, static_data, instance_state=None, shared_state=None, **kwargs): # Load instance state + if instance_state is not None: - instance_state = json.loads(instance_state) + try: + instance_state = json.loads(instance_state) + except: + log.error("Could not load instance state for open ended. Setting it to nothing.: {0}".format(instance_state)) else: instance_state = {} @@ -86,26 +80,24 @@ class OpenEndedChild(object): # None for any element, and score and hint can be None for the last (current) # element. # Scores are on scale from 0 to max_score - self.history = instance_state.get('history', []) - self.state = instance_state.get('state', self.INITIAL) + self.child_history=instance_state.get('child_history',[]) + self.child_state=instance_state.get('child_state', self.INITIAL) + self.child_created = instance_state.get('child_created', False) + self.child_attempts = instance_state.get('child_attempts', 0) - self.created = instance_state.get('created', False) - - self.attempts = instance_state.get('attempts', 0) self.max_attempts = static_data['max_attempts'] - - self.prompt = static_data['prompt'] - self.rubric = static_data['rubric'] + self.child_prompt = static_data['prompt'] + self.child_rubric = static_data['rubric'] self.display_name = static_data['display_name'] self.accept_file_upload = static_data['accept_file_upload'] self.close_date = static_data['close_date'] self.s3_interface = static_data['s3_interface'] self.skip_basic_checks = static_data['skip_basic_checks'] + self._max_score = static_data['max_score'] # Used for progress / grading. Currently get credit just for # completion (doesn't matter if you self-assessed correct/incorrect). - self._max_score = static_data['max_score'] if system.open_ended_grading_interface: self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system) self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface, @@ -147,33 +139,34 @@ class OpenEndedChild(object): #This is a student_facing_error 'error': 'The problem close date has passed, and this problem is now closed.' } - elif self.attempts > self.max_attempts: + elif self.child_attempts > self.max_attempts: return True, { 'success': False, #This is a student_facing_error 'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format( - self.attempts, self.max_attempts) + self.child_attempts, self.max_attempts + ) } else: return False, {} def latest_answer(self): """Empty string if not available""" - if not self.history: + if not self.child_history: return "" - return self.history[-1].get('answer', "") + return self.child_history[-1].get('answer', "") def latest_score(self): """None if not available""" - if not self.history: + if not self.child_history: return None - return self.history[-1].get('score') + return self.child_history[-1].get('score') def latest_post_assessment(self, system): """Empty string if not available""" - if not self.history: + if not self.child_history: return "" - return self.history[-1].get('post_assessment', "") + return self.child_history[-1].get('post_assessment', "") @staticmethod def sanitize_html(answer): @@ -195,30 +188,30 @@ class OpenEndedChild(object): @return: None """ answer = OpenEndedChild.sanitize_html(answer) - self.history.append({'answer': answer}) + self.child_history.append({'answer': answer}) def record_latest_score(self, score): """Assumes that state is right, so we're adding a score to the latest history element""" - self.history[-1]['score'] = score + self.child_history[-1]['score'] = score def record_latest_post_assessment(self, post_assessment): """Assumes that state is right, so we're adding a score to the latest history element""" - self.history[-1]['post_assessment'] = post_assessment + self.child_history[-1]['post_assessment'] = post_assessment def change_state(self, new_state): """ A centralized place for state changes--allows for hooks. If the current state matches the old state, don't run any hooks. """ - if self.state == new_state: + if self.child_state == new_state: return - self.state = new_state + self.child_state = new_state - if self.state == self.DONE: - self.attempts += 1 + if self.child_state == self.DONE: + self.child_attempts += 1 def get_instance_state(self): """ @@ -227,17 +220,17 @@ class OpenEndedChild(object): state = { 'version': self.STATE_VERSION, - 'history': self.history, - 'state': self.state, + 'child_history': self.child_history, + 'child_state': self.child_state, 'max_score': self._max_score, - 'attempts': self.attempts, - 'created': False, + 'child_attempts': self.child_attempts, + 'child_created': False, } return json.dumps(state) def _allow_reset(self): """Can the module be reset?""" - return (self.state == self.DONE and self.attempts < self.max_attempts) + return (self.child_state == self.DONE and self.child_attempts < self.max_attempts) def max_score(self): """ @@ -269,10 +262,10 @@ class OpenEndedChild(object): ''' if self._max_score > 0: try: - return Progress(self.get_score()['score'], self._max_score) + return Progress(int(self.get_score()['score']), int(self._max_score)) except Exception as err: #This is a dev_facing_error - log.exception("Got bad progress from open ended child module. Max Score: {1}".format(self._max_score)) + log.exception("Got bad progress from open ended child module. Max Score: {0}".format(self._max_score)) return None return None @@ -282,7 +275,7 @@ class OpenEndedChild(object): """ #This is a dev_facing_error log.warning("Open ended child state out sync. state: %r, get: %r. %s", - self.state, get, msg) + self.child_state, get, msg) #This is a student_facing_error return {'success': False, 'error': 'The problem state got out-of-sync. Please try reloading the page.'} @@ -364,10 +357,6 @@ class OpenEndedChild(object): if get_data['can_upload_files'] in ['true', '1']: has_file_to_upload = True file = get_data['student_file'][0] - if self.system.track_fuction: - self.system.track_function('open_ended_image_upload', {'filename': file.name}) - else: - log.info("No tracking function found when uploading image.") uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file) if uploaded_to_s3: image_tag = self.generate_image_tag_from_url(s3_public_url, file.name) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py index 5daf1b83b5..85c7a98132 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py @@ -1,7 +1,7 @@ import json import logging -from grading_service_module import GradingService +from .grading_service_module import GradingService log = logging.getLogger(__name__) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index 8911e2890f..5fb901d49c 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -3,13 +3,11 @@ import logging from lxml import etree from xmodule.capa_module import ComplexEncoder -from xmodule.editing_module import EditingDescriptor from xmodule.progress import Progress from xmodule.stringify import stringify_children -from xmodule.xml_module import XmlDescriptor import openendedchild -from combined_open_ended_rubric import CombinedOpenEndedRubric +from .combined_open_ended_rubric import CombinedOpenEndedRubric log = logging.getLogger("mitx.courseware") @@ -31,8 +29,12 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): """ - TEMPLATE_DIR = "combinedopenended/selfassessment" + # states + INITIAL = 'initial' + ASSESSING = 'assessing' + REQUEST_HINT = 'request_hint' + DONE = 'done' def setup_response(self, system, location, definition, descriptor): """ @@ -43,8 +45,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): @param descriptor: SelfAssessmentDescriptor @return: None """ - self.prompt = stringify_children(self.prompt) - self.rubric = stringify_children(self.rubric) + self.child_prompt = stringify_children(self.child_prompt) + self.child_rubric = stringify_children(self.child_rubric) def get_html(self, system): """ @@ -53,18 +55,18 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): @return: Rendered HTML """ #set context variables and render template - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: latest = self.latest_answer() previous_answer = latest if latest is not None else '' else: previous_answer = '' context = { - 'prompt': self.prompt, + 'prompt': self.child_prompt, 'previous_answer': previous_answer, 'ajax_url': system.ajax_url, 'initial_rubric': self.get_rubric_html(system), - 'state': self.state, + 'state': self.child_state, 'allow_reset': self._allow_reset(), 'child_type': 'selfassessment', 'accept_file_upload': self.accept_file_upload, @@ -109,11 +111,11 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): """ Return the appropriate version of the rubric, based on the state. """ - if self.state == self.INITIAL: + if self.child_state == self.INITIAL: return '' rubric_renderer = CombinedOpenEndedRubric(system, False) - rubric_dict = rubric_renderer.render_rubric(self.rubric) + rubric_dict = rubric_renderer.render_rubric(self.child_rubric) success = rubric_dict['success'] rubric_html = rubric_dict['html'] @@ -122,13 +124,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'max_score': self._max_score, } - if self.state == self.ASSESSING: + if self.child_state == self.ASSESSING: context['read_only'] = False - elif self.state in (self.POST_ASSESSMENT, self.DONE): + elif self.child_state in (self.POST_ASSESSMENT, self.DONE): context['read_only'] = True else: #This is a dev_facing_error - raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state)) + raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.child_state)) return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context) @@ -136,10 +138,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): """ Return the appropriate version of the hint view, based on state. """ - if self.state in (self.INITIAL, self.ASSESSING): + if self.child_state in (self.INITIAL, self.ASSESSING): return '' - if self.state == self.DONE: + if self.child_state == self.DONE: # display the previous hint latest = self.latest_post_assessment(system) hint = latest if latest is not None else '' @@ -148,13 +150,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): context = {'hint': hint} - if self.state == self.POST_ASSESSMENT: + if self.child_state == self.POST_ASSESSMENT: context['read_only'] = False - elif self.state == self.DONE: + elif self.child_state == self.DONE: context['read_only'] = True else: #This is a dev_facing_error - raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state)) + raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.child_state)) return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context) @@ -175,7 +177,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): if closed: return msg - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: return self.out_of_sync_error(get) error_message = "" @@ -216,7 +218,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'message_html' only if success is true """ - if self.state != self.ASSESSING: + if self.child_state != self.ASSESSING: return self.out_of_sync_error(get) try: @@ -239,7 +241,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): self.change_state(self.DONE) d['allow_reset'] = self._allow_reset() - d['state'] = self.state + d['state'] = self.child_state return d def save_hint(self, get, system): @@ -253,7 +255,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): with the error key only present if success is False and message_html only if True. ''' - if self.state != self.POST_ASSESSMENT: + if self.child_state != self.POST_ASSESSMENT: # Note: because we only ask for hints on wrong answers, may not have # the same number of hints and answers. return self.out_of_sync_error(get) @@ -276,7 +278,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): return [rubric_scores] -class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): +class SelfAssessmentDescriptor(): """ Module for adding self assessment questions to courses """ @@ -288,6 +290,9 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): has_score = True template_dir_name = "selfassessment" + def __init__(self, system): + self.system =system + @classmethod def definition_from_xml(cls, xml_object, system): """ @@ -318,7 +323,7 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): elt = etree.Element('selfassessment') def add_child(k): - child_str = '<{tag}>{body}'.format(tag=k, body=self.definition[k]) + child_str = '<{tag}>{body}'.format(tag=k, body=getattr(self, k)) child_node = etree.fromstring(child_str) elt.append(child_node) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 2ea8ab0db5..e18f2ceca3 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -6,13 +6,13 @@ from lxml import etree from datetime import datetime from pkg_resources import resource_string from .capa_module import ComplexEncoder -from .editing_module import EditingDescriptor from .stringify import stringify_children from .x_module import XModule -from .xml_module import XmlDescriptor +from xmodule.raw_module import RawDescriptor from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from timeinfo import TimeInfo +from .timeinfo import TimeInfo +from xblock.core import Object, Integer, Boolean, String, Scope from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService @@ -27,7 +27,17 @@ IS_GRADED = True EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff." -class PeerGradingModule(XModule): +class PeerGradingFields(object): + use_for_single_location = Boolean(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 = Boolean(help="Whether or not this module is scored.",default=IS_GRADED, scope=Scope.settings) + display_due_date_string = String(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 = Integer(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.", default=json.dumps({}),scope=Scope.student_state) + + +class PeerGradingModule(PeerGradingFields, XModule): _VERSION = 1 js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'), @@ -39,16 +49,8 @@ class PeerGradingModule(XModule): css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - - # Load instance state - if instance_state is not None: - instance_state = json.loads(instance_state) - else: - instance_state = {} + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) #We need to set the location here so the child modules can use it system.set('location', location) @@ -58,43 +60,34 @@ class PeerGradingModule(XModule): else: self.peer_gs = MockPeerGradingService() - self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION) - if isinstance(self.use_for_single_location, basestring): - self.use_for_single_location = (self.use_for_single_location in TRUE_DICT) - - self.link_to_location = self.metadata.get('link_to_location', USE_FOR_SINGLE_LOCATION) - if self.use_for_single_location == True: + if self.use_for_single_location in TRUE_DICT: try: self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location) except: log.error("Linked location {0} for peer grading module {1} does not exist".format( self.link_to_location, self.location)) raise - due_date = self.linked_problem.metadata.get('peer_grading_due', None) + due_date = self.linked_problem._model_data.get('peer_grading_due', None) if due_date: - self.metadata['due'] = due_date - - self.is_graded = self.metadata.get('is_graded', IS_GRADED) - if isinstance(self.is_graded, basestring): - self.is_graded = (self.is_graded in TRUE_DICT) - - display_due_date_string = self.metadata.get('due', None) - grace_period_string = self.metadata.get('graceperiod', None) + self._model_data['due'] = due_date try: - self.timeinfo = TimeInfo(display_due_date_string, grace_period_string) + self.timeinfo = TimeInfo(self.display_due_date_string, self.grace_period_string) except: log.error("Error parsing due date information in location {0}".format(location)) raise self.display_due_date = self.timeinfo.display_due_date + try: + self.student_data_for_location = json.loads(self.student_data_for_location) + except: + pass + self.ajax_url = self.system.ajax_url if not self.ajax_url.endswith("/"): self.ajax_url = self.ajax_url + "/" - self.student_data_for_location = instance_state.get('student_data_for_location', {}) - self.max_grade = instance_state.get('max_grade', MAX_SCORE) if not isinstance(self.max_grade, (int, long)): #This could result in an exception, but not wrapping in a try catch block so it moves up the stack self.max_grade = int(self.max_grade) @@ -129,7 +122,7 @@ class PeerGradingModule(XModule): """ if self.closed(): return self.peer_grading_closed() - if not self.use_for_single_location: + if self.use_for_single_location not in TRUE_DICT: return self.peer_grading() else: return self.peer_grading_problem({'location': self.link_to_location})['html'] @@ -180,7 +173,7 @@ class PeerGradingModule(XModule): pass def get_score(self): - if not self.use_for_single_location or not self.is_graded: + if self.use_for_single_location not in TRUE_DICT or self.is_graded not in TRUE_DICT: return None try: @@ -214,7 +207,7 @@ class PeerGradingModule(XModule): randomization, and 5/7 on another ''' max_grade = None - if self.use_for_single_location and self.is_graded: + if self.use_for_single_location in TRUE_DICT and self.is_graded in TRUE_DICT: max_grade = self.max_grade return max_grade @@ -467,11 +460,13 @@ class PeerGradingModule(XModule): except GradingServiceError: #This is a student_facing_error error_text = EXTERNAL_GRADER_NO_CONTACT_ERROR + log.error(error_text) success = False # catch error if if the json loads fails except ValueError: #This is a student_facing_error error_text = "Could not get list of problems to peer grade. Please notify course staff." + log.error(error_text) success = False except: log.exception("Could not contact peer grading service.") @@ -494,8 +489,8 @@ class PeerGradingModule(XModule): problem_location = problem['location'] descriptor = _find_corresponding_module_for_location(problem_location) if descriptor: - problem['due'] = descriptor.metadata.get('peer_grading_due', None) - grace_period_string = descriptor.metadata.get('graceperiod', None) + problem['due'] = descriptor._model_data.get('peer_grading_due', None) + grace_period_string = descriptor._model_data.get('graceperiod', None) try: problem_timeinfo = TimeInfo(problem['due'], grace_period_string) except: @@ -506,7 +501,7 @@ class PeerGradingModule(XModule): else: problem['closed'] = False else: - # if we can't find the due date, assume that it doesn't have one + # if we can't find the due date, assume that it doesn't have one problem['due'] = None problem['closed'] = False @@ -529,7 +524,7 @@ class PeerGradingModule(XModule): Show individual problem interface ''' if get is None or get.get('location') is None: - if not self.use_for_single_location: + if self.use_for_single_location not in TRUE_DICT: #This is an error case, because it must be set to use a single location to be called without get parameters #This is a dev_facing_error log.error( @@ -567,9 +562,9 @@ class PeerGradingModule(XModule): return json.dumps(state) -class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor): +class PeerGradingDescriptor(PeerGradingFields, RawDescriptor): """ - Module for adding combined open ended questions + Module for adding peer grading questions """ mako_template = "widgets/raw-edit.html" module_class = PeerGradingModule @@ -578,42 +573,3 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor): stores_state = True has_score = True template_dir_name = "peer_grading" - - js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} - js_module_name = "HTMLEditingDescriptor" - - @classmethod - def definition_from_xml(cls, xml_object, system): - """ - Pull out the individual tasks, the rubric, and the prompt, and parse - - Returns: - { - 'rubric': 'some-html', - 'prompt': 'some-html', - 'task_xml': dictionary of xml strings, - } - """ - expected_children = [] - for child in expected_children: - if len(xml_object.xpath(child)) == 0: - #This is a staff_facing_error - raise ValueError( - "Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format( - child)) - - def parse_task(k): - """Assumes that xml_object has child k""" - return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))] - - def parse(k): - """Assumes that xml_object has child k""" - return xml_object.xpath(k)[0] - - return {} - - - def definition_to_xml(self, resource_fs): - '''Return an xml element representing this definition.''' - elt = etree.Element('peergrading') - return elt diff --git a/common/lib/xmodule/xmodule/plugin.py b/common/lib/xmodule/xmodule/plugin.py new file mode 100644 index 0000000000..5cf9c647aa --- /dev/null +++ b/common/lib/xmodule/xmodule/plugin.py @@ -0,0 +1,64 @@ +import pkg_resources +import logging + +log = logging.getLogger(__name__) + +class PluginNotFoundError(Exception): + pass + + +class Plugin(object): + """ + Base class for a system that uses entry_points to load plugins. + + Implementing classes are expected to have the following attributes: + + entry_point: The name of the entry point to load plugins from + """ + + _plugin_cache = None + + @classmethod + def load_class(cls, identifier, default=None): + """ + Loads a single class instance specified by identifier. If identifier + specifies more than a single class, then logs a warning and returns the + first class identified. + + If default is not None, will return default if no entry_point matching + identifier is found. Otherwise, will raise a ModuleMissingError + """ + if cls._plugin_cache is None: + cls._plugin_cache = {} + + if identifier not in cls._plugin_cache: + identifier = identifier.lower() + classes = list(pkg_resources.iter_entry_points( + cls.entry_point, name=identifier)) + + if len(classes) > 1: + log.warning("Found multiple classes for {entry_point} with " + "identifier {id}: {classes}. " + "Returning the first one.".format( + entry_point=cls.entry_point, + id=identifier, + classes=", ".join( + class_.module_name for class_ in classes))) + + if len(classes) == 0: + if default is not None: + return default + raise PluginNotFoundError(identifier) + + cls._plugin_cache[identifier] = classes[0].load() + return cls._plugin_cache[identifier] + + @classmethod + def load_classes(cls): + """ + Returns a list of containing the identifiers and their corresponding classes for all + of the available instances of this plugin + """ + return [(class_.name, class_.load()) + for class_ + in pkg_resources.iter_entry_points(cls.entry_point)] diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py new file mode 100644 index 0000000000..0fb3bfb496 --- /dev/null +++ b/common/lib/xmodule/xmodule/poll_module.py @@ -0,0 +1,205 @@ +"""Poll module is ungraded xmodule used by students to +to do set of polls. + +On the client side we show: +If student does not yet anwered - Question with set of choices. +If student have answered - Question with statistics for each answers. + +Student can't change his answer. +""" + +import cgi +import json +import logging +from copy import deepcopy +from collections import OrderedDict + +from lxml import etree +from pkg_resources import resource_string + +from xmodule.x_module import XModule +from xmodule.stringify import stringify_children +from xmodule.mako_module import MakoModuleDescriptor +from xmodule.xml_module import XmlDescriptor +from xblock.core import Scope, String, Object, Boolean, List + +log = logging.getLogger(__name__) + + +class PollFields(object): + # Name of poll to use in links to this poll + display_name = String(help="Display name for this module", scope=Scope.settings) + + voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.student_state, default=False) + poll_answer = String(help="Student answer", scope=Scope.student_state, default='') + poll_answers = Object(help="All possible answers for the poll fro other students", scope=Scope.content) + + answers = List(help="Poll answers from xml", scope=Scope.content, default=[]) + question = String(help="Poll question", scope=Scope.content, default='') + + +class PollModule(PollFields, XModule): + """Poll Module""" + js = { + 'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee')], + 'js': [resource_string(__name__, 'js/src/poll/logme.js'), + resource_string(__name__, 'js/src/poll/poll.js'), + resource_string(__name__, 'js/src/poll/poll_main.js')] + } + css = {'scss': [resource_string(__name__, 'css/poll/display.scss')]} + js_module_name = "Poll" + + def handle_ajax(self, dispatch, get): + """Ajax handler. + + Args: + dispatch: string request slug + get: dict request get parameters + + Returns: + json string + """ + if dispatch in self.poll_answers and not self.voted: + # FIXME: fix this, when xblock will support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + temp_poll_answers[dispatch] += 1 + self.poll_answers = temp_poll_answers + + self.voted = True + self.poll_answer = dispatch + return json.dumps({'poll_answers': self.poll_answers, + 'total': sum(self.poll_answers.values()), + 'callback': {'objectName': 'Conditional'} + }) + elif dispatch == 'get_state': + return json.dumps({'poll_answer': self.poll_answer, + 'poll_answers': self.poll_answers, + 'total': sum(self.poll_answers.values()) + }) + elif dispatch == 'reset_poll' and self.voted and \ + self.descriptor.xml_attributes.get('reset', 'True').lower() != 'false': + self.voted = False + + # FIXME: fix this, when xblock will support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + temp_poll_answers[self.poll_answer] -= 1 + self.poll_answers = temp_poll_answers + + self.poll_answer = '' + return json.dumps({'status': 'success'}) + else: # return error message + return json.dumps({'error': 'Unknown Command!'}) + + def get_html(self): + """Renders parameters to template.""" + params = { + 'element_id': self.location.html_id(), + 'element_class': self.location.category, + 'ajax_url': self.system.ajax_url, + 'configuration_json': self.dump_poll(), + } + self.content = self.system.render_template('poll.html', params) + return self.content + + def dump_poll(self): + """Dump poll information. + + Returns: + string - Serialize json. + """ + # FIXME: hack for resolving caching `default={}` during definition + # poll_answers field + if self.poll_answers is None: + self.poll_answers = {} + + answers_to_json = OrderedDict() + + # FIXME: fix this, when xblock support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + + # Fill self.poll_answers, prepare data for template context. + for answer in self.answers: + # Set default count for answer = 0. + if answer['id'] not in temp_poll_answers: + temp_poll_answers[answer['id']] = 0 + answers_to_json[answer['id']] = cgi.escape(answer['text']) + self.poll_answers = temp_poll_answers + + return json.dumps({'answers': answers_to_json, + 'question': cgi.escape(self.question), + # to show answered poll after reload: + 'poll_answer': self.poll_answer, + 'poll_answers': self.poll_answers if self.voted else {}, + 'total': sum(self.poll_answers.values()) if self.voted else 0, + 'reset': str(self.descriptor.xml_attributes.get('reset', 'true')).lower()}) + + +class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor): + _tag_name = 'poll_question' + _child_tag_name = 'answer' + + module_class = PollModule + template_dir_name = 'poll' + stores_state = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + """Pull out the data into dictionary. + + Args: + xml_object: xml from file. + system: `system` object. + + Returns: + (definition, children) - tuple + definition - dict: + { + 'answers': , + 'question': + } + """ + # Check for presense of required tags in xml. + if len(xml_object.xpath(cls._child_tag_name)) == 0: + raise ValueError("Poll_question definition must include \ + at least one 'answer' tag") + + xml_object_copy = deepcopy(xml_object) + answers = [] + for element_answer in xml_object_copy.findall(cls._child_tag_name): + answer_id = element_answer.get('id', None) + if answer_id: + answers.append({ + 'id': answer_id, + 'text': stringify_children(element_answer) + }) + xml_object_copy.remove(element_answer) + + definition = { + 'answers': answers, + 'question': stringify_children(xml_object_copy) + } + children = [] + + return (definition, children) + + def definition_to_xml(self, resource_fs): + """Return an xml element representing to this definition.""" + poll_str = '<{tag_name}>{text}'.format( + tag_name=self._tag_name, text=self.question) + xml_object = etree.fromstring(poll_str) + xml_object.set('display_name', self.display_name) + + def add_child(xml_obj, answer): + child_str = '<{tag_name} id="{id}">{text}'.format( + tag_name=self._child_tag_name, id=answer['id'], + text=answer['text']) + child_node = etree.fromstring(child_str) + xml_object.append(child_node) + + for answer in self.answers: + add_child(xml_object, answer) + + return xml_object diff --git a/common/lib/xmodule/xmodule/randomize_module.py b/common/lib/xmodule/xmodule/randomize_module.py index b336789193..6620ab3cf7 100644 --- a/common/lib/xmodule/xmodule/randomize_module.py +++ b/common/lib/xmodule/xmodule/randomize_module.py @@ -1,19 +1,19 @@ -import json import logging import random -from xmodule.mako_module import MakoModuleDescriptor from xmodule.x_module import XModule -from xmodule.xml_module import XmlDescriptor -from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor -from pkg_resources import resource_string +from xblock.core import Scope, Integer log = logging.getLogger('mitx.' + __name__) -class RandomizeModule(XModule): +class RandomizeFields(object): + choice = Integer(help="Which random child was chosen", scope=Scope.student_state) + + +class RandomizeModule(RandomizeFields, XModule): """ Chooses a random child module. Chooses the same one every time for each student. @@ -35,30 +35,23 @@ class RandomizeModule(XModule): grading interaction is a tangle between super and subclasses of descriptors and modules. """ - - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) # NOTE: calling self.get_children() creates a circular reference-- # it calls get_child_descriptors() internally, but that doesn't work until # we've picked a choice num_choices = len(self.descriptor.get_children()) - self.choice = None - if instance_state is not None: - state = json.loads(instance_state) - self.choice = state.get('choice', None) - if self.choice > num_choices: - # Oops. Children changed. Reset. - self.choice = None + if self.choice > num_choices: + # Oops. Children changed. Reset. + self.choice = None if self.choice is None: # choose one based on the system seed, or randomly if that's not available if num_choices > 0: - if system.seed is not None: - self.choice = system.seed % num_choices + if self.system.seed is not None: + self.choice = self.system.seed % num_choices else: self.choice = random.randrange(0, num_choices) @@ -72,11 +65,6 @@ class RandomizeModule(XModule): self.child_descriptor = None self.child = None - - def get_instance_state(self): - return json.dumps({'choice': self.choice}) - - def get_child_descriptors(self): """ For grading--return just the chosen child. @@ -98,7 +86,7 @@ class RandomizeModule(XModule): return self.child.get_icon_class() if self.child else 'other' -class RandomizeDescriptor(SequenceDescriptor): +class RandomizeDescriptor(RandomizeFields, SequenceDescriptor): # the editing interface can be the same as for sequences -- just a container module_class = RandomizeModule @@ -107,6 +95,7 @@ class RandomizeDescriptor(SequenceDescriptor): stores_state = True def definition_to_xml(self, resource_fs): + xml_object = etree.Element('randomize') for child in self.get_children(): xml_object.append( diff --git a/common/lib/xmodule/xmodule/raw_module.py b/common/lib/xmodule/xmodule/raw_module.py index 4a2bfbceaf..2c6e157018 100644 --- a/common/lib/xmodule/xmodule/raw_module.py +++ b/common/lib/xmodule/xmodule/raw_module.py @@ -3,6 +3,7 @@ from xmodule.editing_module import XMLEditingDescriptor from xmodule.xml_module import XmlDescriptor import logging import sys +from xblock.core import String, Scope log = logging.getLogger(__name__) @@ -12,17 +13,19 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor): Module that provides a raw editing view of its data and children. It requires that the definition xml is valid. """ + data = String(help="XML data for the module", scope=Scope.content) + @classmethod def definition_from_xml(cls, xml_object, system): - return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')} + return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}, [] def definition_to_xml(self, resource_fs): try: - return etree.fromstring(self.definition['data']) + return etree.fromstring(self.data) except etree.XMLSyntaxError as err: # Can't recover here, so just add some info and # re-raise - lines = self.definition['data'].split('\n') + lines = self.data.split('\n') line, offset = err.position msg = ("Unable to create xml for problem {loc}. " "Context: '{context}'".format( diff --git a/common/lib/xmodule/xmodule/schematic_module.py b/common/lib/xmodule/xmodule/schematic_module.py index 21dd33a897..d15d629c24 100644 --- a/common/lib/xmodule/xmodule/schematic_module.py +++ b/common/lib/xmodule/xmodule/schematic_module.py @@ -1,6 +1,6 @@ import json -from x_module import XModule, XModuleDescriptor +from .x_module import XModule, XModuleDescriptor class ModuleDescriptor(XModuleDescriptor): diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 36011744f5..f8e982f1a0 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -8,6 +8,7 @@ from xmodule.xml_module import XmlDescriptor from xmodule.x_module import XModule from xmodule.progress import Progress from xmodule.exceptions import NotFoundError +from xblock.core import Integer, Scope from pkg_resources import resource_string log = logging.getLogger(__name__) @@ -17,7 +18,15 @@ log = logging.getLogger(__name__) class_priority = ['video', 'problem'] -class SequenceModule(XModule): +class SequenceFields(object): + has_children = True + + # NOTE: Position is 1-indexed. This is silly, but there are now student + # positions saved on prod, so it's not easy to fix. + position = Integer(help="Last tab viewed in this sequence", scope=Scope.student_state) + + +class SequenceModule(SequenceFields, XModule): ''' Layout module which lays out content in a temporal sequence ''' js = {'coffee': [resource_string(__name__, @@ -26,22 +35,13 @@ class SequenceModule(XModule): css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]} js_module_name = "Sequence" - def __init__(self, system, location, definition, descriptor, instance_state=None, - shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - # NOTE: Position is 1-indexed. This is silly, but there are now student - # positions saved on prod, so it's not easy to fix. - self.position = 1 - if instance_state is not None: - state = json.loads(instance_state) - if 'position' in state: - self.position = int(state['position']) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) # if position is specified in system, then use that instead - if system.get('position'): - self.position = int(system.get('position')) + if self.system.get('position'): + self.position = int(self.system.get('position')) self.rendered = False @@ -70,6 +70,11 @@ class SequenceModule(XModule): raise NotFoundError('Unexpected dispatch type') def render(self): + # If we're rendering this sequence, but no position is set yet, + # default the position to the first element + if self.position is None: + self.position = 1 + if self.rendered: return ## Returns a set of all types of all sub-children @@ -79,9 +84,9 @@ class SequenceModule(XModule): childinfo = { 'content': child.get_html(), 'title': "\n".join( - grand_child.display_name.strip() + grand_child.display_name for grand_child in child.get_children() - if 'display_name' in grand_child.metadata + if grand_child.display_name is not None ), 'progress_status': Progress.to_js_status_str(progress), 'progress_detail': Progress.to_js_detail_str(progress), @@ -89,7 +94,7 @@ class SequenceModule(XModule): 'id': child.id, } if childinfo['title'] == '': - childinfo['title'] = child.metadata.get('display_name', '') + childinfo['title'] = child.display_name_with_default contents.append(childinfo) params = {'items': contents, @@ -112,11 +117,11 @@ class SequenceModule(XModule): return new_class -class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor): +class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): mako_template = 'widgets/sequence-edit.html' module_class = SequenceModule - stores_state = True # For remembering where in the sequence the student is + stores_state = True # For remembering where in the sequence the student is js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]} js_module_name = "SequenceDescriptor" @@ -132,7 +137,7 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor): if system.error_tracker is not None: system.error_tracker("ERROR: " + str(e)) continue - return {'children': children} + return {}, children def definition_to_xml(self, resource_fs): xml_object = etree.Element('sequential') diff --git a/common/lib/xmodule/xmodule/stringify.py b/common/lib/xmodule/xmodule/stringify.py index 5a640e91b1..35587d3b09 100644 --- a/common/lib/xmodule/xmodule/stringify.py +++ b/common/lib/xmodule/xmodule/stringify.py @@ -1,4 +1,5 @@ -from itertools import chain +# -*- coding: utf-8 -*- + from lxml import etree diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index 5f376945eb..d79d2a163e 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -28,11 +28,6 @@ class CustomTagModule(XModule): More information given in the text """ - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - def get_html(self): return self.descriptor.rendered_html @@ -62,19 +57,15 @@ class CustomTagDescriptor(RawDescriptor): # cdodge: look up the template as a module template_loc = self.location._replace(category='custom_tag_template', name=template_name) - template_module = self.system.load_item(template_loc) - template_module_data = template_module.definition['data'] + template_module = modulestore().get_instance(system.course_id, template_loc) + template_module_data = template_module.data template = Template(template_module_data) return template.render(**params) - def __init__(self, system, definition, **kwargs): - '''Render and save the template for this descriptor instance''' - super(CustomTagDescriptor, self).__init__(system, definition, **kwargs) - @property def rendered_html(self): - return self.render_template(self.system, self.definition['data']) + return self.render_template(self.system, self.data) def export_to_file(self): """ diff --git a/common/lib/xmodule/xmodule/templates/course/empty.yaml b/common/lib/xmodule/xmodule/templates/course/empty.yaml index cb2f3bcec6..89f1bfcf21 100644 --- a/common/lib/xmodule/xmodule/templates/course/empty.yaml +++ b/common/lib/xmodule/xmodule/templates/course/empty.yaml @@ -2,5 +2,123 @@ metadata: display_name: Empty start: 2020-10-10T10:00 + checklists: [ + {"short_description" : "Getting Started With Studio", + "items" : [{"short_description": "Add Course Team Members", + "long_description": "Grant your collaborators permission to edit your course so you can work together.", + "is_checked": false, + "action_url": "ManageUsers", + "action_text": "Edit Course Team", + "action_external": false}, + {"short_description": "Set Important Dates for Your Course", + "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", + "is_checked": false, + "action_url": "SettingsDetails", + "action_text": "Edit Course Details & Schedule", + "action_external": false}, + {"short_description": "Draft Your Course's Grading Policy", + "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", + "is_checked": false, + "action_url": "SettingsGrading", + "action_text": "Edit Grading Settings", + "action_external": false}, + {"short_description": "Explore the Other Studio Checklists", + "long_description": "Discover other available course authoring tools, and find help when you need it.", + "is_checked": false, + "action_url": "", + "action_text": "", + "action_external": false}] + }, + {"short_description" : "Draft a Rough Course Outline", + "items" : [{"short_description": "Create Your First Section and Subsection", + "long_description": "Use your course outline to build your first Section and Subsection.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}, + {"short_description": "Set Section Release Dates", + "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}, + {"short_description": "Designate a Subsection as Graded", + "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}, + {"short_description": "Reordering Course Content", + "long_description": "Use drag and drop to reorder the content in your course.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}, + {"short_description": "Renaming Sections", + "long_description": "Rename Sections by clicking the Section name from the Course Outline.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}, + {"short_description": "Deleting Course Content", + "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}, + {"short_description": "Add an Instructor-Only Section to Your Outline", + "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", + "is_checked": false, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": false}] + }, + {"short_description" : "Explore edX's Support Tools", + "items" : [{"short_description": "Explore the Studio Help Forum", + "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", + "is_checked": false, + "action_url": "http://help.edge.edx.org/", + "action_text": "Visit Studio Help", + "action_external": true}, + {"short_description": "Enroll in edX 101", + "long_description": "Register for edX 101, edX's primer for course creation.", + "is_checked": false, + "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", + "action_text": "Register for edX 101", + "action_external": true}, + {"short_description": "Download the Studio Documentation", + "long_description": "Download the searchable Studio reference documentation in PDF form.", + "is_checked": false, + "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", + "action_text": "Download Documentation", + "action_external": true}] + }, + {"short_description" : "Draft Your Course About Page", + "items" : [{"short_description": "Draft a Course Description", + "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", + "is_checked": false, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": false}, + {"short_description": "Add Staff Bios", + "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", + "is_checked": false, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": false}, + {"short_description": "Add Course FAQs", + "long_description": "Include a short list of frequently asked questions about your course.", + "is_checked": false, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": false}, + {"short_description": "Add Course Prerequisites", + "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", + "is_checked": false, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": false}] + } + ] data: { 'textbooks' : [ ], 'wiki_slug' : null } children: [] diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 43c2bbe24d..1a10654f6c 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -54,6 +54,7 @@ def test_system(): debug=True, xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), + xblock_model_data=lambda descriptor: descriptor._model_data, anonymous_student_id='student', open_ended_grading_interface= open_ended_grading_interface ) diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py index 30f9c9ff92..43eae8e43e 100644 --- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -28,13 +28,11 @@ class AnnotatableModuleTestCase(unittest.TestCase): The Iliad of Homer by Samuel Butler ''' - definition = { 'data': sample_xml } descriptor = Mock() - instance_state = None - shared_state = None + module_data = {'data': sample_xml} def setUp(self): - self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state) + self.annotatable = AnnotatableModule(test_system(), self.location, self.descriptor, self.module_data) def test_annotation_data_attr(self): el = etree.fromstring('test') diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index cb77921957..d2458cb3d0 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -59,7 +59,8 @@ class CapaFactory(object): force_save_button=None, attempts=None, problem_state=None, - correct=False + correct=False, + done=None ): """ All parameters are optional, and are added to the created problem if specified. @@ -77,48 +78,42 @@ class CapaFactory(object): attempts: also added to instance state. Will be converted to an int. """ - definition = {'data': CapaFactory.sample_problem_xml, } location = Location(["i4x", "edX", "capa_test", "problem", - "SampleProblem%d" % CapaFactory.next_num()]) - metadata = {} - if graceperiod is not None: - metadata['graceperiod'] = graceperiod - if due is not None: - metadata['due'] = due - if max_attempts is not None: - metadata['attempts'] = max_attempts - if showanswer is not None: - metadata['showanswer'] = showanswer - if force_save_button is not None: - metadata['force_save_button'] = force_save_button - if rerandomize is not None: - metadata['rerandomize'] = rerandomize + "SampleProblem{0}".format(CapaFactory.next_num())]) + model_data = {'data': CapaFactory.sample_problem_xml} + if graceperiod is not None: + model_data['graceperiod'] = graceperiod + if due is not None: + model_data['due'] = due + if max_attempts is not None: + model_data['max_attempts'] = max_attempts + if showanswer is not None: + model_data['showanswer'] = showanswer + if force_save_button is not None: + model_data['force_save_button'] = force_save_button + if rerandomize is not None: + model_data['rerandomize'] = rerandomize + if done is not None: + model_data['done'] = done descriptor = Mock(weight="1") - instance_state_dict = {} if problem_state is not None: - instance_state_dict = problem_state - + model_data.update(problem_state) if attempts is not None: # converting to int here because I keep putting "0" and "1" in the tests # since everything else is a string. - instance_state_dict['attempts'] = int(attempts) - - if len(instance_state_dict) > 0: - instance_state = json.dumps(instance_state_dict) - else: - instance_state = None + model_data['attempts'] = int(attempts) system = test_system() system.render_template = Mock(return_value="
          Test Template HTML
          ") - module = CapaModule(system, location, - definition, descriptor, - instance_state, None, metadata=metadata) + module = CapaModule(system, location, descriptor, model_data) if correct: # TODO: probably better to actually set the internal state properly, but... module.get_score = lambda: {'score': 1, 'total': 1} + else: + module.get_score = lambda: {'score': 0, 'total': 1} return module @@ -356,7 +351,7 @@ class CapaModuleTest(unittest.TestCase): valid_get_dict = self._querydict_from_dict({'input_2[]': ['test1', 'test2']}) result = CapaModule.make_dict_of_responses(valid_get_dict) self.assertTrue('2' in result) - self.assertEqual(['test1','test2'], result['2']) + self.assertEqual(['test1', 'test2'], result['2']) # If we use [] at the end of a key name, we should always # get a list, even if there's just one value @@ -374,7 +369,7 @@ class CapaModuleTest(unittest.TestCase): # One of the values would overwrite the other, so detect this # and raise an exception invalid_get_dict = self._querydict_from_dict({'input_1[]': 'test 1', - 'input_1': 'test 2' }) + 'input_1': 'test 2'}) with self.assertRaises(ValueError): result = CapaModule.make_dict_of_responses(invalid_get_dict) @@ -412,7 +407,7 @@ class CapaModuleTest(unittest.TestCase): mock_html.return_value = "Test HTML" # Check the problem - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect that the problem is marked correct @@ -424,7 +419,6 @@ class CapaModuleTest(unittest.TestCase): # Expect that the number of attempts is incremented by 1 self.assertEqual(module.attempts, 2) - def test_check_problem_incorrect(self): module = CapaFactory.create(attempts=0) @@ -434,7 +428,7 @@ class CapaModuleTest(unittest.TestCase): mock_is_correct.return_value = False # Check the problem - get_request_dict = { CapaFactory.input_key(): '0' } + get_request_dict = { CapaFactory.input_key(): '0'} result = module.check_problem(get_request_dict) # Expect that the problem is marked correct @@ -452,38 +446,33 @@ class CapaModuleTest(unittest.TestCase): with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: mock_closed.return_value = True with self.assertRaises(xmodule.exceptions.NotFoundError): - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} module.check_problem(get_request_dict) # Expect that number of attempts NOT incremented self.assertEqual(module.attempts, 3) - def test_check_problem_resubmitted_with_randomize(self): # Randomize turned on module = CapaFactory.create(rerandomize='always', attempts=0) # Simulate that the problem is completed - module.lcp.done = True + module.done = True # Expect that we cannot submit with self.assertRaises(xmodule.exceptions.NotFoundError): - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = {CapaFactory.input_key(): '3.14'} module.check_problem(get_request_dict) # Expect that number of attempts NOT incremented self.assertEqual(module.attempts, 0) - def test_check_problem_resubmitted_no_randomize(self): # Randomize turned off - module = CapaFactory.create(rerandomize='never', attempts=0) - - # Simulate that the problem is completed - module.lcp.done = True + module = CapaFactory.create(rerandomize='never', attempts=0, done=True) # Expect that we can submit successfully - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = {CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) self.assertEqual(result['success'], 'correct') @@ -491,7 +480,6 @@ class CapaModuleTest(unittest.TestCase): # Expect that number of attempts IS incremented self.assertEqual(module.attempts, 1) - def test_check_problem_queued(self): module = CapaFactory.create(attempts=1) @@ -504,7 +492,7 @@ class CapaModuleTest(unittest.TestCase): mock_is_queued.return_value = True mock_get_queuetime.return_value = datetime.datetime.now() - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' @@ -521,7 +509,7 @@ class CapaModuleTest(unittest.TestCase): with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: mock_grade.side_effect = capa.responsetypes.StudentInputError('test error') - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.check_problem(get_request_dict) # Expect an AJAX alert message in 'success' @@ -532,13 +520,8 @@ class CapaModuleTest(unittest.TestCase): def test_reset_problem(self): - module = CapaFactory.create() - - # Mock the module's capa problem - # to simulate that the problem is done - mock_problem = MagicMock(capa.capa_problem.LoncapaProblem) - mock_problem.done = True - module.lcp = mock_problem + module = CapaFactory.create(done=True) + module.new_lcp = Mock(wraps=module.new_lcp) # Stub out HTML rendering with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html: @@ -556,7 +539,7 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(result['html'], "
          Test HTML
          ") # Expect that the problem was reset - mock_problem.do_reset.assert_called_once_with() + module.new_lcp.assert_called_once_with({'seed': None}) def test_reset_problem_closed(self): @@ -575,10 +558,8 @@ class CapaModuleTest(unittest.TestCase): def test_reset_problem_not_done(self): - module = CapaFactory.create() - # Simulate that the problem is NOT done - module.lcp.done = False + module = CapaFactory.create(done=False) # Try to reset the problem get_request_dict = {} @@ -589,17 +570,14 @@ class CapaModuleTest(unittest.TestCase): def test_save_problem(self): - module = CapaFactory.create() - - # Simulate that the problem is not done (not attempted or reset) - module.lcp.done = False + module = CapaFactory.create(done=False) # Save the problem - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that answers are saved to the problem - expected_answers = { CapaFactory.answer_key(): '3.14' } + expected_answers = { CapaFactory.answer_key(): '3.14'} self.assertEqual(module.lcp.student_answers, expected_answers) # Expect that the result is success @@ -607,17 +585,14 @@ class CapaModuleTest(unittest.TestCase): def test_save_problem_closed(self): - module = CapaFactory.create() - - # Simulate that the problem is NOT done (not attempted or reset) - module.lcp.done = False + module = CapaFactory.create(done=False) # Simulate that the problem is closed with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: mock_closed.return_value = True # Try to save the problem - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that the result is failure @@ -625,13 +600,10 @@ class CapaModuleTest(unittest.TestCase): def test_save_problem_submitted_with_randomize(self): - module = CapaFactory.create(rerandomize='always') - - # Simulate that the problem is completed - module.lcp.done = True + module = CapaFactory.create(rerandomize='always', done=True) # Try to save - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that we cannot save @@ -639,13 +611,10 @@ class CapaModuleTest(unittest.TestCase): def test_save_problem_submitted_no_randomize(self): - module = CapaFactory.create(rerandomize='never') - - # Simulate that the problem is completed - module.lcp.done = True + module = CapaFactory.create(rerandomize='never', done=True) # Try to save - get_request_dict = { CapaFactory.input_key(): '3.14' } + get_request_dict = { CapaFactory.input_key(): '3.14'} result = module.save_problem(get_request_dict) # Expect that we succeed @@ -657,7 +626,7 @@ class CapaModuleTest(unittest.TestCase): # Just in case, we also check what happens if we have # more attempts than allowed. attempts = random.randint(1, 10) - module = CapaFactory.create(attempts=attempts-1, max_attempts=attempts) + module = CapaFactory.create(attempts=attempts -1, max_attempts=attempts) self.assertEqual(module.check_button_name(), "Final Check") module = CapaFactory.create(attempts=attempts, max_attempts=attempts) @@ -667,14 +636,14 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(module.check_button_name(), "Final Check") # Otherwise, button name is "Check" - module = CapaFactory.create(attempts=attempts-2, max_attempts=attempts) + module = CapaFactory.create(attempts=attempts -2, max_attempts=attempts) self.assertEqual(module.check_button_name(), "Check") - module = CapaFactory.create(attempts=attempts-3, max_attempts=attempts) + module = CapaFactory.create(attempts=attempts -3, max_attempts=attempts) self.assertEqual(module.check_button_name(), "Check") # If no limit on attempts, then always show "Check" - module = CapaFactory.create(attempts=attempts-3) + module = CapaFactory.create(attempts=attempts -3) self.assertEqual(module.check_button_name(), "Check") module = CapaFactory.create(attempts=0) @@ -682,7 +651,7 @@ class CapaModuleTest(unittest.TestCase): def test_should_show_check_button(self): - attempts = random.randint(1,10) + attempts = random.randint(1, 10) # If we're after the deadline, do NOT show check button module = CapaFactory.create(due=self.yesterday_str) @@ -699,8 +668,7 @@ class CapaModuleTest(unittest.TestCase): # If user submitted a problem but hasn't reset, # do NOT show the check button # Note: we can only reset when rerandomize="always" - module = CapaFactory.create(rerandomize="always") - module.lcp.done = True + module = CapaFactory.create(rerandomize="always", done=True) self.assertFalse(module.should_show_check_button()) # Otherwise, DO show the check button @@ -711,105 +679,101 @@ class CapaModuleTest(unittest.TestCase): # and we do NOT have a reset button, then we can show the check button # Setting rerandomize to "never" ensures that the reset button # is not shown - module = CapaFactory.create(rerandomize="never") - module.lcp.done = True + module = CapaFactory.create(rerandomize="never", done=True) self.assertTrue(module.should_show_check_button()) def test_should_show_reset_button(self): - attempts = random.randint(1,10) + attempts = random.randint(1, 10) # If we're after the deadline, do NOT show the reset button - module = CapaFactory.create(due=self.yesterday_str) - module.lcp.done = True + module = CapaFactory.create(due=self.yesterday_str, done=True) self.assertFalse(module.should_show_reset_button()) # If the user is out of attempts, do NOT show the reset button - module = CapaFactory.create(attempts=attempts, max_attempts=attempts) - module.lcp.done = True + module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True) self.assertFalse(module.should_show_reset_button()) # If we're NOT randomizing, then do NOT show the reset button - module = CapaFactory.create(rerandomize="never") - module.lcp.done = True + module = CapaFactory.create(rerandomize="never", done=True) self.assertFalse(module.should_show_reset_button()) # If the user hasn't submitted an answer yet, # then do NOT show the reset button - module = CapaFactory.create() - module.lcp.done = False + module = CapaFactory.create(done=False) self.assertFalse(module.should_show_reset_button()) # Otherwise, DO show the reset button - module = CapaFactory.create() - module.lcp.done = True + module = CapaFactory.create(done=True) self.assertTrue(module.should_show_reset_button()) # If survey question for capa (max_attempts = 0), # DO show the reset button - module = CapaFactory.create(max_attempts=0) - module.lcp.done = True + module = CapaFactory.create(max_attempts=0, done=True) self.assertTrue(module.should_show_reset_button()) def test_should_show_save_button(self): - attempts = random.randint(1,10) + attempts = random.randint(1, 10) # If we're after the deadline, do NOT show the save button - module = CapaFactory.create(due=self.yesterday_str) - module.lcp.done = True + module = CapaFactory.create(due=self.yesterday_str, done=True) self.assertFalse(module.should_show_save_button()) # If the user is out of attempts, do NOT show the save button - module = CapaFactory.create(attempts=attempts, max_attempts=attempts) - module.lcp.done = True + module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True) self.assertFalse(module.should_show_save_button()) # If user submitted a problem but hasn't reset, do NOT show the save button - module = CapaFactory.create(rerandomize="always") - module.lcp.done = True + module = CapaFactory.create(rerandomize="always", done=True) + self.assertFalse(module.should_show_save_button()) + + # If the user has unlimited attempts and we are not randomizing, + # then do NOT show a save button + # because they can keep using "Check" + module = CapaFactory.create(max_attempts=None, rerandomize="never", done=False) + self.assertFalse(module.should_show_save_button()) + + module = CapaFactory.create(max_attempts=None, rerandomize="never", done=True) self.assertFalse(module.should_show_save_button()) # Otherwise, DO show the save button - module = CapaFactory.create() - module.lcp.done = False + module = CapaFactory.create(done=False) self.assertTrue(module.should_show_save_button()) - # If we're not randomizing, then we can re-save - module = CapaFactory.create(rerandomize="never") - module.lcp.done = True + # If we're not randomizing and we have limited attempts, then we can save + module = CapaFactory.create(rerandomize="never", max_attempts=2, done=True) self.assertTrue(module.should_show_save_button()) # If survey question for capa (max_attempts = 0), # DO show the save button - module = CapaFactory.create(max_attempts=0) - module.lcp.done = False + module = CapaFactory.create(max_attempts=0, done=False) self.assertTrue(module.should_show_save_button()) def test_should_show_save_button_force_save_button(self): # If we're after the deadline, do NOT show the save button # even though we're forcing a save module = CapaFactory.create(due=self.yesterday_str, - force_save_button="true") - module.lcp.done = True + force_save_button="true", + done=True) self.assertFalse(module.should_show_save_button()) # If the user is out of attempts, do NOT show the save button - attempts = random.randint(1,10) + attempts = random.randint(1, 10) module = CapaFactory.create(attempts=attempts, max_attempts=attempts, - force_save_button="true") - module.lcp.done = True + force_save_button="true", + done=True) self.assertFalse(module.should_show_save_button()) # Otherwise, if we force the save button, # then show it even if we would ordinarily # require a reset first module = CapaFactory.create(force_save_button="true", - rerandomize="always") - module.lcp.done = True + rerandomize="always", + done=True) self.assertTrue(module.should_show_save_button()) def test_no_max_attempts(self): @@ -823,9 +787,9 @@ class CapaModuleTest(unittest.TestCase): # We've tested the show/hide button logic in other tests, # so here we hard-wire the values - show_check_button = bool(random.randint(0,1) % 2) - show_reset_button = bool(random.randint(0,1) % 2) - show_save_button = bool(random.randint(0,1) % 2) + show_check_button = bool(random.randint(0, 1) % 2) + show_reset_button = bool(random.randint(0, 1) % 2) + show_save_button = bool(random.randint(0, 1) % 2) module.should_show_check_button = Mock(return_value=show_check_button) module.should_show_reset_button = Mock(return_value=show_reset_button) @@ -848,7 +812,7 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(html, "
          Test Template HTML
          ") # Check the rendering context - render_args,_ = module.system.render_template.call_args + render_args, _ = module.system.render_template.call_args self.assertEqual(len(render_args), 2) template_name = render_args[0] @@ -889,7 +853,7 @@ class CapaModuleTest(unittest.TestCase): html = module.get_problem_html() # Check the rendering context - render_args,_ = module.system.render_template.call_args + render_args, _ = module.system.render_template.call_args context = render_args[1] self.assertTrue("error" in context['problem']['html']) diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 8a14e03ded..55c31ded58 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -57,7 +57,8 @@ class OpenEndedChildTest(unittest.TestCase): def setUp(self): self.test_system = test_system() self.openendedchild = OpenEndedChild(self.test_system, self.location, - self.definition, self.descriptor, self.static_data, self.metadata) + self.definition, self.descriptor, self.static_data, self.metadata) + def test_latest_answer_empty(self): answer = self.openendedchild.latest_answer() @@ -123,7 +124,7 @@ class OpenEndedChildTest(unittest.TestCase): def test_reset(self): self.openendedchild.reset(self.test_system) state = json.loads(self.openendedchild.get_instance_state()) - self.assertEqual(state['state'], OpenEndedChild.INITIAL) + self.assertEqual(state['child_state'], OpenEndedChild.INITIAL) def test_is_last_response_correct(self): new_answer = "New Answer" @@ -182,7 +183,10 @@ class OpenEndedModuleTest(unittest.TestCase): self.test_system.location = self.location self.mock_xqueue = MagicMock() self.mock_xqueue.send_to_queue.return_value = (None, "Message") - self.test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', + def constructed_callback(dispatch="score_update"): + return dispatch + + self.test_system.xqueue = {'interface': self.mock_xqueue, 'construct_callback': constructed_callback, 'default_queuename': 'testqueue', 'waittime': 1} self.openendedmodule = OpenEndedModule(self.test_system, self.location, self.definition, self.descriptor, self.static_data, self.metadata) @@ -209,7 +213,7 @@ class OpenEndedModuleTest(unittest.TestCase): self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY) state = json.loads(self.openendedmodule.get_instance_state()) - self.assertIsNotNone(state['state'], OpenEndedModule.DONE) + self.assertIsNotNone(state['child_state'], OpenEndedModule.DONE) def test_send_to_grader(self): submission = "This is a student submission" @@ -335,12 +339,15 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): def setUp(self): self.test_system = test_system() + # TODO: this constructor call is definitely wrong, but neither branch + # of the merge matches the module constructor. Someone (Vik?) should fix this. self.combinedoe = CombinedOpenEndedV1Module(self.test_system, self.location, self.definition, self.descriptor, static_data=self.static_data, - metadata=self.metadata) + metadata=self.metadata, + instance_state={}) def test_get_tag_name(self): name = self.combinedoe.get_tag_name("Tag") diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 16bd222b9e..1b2da0b74a 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -73,24 +73,21 @@ class ConditionalModuleTest(unittest.TestCase): """Make sure that conditional module works""" print "Starting import" - course = self.get_course('conditional') + course = self.get_course('conditional_and_poll') print "Course: ", course print "id: ", course.id - instance_states = dict(problem=None) - shared_state = None - def inner_get_module(descriptor): if isinstance(descriptor, Location): location = descriptor descriptor = self.modulestore.get_instance(course.id, location, depth=None) location = descriptor.location - instance_state = instance_states.get(location.category, None) - print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state) - return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state) + return descriptor.xmodule(self.test_system) - location = Location(["i4x", "edX", "cond_test", "conditional", "condone"]) + # edx - HarvardX + # cond_test - ER22x + location = Location(["i4x", "HarvardX", "ER22x", "conditional", "condone"]) def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): return text @@ -99,26 +96,28 @@ class ConditionalModuleTest(unittest.TestCase): module = inner_get_module(location) print "module: ", module - print "module definition: ", module.definition + print "module.conditions_map: ", module.conditions_map print "module children: ", module.get_children() print "module display items (children): ", module.get_display_items() html = module.get_html() print "html type: ", type(html) print "html: ", html - html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}" + html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-HarvardX-ER22x-conditional-condone', 'id': 'i4x://HarvardX/ER22x/conditional/condone', 'depends': 'i4x-HarvardX-ER22x-problem-choiceprob'}" self.assertEqual(html, html_expect) gdi = module.get_display_items() print "gdi=", gdi ajax = json.loads(module.handle_ajax('', '')) - self.assertTrue('xmodule.conditional_module' in ajax['html']) print "ajax: ", ajax + html = ajax['html'] + self.assertFalse(any(['This is a secret' in item for item in html])) # now change state of the capa problem to make it completed - instance_states['problem'] = json.dumps({'attempts': 1}) + inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob')).attempts = 1 ajax = json.loads(module.handle_ajax('', '')) - self.assertTrue('This is a secret' in ajax['html']) print "post-attempt ajax: ", ajax + html = ajax['html'] + self.assertTrue(any(['This is a secret' in item for item in html])) diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 712b095696..eda9cf386c 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -1,5 +1,6 @@ import unittest from time import strptime + from fs.memoryfs import MemoryFS from mock import Mock, patch @@ -39,7 +40,7 @@ class DummySystem(ImportSystem): class IsNewCourseTestCase(unittest.TestCase): """Make sure the property is_new works on courses""" @staticmethod - def get_dummy_course(start, announcement=None, is_new=None): + def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None): """Get a dummy course""" system = DummySystem(load_error_modules=True) @@ -49,71 +50,103 @@ class IsNewCourseTestCase(unittest.TestCase): is_new = to_attrb('is_new', is_new) announcement = to_attrb('announcement', announcement) + advertised_start = to_attrb('advertised_start', advertised_start) start_xml = ''' + {is_new} + {advertised_start}> Two houses, ... '''.format(org=ORG, course=COURSE, start=start, is_new=is_new, - announcement=announcement) + announcement=announcement, advertised_start=advertised_start) return system.process_xml(start_xml) @patch('xmodule.course_module.time.gmtime') def test_sorting_score(self, gmtime_mock): gmtime_mock.return_value = NOW - dates = [('2012-10-01T12:00', '2012-09-01T12:00'), # 0 - ('2012-12-01T12:00', '2012-11-01T12:00'), # 1 - ('2013-02-01T12:00', '2012-12-01T12:00'), # 2 - ('2013-02-01T12:00', '2012-11-10T12:00'), # 3 - ('2013-02-01T12:00', None), # 4 - ('2013-03-01T12:00', None), # 5 - ('2013-04-01T12:00', None), # 6 - ('2012-11-01T12:00', None), # 7 - ('2012-09-01T12:00', None), # 8 - ('1990-01-01T12:00', None), # 9 - ('2013-01-02T12:00', None), # 10 - ('2013-01-10T12:00', '2012-12-31T12:00'), # 11 - ('2013-01-10T12:00', '2013-01-01T12:00'), # 12 + + day1 = '2012-01-01T12:00' + day2 = '2012-01-02T12:00' + + dates = [ + # Announce date takes priority over actual start + # and courses announced on a later date are newer + # than courses announced for an earlier date + ((day1, day2, None), (day1, day1, None), self.assertLess), + ((day1, day1, None), (day2, day1, None), self.assertEqual), + + # Announce dates take priority over advertised starts + ((day1, day2, day1), (day1, day1, day1), self.assertLess), + ((day1, day1, day2), (day2, day1, day2), self.assertEqual), + + # Later start == newer course + ((day2, None, None), (day1, None, None), self.assertLess), + ((day1, None, None), (day1, None, None), self.assertEqual), + + # Non-parseable advertised starts are ignored in preference to actual starts + ((day2, None, "Spring"), (day1, None, "Fall"), self.assertLess), + ((day1, None, "Spring"), (day1, None, "Fall"), self.assertEqual), + + # Partially parsable advertised starts should take priority over start dates + ((day2, None, "October 2013"), (day2, None, "October 2012"), self.assertLess), + ((day2, None, "October 2013"), (day1, None, "October 2013"), self.assertEqual), + + # Parseable advertised starts take priority over start dates + ((day1, None, day2), (day1, None, day1), self.assertLess), + ((day2, None, day2), (day1, None, day2), self.assertEqual), ] - data = [] - for i, d in enumerate(dates): - descriptor = self.get_dummy_course(start=d[0], announcement=d[1]) - score = descriptor.sorting_score - data.append((score, i)) - - result = [d[1] for d in sorted(data)] - assert(result == [12, 11, 2, 3, 1, 0, 6, 5, 4, 10, 7, 8, 9]) - + for a, b, assertion in dates: + a_score = self.get_dummy_course(start=a[0], announcement=a[1], advertised_start=a[2]).sorting_score + b_score = self.get_dummy_course(start=b[0], announcement=b[1], advertised_start=b[2]).sorting_score + print "Comparing %s to %s" % (a, b) + assertion(a_score, b_score) @patch('xmodule.course_module.time.gmtime') - def test_is_new(self, gmtime_mock): + def test_start_date_text(self, gmtime_mock): + gmtime_mock.return_value = NOW + + settings = [ + # start, advertized, result + ('2012-12-02T12:00', None, 'Dec 02, 2012'), + ('2012-12-02T12:00', '2011-11-01T12:00', 'Nov 01, 2011'), + ('2012-12-02T12:00', 'Spring 2012', 'Spring 2012'), + ('2012-12-02T12:00', 'November, 2011', 'November, 2011'), + ] + + for s in settings: + d = self.get_dummy_course(start=s[0], advertised_start=s[1]) + print "Checking start=%s advertised=%s" % (s[0], s[1]) + self.assertEqual(d.start_date_text, s[2]) + + @patch('xmodule.course_module.time.gmtime') + def test_is_newish(self, gmtime_mock): gmtime_mock.return_value = NOW descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True) - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False) - assert(descriptor.is_new is False) + assert(descriptor.is_newish is False) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True) - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2013-01-15T12:00') - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) - descriptor = self.get_dummy_course(start='2013-03-00T12:00') - assert(descriptor.is_new is True) + descriptor = self.get_dummy_course(start='2013-03-01T12:00') + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2012-10-15T12:00') - assert(descriptor.is_new is False) + assert(descriptor.is_newish is False) descriptor = self.get_dummy_course(start='2012-12-31T12:00') - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index e9fb89e9f6..443014f9ef 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -18,27 +18,16 @@ TEST_DIR = TEST_DIR / 'test' DATA_DIR = TEST_DIR / 'data' -def strip_metadata(descriptor, key): - """ - Recursively strips tag from all children. - """ - print "strip {key} from {desc}".format(key=key, desc=descriptor.location.url()) - descriptor.metadata.pop(key, None) - for d in descriptor.get_children(): - strip_metadata(d, key) - - def strip_filenames(descriptor): """ Recursively strips 'filename' from all children's definitions. """ print "strip filename from {desc}".format(desc=descriptor.location.url()) - descriptor.definition.pop('filename', None) + descriptor._model_data.pop('filename', None) for d in descriptor.get_children(): strip_filenames(d) - class RoundTripTestCase(unittest.TestCase): ''' Check that our test courses roundtrip properly. Same course imported , than exported, then imported again. @@ -77,10 +66,6 @@ class RoundTripTestCase(unittest.TestCase): exported_course = courses2[0] print "Checking course equality" - # HACK: data_dir metadata tags break equality because they - # aren't real metadata, and depend on paths. Remove them. - strip_metadata(initial_course, 'data_dir') - strip_metadata(exported_course, 'data_dir') # HACK: filenames change when changing file formats # during imports from old-style courses. Ignore them. @@ -105,7 +90,6 @@ class RoundTripTestCase(unittest.TestCase): self.assertEquals(initial_import.modules[course_id][location], second_import.modules[course_id][location]) - def setUp(self): self.maxDiff = None self.temp_dir = mkdtemp() @@ -120,6 +104,9 @@ class RoundTripTestCase(unittest.TestCase): def test_full_roundtrip(self): self.check_export_roundtrip(DATA_DIR, "full") + def test_conditional_and_poll_roundtrip(self): + self.check_export_roundtrip(DATA_DIR, "conditional_and_poll") + def test_selfassessment_roundtrip(self): #Test selfassessment xmodule to see if it exports correctly self.check_export_roundtrip(DATA_DIR, "self_assessment") diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py new file mode 100644 index 0000000000..7c8872efc1 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_fields.py @@ -0,0 +1,80 @@ +"""Tests for Date class defined in fields.py.""" +import datetime +import unittest +from django.utils.timezone import UTC +from xmodule.fields import Date +import time + +class DateTest(unittest.TestCase): + date = Date() + + @staticmethod + def struct_to_datetime(struct_time): + return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, + struct_time.tm_mday, struct_time.tm_hour, + struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) + + def compare_dates(self, date1, date2, expected_delta): + dt1 = DateTest.struct_to_datetime(date1) + dt2 = DateTest.struct_to_datetime(date2) + self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + + str(date2) + "!=" + str(expected_delta)) + + def test_from_json(self): + '''Test conversion from iso compatible date strings to struct_time''' + self.compare_dates( + DateTest.date.from_json("2013-01-01"), + DateTest.date.from_json("2012-12-31"), + datetime.timedelta(days=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00"), + DateTest.date.from_json("2012-12-31T23"), + datetime.timedelta(hours=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00:00"), + DateTest.date.from_json("2012-12-31T23:59"), + datetime.timedelta(minutes=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00:00:00"), + DateTest.date.from_json("2012-12-31T23:59:59"), + datetime.timedelta(seconds=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00:00:00Z"), + DateTest.date.from_json("2012-12-31T23:59:59Z"), + datetime.timedelta(seconds=1)) + self.compare_dates( + DateTest.date.from_json("2012-12-31T23:00:01-01:00"), + DateTest.date.from_json("2013-01-01T00:00:00+01:00"), + datetime.timedelta(hours=1, seconds=1)) + + def test_return_None(self): + self.assertIsNone(DateTest.date.from_json("")) + self.assertIsNone(DateTest.date.from_json(None)) + self.assertIsNone(DateTest.date.from_json(['unknown value'])) + + def test_old_due_date_format(self): + current = datetime.datetime.today() + self.assertEqual( + time.struct_time((current.year, 3, 12, 12, 0, 0, 1, 71, 0)), + DateTest.date.from_json("March 12 12:00")) + self.assertEqual( + time.struct_time((current.year, 12, 4, 16, 30, 0, 2, 338, 0)), + DateTest.date.from_json("December 4 16:30")) + + def test_to_json(self): + ''' + Test converting time reprs to iso dates + ''' + self.assertEqual( + DateTest.date.to_json( + time.strptime("2012-12-31T23:59:59Z", "%Y-%m-%dT%H:%M:%SZ")), + "2012-12-31T23:59:59Z") + self.assertEqual( + DateTest.date.to_json( + DateTest.date.from_json("2012-12-31T23:59:59Z")), + "2012-12-31T23:59:59Z") + self.assertEqual( + DateTest.date.to_json( + DateTest.date.from_json("2012-12-31T23:00:01-01:00")), + "2013-01-01T00:00:01Z") + diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 42072ffe4d..37b1d35938 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from path import path import unittest from fs.memoryfs import MemoryFS @@ -12,6 +14,7 @@ from xmodule.errortracker import make_error_tracker from xmodule.modulestore import Location from xmodule.modulestore.xml import ImportSystem, XMLModuleStore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.inheritance import compute_inherited_metadata from .test_export import DATA_DIR @@ -75,7 +78,6 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(descriptor.__class__.__name__, 'ErrorDescriptor') - def test_unique_url_names(self): '''Check that each error gets its very own url_name''' bad_xml = '''''' @@ -87,7 +89,6 @@ class ImportTestCase(BaseCourseTestCase): self.assertNotEqual(descriptor1.location, descriptor2.location) - def test_reimport(self): '''Make sure an already-exported error xml tag loads properly''' @@ -103,8 +104,10 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(re_import_descriptor.__class__.__name__, 'ErrorDescriptor') - self.assertEqual(descriptor.definition['data'], - re_import_descriptor.definition['data']) + self.assertEqual(descriptor.contents, + re_import_descriptor.contents) + self.assertEqual(descriptor.error_msg, + re_import_descriptor.error_msg) def test_fixed_xml_tag(self): """Make sure a tag that's been fixed exports as the original tag type""" @@ -138,23 +141,20 @@ class ImportTestCase(BaseCourseTestCase): url_name = 'test1' start_xml = ''' + due="{due}" url_name="{url_name}" unicorn="purple"> Two houses, ... - '''.format(grace=v, org=ORG, course=COURSE, url_name=url_name) + '''.format(due=v, org=ORG, course=COURSE, url_name=url_name) descriptor = system.process_xml(start_xml) + compute_inherited_metadata(descriptor) - print descriptor, descriptor.metadata - self.assertEqual(descriptor.metadata['graceperiod'], v) - self.assertEqual(descriptor.metadata['unicorn'], 'purple') + print descriptor, descriptor._model_data + self.assertEqual(descriptor.lms.due, v) - # Check that the child inherits graceperiod correctly + # Check that the child inherits due correctly child = descriptor.get_children()[0] - self.assertEqual(child.metadata['graceperiod'], v) - - # check that the child does _not_ inherit any unicorns - self.assertTrue('unicorn' not in child.metadata) + self.assertEqual(child.lms.due, v) # Now export and check things resource_fs = MemoryFS() @@ -181,12 +181,12 @@ class ImportTestCase(BaseCourseTestCase): # did we successfully strip the url_name from the definition contents? self.assertTrue('url_name' not in course_xml.attrib) - # Does the chapter tag now have a graceperiod attribute? + # Does the chapter tag now have a due attribute? # hardcoded path to child with resource_fs.open('chapter/ch.xml') as f: chapter_xml = etree.fromstring(f.read()) self.assertEqual(chapter_xml.tag, 'chapter') - self.assertFalse('graceperiod' in chapter_xml.attrib) + self.assertFalse('due' in chapter_xml.attrib) def test_is_pointer_tag(self): """ @@ -224,13 +224,12 @@ class ImportTestCase(BaseCourseTestCase): def check_for_key(key, node): "recursive check for presence of key" print "Checking {0}".format(node.location.url()) - self.assertTrue(key in node.metadata) + self.assertTrue(key in node._model_data) for c in node.get_children(): check_for_key(key, c) check_for_key('graceperiod', course) - def test_policy_loading(self): """Make sure that when two courses share content with the same org and course names, policy applies to the right one.""" @@ -252,8 +251,7 @@ class ImportTestCase(BaseCourseTestCase): # Also check that keys from policy are run through the # appropriate attribute maps -- 'graded' should be True, not 'true' - self.assertEqual(toy.metadata['graded'], True) - + self.assertEqual(toy.lms.graded, True) def test_definition_loading(self): """When two courses share the same org and course name and @@ -271,9 +269,8 @@ class ImportTestCase(BaseCourseTestCase): location = Location(["i4x", "edX", "toy", "video", "Welcome"]) toy_video = modulestore.get_instance(toy_id, location) two_toy_video = modulestore.get_instance(two_toy_id, location) - self.assertEqual(toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh8") - self.assertEqual(two_toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh9") - + self.assertEqual(etree.fromstring(toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh8") + self.assertEqual(etree.fromstring(two_toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh9") def test_colon_in_url_name(self): """Ensure that colons in url_names convert to file paths properly""" @@ -331,6 +328,22 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(len(video.url_name), len('video_') + 12) + def test_poll_and_conditional_xmodule(self): + modulestore = XMLModuleStore(DATA_DIR, course_dirs=['conditional_and_poll']) + + course = modulestore.get_courses()[0] + chapters = course.get_children() + ch1 = chapters[0] + sections = ch1.get_children() + + self.assertEqual(len(sections), 1) + + location = course.location + location = Location(location.tag, location.org, location.course, + 'sequential', 'Problem_Demos') + module = modulestore.get_instance(course.id, location) + self.assertEqual(len(module.children), 2) + def test_error_on_import(self): '''Check that when load_error_module is false, an exception is raised, rather than returning an ErrorModule''' @@ -354,7 +367,7 @@ class ImportTestCase(BaseCourseTestCase): render_string_from_sample_gst_xml = """ \ """.strip() - self.assertEqual(gst_sample.definition['render'], render_string_from_sample_gst_xml) + self.assertEqual(gst_sample.render, render_string_from_sample_gst_xml) def test_cohort_config(self): """ @@ -370,13 +383,13 @@ class ImportTestCase(BaseCourseTestCase): self.assertFalse(course.is_cohorted) # empty config -> False - course.metadata['cohort_config'] = {} + course.cohort_config = {} self.assertFalse(course.is_cohorted) # false config -> False - course.metadata['cohort_config'] = {'cohorted': False} + course.cohort_config = {'cohorted': False} self.assertFalse(course.is_cohorted) # and finally... - course.metadata['cohort_config'] = {'cohorted': True} + course.cohort_config = {'cohorted': True} self.assertTrue(course.is_cohorted) diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py new file mode 100644 index 0000000000..018b40427e --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_logic.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +import json +import unittest + +from xmodule.poll_module import PollDescriptor +from xmodule.conditional_module import ConditionalDescriptor + + +class LogicTest(unittest.TestCase): + """Base class for testing xmodule logic.""" + descriptor_class = None + raw_model_data = {} + + def setUp(self): + class EmptyClass: pass + + self.system = None + self.location = None + self.descriptor = EmptyClass() + + self.xmodule_class = self.descriptor_class.module_class + self.xmodule = self.xmodule_class(self.system, self.location, + self.descriptor, self.raw_model_data) + + def ajax_request(self, dispatch, get): + return json.loads(self.xmodule.handle_ajax(dispatch, get)) + + +class PollModuleTest(LogicTest): + descriptor_class = PollDescriptor + raw_model_data = { + 'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0}, + 'voted': False, + 'poll_answer': '' + } + + def test_bad_ajax_request(self): + response = self.ajax_request('bad_answer', {}) + self.assertDictEqual(response, {'error': 'Unknown Command!'}) + + def test_good_ajax_request(self): + response = self.ajax_request('No', {}) + + poll_answers = response['poll_answers'] + total = response['total'] + callback = response['callback'] + + self.assertDictEqual(poll_answers, {'Yes': 1, 'Dont_know': 0, 'No': 1}) + self.assertEqual(total, 2) + self.assertDictEqual(callback, {'objectName': 'Conditional'}) + self.assertEqual(self.xmodule.poll_answer, 'No') + + +class ConditionalModuleTest(LogicTest): + descriptor_class = ConditionalDescriptor + + def test_ajax_request(self): + # Mock is_condition_satisfied + self.xmodule.is_condition_satisfied = lambda: True + setattr(self.xmodule.descriptor, 'get_children', lambda: []) + + response = self.ajax_request('No', {}) + html = response['html'] + + self.assertEqual(html, []) diff --git a/common/lib/xmodule/xmodule/tests/test_randomize_module.py b/common/lib/xmodule/xmodule/tests/test_randomize_module.py index 456fd379a5..59cf5a59f3 100644 --- a/common/lib/xmodule/xmodule/tests/test_randomize_module.py +++ b/common/lib/xmodule/xmodule/tests/test_randomize_module.py @@ -13,7 +13,7 @@ COURSE = 'test_course' START = '2013-01-01T01:00:00' -from test_course_module import DummySystem as DummyImportSystem +from .test_course_module import DummySystem as DummyImportSystem from . import test_system diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py index a7f2a9fdfe..593b3fea01 100644 --- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py +++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py @@ -29,8 +29,6 @@ class SelfAssessmentTest(unittest.TestCase): location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"]) - metadata = {'attempts': '10'} - descriptor = Mock() def setUp(self): @@ -54,9 +52,9 @@ class SelfAssessmentTest(unittest.TestCase): } self.module = SelfAssessmentModule(test_system(), self.location, - self.definition, self.descriptor, - static_data, - state, metadata=self.metadata) + self.definition, + self.descriptor, + static_data) def test_get_html(self): html = self.module.get_html(self.module.system) @@ -85,18 +83,18 @@ class SelfAssessmentTest(unittest.TestCase): self.module.save_answer({'student_answer': "I am an answer"}, self.module.system) - self.assertEqual(self.module.state, self.module.ASSESSING) + self.assertEqual(self.module.child_state, self.module.ASSESSING) self.module.save_assessment(mock_query_dict, self.module.system) - self.assertEqual(self.module.state, self.module.DONE) + self.assertEqual(self.module.child_state, self.module.DONE) d = self.module.reset({}) self.assertTrue(d['success']) - self.assertEqual(self.module.state, self.module.INITIAL) + self.assertEqual(self.module.child_state, self.module.INITIAL) # if we now assess as right, skip the REQUEST_HINT state self.module.save_answer({'student_answer': 'answer 4'}, self.module.system) responses['assessment'] = '1' self.module.save_assessment(mock_query_dict, self.module.system) - self.assertEqual(self.module.state, self.module.DONE) + self.assertEqual(self.module.child_state, self.module.DONE) diff --git a/common/lib/xmodule/xmodule/timeinfo.py b/common/lib/xmodule/xmodule/timeinfo.py index 6c6a72e700..615a7b2c73 100644 --- a/common/lib/xmodule/xmodule/timeinfo.py +++ b/common/lib/xmodule/xmodule/timeinfo.py @@ -1,7 +1,7 @@ import dateutil import dateutil.parser import datetime -from timeparse import parse_timedelta +from .timeparse import parse_timedelta import logging log = logging.getLogger(__name__) diff --git a/common/lib/xmodule/xmodule/timelimit_module.py b/common/lib/xmodule/xmodule/timelimit_module.py index 9abb5d183f..efa47a5dca 100644 --- a/common/lib/xmodule/xmodule/timelimit_module.py +++ b/common/lib/xmodule/xmodule/timelimit_module.py @@ -9,35 +9,31 @@ from xmodule.xml_module import XmlDescriptor from xmodule.x_module import XModule from xmodule.progress import Progress from xmodule.exceptions import NotFoundError +from xblock.core import Float, String, Boolean, Scope log = logging.getLogger(__name__) -class TimeLimitModule(XModule): - ''' + +class TimeLimitFields(object): + beginning_at = Float(help="The time this timer was started", scope=Scope.student_state) + ending_at = Float(help="The time this timer will end", scope=Scope.student_state) + accomodation_code = String(help="A code indicating accommodations to be given the student", scope=Scope.student_state) + time_expired_redirect_url = String(help="Url to redirect users to after the timelimit has expired", scope=Scope.settings) + duration = Float(help="The length of this timer", scope=Scope.settings) + suppress_toplevel_navigation = Boolean(help="Whether the toplevel navigation should be suppressed when viewing this module", scope=Scope.settings) + + +class TimeLimitModule(TimeLimitFields, XModule): + ''' Wrapper module which imposes a time constraint for the completion of its child. ''' - def __init__(self, system, location, definition, descriptor, instance_state=None, - shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) self.rendered = False - self.beginning_at = None - self.ending_at = None - self.accommodation_code = None - - if instance_state is not None: - state = json.loads(instance_state) - if 'beginning_at' in state: - self.beginning_at = state['beginning_at'] - if 'ending_at' in state: - self.ending_at = state['ending_at'] - if 'accommodation_code' in state: - self.accommodation_code = state['accommodation_code'] - # For a timed activity, we are only interested here # in time-related accommodations, and these should be disjoint. # (For proctored exams, it is possible to have multiple accommodations @@ -50,7 +46,7 @@ class TimeLimitModule(XModule): ) def _get_accommodated_duration(self, duration): - ''' + ''' Get duration for activity, as adjusted for accommodations. Input and output are expressed in seconds. ''' @@ -70,35 +66,25 @@ class TimeLimitModule(XModule): @property def has_begun(self): return self.beginning_at is not None - - @property + + @property def has_ended(self): if not self.ending_at: return False return self.ending_at < time() - + def begin(self, duration): - ''' + ''' Sets the starting time and ending time for the activity, based on the duration provided (in seconds). ''' self.beginning_at = time() modified_duration = self._get_accommodated_duration(duration) self.ending_at = self.beginning_at + modified_duration - + def get_remaining_time_in_ms(self): return int((self.ending_at - time()) * 1000) - def get_instance_state(self): - state = {} - if self.beginning_at: - state['beginning_at'] = self.beginning_at - if self.ending_at: - state['ending_at'] = self.ending_at - if self.accommodation_code: - state['accommodation_code'] = self.accommodation_code - return json.dumps(state) - def get_html(self): self.render() return self.content @@ -133,12 +119,12 @@ class TimeLimitModule(XModule): else: return "other" -class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor): +class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor): module_class = TimeLimitModule # For remembering when a student started, and when they should end - stores_state = True + stores_state = True @classmethod def definition_from_xml(cls, xml_object, system): @@ -151,7 +137,7 @@ class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor): if system.error_tracker is not None: system.error_tracker("ERROR: " + str(e)) continue - return {'children': children} + return {}, children def definition_to_xml(self, resource_fs): xml_object = etree.Element('timelimit') diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py index 5827ea96a9..610d180c11 100644 --- a/common/lib/xmodule/xmodule/vertical_module.py +++ b/common/lib/xmodule/xmodule/vertical_module.py @@ -8,11 +8,15 @@ from pkg_resources import resource_string class_priority = ['video', 'problem'] -class VerticalModule(XModule): +class VerticalFields(object): + has_children = True + + +class VerticalModule(VerticalFields, XModule): ''' Layout module for laying out submodules vertically.''' - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) self.contents = None def get_html(self): @@ -42,7 +46,7 @@ class VerticalModule(XModule): return new_class -class VerticalDescriptor(SequenceDescriptor): +class VerticalDescriptor(VerticalFields, SequenceDescriptor): module_class = VerticalModule js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 27388f7630..0203299b40 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -8,9 +8,8 @@ from django.http import Http404 from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor -from xmodule.modulestore.xml import XMLModuleStore -from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent +from xblock.core import Integer, Scope, String import datetime import time @@ -18,7 +17,13 @@ import time log = logging.getLogger(__name__) -class VideoModule(XModule): +class VideoFields(object): + data = String(help="XML data for the problem", scope=Scope.content) + position = Integer(help="Current position in the video", scope=Scope.student_state, default=0) + display_name = String(help="Display name for this module", scope=Scope.settings) + + +class VideoModule(VideoFields, XModule): video_time = 0 icon_class = 'video' @@ -32,23 +37,16 @@ class VideoModule(XModule): css = {'scss': [resource_string(__name__, 'css/video/display.scss')]} js_module_name = "Video" - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - xmltree = etree.fromstring(self.definition['data']) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) + + xmltree = etree.fromstring(self.data) self.youtube = xmltree.get('youtube') - self.position = 0 self.show_captions = xmltree.get('show_captions', 'true') self.source = self._get_source(xmltree) self.track = self._get_track(xmltree) self.start_time, self.end_time = self._get_timeframe(xmltree) - if instance_state is not None: - state = json.loads(instance_state) - if 'position' in state: - self.position = int(float(state['position'])) - def _get_source(self, xmltree): # find the first valid source return self._get_first_external(xmltree, 'source') @@ -120,13 +118,6 @@ class VideoModule(XModule): return self.youtube def get_html(self): - if isinstance(modulestore(), XMLModuleStore): - # VS[compat] - # cdodge: filesystem static content support. - caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir']) - else: - caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_' - # We normally let JS parse this, but in the case that we need a hacked # out player because YouTube has broken their \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/chapter/Staff.xml b/common/test/data/conditional_and_poll/chapter/Staff.xml new file mode 100644 index 0000000000..e1d5216f6d --- /dev/null +++ b/common/test/data/conditional_and_poll/chapter/Staff.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/conditional_and_poll/conditional/condone.xml b/common/test/data/conditional_and_poll/conditional/condone.xml new file mode 100644 index 0000000000..80b061e244 --- /dev/null +++ b/common/test/data/conditional_and_poll/conditional/condone.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/conditional_and_poll/course.xml b/common/test/data/conditional_and_poll/course.xml new file mode 120000 index 0000000000..f4f5c17b87 --- /dev/null +++ b/common/test/data/conditional_and_poll/course.xml @@ -0,0 +1 @@ +roots/2013_Spring.xml \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/course/2013_Spring.xml b/common/test/data/conditional_and_poll/course/2013_Spring.xml new file mode 100644 index 0000000000..2eea422a2f --- /dev/null +++ b/common/test/data/conditional_and_poll/course/2013_Spring.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/common/test/data/conditional_and_poll/creating_course.xml b/common/test/data/conditional_and_poll/creating_course.xml new file mode 100644 index 0000000000..4c90f1c2ec --- /dev/null +++ b/common/test/data/conditional_and_poll/creating_course.xml @@ -0,0 +1,8 @@ + diff --git a/common/test/data/conditional_and_poll/html/secret_page.xml b/common/test/data/conditional_and_poll/html/secret_page.xml new file mode 100644 index 0000000000..63be3cfa8d --- /dev/null +++ b/common/test/data/conditional_and_poll/html/secret_page.xml @@ -0,0 +1,4 @@ + +

          This is a secret!

          + + diff --git a/common/test/data/conditional_and_poll/info/2013_Spring/handouts.html b/common/test/data/conditional_and_poll/info/2013_Spring/handouts.html new file mode 100644 index 0000000000..35f2c89474 --- /dev/null +++ b/common/test/data/conditional_and_poll/info/2013_Spring/handouts.html @@ -0,0 +1,3 @@ +
            +
          1. A list of course handouts, or an empty file if there are none.
          2. +
          diff --git a/common/test/data/conditional_and_poll/info/2013_Spring/updates.html b/common/test/data/conditional_and_poll/info/2013_Spring/updates.html new file mode 100644 index 0000000000..9744c1699d --- /dev/null +++ b/common/test/data/conditional_and_poll/info/2013_Spring/updates.html @@ -0,0 +1,10 @@ + +
            + +
          1. December 9

            +
            +

            Announcement text

            +
            +
          2. + +
          diff --git a/common/test/data/conditional_and_poll/policies/2013_Spring/policy.json b/common/test/data/conditional_and_poll/policies/2013_Spring/policy.json new file mode 100644 index 0000000000..e2a204815c --- /dev/null +++ b/common/test/data/conditional_and_poll/policies/2013_Spring/policy.json @@ -0,0 +1,8 @@ +{ + "course/2013_Spring": { + "start": "2099-01-01T00:00", + "advertised_start" : "Spring 2013", + "display_name": "Justice" + } + +} diff --git a/common/test/data/conditional_and_poll/problem/choiceprob.xml b/common/test/data/conditional_and_poll/problem/choiceprob.xml new file mode 100644 index 0000000000..fa91954977 --- /dev/null +++ b/common/test/data/conditional_and_poll/problem/choiceprob.xml @@ -0,0 +1,22 @@ + + + +

          Consider a hypothetical magnetic field pointing out of your computer screen. Now imagine an electron traveling from right to left in the plane of your screen. A diagram of this situation is show below…

          +
          + +

          a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)

          + + + + +Magnetic field strength… +Electric field strength… +Electric charge of the electron… +Radius of the electron… +Mass of the electron… +Velocity of the electron… + + + + +
          diff --git a/common/test/data/conditional_and_poll/roots/2013_Spring.xml b/common/test/data/conditional_and_poll/roots/2013_Spring.xml new file mode 100644 index 0000000000..1b97a5a714 --- /dev/null +++ b/common/test/data/conditional_and_poll/roots/2013_Spring.xml @@ -0,0 +1,2 @@ + + diff --git a/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml b/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml new file mode 100644 index 0000000000..e10298336d --- /dev/null +++ b/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml @@ -0,0 +1,31 @@ + + + +

          What's the Right Thing to Do?

          +

          Suppose four shipwrecked sailors are stranded at sea in a lifeboat, without + food or water. Would it be wrong for three of them to kill and eat the cabin + boy, in order to save their own lives?

          + Yes + No + Don't know +
          + +

          What's the Right Thing to Do?

          +

          Suppose four shipwrecked sailors are stranded at sea in a lifeboat, without + food or water. Would it be wrong for three of them to kill and eat the cabin + boy, in order to save their own lives?

          + Yes + No + Don't know +
          +
          + + + + Condition: first_poll - Yes + + In first condition. + + + +
          diff --git a/common/test/data/conditional_and_poll/static/README b/common/test/data/conditional_and_poll/static/README new file mode 100644 index 0000000000..e22f378b5e --- /dev/null +++ b/common/test/data/conditional_and_poll/static/README @@ -0,0 +1,5 @@ +Images, handouts, and other statically-served content should go ONLY +in this directory. + +Images for the front page should go in static/images. The frontpage +banner MUST be named course_image.jpg \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/static/images/course_image.jpg b/common/test/data/conditional_and_poll/static/images/course_image.jpg new file mode 100644 index 0000000000..b6a64b9396 Binary files /dev/null and b/common/test/data/conditional_and_poll/static/images/course_image.jpg differ diff --git a/common/test/data/conditional_and_poll/static/images/professor-sandel.jpg b/common/test/data/conditional_and_poll/static/images/professor-sandel.jpg new file mode 100644 index 0000000000..41bde60165 Binary files /dev/null and b/common/test/data/conditional_and_poll/static/images/professor-sandel.jpg differ diff --git a/common/test/data/full/vertical/vertical_89.xml b/common/test/data/full/vertical/vertical_89.xml index c2b68b6bc2..cf2dd23462 100644 --- a/common/test/data/full/vertical/vertical_89.xml +++ b/common/test/data/full/vertical/vertical_89.xml @@ -7,4 +7,9 @@ + +

          Have you changed your mind?

          + Yes + No +
          diff --git a/doc/development.md b/doc/development.md index 16c689ff05..184767a139 100644 --- a/doc/development.md +++ b/doc/development.md @@ -9,9 +9,8 @@ This will read the `Gemfile` and install all of the gems specified there. ### Python -In order, run the following: +Run the following:: - pip install -r pre-requirements.txt pip install -r requirements.txt pip install -r test-requirements.txt diff --git a/doc/public/Makefile b/doc/public/Makefile index f162e1b05c..378a64b7fb 100644 --- a/doc/public/Makefile +++ b/doc/public/Makefile @@ -5,7 +5,7 @@ SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = -BUILDDIR = _build +BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 diff --git a/doc/public/course_data_formats/conditional_module/conditional_module.rst b/doc/public/course_data_formats/conditional_module/conditional_module.rst new file mode 100644 index 0000000000..82c555d3e7 --- /dev/null +++ b/doc/public/course_data_formats/conditional_module/conditional_module.rst @@ -0,0 +1,77 @@ +********************************************** +Xml format of conditional module [xmodule] +********************************************** + +.. module:: conditional_module + +Format description +================== + +The main tag of Conditional module input is: + +.. code-block:: xml + + ... + +``conditional`` can include any number of any xmodule tags (``html``, ``video``, ``poll``, etc.) or ``show`` tags. + +conditional tag +--------------- + +The main container for a single instance of Conditional module. The following attributes can +be specified for this tag:: + + sources - location id of required modules, separated by ';' + [message | ""] - message for case, where one or more are not passed. Here you can use variable {link}, which generate link to required module. + + [completed] - map to `is_completed` module method + [attempted] - map to `is_attempted` module method + [poll_answer] - map to `poll_answer` module attribute + [voted] - map to `voted` module attribute + +show tag +-------- + +Symlink to some set of xmodules. The following attributes can +be specified for this tag:: + + sources - location id of modules, separated by ';' + +Example +======= + +Examples of conditional depends on poll +------------------------------------------- + +.. code-block:: xml + + + +

          You see this, cause your vote value for "First question" was "man"

          + +
          + +Examples of conditional depends on poll (use tag) +------------------------------------------- + +.. code-block:: xml + + + + + + + +Examples of conditional depends on problem +------------------------------------------- + +.. code-block:: xml + + + You see this, cause "lec27_Q1" is attempted. + + + You see this, cause "lec27_Q1" is not attempted. + \ No newline at end of file diff --git a/doc/public/course_data_formats/poll_module/poll_module.rst b/doc/public/course_data_formats/poll_module/poll_module.rst new file mode 100644 index 0000000000..9b16758877 --- /dev/null +++ b/doc/public/course_data_formats/poll_module/poll_module.rst @@ -0,0 +1,67 @@ +********************************************** +Xml format of poll module [xmodule] +********************************************** + +.. module:: poll_module + +Format description +================== + +The main tag of Poll module input is: + +.. code-block:: xml + + ... + +``poll_question`` can include any number of the following tags: +any xml and ``answer`` tag. All inner xml, except for ``answer`` tags, we call "question". + +poll_question tag +----------------- + +Xmodule for creating poll functionality - voting system. The following attributes can +be specified for this tag:: + + name - Name of xmodule. + [display_name| AUTOGENERATE] - Display name of xmodule. When this attribute is not defined - display name autogenerate with some hash. + [reset | False] - Can reset/revote many time (value = True/False) + + +answer tag +---------- + +Define one of the possible answer for poll module. The following attributes can +be specified for this tag:: + + id - unique identifier (using to identify the different answers) + +Inner text - Display text for answer choice. + +Example +======= + +Examples of poll +---------------- + +.. code-block:: xml + + +

          Age

          +

          How old are you?

          + < 18 + from 10 to 25 + > 25 +
          + +Examples of poll with unable reset functionality +------------------------------------------------ + +.. code-block:: xml + + +

          Your gender

          +

          You are man or woman?

          + Man + Woman +
          \ No newline at end of file diff --git a/doc/public/course_data_formats/symbolic_response.rst b/doc/public/course_data_formats/symbolic_response.rst new file mode 100644 index 0000000000..8463faab3c --- /dev/null +++ b/doc/public/course_data_formats/symbolic_response.rst @@ -0,0 +1,40 @@ +################# +Symbolic Response +################# + +This document plans to document features that the current symbolic response +supports. In general it allows the input and validation of math expressions, +up to commutativity and some identities. + + +******** +Features +******** + +This is a partial list of features, to be revised as we go along: + * sub and superscripts: an expression following the ``^`` character + indicates exponentiation. To use superscripts in variables, the syntax + is ``b_x__d`` for the variable ``b`` with subscript ``x`` and super + ``d``. + + An example of a problem:: + + + + + + It's a bit of a pain to enter that. + + * The script-style math variant. What would be outputted in latex if you + entered ``\mathcal{N}``. This is used in some variables. + + An example:: + + + + + + There is no fancy preprocessing needed, but if you had superscripts or + something, you would need to include that part. diff --git a/doc/public/index.rst b/doc/public/index.rst index 084340e855..ee681a822e 100644 --- a/doc/public/index.rst +++ b/doc/public/index.rst @@ -24,6 +24,8 @@ Specific Problem Types course_data_formats/drag_and_drop/drag_and_drop_input.rst course_data_formats/graphical_slider_tool/graphical_slider_tool.rst + course_data_formats/poll_module/poll_module.rst + course_data_formats/conditional_module/conditional_module.rst course_data_formats/custom_response.rst diff --git a/doc/public/internal_data_formats/sql_schema.rst b/doc/public/internal_data_formats/sql_schema.rst index 409ec1c065..92c5c4fa0e 100644 --- a/doc/public/internal_data_formats/sql_schema.rst +++ b/doc/public/internal_data_formats/sql_schema.rst @@ -313,14 +313,18 @@ There is an important split in demographic data gathered for the students who si - This student signed up before this information was collected * - `''` (blank) - User did not specify level of education. + * - `'p'` + - Doctorate * - `'p_se'` - - Doctorate in science or engineering + - Doctorate in science or engineering (no longer used) * - `'p_oth'` - - Doctorate in another field + - Doctorate in another field (no longer used) * - `'m'` - Master's or professional degree * - `'b'` - Bachelor's degree + * - `'a'` + - Associate's degree * - `'hs'` - Secondary/high school * - `'jhs'` @@ -624,4 +628,4 @@ The generatedcertificate table tracks certificate state for students who have be `grade` ------- - The grade of the student recorded at the time the certificate was generated. This may be different than the current grade since grading is only done once for a course when it ends. \ No newline at end of file + The grade of the student recorded at the time the certificate was generated. This may be different than the current grade since grading is only done once for a course when it ends. diff --git a/docs/source/drag-n-drop-demo.xml b/docs/source/drag-n-drop-demo.xml deleted file mode 100644 index 67712407a1..0000000000 --- a/docs/source/drag-n-drop-demo.xml +++ /dev/null @@ -1,526 +0,0 @@ - - - - -

          [Anyof rule example]


          -

          Please label hydrogen atoms connected with left carbon atom.

          -
          -
          - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Complex grading example]


          -

          Describe carbon molecule in LCAO-MO.

          -
          -
          - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Another complex grading example]


          -

          Describe oxygen molecule in LCAO-MO

          -
          -
          - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Individual targets with outlines, One draggable per target]


          -

          - Drag -Ant- to first position and -Star- to third position


          -
          - - - - - - - - - - - - - - - - - - -
          - - - -

          [SMALL IMAGE, Individual targets WITHOUT outlines, One draggable - per target]


          -

          - Move -Star- to the volcano opening, and -Label3- on to - the right ear of the cow. -


          -
          - - - - - - - - - - - - - - - - - -
          - - - -

          [Many draggables per target]


          -

          Move -Star- and -Ant- to most left target - and -Label3- and -Label2- to most right target.


          -
          - - - - - - - - - - - - - - - - - - -
          - - - -

          [Draggables can be placed anywhere on base image]


          -

          - Place -Grass- in the middle of the image and -Ant- in the - right upper corner.


          -
          - - - - - - - - - - - - - - -
          - - - -

          [Another anyof example]


          -

          Please identify the Carbon and Oxygen atoms in the molecule.


          -
          - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Again another anyof example]


          -

          If the element appears in this molecule, drag the label onto it

          -
          -
          - - - - - - - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Wrong base image url example] -


          -
          - - - - - - - - - - - - - - - - - -
          - -
          diff --git a/docs/source/drag-n-drop-demo2.xml b/docs/source/drag-n-drop-demo2.xml deleted file mode 100644 index 6ffac18e44..0000000000 --- a/docs/source/drag-n-drop-demo2.xml +++ /dev/null @@ -1,373 +0,0 @@ - - - - -

          [Draggable is reusable example]

          -
          -

          Please label all hydrogen atoms.

          -
          -
          - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Complex grading example]


          -

          Describe carbon molecule in LCAO-MO.

          -
          -
          - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Many draggables per target]


          -

          Move two Stars and three Ants to most left target - and one Label3 and four Label2 to most right target.


          -
          - - - - - - - - - - - - - - - - - - -
          - - - -

          [Draggables can be placed anywhere on base image]


          -

          - Place -Grass- in the middle of the image and -Ant- in the - right upper corner.


          -
          - - - - - - - - - - - - - - -
          - - - -

          [Another anyof example]


          -

          Please identify the Carbon and Oxygen atoms in the molecule.


          -
          - - - - - - - - - - - - - - - - - - - - - - -
          - - - -

          [Exact number of draggables for a set of targets.]


          -

          Drag two Grass and one Star to first or second positions, and three Cloud to any of the three positions.

          -
          -
          - - - - - - - - - - - - - - - - - - -
          - - - -

          [As many as you like draggables for a set of targets.]


          -

          Drag some Grass to any of the targets, and some Stars to either first or last target.

          -
          -
          - - - - - - - - - - - - - - - - - - -
          - -
          diff --git a/docs/source/drag_and_drop_input.rst b/docs/source/drag_and_drop_input.rst deleted file mode 100644 index 06a28a5926..0000000000 --- a/docs/source/drag_and_drop_input.rst +++ /dev/null @@ -1,323 +0,0 @@ -********************************************** -Xml format of drag and drop input [inputtypes] -********************************************** - -.. module:: drag_and_drop_input - -Format description -================== - -The main tag of Drag and Drop (DnD) input is:: - - ... - -``drag_and_drop_input`` can include any number of the following 2 tags: -``draggable`` and ``target``. - -drag_and_drop_input tag ------------------------ - -The main container for a single instance of DnD. The following attributes can -be specified for this tag:: - - img - Relative path to an image that will be the base image. All draggables - can be dragged onto it. - target_outline - Specify whether an outline (gray dashed line) should be - drawn around targets (if they are specified). It can be either - 'true' or 'false'. If not specified, the default value is - 'false'. - one_per_target - Specify whether to allow more than one draggable to be - placed onto a single target. It can be either 'true' or 'false'. If - not specified, the default value is 'true'. - no_labels - default is false, in default behaviour if label is not set, label - is obtained from id. If no_labels is true, labels are not automatically - populated from id, and one can not set labels and obtain only icons. - -draggable tag -------------- - -Draggable tag specifies a single draggable object which has the following -attributes:: - - id - Unique identifier of the draggable object. - label - Human readable label that will be shown to the user. - icon - Relative path to an image that will be shown to the user. - can_reuse - true or false, default is false. If true, same draggable can be - used multiple times. - -A draggable is what the user must drag out of the slider and place onto the -base image. After a drag operation, if the center of the draggable ends up -outside the rectangular dimensions of the image, it will be returned back -to the slider. - -In order for the grader to work, it is essential that a unique ID -is provided. Otherwise, there will be no way to tell which draggable is at what -coordinate, or over what target. Label and icon attributes are optional. If -they are provided they will be used, otherwise, you can have an empty -draggable. The path is relative to 'course_folder' folder, for example, -/static/images/img1.png. - -target tag ----------- - -Target tag specifies a single target object which has the following required -attributes:: - - id - Unique identifier of the target object. - x - X-coordinate on the base image where the top left corner of the target - will be positioned. - y - Y-coordinate on the base image where the top left corner of the target - will be positioned. - w - Width of the target. - h - Height of the target. - -A target specifies a place on the base image where a draggable can be -positioned. By design, if the center of a draggable lies within the target -(i.e. in the rectangle defined by [[x, y], [x + w, y + h]], then it is within -the target. Otherwise, it is outside. - -If at lest one target is provided, the behavior of the client side logic -changes. If a draggable is not dragged on to a target, it is returned back to -the slider. - -If no targets are provided, then a draggable can be dragged and placed anywhere -on the base image. - -correct answer format ---------------------- - -There are two correct answer formats: short and long -If short from correct answer is mapping of 'draggable_id' to 'target_id':: - - correct_answer = {'grass': [[300, 200], 200], 'ant': [[500, 0], 200]} - correct_answer = {'name4': 't1', '7': 't2'} - -In long form correct answer is list of dicts. Every dict has 3 keys: -draggables, targets and rule. For example:: - - correct_answer = [ - { - 'draggables': ['7', '8'], - 'targets': ['t5_c', 't6_c'], - 'rule': 'anyof' - }, - { - 'draggables': ['1', '2'], - 'targets': ['t2_h', 't3_h', 't4_h', 't7_h', 't8_h', 't10_h'], - 'rule': 'anyof' - }] - -Draggables is list of draggables id. Target is list of targets id, draggables -must be dragged to with considering rule. Rule is string. - -Draggables in dicts inside correct_answer list must not intersect!!! - -Wrong (for draggable id 7):: - - correct_answer = [ - { - 'draggables': ['7', '8'], - 'targets': ['t5_c', 't6_c'], - 'rule': 'anyof' - }, - { - 'draggables': ['7', '2'], - 'targets': ['t2_h', 't3_h', 't4_h', 't7_h', 't8_h', 't10_h'], - 'rule': 'anyof' - }] - -Rules are: exact, anyof, unordered_equal, anyof+number, unordered_equal+number - - -.. such long lines are needed for sphinx to display lists correctly - -- Exact rule means that targets for draggable id's in user_answer are the same that targets from correct answer. For example, for draggables 7 and 8 user must drag 7 to target1 and 8 to target2 if correct_answer is:: - - correct_answer = [ - { - 'draggables': ['7', '8'], - 'targets': ['tartget1', 'target2'], - 'rule': 'exact' - }] - - -- unordered_equal rule allows draggables be dragged to targets unordered. If one want to allow for student to drag 7 to target1 or target2 and 8 to target2 or target 1 and 7 and 8 must be in different targets, then correct answer must be:: - - correct_answer = [ - { - 'draggables': ['7', '8'], - 'targets': ['tartget1', 'target2'], - 'rule': 'unordered_equal' - }] - - -- Anyof rule allows draggables to be dragged to any of targets. If one want to allow for student to drag 7 and 8 to target1 or target2, which means that if 7 is on target1 and 8 is on target1 or 7 on target2 and 8 on target2 or 7 on target1 and 8 on target2. Any of theese are correct which anyof rule:: - - correct_answer = [ - { - 'draggables': ['7', '8'], - 'targets': ['tartget1', 'target2'], - 'rule': 'anyof' - }] - - -- If you have can_reuse true, then you, for example, have draggables a,b,c and 10 targets. These will allow you to drag 4 'a' draggables to ['target1', 'target4', 'target7', 'target10'] , you do not need to write 'a' four times. Also this will allow you to drag 'b' draggable to target2 or target5 for target5 and target2 etc..:: - - correct_answer = [ - { - 'draggables': ['a'], - 'targets': ['target1', 'target4', 'target7', 'target10'], - 'rule': 'unordered_equal' - }, - { - 'draggables': ['b'], - 'targets': ['target2', 'target5', 'target8'], - 'rule': 'anyof' - }, - { - 'draggables': ['c'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'unordered_equal' - }] - -- And sometimes you want to allow drag only two 'b' draggables, in these case you sould use 'anyof+number' of 'unordered_equal+number' rule:: - - correct_answer = [ - { - 'draggables': ['a', 'a', 'a'], - 'targets': ['target1', 'target4', 'target7'], - 'rule': 'unordered_equal+numbers' - }, - { - 'draggables': ['b', 'b'], - 'targets': ['target2', 'target5', 'target8'], - 'rule': 'anyof+numbers' - }, - { - 'draggables': ['c'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'unordered_equal' - }] - -In case if we have no multiple draggables per targets (one_per_target="true"), -for same number of draggables, anyof is equal to unordered_equal - -If we have can_reuse=true, than one must use only long form of correct answer. - - -Grading logic -------------- - -1. User answer (that comes from browser) and correct answer (from xml) are parsed to the same format:: - - group_id: group_draggables, group_targets, group_rule - - -Group_id is ordinal number, for every dict in correct answer incremental -group_id is assigned: 0, 1, 2, ... - -Draggables from user answer are added to same group_id where identical draggables -from correct answer are, for example:: - - If correct_draggables[group_0] = [t1, t2] then - user_draggables[group_0] are all draggables t1 and t2 from user answer: - [t1] or [t1, t2] or [t1, t2, t2] etc.. - -2. For every group from user answer, for that group draggables, if 'number' is in group rule, set() is applied, -if 'number' is not in rule, set is not applied:: - - set() : [t1, t2, t3, t3] -> [t1, t2, ,t3] - -For every group, at this step, draggables lists are equal. - - -3. For every group, lists of targets are compared using rule for that group. - - -Set and '+number' cases -....................... - -Set() and '+number' are needed only for case of reusable draggables, -for other cases there are no equal draggables in list, so set() does nothing. - -.. such long lines needed for sphinx to display nicely - -* Usage of set() operation allows easily create rule for case of "any number of same draggable can be dragged to some targets":: - - { - 'draggables': ['draggable_1'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'anyof' - } - - - - -* 'number' rule is used for the case of reusable draggables, when one want to fix number of draggable to drag. In this example only two instances of draggables_1 are allowed to be dragged:: - - { - 'draggables': ['draggable_1', 'draggable_1'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'anyof+number' - } - - -* Note, that in using rule 'exact', one does not need 'number', because you can't recognize from user interface which reusable draggable is on which target. Absurd example:: - - { - 'draggables': ['draggable_1', 'draggable_1', 'draggable_2'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'exact' - } - - - Correct handling of this example is to create different rules for draggable_1 and - draggable_2 - -* For 'unordered_equal' (or 'exact' too) we don't need 'number' if you have only same draggable in group, as targets length will provide constraint for the number of draggables:: - - { - 'draggables': ['draggable_1'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'unordered_equal' - } - - - This means that only three draggaggables 'draggable_1' can be dragged. - -* But if you have more that one different reusable draggable in list, you may use 'number' rule:: - - { - 'draggables': ['draggable_1', 'draggable_1', 'draggable_2'], - 'targets': ['target3', 'target6', 'target9'], - 'rule': 'unordered_equal+number' - } - - - If not use number, draggables list will be setted to ['draggable_1', 'draggable_2'] - - - - -Logic flow ----------- - -(Click on image to see full size version.) - -.. image:: draganddrop_logic_flow.png - :width: 100% - :target: _images/draganddrop_logic_flow.png - - -Example -======= - -Examples of draggables that can't be reused -------------------------------------------- - -.. literalinclude:: drag-n-drop-demo.xml - -Draggables can be reused ------------------------- - -.. literalinclude:: drag-n-drop-demo2.xml diff --git a/docs/source/draganddrop_logic_flow.png b/docs/source/draganddrop_logic_flow.png deleted file mode 100644 index 2bb1c11a41..0000000000 Binary files a/docs/source/draganddrop_logic_flow.png and /dev/null differ diff --git a/docs/source/graphical_slider_tool.rst b/docs/source/graphical_slider_tool.rst deleted file mode 100644 index 37b17136e8..0000000000 --- a/docs/source/graphical_slider_tool.rst +++ /dev/null @@ -1,563 +0,0 @@ -********************************************* -Xml format of graphical slider tool [xmodule] -********************************************* - -.. module:: xml_format_gst - - -Format description -================== - -Graphical slider tool (GST) main tag is:: - - BODY - -``graphical_slider_tool`` tag must have two children tags: ``render`` -and ``configuration``. - - -Render tag ----------- - -Render tag can contain usual html tags mixed with some GST specific tags:: - - - represents jQuery slider for changing a parameter's value - - represents a text input field for changing a parameter's value - - represents Flot JS plot element - -Also GST will track all elements inside ```` where ``id`` -attribute is set, and a corresponding parameter referencing that ``id`` is present -in the configuration section below. These will be referred to as dynamic elements. - -The contents of the section will be shown to the user after -all occurrences of:: - - - - - -have been converted to actual sliders, text inputs, and a plot graph. -Everything in square brackets is optional. After initialization, all -text input fields, sliders, and dynamic elements will be set to the initial -values of the parameters that they are assigned to. - -``{parameter name}`` specifies the parameter to which the slider or text -input will be attached to. - -[style="{CSS statements}"] specifies valid CSS styling. It will be passed -directly to the browser without any parsing. - -There is a one-to-one relationship between a slider and a parameter. -I.e. for one parameter you can put only one ```` in the -```` section. However, you don't have to specify a slider - they -are optional. - -There is a many-to-one relationship between text inputs and a -parameter. I.e. for one parameter you can put many '' elements in -the ```` section. However, you don't have to specify a text -input - they are optional. - -You can put only one ```` in the ```` section. It is not -required. - - -Slider tag -.......... - -Slider tag must have ``var`` attribute and optional ``style`` attribute:: - - - -After processing, slider tags will be replaced by jQuery UI sliders with applied -``style`` attribute. - -``var`` attribute must correspond to a parameter. Parameters can be used in any -of the ``function`` tags in ``functions`` tag. By moving slider, value of -parameter ``a`` will change, and so result of function, that depends on parameter -``a``, will also change. - - -Textbox tag -........... - -Texbox tag must have ``var`` attribute and optional ``style`` attribute:: - - - -After processing, textbox tags will be replaced by html text inputs with applied -``style`` attribute. If you want a readonly text input, then you should use a -dynamic element instead (see section below "HTML tagsd with ID"). - -``var`` attribute must correspond to a parameter. Parameters can be used in any -of the ``function`` tags in ``functions`` tag. By changing the value on the text input, -value of parameter ``a`` will change, and so result of function, that depends on -parameter ``a``, will also change. - - -Plot tag -........ - -Plot tag may have optional ``style`` attribute:: - - - -After processing plot tags will be replaced by Flot JS plot with applied -``style`` attribute. - - -HTML tags with ID (dynamic elements) -.................................... - -Any HTML tag with ID, e.g. ```` can be used as a -place where result of function can be inserted. To insert function result to -an element, element ID must be included in ``function`` tag as ``el_id`` attribute -and ``output`` value must be ``"element"``:: - - - function add(a, b, precision) { - var x = Math.pow(10, precision || 2); - return (Math.round(a * x) + Math.round(b * x)) / x; - } - - return add(a, b, 5); - - - -Configuration tag ------------------ - -The configuration tag contains parameter settings, graph -settings, and function definitions which are to be plotted on the -graph and that use specified parameters. - -Configuration tag contains two mandatory tag ``functions`` and ``parameters`` and -may contain another ``plot`` tag. - - -Parameters tag -.............. - -``Parameters`` tag contains ``parameter`` tags. Each ``parameter`` tag must have -``var``, ``max``, ``min``, ``step`` and ``initial`` attributes:: - - - - - - -``var`` attribute links min, max, step and initial values to parameter name. - -``min`` attribute is the minimal value that a parameter can take. Slider and input -values can not go below it. - -``max`` attribute is the maximal value that a parameter can take. Slider and input -values can not go over it. - -``step`` attribute is value of slider step. When a slider increase or decreases -the specified parameter, it will do so by the amount specified with 'step' - -``initial`` attribute is the initial value that the specified parameter should be -set to. Sliders and inputs will initially show this value. - -The parameter's name is specified by the ``var`` property. All occurrences -of sliders and/or text inputs that specify a ``var`` property, will be -connected to this parameter - i.e. they will reflect the current -value of the parameter, and will be updated when the parameter -changes. - -If at lest one of these attributes is not set, then the parameter -will not be used, slider's and/or text input elements that specify -this parameter will not be activated, and the specified functions -which use this parameter will not return a numeric value. This means -that neglecting to specify at least one of the attributes for some -parameter will have the result of the whole GST instance not working -properly. - - -Functions tag -............. - -For the GST to do something, you must defined at least one -function, which can use any of the specified parameter values. The -function expects to take the ``x`` value, do some calculations, and -return the ``y`` value. I.e. this is a 2D plot in Cartesian -coordinates. This is how the default function is meant to be used for -the graph. - -There are other special cases of functions. They are used mainly for -outputting to elements, plot labels, or for custom output. Because -the return a single value, and that value is meant for a single element, -these function are invoked only with the set of all of the parameters. -I.e. no ``x`` value is available inside them. They are useful for -showing the current value of a parameter, showing complex static -formulas where some parameter's value must change, and other useful -things. - -The different style of function is specified by the ``output`` attribute. - -Each function must be defined inside ``function`` tag in ``functions`` tag:: - - - - function add(a, b, precision) { - var x = Math.pow(10, precision || 2); - return (Math.round(a * x) + Math.round(b * x)) / x; - } - - return add(a, b, 5); - - - -The parameter names (along with their values, as provided from text -inputs and/or sliders), will be available inside all defined -functions. A defined function body string will be parsed internally -by the browser's JavaScript engine and converted to a true JS -function. - -The function's parameter list will automatically be created and -populated, and will include the ``x`` (when ``output`` is not specified or -is set to ``"graph"``), and all of the specified parameter values (from sliders -and text inputs). This means that each of the defined functions will have -access to all of the parameter values. You don't have to use them, but -they will be there. - -Examples:: - - - return x; - - - - return (x + a) * Math.sin(x * b); - - - - function helperFunc(c1) { - return c1 * c1 - a; - } - return helperFunc(x + 10 * a * b) + Math.sin(a - x); - - -Required parameters:: - - function body: - - A string composing a normal JavaScript function - except that there is no function declaration - (along with parameters), and no closing bracket. - - So if you normally would have written your - JavaScript function like this: - - function myFunc(x, a, b) { - return x * a + b; - } - - here you must specify just the function body - (everything that goes between '{' and '}'). So, - you would specify the above function like so (the - bare-bone minimum): - - return x * a + b; - - VERY IMPORTANT: Because the function will be passed - to the browser as a single string, depending on implementation - specifics, the end-of-line characters can be stripped. This - means that single line JavaScript comments (starting with "//") - can lead to the effect that everything after the first such comment - will be treated as a comment. Therefore, it is absolutely - necessary that such single line comments are not used when - defining functions for GST. You can safely use the alternative - multiple line JavaScript comments (such comments start with "/*" - and end with "*/). - - VERY IMPORTANT: If you have a large function body, and decide to - split it into several lines, than you must wrap it in "CDATA" like - so: - - - - - -Optional parameters:: - - - color: Color name ('red', 'green', etc.) or in the form of - '#FFFF00'. If not specified, a default color (different - one for each graphed function) will be given by Flot JS. - line: A string - 'true' or 'false'. Should the data points be - connected by a line on the graph? Default is 'true'. - dot: A string - 'true' or 'false'. Should points be shown for - each data point on the graph? Default is 'false'. - bar: A string - 'true' or 'false'. When set to 'true', points - will be plotted as bars. - label: A string. If provided, will be shown in the legend, along - with the color that was used to plot the function. - output: 'element', 'none', 'plot_label', or 'graph'. If not defined, - function will be plotted (same as setting 'output' to 'graph'). - If defined, and other than 'graph', function will not be - plotted, but it's output will be inserted into the element - with ID specified by 'el_id' attribute. - el_id: Id of HTML element, defined in '' section. Value of - function will be inserted as content of this element. - disable_auto_return: By default, if JavaScript function string is written - without a "return" statement, the "return" will be - prepended to it. Set to "true" to disable this - functionality. This is done so that simple functions - can be defined in an easy fashion (for example, "a", - which will be translated into "return a"). - update_on: A string - 'change', or 'slide'. Default (if not set) is - 'slide'. This defines the event on which a given function is - called, and its result is inserted into an element. This - setting is relevant only when "output" is other than "graph". - -When specifying ``el_id``, it is essential to set "output" to one of - element - GST will invoke the function, and the return of it will be - inserted into a HTML element with id specified by ``el_id``. - none - GST will simply inoke the function. It is left to the instructor - who writes the JavaScript function body to update all necesary - HTML elements inside the function, before it exits. This is done - so that extra steps can be preformed after an HTML element has - been updated with a value. Note, that because the return value - from this function is not actually used, it will be tempting to - omit the "return" statement. However, in this case, the attribute - "disable_auto_return" must be set to "true" in order to prevent - GST from inserting a "return" statement automatically. - plot_label - GST will process all plot labels (which are strings), and - will replace the all instances of substrings specified by - ``el_id`` with the returned value of the function. This is - necessary if you want a label in the graph to have some changing - number. Because of the nature of Flot JS, it is impossible to - achieve the same effect by setting the "output" attribute - to "element", and including a HTML element in the label. - -The above values for "output" will tell GST that the function is meant for an -HTML element (not for graph), and that it should not get an 'x' parameter (along -with some value). - - -[Note on MathJax and labels] -............................ - -Independently of this module, will render all TeX code -within the ```` section into nice mathematical formulas. Just -remember to wrap it in one of:: - - \( and \) - for inline formulas (formulas surrounded by - standard text) - \[ and \] - if you want the formula to be a separate line - -It is possible to define a label in standard TeX notation. The JS -library MathJax will work on these labels also because they are -inserted on top of the plot as standard HTML (text within a DIV). - -If the label is dynamic, i.e. it will contain some text (numeric, or other) -that has to be updated on a parameter's change, then one can define -a special function to handle this. The "output" of such a function must be -set to "none", and the JavaScript code inside this function must update the -MathJax element by itself. Before exiting, MathJax typeset function should -be called so that the new text will be re-rendered by MathJax. For example, - - - ... - - - ... - - - - ... - - -Plot tag -........ - -``Plot`` tag inside ``configuration`` tag defines settings for plot output. - -Required parameters:: - - xrange: 2 functions that must return value. Value is constant (3.1415) - or depend on parameter from parameters section: - - return 0; - return 30; - - or - - return -a; - return a; - - - All functions will be calculated over domain between xrange:min - and xrange:max. Xrange depending on parameter is extremely - useful when domain(s) of your function(s) depends on parameter - (like circle, when parameter is radius and you want to allow - to change it). - -Optional parameters:: - - num_points: Number of data points to generated for the plot. If - this is not set, the number of points will be - calculated as width / 5. - - bar_width: If functions are present which are to be plotted as bars, - then this parameter specifies the width of the bars. A - numeric value for this parameter is expected. - - bar_align: If functions are present which are to be plotted as bars, - then this parameter specifies how to align the bars relative - to the tick. Available values are "left" and "center". - - xticks, - yticks: 3 floating point numbers separated by commas. This - specifies how many ticks are created, what number they - start at, and what number they end at. This is different - from the 'xrange' setting in that it has nothing to do - with the data points - it control what area of the - Cartesian space you will see. The first number is the - first tick's value, the second number is the step - between each tick, the third number is the value of the - last tick. If these configurations are not specified, - Flot will chose them for you based on the data points - set that he is currently plotting. Usually, this results - in a nice graph, however, sometimes you need to fine - grain the controls. For example, when you want to show - a fixed area of the Cartesian space, even when the data - set changes. On it's own, Flot will recalculate the - ticks, which will result in a different graph each time. - By specifying the xticks, yticks configurations, only - the plotted data will change - the axes (ticks) will - remain as you have defined them. - - xticks_names, yticks_names: - A JSON string which represents a mapping of xticks, yticks - values to some defined strings. If specified, the graph will - not have any xticks, yticks except those for which a string - value has been defined in the JSON string. Note that the - matching will be string-based and not numeric. I.e. if a tick - value was "3.70" before, then inside the JSON there should be - a mapping like {..., "3.70": "Some string", ...}. Example: - - - - - - - - - - xunits, - yunits: Units values to be set on axes. Use MathJax. Example: - \(cm\) - \(m\) - - moving_label: - A way to specify a label that should be positioned dynamically, - based on the values of some parameters, or some other factors. - It is similar to a , but it is only valid for a plot - because it is drawn relative to the plot coordinate system. - - Multiple "moving_label" configurations can be provided, each one - with a unique text and a unique set of functions that determine - it's dynamic positioning. - - Each "moving_label" can have a "color" attribute (CSS color notation), - and a "weight" attribute. "weight" can be one of "normal" or "bold", - and determines the styling of moving label's text. - - Each "moving_label" function should return an object with a 'x' - and 'y properties. Within those functions, all of the parameter - names along with their value are available. - - Example (note that "return" statement is missing; it will be automatically - inserted by GST): - - - - -

          Graphic slider tool: Bar graph example.

          - -

          We can request the API to plot us a bar graph.

          -
          -

          a

          - - -


          -

          b

          - - -
          - -
          - - - - - - - - 0.9) && (x<1.1)) || ((x>4.9) && (x<5.1))) { return Math.sin(a * 0.01 * Math.PI + 2.952 * x); } - else {return undefined;}]]> - - - 1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos(b * 0.01 * Math.PI + 3.432 * x); } - else {return undefined;}]]> - - - 1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos((b - 10 * a) * 0.01 * Math.PI + 3.432 * x); } - else {return undefined;}]]> - - - 1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos((b + 7 * a) * 0.01 * Math.PI + 3.432 * x); } - else {return undefined;}]]> - - - - 15 - 5 - 0, 0.5, 6 - -1.5, 0.1, 1.5 - - - - - - - 0.4 - - -
          - diff --git a/docs/source/gst_example_dynamic_labels.xml b/docs/source/gst_example_dynamic_labels.xml deleted file mode 100644 index 05cbe407fb..0000000000 --- a/docs/source/gst_example_dynamic_labels.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - -

          Graphic slider tool: Dynamic labels.

          -

          There are two kinds of dynamic lables. - 1) Dynamic changing values in graph legends. - 2) Dynamic labels, which coordinates depend on parameters

          -

          a:

          -
          -

          b:

          -

          - -
          - - - - - - - - a * x + b - - a - - - 030 - 10 - 0, 6, 30 - -9, 1, 9 - - - - - - - - - -
          -
          \ No newline at end of file diff --git a/docs/source/gst_example_dynamic_range.xml b/docs/source/gst_example_dynamic_range.xml deleted file mode 100644 index 0ce4263d62..0000000000 --- a/docs/source/gst_example_dynamic_range.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - -

          Graphic slider tool: Dynamic range and implicit functions.

          - -

          You can make x range (not ticks of x axis) of functions to depend on - parameter value. This can be useful when function domain depends - on parameter.

          -

          Also implicit functons like circle can be plotted as 2 separate - functions of same color.

          -
          - - -
          - -
          - - - - - - Math.sqrt(a * a - x * x) - -Math.sqrt(a * a - x * x) - - - - - -a - a - - 1000 - -30, 6, 30 - -30, 6, 30 - - -
          -
          diff --git a/docs/source/gst_example_html_element_output.xml b/docs/source/gst_example_html_element_output.xml deleted file mode 100644 index 340783871a..0000000000 --- a/docs/source/gst_example_html_element_output.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - -

          Graphic slider tool: Output to DOM element.

          - -

          a + b =

          - -
          -

          a

          - - -
          - -
          -

          b

          - - -
          -


          - -
          - - - - - - - - - function add(a, b, precision) { - var x = Math.pow(10, precision || 2); - return (Math.round(a * x) + Math.round(b * x)) / x; - } - - return add(a, b, 5); - - - -
          -
          diff --git a/docs/source/gst_example_with_documentation.xml b/docs/source/gst_example_with_documentation.xml deleted file mode 100644 index addada5b10..0000000000 --- a/docs/source/gst_example_with_documentation.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - -

          Graphic slider tool: full example.

          -

          - A simple equation - \( - y_1 = 10 \times b \times \frac{sin(a \times x) \times sin(b \times x)}{cos(b \times x) + 10} - \) - can be plotted. -

          - - -
          -

          Currently \(a\) is

          - - -
          - -

          This one - \( - y_2 = sin(a \times x) - \) - will be overlayed on top. -

          -
          -

          Currently \(b\) is

          - -
          -
          -

          To change \(a\) use:

          - -
          -
          -

          To change \(b\) use:

          - -
          - -
          -

          Second input for b:

          - - -
          -
          - - - - - - - - - - - - return 10.0 * b * Math.sin(a * x) * Math.sin(b * x) / (Math.cos(b * x) + 10); - - - - Math.sin(a * x); - - - function helperFunc(c1) { - return c1 * c1 - a; - } - - return helperFunc(x + 10 * a * b) + Math.sin(a - x); - - a - - - - - - return 0; - - 30 - - - 120 - - 0, 3, 30 - -1.5, 1.5, 13.5 - - \(cm\) - \(m\) - - -
          -
          diff --git a/docs/source/index.rst b/docs/source/index.rst index d2082ff3a0..eceb5e23e8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,7 +14,6 @@ Contents: overview.rst common-lib.rst djangoapps.rst - xml_formats.rst Indices and tables ================== diff --git a/docs/source/xml_formats.rst b/docs/source/xml_formats.rst deleted file mode 100644 index 7c92546a5e..0000000000 --- a/docs/source/xml_formats.rst +++ /dev/null @@ -1,9 +0,0 @@ -XML formats of Inputtypes and Xmodule -===================================== -Contents: - -.. toctree:: - :maxdepth: 2 - - graphical_slider_tool.rst - drag_and_drop_input.rst diff --git a/jenkins/base.sh b/jenkins/base.sh new file mode 100644 index 0000000000..c7175e6e52 --- /dev/null +++ b/jenkins/base.sh @@ -0,0 +1,12 @@ + +function github_status { + gcli status create mitx mitx $GIT_COMMIT \ + --params=$1 \ + target_url:$BUILD_URL \ + description:"Build #$BUILD_NUMBER is running" \ + -f csv +} + +function github_mark_failed_on_exit { + trap '[ $? == "0" ] || github_status state:failed' EXIT +} \ No newline at end of file diff --git a/jenkins/test.sh b/jenkins/test.sh index f8ffab29fc..1f5ce70b1b 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -39,8 +39,8 @@ pip install -q -r pre-requirements.txt yes w | pip install -q -r test-requirements.txt -r requirements.txt rake clobber -rake pep8 -rake pylint +rake pep8 > pep8.log || cat pep8.log +rake pylint > pylint.log || cat pylint.log TESTS_FAILED=0 rake test_cms[false] || TESTS_FAILED=1 diff --git a/lms/djangoapps/circuit/views.py b/lms/djangoapps/circuit/views.py index 9711e0648c..40a31a2e3a 100644 --- a/lms/djangoapps/circuit/views.py +++ b/lms/djangoapps/circuit/views.py @@ -9,7 +9,7 @@ from django.http import HttpResponse from django.shortcuts import redirect from mitxmako.shortcuts import render_to_response, render_to_string -from models import ServerCircuit +from .models import ServerCircuit def circuit_line(circuit): diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index cecc4f9cf9..620cf104d7 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -3,13 +3,11 @@ from django.test.utils import override_settings import xmodule.modulestore.django -from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_importer import import_from_xml - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class WikiRedirectTestCase(PageLoader): +class WikiRedirectTestCase(LoginEnrollmentTestCase): def setUp(self): xmodule.modulestore.django._MODULESTORES = {} courses = modulestore().get_courses() @@ -30,8 +28,6 @@ class WikiRedirectTestCase(PageLoader): self.activate_user(self.student) self.activate_user(self.instructor) - - def test_wiki_redirect(self): """ Test that requesting wiki URLs redirect properly to or out of classes. @@ -69,7 +65,6 @@ class WikiRedirectTestCase(PageLoader): self.assertEqual(resp.status_code, 302) self.assertEqual(resp['Location'], 'http://testserver' + destination) - def create_course_page(self, course): """ Test that loading the course wiki page creates the wiki page. @@ -98,7 +93,6 @@ class WikiRedirectTestCase(PageLoader): self.assertTrue("course info" in resp.content.lower()) self.assertTrue("courseware" in resp.content.lower()) - def test_course_navigator(self): """" Test that going from a course page to a wiki page contains the course navigator. @@ -108,7 +102,6 @@ class WikiRedirectTestCase(PageLoader): self.enroll(self.toy) self.create_course_page(self.toy) - course_wiki_page = reverse('wiki:get', kwargs={'path': self.toy.wiki_slug + '/'}) referer = reverse("courseware", kwargs={'course_id': self.toy.id}) diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py index 6e9f2e38de..6ab106ed70 100644 --- a/lms/djangoapps/course_wiki/views.py +++ b/lms/djangoapps/course_wiki/views.py @@ -95,7 +95,7 @@ def course_wiki_redirect(request, course_id): root, course_slug, title=course_slug, - content="This is the wiki for **{0}**'s _{1}_.".format(course.org, course.title), + content="This is the wiki for **{0}**'s _{1}_.".format(course.org, course.display_name_with_default), user_message="Course page automatically created.", user=None, ip_address=None, diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index eaf06d79dc..08bf49ac98 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -164,7 +164,7 @@ def _has_access_course_desc(user, course, action): if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'): # if this feature is on, only allow courses that have ispublic set to be # seen by non-staff - if course.metadata.get('ispublic'): + if course.lms.ispublic: debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic") return True return _has_staff_access_to_descriptor(user, course) @@ -240,7 +240,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): return True # Check start date - if descriptor.start is not None: + if descriptor.lms.start is not None: now = time.gmtime() effective_start = _adjust_start_date_for_beta_testers(user, descriptor) if now > effective_start: @@ -495,9 +495,9 @@ def _adjust_start_date_for_beta_testers(user, descriptor): NOTE: If testing manually, make sure MITX_FEATURES['DISABLE_START_DATES'] = False in envs/dev.py! """ - if descriptor.days_early_for_beta is None: + if descriptor.lms.days_early_for_beta is None: # bail early if no beta testing is set up - return descriptor.start + return descriptor.lms.start user_groups = [g.name for g in user.groups.all()] @@ -508,13 +508,13 @@ def _adjust_start_date_for_beta_testers(user, descriptor): # subtract, convert back. # (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for # converting time_structs into datetimes) - start_as_datetime = datetime(*descriptor.start[:6]) - delta = timedelta(descriptor.days_early_for_beta) + start_as_datetime = datetime(*descriptor.lms.start[:6]) + delta = timedelta(descriptor.lms.days_early_for_beta) effective = start_as_datetime - delta # ...and back to time_struct return effective.timetuple() - return descriptor.start + return descriptor.lms.start def _has_instructor_access_to_location(user, location, course_context=None): diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 52346d7583..3e1162bc03 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -11,7 +11,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.http import Http404 -from module_render import get_module +from .module_render import get_module from xmodule.course_module import CourseDescriptor from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -19,10 +19,10 @@ from xmodule.contentstore.content import StaticContent from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.x_module import XModule +from courseware.model_data import ModelDataCache from static_replace import replace_static_urls from courseware.access import has_access import branding -from courseware.models import StudentModuleCache from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger(__name__) @@ -89,7 +89,7 @@ def course_image_url(course): """Try to look up the image url for the course. If it's not found, log an error and return the dead link""" if isinstance(modulestore(), XMLModuleStore): - return '/static/' + course.metadata['data_dir'] + "/images/course_image.jpg" + return '/static/' + course.data_dir + "/images/course_image.jpg" else: loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg') path = StaticContent.get_url_path_from_location(loc) @@ -153,12 +153,23 @@ def get_course_about_section(course, section_key): request = get_request_for_thread() loc = course.location._replace(category='about', name=section_key) - course_module = get_module(request.user, request, loc, None, course.id, not_found_ok=True, wrap_xmodule_display=False) + + # Use an empty cache + model_data_cache = ModelDataCache([], course.id, request.user) + about_module = get_module( + request.user, + request, + loc, + model_data_cache, + course.id, + not_found_ok=True, + wrap_xmodule_display=False + ) html = '' - if course_module is not None: - html = course_module.get_html() + if about_module is not None: + html = about_module.get_html() return html @@ -167,7 +178,7 @@ def get_course_about_section(course, section_key): key=section_key, url=course.location.url())) return None elif section_key == "title": - return course.metadata.get('display_name', course.url_name) + return course.display_name_with_default elif section_key == "university": return course.location.org elif section_key == "number": @@ -177,7 +188,7 @@ def get_course_about_section(course, section_key): -def get_course_info_section(request, cache, course, section_key): +def get_course_info_section(request, course, section_key): """ This returns the snippet of html to be rendered on the course info page, given the key for the section. @@ -191,11 +202,22 @@ def get_course_info_section(request, cache, course, section_key): loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) - course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display=False) + + # Use an empty cache + model_data_cache = ModelDataCache([], course.id, request.user) + info_module = get_module( + request.user, + request, + loc, + model_data_cache, + course.id, + wrap_xmodule_display=False + ) + html = '' - if course_module is not None: - html = course_module.get_html() + if info_module is not None: + html = info_module.get_html() return html @@ -226,7 +248,7 @@ def get_course_syllabus_section(course, section_key): with fs.open(filepath) as htmlFile: return replace_static_urls( htmlFile.read().decode('utf-8'), - course.metadata['data_dir'], + getattr(course, 'data_dir', None), course_namespace=course.location ) except ResourceNotFoundError: diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 2e19696ad4..f6256adfa1 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -1,99 +1,173 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from django.core.management import call_command from nose.tools import assert_equals, assert_in from lettuce.django import django_url -from django.conf import settings from django.contrib.auth.models import User from student.models import CourseEnrollment -import time +from xmodule.modulestore import Location +from xmodule.modulestore.django import _MODULESTORES, modulestore +from xmodule.templates import update_templates +from xmodule.course_module import CourseDescriptor +from courseware.courses import get_course_by_id +from xmodule import seq_module, vertical_module from logging import getLogger logger = getLogger(__name__) - -@step(u'I wait (?:for )?"(\d+)" seconds?$') -def wait(step, seconds): - time.sleep(float(seconds)) +TEST_COURSE_ORG = 'edx' +TEST_COURSE_NAME = 'Test Course' +TEST_SECTION_NAME = "Problem" -@step('I (?:visit|access|open) the homepage$') -def i_visit_the_homepage(step): - world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('header.global', 10) +@step(u'The course "([^"]*)" exists$') +def create_course(step, course): + + # First clear the modulestore so we don't try to recreate + # the same course twice + # This also ensures that the necessary templates are loaded + world.clear_courses() + + # Create the course + # We always use the same org and display name, + # but vary the course identifier (e.g. 600x or 191x) + course = world.CourseFactory.create(org=TEST_COURSE_ORG, + number=course, + display_name=TEST_COURSE_NAME) + + # Add a section to the course to contain problems + section = world.ItemFactory.create(parent_location=course.location, + display_name=TEST_SECTION_NAME) + + problem_section = world.ItemFactory.create(parent_location=section.location, + template='i4x://edx/templates/sequential/Empty', + display_name=TEST_SECTION_NAME) -@step(u'I (?:visit|access|open) the dashboard$') -def i_visit_the_dashboard(step): - world.browser.visit(django_url('/dashboard')) - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) +@step(u'I am registered for the course "([^"]*)"$') +def i_am_registered_for_the_course(step, course): + # Create the course + create_course(step, course) - -@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') -def click_the_link_called(step, text): - world.browser.find_link_by_text(text).click() - - -@step('I should be on the dashboard page$') -def i_should_be_on_the_dashboard(step): - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - assert world.browser.title == 'Dashboard' - - -@step(u'I (?:visit|access|open) the courses page$') -def i_am_on_the_courses_page(step): - world.browser.visit(django_url('/courses')) - assert world.browser.is_element_present_by_css('section.courses') - - -@step('I should see that the path is "([^"]*)"$') -def i_should_see_that_the_path_is(step, path): - assert world.browser.url == django_url(path) - - -@step(u'the page title should be "([^"]*)"$') -def the_page_title_should_be(step, title): - assert world.browser.title == title - - -@step(r'should see that the url is "([^"]*)"$') -def should_have_the_url(step, url): - assert_equals(world.browser.url, url) - - -@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') -def should_see_a_link_called(step, text): - assert len(world.browser.find_link_by_text(text)) > 0 - - -@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') -def should_see_in_the_page(step, text): - assert_in(text, world.browser.html) - - -@step('I am logged in$') -def i_am_logged_in(step): - world.create_user('robot') - world.log_in('robot@edx.org', 'test') - - -@step('I am not logged in$') -def i_am_not_logged_in(step): - world.browser.cookies.delete() - - -@step(u'I am registered for a course$') -def i_am_registered_for_a_course(step): + # Create the user world.create_user('robot') u = User.objects.get(username='robot') - CourseEnrollment.objects.create(user=u, course_id='MITx/6.002x/2012_Fall') - world.log_in('robot@edx.org', 'test') + + # If the user is not already enrolled, enroll the user. + # TODO: change to factory + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(course)) + + world.log_in('robot', 'test') -@step(u'I am an edX user$') -def i_am_an_edx_user(step): - world.create_user('robot') +@step(u'The course "([^"]*)" has extra tab "([^"]*)"$') +def add_tab_to_course(step, course, extra_tab_name): + section_item = world.ItemFactory.create(parent_location=course_location(course), + template="i4x://edx/templates/static_tab/Empty", + display_name=str(extra_tab_name)) -@step(u'User "([^"]*)" is an edX user$') -def registered_edx_user(step, uname): - world.create_user(uname) +def course_id(course_num): + return "%s/%s/%s" % (TEST_COURSE_ORG, course_num, + TEST_COURSE_NAME.replace(" ", "_")) + + +def course_location(course_num): + return Location(loc_or_tag="i4x", + org=TEST_COURSE_ORG, + course=course_num, + category='course', + name=TEST_COURSE_NAME.replace(" ", "_")) + + +def section_location(course_num): + return Location(loc_or_tag="i4x", + org=TEST_COURSE_ORG, + course=course_num, + category='sequential', + name=TEST_SECTION_NAME.replace(" ", "_")) + + +def get_courses(): + ''' + Returns dict of lists of courses available, keyed by course.org (ie university). + Courses are sorted by course.number. + ''' + courses = [c for c in modulestore().get_courses() + if isinstance(c, CourseDescriptor)] + courses = sorted(courses, key=lambda course: course.number) + return courses + + +def get_courseware_with_tabs(course_id): + """ + Given a course_id (string), return a courseware array of dictionaries for the + top three levels of navigation. Same as get_courseware() except include + the tabs on the right hand main navigation page. + + This hides the appropriate courseware as defined by the hide_from_toc field: + chapter.lms.hide_from_toc + + Example: + + [{ + 'chapter_name': 'Overview', + 'sections': [{ + 'clickable_tab_count': 0, + 'section_name': 'Welcome', + 'tab_classes': [] + }, { + 'clickable_tab_count': 1, + 'section_name': 'System Usage Sequence', + 'tab_classes': ['VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Lab0: Using the tools', + 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Circuit Sandbox', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Week 1', + 'sections': [{ + 'clickable_tab_count': 4, + 'section_name': 'Administrivia and Circuit Elements', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Basic Circuit Analysis', + 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Resistor Divider', + 'tab_classes': [] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Week 1 Tutorials', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Midterm Exam', + 'sections': [{ + 'clickable_tab_count': 2, + 'section_name': 'Midterm Exam', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] + }] + }] + """ + + course = get_course_by_id(course_id) + chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc] + courseware = [{'chapter_name': c.display_name_with_default, + 'sections': [{'section_name': s.display_name_with_default, + 'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0, + 'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0, + 'class': t.__class__.__name__} + for t in s.get_children()]} + for s in c.get_children() if not s.lms.hide_from_toc]} + for c in chapters] + + return courseware diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py deleted file mode 100644 index ba0bcd359b..0000000000 --- a/lms/djangoapps/courseware/features/courses.py +++ /dev/null @@ -1,234 +0,0 @@ -from lettuce import world -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.django import modulestore -from courseware.courses import get_course_by_id -from xmodule import seq_module, vertical_module - -from logging import getLogger -logger = getLogger(__name__) - -## support functions - -def get_courses(): - ''' - Returns dict of lists of courses available, keyed by course.org (ie university). - Courses are sorted by course.number. - ''' - courses = [c for c in modulestore().get_courses() - if isinstance(c, CourseDescriptor)] - courses = sorted(courses, key=lambda course: course.number) - return courses - - -def get_courseware_with_tabs(course_id): - """ - Given a course_id (string), return a courseware array of dictionaries for the - top three levels of navigation. Same as get_courseware() except include - the tabs on the right hand main navigation page. - - This hides the appropriate courseware as defined by the XML flag test: - chapter.metadata.get('hide_from_toc','false').lower() == 'true' - - Example: - - [{ - 'chapter_name': 'Overview', - 'sections': [{ - 'clickable_tab_count': 0, - 'section_name': 'Welcome', - 'tab_classes': [] - }, { - 'clickable_tab_count': 1, - 'section_name': 'System Usage Sequence', - 'tab_classes': ['VerticalDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Lab0: Using the tools', - 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Circuit Sandbox', - 'tab_classes': [] - }] - }, { - 'chapter_name': 'Week 1', - 'sections': [{ - 'clickable_tab_count': 4, - 'section_name': 'Administrivia and Circuit Elements', - 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Basic Circuit Analysis', - 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Resistor Divider', - 'tab_classes': [] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Week 1 Tutorials', - 'tab_classes': [] - }] - }, { - 'chapter_name': 'Midterm Exam', - 'sections': [{ - 'clickable_tab_count': 2, - 'section_name': 'Midterm Exam', - 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] - }] - }] - """ - - course = get_course_by_id(course_id) - chapters = [chapter for chapter in course.get_children() if chapter.metadata.get('hide_from_toc', 'false').lower() != 'true'] - courseware = [{'chapter_name': c.display_name, - 'sections': [{'section_name': s.display_name, - 'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0, - 'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0, - 'class': t.__class__.__name__} - for t in s.get_children()]} - for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} - for c in chapters] - - return courseware - - -def process_section(element, num_tabs=0): - ''' - Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. - - This function is recursive - - There are 6 types, with 6 actions. - - Sequence Module - -contains one child module - - Vertical Module - -contains other modules - -process it and get its children, then process them - - Capa Module - -problem type, contains only one problem - -for this, the most complex type, we created a separate method, process_problem - - Video Module - -video type, contains only one video - -we only check to ensure that a section with class of video exists - - HTML Module - -html text - -we do not check anything about it - - Custom Tag Module - -a custom 'hack' module type - -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type - - can be used like this: - e = world.browser.find_by_css('section.course-content section') - process_section(e) - - ''' - if element.has_class('xmodule_display xmodule_SequenceModule'): - logger.debug('####### Processing xmodule_SequenceModule') - child_modules = element.find_by_css("div>div>section[class^='xmodule']") - for mod in child_modules: - process_section(mod) - - elif element.has_class('xmodule_display xmodule_VerticalModule'): - logger.debug('####### Processing xmodule_VerticalModule') - vert_list = element.find_by_css("li section[class^='xmodule']") - for item in vert_list: - process_section(item) - - elif element.has_class('xmodule_display xmodule_CapaModule'): - logger.debug('####### Processing xmodule_CapaModule') - assert element.find_by_css("section[id^='problem']"), "No problems found in Capa Module" - p = element.find_by_css("section[id^='problem']").first - p_id = p['id'] - logger.debug('####################') - logger.debug('id is "%s"' % p_id) - logger.debug('####################') - process_problem(p, p_id) - - elif element.has_class('xmodule_display xmodule_VideoModule'): - logger.debug('####### Processing xmodule_VideoModule') - assert element.find_by_css("section[class^='video']"), "No video found in Video Module" - - elif element.has_class('xmodule_display xmodule_HtmlModule'): - logger.debug('####### Processing xmodule_HtmlModule') - pass - - elif element.has_class('xmodule_display xmodule_CustomTagModule'): - logger.debug('####### Processing xmodule_CustomTagModule') - pass - - else: - assert False, "Class for element not recognized!!" - - - -def process_problem(element, problem_id): - ''' - Process problem attempts to - 1) scan all the input fields and reset them - 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') - 3) click the 'show answer' button IF it exists and IF the answer is not already displayed - 4) enter the correct answer in each input box - 5) click the 'check' button and verify that answers are correct - - Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. - The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. - ''' - - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - - ## clear out all input to ensure an incorrect result - for field in input_fields: - field.find_by_css("input").first.fill('') - - ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' - # This would need to be reworked because multiple choice problems don't have this status - # if prob_xmod.find_by_css("p.status").first.text.strip().lower() != 'incorrect': - prob_xmod.find_by_css("section.action input.check").first.click() - - ## all elements become disconnected after the click - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - # Wait for the ajax reload - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - for field in input_fields: - assert field.find_by_css("div.incorrect"), "The 'check' button did not work for %s" % (problem_id) - - show_button = element.find_by_css("section.action input.show").first - ## this logic is to ensure we do not accidentally hide the answers - if show_button.value.lower() == 'show answer': - show_button.click() - else: - pass - - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - - ## in each field, find the answer, and send it to the field. - ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" - for field in input_fields: - field.find_by_css("input").first.fill(field.find_by_css("p[id^='answer']").first.text) - - prob_xmod.find_by_css("section.action input.check").first.click() - - ## assert that we entered the correct answers - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - for field in input_fields: - ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) - assert field.find_by_css("div[class^='correct']"), "The check answer values were not correct for %s" % problem_id diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature deleted file mode 100644 index 279e5732c9..0000000000 --- a/lms/djangoapps/courseware/features/courseware.feature +++ /dev/null @@ -1,11 +0,0 @@ -Feature: View the Courseware Tab - As a student in an edX course - In order to work on the course - I want to view the info on the courseware tab - - Scenario: I can get to the courseware tab when logged in - Given I am registered for a course - And I log in - And I click on View Courseware - When I click on the "Courseware" tab - Then the "Courseware" tab is active diff --git a/lms/djangoapps/courseware/features/courseware.py b/lms/djangoapps/courseware/features/courseware.py index 7e99cc9f55..234f3a84d2 100644 --- a/lms/djangoapps/courseware/features/courseware.py +++ b/lms/djangoapps/courseware/features/courseware.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 96304e016f..4e9aa3fb7b 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -1,23 +1,23 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from lettuce.django import django_url @step('I click on View Courseware') def i_click_on_view_courseware(step): - css = 'a.enter-course' - world.browser.find_by_css(css).first.click() + world.css_click('a.enter-course') @step('I click on the "([^"]*)" tab$') -def i_click_on_the_tab(step, tab): - world.browser.find_link_by_partial_text(tab).first.click() +def i_click_on_the_tab(step, tab_text): + world.click_link(tab_text) world.save_the_html() @step('I visit the courseware URL$') def i_visit_the_course_info_url(step): - url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') - world.browser.visit(url) + world.visit('/courses/MITx/6.002x/2012_Fall/courseware') @step(u'I do not see "([^"]*)" anywhere on the page') @@ -27,18 +27,15 @@ def i_do_not_see_text_anywhere_on_the_page(step, text): @step(u'I am on the dashboard page$') def i_am_on_the_dashboard_page(step): - assert world.browser.is_element_present_by_css('section.courses') - assert world.browser.url == django_url('/dashboard') + assert world.is_css_present('section.courses') + assert world.url_equals('/dashboard') @step('the "([^"]*)" tab is active$') -def the_tab_is_active(step, tab): - css = '.course-tabs a.active' - active_tab = world.browser.find_by_css(css) - assert (active_tab.text == tab) +def the_tab_is_active(step, tab_text): + assert world.css_text('.course-tabs a.active') == tab_text @step('the login dialog is visible$') def login_dialog_visible(step): - css = 'form#login_form.login_form' - assert world.browser.find_by_css(css).visible + assert world.css_visible('form#login_form.login_form') diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature index 2e9c4f1886..c60ec7b374 100644 --- a/lms/djangoapps/courseware/features/high-level-tabs.feature +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -3,21 +3,18 @@ Feature: All the high level tabs should work As a student I want to navigate through the high level tabs -# Note this didn't work as a scenario outline because -# before each scenario was not flushing the database -# TODO: break this apart so that if one fails the others -# will still run - Scenario: A student can see all tabs of the course - Given I am registered for a course - And I log in - And I click on View Courseware - When I click on the "Courseware" tab - Then the page title should be "6.002x Courseware" - When I click on the "Course Info" tab - Then the page title should be "6.002x Course Info" - When I click on the "Textbook" tab - Then the page title should be "6.002x Textbook" - When I click on the "Wiki" tab - Then the page title should be "6.002x | edX Wiki" - When I click on the "Progress" tab - Then the page title should be "6.002x Progress" +Scenario: I can navigate to all high - level tabs in a course + Given: I am registered for the course "6.002x" + And The course "6.002x" has extra tab "Custom Tab" + And I am logged in + And I click on View Courseware + When I click on the "" tab + Then the page title should contain "" + + Examples: + | TabName | PageTitle | + | Courseware | 6.002x Courseware | + | Course Info | 6.002x Course Info | + | Custom Tab | 6.002x Custom Tab | + | Wiki | edX Wiki | + | Progress | 6.002x Progress | diff --git a/lms/djangoapps/courseware/features/homepage.feature b/lms/djangoapps/courseware/features/homepage.feature index 06a45c4bfa..c0c1c32f02 100644 --- a/lms/djangoapps/courseware/features/homepage.feature +++ b/lms/djangoapps/courseware/features/homepage.feature @@ -39,9 +39,9 @@ Feature: Homepage for web users | MITx | | HarvardX | | BerkeleyX | - | UTx | + | UTx | | WellesleyX | - | GeorgetownX | + | GeorgetownX | # # TODO: Add scenario that tests the courses available # # using a policy or a configuration file diff --git a/lms/djangoapps/courseware/features/homepage.py b/lms/djangoapps/courseware/features/homepage.py index 442098c161..62e9096e70 100644 --- a/lms/djangoapps/courseware/features/homepage.py +++ b/lms/djangoapps/courseware/features/homepage.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_in diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index ca7d710c61..bc90ea301c 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import step, world from django.contrib.auth.models import User @@ -28,12 +31,11 @@ def i_should_see_the_login_error_message(step, msg): @step(u'click the dropdown arrow$') def click_the_dropdown(step): - css = ".dropdown" - e = world.browser.find_by_css(css) - e.click() + world.css_click('.dropdown') #### helper functions + def user_is_an_unactivated_user(uname): u = User.objects.get(username=uname) u.is_active = False diff --git a/lms/djangoapps/courseware/features/openended.feature b/lms/djangoapps/courseware/features/openended.feature index cc9f6e1c5f..1ab496144f 100644 --- a/lms/djangoapps/courseware/features/openended.feature +++ b/lms/djangoapps/courseware/features/openended.feature @@ -3,10 +3,10 @@ Feature: Open ended grading In order to complete the courseware questions I want the machine learning grading to be functional - # Commenting these all out right now until we can + # Commenting these all out right now until we can # make a reference implementation for a course with # an open ended grading problem that is always available - # + # # Scenario: An answer that is too short is rejected # Given I navigate to an openended question # And I enter the answer "z" diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 0725a051ff..d848eb55d7 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url from nose.tools import assert_equals, assert_in @@ -12,7 +15,7 @@ def navigate_to_an_openended_question(step): problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' - world.browser.find_by_css(tab_css).click() + world.css_click(tab_css) @step('I navigate to an openended question as staff$') @@ -22,81 +25,69 @@ def navigate_to_an_openended_question_as_staff(step): problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' - world.browser.find_by_css(tab_css).click() + world.css_click(tab_css) @step(u'I enter the answer "([^"]*)"$') def enter_the_answer_text(step, text): - textarea_css = 'textarea' - world.browser.find_by_css(textarea_css).first.fill(text) + world.css_fill('textarea', text) @step(u'I submit the answer "([^"]*)"$') def i_submit_the_answer_text(step, text): - textarea_css = 'textarea' - world.browser.find_by_css(textarea_css).first.fill(text) - check_css = 'input.check' - world.browser.find_by_css(check_css).click() + world.css_fill('textarea', text) + world.css_click('input.check') @step('I click the link for full output$') def click_full_output_link(step): - link_css = 'a.full' - world.browser.find_by_css(link_css).first.click() + world.css_click('a.full') @step(u'I visit the staff grading page$') def i_visit_the_staff_grading_page(step): - # course_u = '/courses/MITx/3.091x/2012_Fall' - # sg_url = '%s/staff_grading' % course_u - world.browser.click_link_by_text('Instructor') - world.browser.click_link_by_text('Staff grading') - # world.browser.visit(django_url(sg_url)) + world.click_link('Instructor') + world.click_link('Staff grading') @step(u'I see the grader message "([^"]*)"$') def see_grader_message(step, msg): message_css = 'div.external-grader-message' - grader_msg = world.browser.find_by_css(message_css).text - assert_in(msg, grader_msg) + assert_in(msg, world.css_text(message_css)) @step(u'I see the grader status "([^"]*)"$') def see_the_grader_status(step, status): status_css = 'div.grader-status' - grader_status = world.browser.find_by_css(status_css).text - assert_equals(status, grader_status) + assert_equals(status, world.css_text(status_css)) @step('I see the red X$') def see_the_red_x(step): - x_css = 'div.grader-status > span.incorrect' - assert world.browser.find_by_css(x_css) + assert world.is_css_present('div.grader-status > span.incorrect') @step(u'I see the grader score "([^"]*)"$') def see_the_grader_score(step, score): score_css = 'div.result-output > p' - score_text = world.browser.find_by_css(score_css).text + score_text = world.css_text(score_css) assert_equals(score_text, 'Score: %s' % score) @step('I see the link for full output$') def see_full_output_link(step): - link_css = 'a.full' - assert world.browser.find_by_css(link_css) + assert world.is_css_present('a.full') @step('I see the spelling grading message "([^"]*)"$') def see_spelling_msg(step, msg): - spelling_css = 'div.spelling' - spelling_msg = world.browser.find_by_css(spelling_css).text + spelling_msg = world.css_text('div.spelling') assert_equals('Spelling: %s' % msg, spelling_msg) @step(u'my answer is queued for instructor grading$') def answer_is_queued_for_instructor_grading(step): list_css = 'ul.problem-list > li > a' - actual_msg = world.browser.find_by_css(list_css).text + actual_msg = world.css_text(list_css) expected_msg = "(0 graded, 1 pending)" assert_in(expected_msg, actual_msg) diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature new file mode 100644 index 0000000000..dc8495af60 --- /dev/null +++ b/lms/djangoapps/courseware/features/problems.feature @@ -0,0 +1,81 @@ +Feature: Answer problems + As a student in an edX course + In order to test my understanding of the material + I want to answer problems + + Scenario: I can answer a problem correctly + Given External graders respond "correct" + And I am viewing a "" problem + When I answer a "" problem "correctly" + Then My "" answer is marked "correct" + And The "" problem displays a "correct" answer + + Examples: + | ProblemType | + | drop down | + | multiple choice | + | checkbox | + | string | + | numerical | + | formula | + | script | + | code | + + Scenario: I can answer a problem incorrectly + Given External graders respond "incorrect" + And I am viewing a "" problem + When I answer a "" problem "incorrectly" + Then My "" answer is marked "incorrect" + And The "" problem displays a "incorrect" answer + + Examples: + | ProblemType | + | drop down | + | multiple choice | + | checkbox | + | string | + | numerical | + | formula | + | script | + | code | + + Scenario: I can submit a blank answer + Given I am viewing a "" problem + When I check a problem + Then My "" answer is marked "incorrect" + And The "" problem displays a "blank" answer + + Examples: + | ProblemType | + | drop down | + | multiple choice | + | checkbox | + | string | + | numerical | + | formula | + | script | + + + Scenario: I can reset a problem + Given I am viewing a "" problem + And I answer a "" problem "ly" + When I reset the problem + Then My "" answer is marked "unanswered" + And The "" problem displays a "blank" answer + + Examples: + | ProblemType | Correctness | + | drop down | correct | + | drop down | incorrect | + | multiple choice | correct | + | multiple choice | incorrect | + | checkbox | correct | + | checkbox | incorrect | + | string | correct | + | string | incorrect | + | numerical | correct | + | numerical | incorrect | + | formula | correct | + | formula | incorrect | + | script | correct | + | script | incorrect | diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py new file mode 100644 index 0000000000..b25d606c4e --- /dev/null +++ b/lms/djangoapps/courseware/features/problems.py @@ -0,0 +1,397 @@ +''' +Steps for problem.feature lettuce tests +''' + +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from lettuce.django import django_url +import random +import textwrap +from common import i_am_registered_for_the_course, \ + TEST_SECTION_NAME, section_location +from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ + ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ + StringResponseXMLFactory, NumericalResponseXMLFactory, \ + FormulaResponseXMLFactory, CustomResponseXMLFactory, \ + CodeResponseXMLFactory + +# Factories from capa.tests.response_xml_factory that we will use +# to generate the problem XML, with the keyword args used to configure +# the output. +PROBLEM_FACTORY_DICT = { + 'drop down': { + 'factory': OptionResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Option 2', + 'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'], + 'correct_option': 'Option 2'}}, + + 'multiple choice': { + 'factory': MultipleChoiceResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Choice 3', + 'choices': [False, False, True, False], + 'choice_names': ['choice_0', 'choice_1', 'choice_2', 'choice_3']}}, + + 'checkbox': { + 'factory': ChoiceResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Choices 1 and 3', + 'choice_type': 'checkbox', + 'choices': [True, False, True, False, False], + 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, + + 'string': { + 'factory': StringResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The answer is "correct string"', + 'case_sensitive': False, + 'answer': 'correct string'}}, + + 'numerical': { + 'factory': NumericalResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The answer is pi + 1', + 'answer': '4.14159', + 'tolerance': '0.00001', + 'math_display': True}}, + + 'formula': { + 'factory': FormulaResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]', + 'sample_dict': {'x': (-100, 100), 'y': (-100, 100)}, + 'num_samples': 10, + 'tolerance': 0.00001, + 'math_display': True, + 'answer': 'x^2+2*x+y'}}, + + 'script': { + 'factory': CustomResponseXMLFactory(), + 'kwargs': { + 'question_text': 'Enter two integers that sum to 10.', + 'cfn': 'test_add_to_ten', + 'expect': '10', + 'num_inputs': 2, + 'script': textwrap.dedent(""" + def test_add_to_ten(expect,ans): + try: + a1=int(ans[0]) + a2=int(ans[1]) + except ValueError: + a1=0 + a2=0 + return (a1+a2)==int(expect) + """)}}, + 'code': { + 'factory': CodeResponseXMLFactory(), + 'kwargs': { + 'question_text': 'Submit code to an external grader', + 'initial_display': 'print "Hello world!"', + 'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', }}, + } + + +def add_problem_to_course(course, problem_type): + ''' + Add a problem to the course we have created using factories. + ''' + + assert(problem_type in PROBLEM_FACTORY_DICT) + + # Generate the problem XML using capa.tests.response_xml_factory + factory_dict = PROBLEM_FACTORY_DICT[problem_type] + problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs']) + + # Create a problem item using our generated XML + # We set rerandomize=always in the metadata so that the "Reset" button + # will appear. + template_name = "i4x://edx/templates/problem/Blank_Common_Problem" + world.ItemFactory.create(parent_location=section_location(course), + template=template_name, + display_name=str(problem_type), + data=problem_xml, + metadata={'rerandomize': 'always'}) + + +@step(u'I am viewing a "([^"]*)" problem') +def view_problem(step, problem_type): + i_am_registered_for_the_course(step, 'model_course') + + # Ensure that the course has this problem type + add_problem_to_course('model_course', problem_type) + + # Go to the one section in the factory-created course + # which should be loaded with the correct problem + chapter_name = TEST_SECTION_NAME.replace(" ", "_") + section_name = chapter_name + url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' % + (chapter_name, section_name)) + + world.browser.visit(url) + + +@step(u'External graders respond "([^"]*)"') +def set_external_grader_response(step, correctness): + assert(correctness in ['correct', 'incorrect']) + + response_dict = {'correct': True if correctness == 'correct' else False, + 'score': 1 if correctness == 'correct' else 0, + 'msg': 'Your problem was graded %s' % correctness} + + # Set the fake xqueue server to always respond + # correct/incorrect when asked to grade a problem + world.xqueue_server.set_grade_response(response_dict) + + +@step(u'I answer a "([^"]*)" problem "([^"]*)ly"') +def answer_problem(step, problem_type, correctness): + """ Mark a given problem type correct or incorrect, then submit it. + + *problem_type* is a string representing the type of problem (e.g. 'drop down') + *correctness* is in ['correct', 'incorrect'] + """ + + assert(correctness in ['correct', 'incorrect']) + + if problem_type == "drop down": + select_name = "input_i4x-edx-model_course-problem-drop_down_2_1" + option_text = 'Option 2' if correctness == 'correct' else 'Option 3' + world.browser.select(select_name, option_text) + + elif problem_type == "multiple choice": + if correctness == 'correct': + inputfield('multiple choice', choice='choice_2').check() + else: + inputfield('multiple choice', choice='choice_1').check() + + elif problem_type == "checkbox": + if correctness == 'correct': + inputfield('checkbox', choice='choice_0').check() + inputfield('checkbox', choice='choice_2').check() + else: + inputfield('checkbox', choice='choice_3').check() + + elif problem_type == 'string': + textvalue = 'correct string' if correctness == 'correct' \ + else 'incorrect' + inputfield('string').fill(textvalue) + + elif problem_type == 'numerical': + textvalue = "pi + 1" if correctness == 'correct' \ + else str(random.randint(-2, 2)) + inputfield('numerical').fill(textvalue) + + elif problem_type == 'formula': + textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2' + inputfield('formula').fill(textvalue) + + elif problem_type == 'script': + # Correct answer is any two integers that sum to 10 + first_addend = random.randint(-100, 100) + second_addend = 10 - first_addend + + # If we want an incorrect answer, then change + # the second addend so they no longer sum to 10 + if correctness == 'incorrect': + second_addend += random.randint(1, 10) + + inputfield('script', input_num=1).fill(str(first_addend)) + inputfield('script', input_num=2).fill(str(second_addend)) + + elif problem_type == 'code': + # The fake xqueue server is configured to respond + # correct / incorrect no matter what we submit. + # Furthermore, since the inline code response uses + # JavaScript to make the code display nicely, it's difficult + # to programatically input text + # (there's not + % endif - +
          % if registration: Cancel Update - % else: + % else: Cancel Registration % endif @@ -325,68 +325,68 @@

            - + % if registration: % if registration.accommodation_request and len(registration.accommodation_request) > 0: % endif - % else: + % else: Special (ADA) Accommodations % endif - +
            %else: -
            +
            diff --git a/lms/urls.py b/lms/urls.py index 2c738913c3..de5c8184fa 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -69,44 +69,22 @@ urlpatterns = ('', url(r'^heartbeat$', include('heartbeat.urls')), - url(r'^university_profile/UTx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'UTx'}), - url(r'^university_profile/WellesleyX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), - url(r'^university_profile/GeorgetownX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/GeorgetownX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'GeorgetownX'}), - - # Dan accidentally sent out a press release with lower case urls for McGill, Toronto, - # Rice, ANU, Delft, and EPFL. Hence the redirects. - url(r'^university_profile/McGillX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'McGillX'}), - url(r'^university_profile/mcgillx$', - RedirectView.as_view(url='/university_profile/McGillX')), - - url(r'^university_profile/TorontoX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'TorontoX'}), - url(r'^university_profile/torontox$', - RedirectView.as_view(url='/university_profile/TorontoX')), - - url(r'^university_profile/RiceX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'RiceX'}), - url(r'^university_profile/ricex$', - RedirectView.as_view(url='/university_profile/RiceX')), - - url(r'^university_profile/ANUx$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'ANUx'}), - url(r'^university_profile/anux$', - RedirectView.as_view(url='/university_profile/ANUx')), - - url(r'^university_profile/DelftX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/DelftX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'DelftX'}), - url(r'^university_profile/delftx$', - RedirectView.as_view(url='/university_profile/DelftX')), - - url(r'^university_profile/EPFLx$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'EPFLx'}), - url(r'^university_profile/epflx$', - RedirectView.as_view(url='/university_profile/EPFLx')), url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', name="university_profile"), @@ -172,11 +150,13 @@ urlpatterns = ('', url(r'^press/edx-expands-internationally$', 'static_template_view.views.render', {'template': 'press_releases/edx_expands_internationally.html'}, name="press/edx-expands-internationally"), - + url(r'^press/xblock_announcement$', 'static_template_view.views.render', + {'template': 'press_releases/xblock_announcement.html'}, + name="press/xblock-announcement"), # Should this always update to point to the latest press release? (r'^pressrelease$', 'django.views.generic.simple.redirect_to', - {'url': '/press/edx-expands-internationally'}), + {'url': '/press/xblock-announcement'}), (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py new file mode 100644 index 0000000000..423c0eb0ec --- /dev/null +++ b/lms/xmodule_namespace.py @@ -0,0 +1,68 @@ +""" +Namespace that defines fields common to all blocks used in the LMS +""" +from xblock.core import Namespace, Boolean, Scope, String, Float +from xmodule.fields import Date, Timedelta + + +class StringyBoolean(Boolean): + """ + Reads strings from JSON as booleans. + + 'true' (case insensitive) return True, other strings return False + Other types are returned unchanged + """ + def from_json(self, value): + if isinstance(value, basestring): + return value.lower() == 'true' + return value + + +class StringyFloat(Float): + """ + Reads values as floats. If the value parses as a float, returns + that, otherwise returns None + """ + def from_json(self, value): + try: + return float(value) + except: + return None + + +class LmsNamespace(Namespace): + """ + Namespace that defines fields common to all blocks used in the LMS + """ + hide_from_toc = StringyBoolean( + help="Whether to display this module in the table of contents", + default=False, + scope=Scope.settings + ) + graded = Boolean( + help="Whether this module contributes to the final course grade", + default=False, + scope=Scope.settings + ) + format = String( + help="What format this module is in (used for deciding which " + "grader to apply, and what to show in the TOC)", + scope=Scope.settings, + ) + + start = Date(help="Start time when this module is visible", scope=Scope.settings) + due = String(help="Date that this problem is due by", scope=Scope.settings, default='') + source_file = String(help="DO NOT USE", scope=Scope.settings) + xqa_key = String(help="DO NOT USE", scope=Scope.settings) + ispublic = Boolean(help="Whether this course is open to the public, or only to admins", 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") + rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) + days_early_for_beta = StringyFloat( + help="Number of days early to show content to beta users", + default=None, + scope=Scope.settings + ) diff --git a/local-requirements.txt b/local-requirements.txt index a4d153dd36..de2d274719 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,3 +1,9 @@ # Python libraries to install that are local to the mitx repo -e common/lib/capa -e common/lib/xmodule +-e . + +# XBlock: +# Might change frequently, so put it in local-requirements.txt, +# but conceptually is an external package, so it is in a separate repo. +-e git+ssh://git@github.com/MITx/xmodule-debugger@9a4f883a#egg=XBlock diff --git a/prod-requirements.txt b/prod-requirements.txt deleted file mode 100644 index 20c3e9b068..0000000000 --- a/prod-requirements.txt +++ /dev/null @@ -1,53 +0,0 @@ -Django==1.3.1 -flup==1.0.3.dev-20110405 -lxml==2.3.4 -Mako==0.7.0 -Markdown==2.1.1 -markdown2==1.4.2 -python-memcached==1.48 -numpy==1.6.1 -Pygments==1.5 -boto==2.3.0 -django-storages==1.1.4 -django-masquerade==0.1.5 -fs==0.4.0 -django-jasmine==0.3.2 -path.py==2.2.2 -requests==0.12.1 -BeautifulSoup==3.2.1 -BeautifulSoup4==4.1.1 -newrelic==1.3.0.289 -ipython==0.12.1 -django-pipeline==1.2.12 -django-staticfiles==1.2.1 -glob2==0.3 -sympy==0.7.1 -pymongo==2.2.1 -rednose==0.3.3 -mock==0.8.0 -GitPython==0.3.2.RC1 -PyYAML==3.10 -feedparser==5.1.2 -MySQL-python==1.2.3 -matplotlib==1.1.0 -scipy==0.10.1 -akismet==0.2.0 -Coffin==0.3.6 -django-celery==2.2.7 -django-countries==1.0.5 -django-followit==0.0.3 -django-keyedcache==1.4-6 -django-kombu==0.9.2 -django-mako==0.1.5pre -django-recaptcha-works==0.3.4 -django-robots==0.8.1 -django-ses==0.4.1 -django-threaded-multihost==1.4-1 -html5lib==0.90 -Jinja2==2.6 -oauth2==1.5.211 -pystache==0.3.1 -python-openid==2.2.5 -South==0.7.5 -Unidecode==0.04.9 -dogstatsd-python==0.2.1 diff --git a/rakefile b/rakefile index f62e75507c..80669afed6 100644 --- a/rakefile +++ b/rakefile @@ -120,8 +120,8 @@ default_options = { } task :predjango do - sh("find . -type f -name '*.pyc' -delete") - sh('pip install -q --upgrade --no-deps -r local-requirements.txt') + sh("find . -type f -name *.pyc -delete") + sh('pip install -q --no-index -r local-requirements.txt') end task :clean_test_files do @@ -140,12 +140,8 @@ end desc "Run pylint on all #{system} code" task "pylint_#{system}" => report_dir do - Dir["#{system}/djangoapps/*", "#{system}/lib/*"].each do |app| - if File.exists? "#{app}/setup.py" - pythonpath_prefix = "PYTHONPATH=#{app}" - else - pythonpath_prefix = "PYTHONPATH=#{File.dirname(app)}" - end + Dir["#{system}/*.py", "#{system}/djangoapps/*", "#{system}/lib/*"].each do |app| + pythonpath_prefix = "PYTHONPATH=#{system}:#{system}/djangoapps:#{system}/lib:common/djangoapps:common/lib" app = File.basename(app) if app =~ /.py$/ app = app.gsub('.py', '') @@ -183,6 +179,12 @@ end TEST_TASK_DIRS = [] +task :fastlms do + # this is >2 times faster that rake [lms], and does not need web, good for local dev + django_admin = ENV['DJANGO_ADMIN_PATH'] || select_executable('django-admin.py', 'django-admin') + sh("#{django_admin} runserver --traceback --settings=lms.envs.dev --pythonpath=.") +end + [:lms, :cms].each do |system| report_dir = report_dir_path(system) @@ -197,6 +199,8 @@ TEST_TASK_DIRS = [] run_tests(system, report_dir, args.stop_on_failure) end + task :fasttest => "fasttest_#{system}" + TEST_TASK_DIRS << system desc <<-desc @@ -415,7 +419,7 @@ end namespace :cms do desc "Delete existing MongoDB based course" task :delete_course do - + if ENV['LOC'] and ENV['COMMIT'] sh(django_admin(:cms, :dev, :delete_course, ENV['LOC'], ENV['COMMIT'])) elsif ENV['LOC'] @@ -483,16 +487,30 @@ task :autodeploy_properties do end end + +# --- Develop and public documentation --- desc "Invoke sphinx 'make build' to generate docs." -task :builddocs do - Dir.chdir('docs') do +task :builddocs, [:options] do |t, args| + if args.options == 'pub' + path = "doc/public" + else + path = "docs" + end + + Dir.chdir(path) do sh('make html') end end desc "Show docs in browser (mac and ubuntu)." -task :showdocs do - Dir.chdir('docs/build/html') do +task :showdocs, [:options] do |t, args| + if args.options == 'pub' + path = "doc/public" + else + path = "docs" + end + + Dir.chdir("#{path}/build/html") do if RUBY_PLATFORM.include? 'darwin' # mac os sh('open index.html') elsif RUBY_PLATFORM.include? 'linux' # make more ubuntu specific? @@ -500,12 +518,13 @@ task :showdocs do else raise "\nUndefined how to run browser on your machine. Please use 'rake builddocs' and then manually open -'mitx/docs/build/html/index.html." +'mitx/#{path}/build/html/index.html." end end end desc "Build docs and show them in browser" -task :doc => :builddocs do - Rake::Task["showdocs"].invoke +task :doc, [:options] => :builddocs do |t, args| + Rake::Task["showdocs"].invoke(args.options) end +# --- Develop and public documentation --- diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..48572de6de --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup, find_packages + +setup( + name="edX Apps", + version="0.1", + install_requires=['distribute'], + requires=[ + 'xmodule', + ], + py_modules=['lms.xmodule_namespace', 'cms.xmodule_namespace'], + # See http://guide.python-distribute.org/creation.html#entry-points + # for a description of entry_points + entry_points={ + 'xblock.namespace': [ + 'lms = lms.xmodule_namespace:LmsNamespace', + 'cms = cms.xmodule_namespace:CmsNamespace', + ], + } +) \ No newline at end of file diff --git a/test-requirements.txt b/test-requirements.txt index 6c3322acbf..d9db89f107 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,7 @@ -coverage -pylint -pep8 -lettuce -selenium -splinter +coverage==3.6 +pylint==0.27.0 +pep8==1.4.5 +lettuce==0.2.15 +selenium==2.31.0 +splinter==0.5.0