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("
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("
" + escaped + "
")
# No try/catch b/c failure generates an error back to client
new_html_parsed = html.fromstring('
' + update['date'] + '
' + update['content'] + '
')
@@ -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("
" + 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 += '
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.
+
+
+
+%block>
\ 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>
+
+<%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.
<%
- start_date = datetime.fromtimestamp(mktime(subsection.start)) if subsection.start is not None else None
- parent_start_date = datetime.fromtimestamp(mktime(parent_item.start)) if parent_item.start is not None else None
+ start_date = datetime.fromtimestamp(mktime(subsection.lms.start)) if subsection.lms.start is not None else None
+ parent_start_date = datetime.fromtimestamp(mktime(parent_item.lms.start)) if parent_item.lms.start is not None else None
%>
-
+
- % if subsection.start != parent_item.start and subsection.start:
+ % if subsection.lms.start != parent_item.lms.start and subsection.lms.start:
% if parent_start_date is None:
-
The date above differs from the release date of ${parent_item.display_name}, which is unset.
+
The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
% else:
-
The date above differs from the release date of ${parent_item.display_name} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}.
+
The date above differs from the release date of ${parent_item.display_name_with_default} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}.
% endif
- Sync to ${parent_item.display_name}.
<%
# due date uses it own formatting for stringifying the date. As with capa_module.py, there's a utility module available for us to use
- due_date = dateutil.parser.parse(subsection.metadata.get('due')) if 'due' in subsection.metadata else None
+ due_date = dateutil.parser.parse(subsection.lms.due) if subsection.lms.due else None
%>
-
+
Remove due date
@@ -110,7 +98,7 @@
-
+
%block>
\ No newline at end of file
diff --git a/cms/templates/index.html b/cms/templates/index.html
index fdb46612a0..9482b9d9af 100644
--- a/cms/templates/index.html
+++ b/cms/templates/index.html
@@ -1,6 +1,6 @@
<%inherit file="base.html" />
-<%block name="title">Courses%block>
+<%block name="title">My Courses%block>
<%block name="bodyclass">is-signedin index dashboard%block>
<%block name="header_extras">
diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html
index 722e756203..8a6b2fccea 100644
--- a/cms/templates/manage_users.html
+++ b/cms/templates/manage_users.html
@@ -1,5 +1,5 @@
<%inherit file="base.html" />
-<%block name="title">Course Staff Manager%block>
+<%block name="title">Course Team Settings%block>
<%block name="bodyclass">is-signedin course users settings team%block>
diff --git a/cms/templates/new_item.html b/cms/templates/new_item.html
index 60da39fd2a..45cb157845 100644
--- a/cms/templates/new_item.html
+++ b/cms/templates/new_item.html
@@ -8,7 +8,7 @@
<%
- start_date = datetime.fromtimestamp(mktime(section.start)) if section.start is not None else None
+ start_date = datetime.fromtimestamp(mktime(section.lms.start)) if section.lms.start is not None else None
start_date_str = start_date.strftime('%m/%d/%Y') if start_date is not None else ''
start_time_str = start_date.strftime('%H:%M') if start_date is not None else ''
%>
@@ -174,9 +174,9 @@
Will Release: ${start_date_str} at ${start_time_str}Edit
%endif
-
These are used in your course URL, and cannot be changed
diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html
index ceee406398..838af5ada9 100644
--- a/cms/templates/settings_advanced.html
+++ b/cms/templates/settings_advanced.html
@@ -21,7 +21,6 @@ $(document).ready(function () {
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
var advancedModel = new CMS.Models.Settings.Advanced(${advanced_dict | n}, {parse: true});
- advancedModel.blacklistKeys = ${advanced_blacklist | n};
advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
var editor = new CMS.Views.Settings.Advanced({
@@ -61,18 +60,11 @@ editor.render();
Manually Edit Course Policy Values (JSON Key / Value pairs)
-
Warning: Add only manual policy data that you are familiar
- with.
+
Warning: Do not modify these policies unless you are familiar with their purpose.
- %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
@@ -39,7 +39,7 @@ This def will enumerate through a passed in subsection and list all of the units
-%def>
+%def>
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++5⁄2SO42-'
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': '''
-
-''',
+
+ ''',
}
wrong_answers = {'1_2_1': '2',
'1_2_1_dynamath': '''
''',
- }
+
+ 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}{tag}>'.format(tag=k, body=self.definition[k])
+ child_str = '<{tag}>{body}{tag}>'.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}{tag}>'.format(tag=k, body=self.definition[k])
+ child_str = '<{tag}>{body}{tag}>'.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}{tag_name}>'.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}{tag_name}>'.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
'
);
- targetEl.appendTo(state.baseImageEl.parent());
+
+ if (fromTargetField === true) {
+ targetEl.appendTo(draggableObj.iconEl);
+ } else {
+ targetEl.appendTo(state.baseImageEl.parent());
+ }
+
targetEl.mousedown(function (event) {
event.preventDefault();
});
@@ -68,8 +124,13 @@ define(['logme'], function (logme) {
}
targetObj = {
+ 'uniqueId': state.getUniqueId(),
+
'id': obj.id,
+ 'x': obj.x,
+ 'y': obj.y,
+
'w': obj.w,
'h': obj.h,
@@ -86,9 +147,21 @@ define(['logme'], function (logme) {
'updateNumTextEl': updateNumTextEl,
'removeDraggable': removeDraggable,
- 'addDraggable': addDraggable
+ 'addDraggable': addDraggable,
+
+ 'type': 'base',
+ 'draggableObj': null
};
+ if (fromTargetField === true) {
+ targetObj.offset = draggableObj.iconEl.position();
+ targetObj.offset.top += obj.y;
+ targetObj.offset.left += obj.x;
+
+ targetObj.type = 'on_drag';
+ targetObj.draggableObj = draggableObj;
+ }
+
if (state.config.onePerTarget === false) {
numTextEl.appendTo(state.baseImageEl.parent());
numTextEl.mousedown(function (event) {
@@ -99,7 +172,11 @@ define(['logme'], function (logme) {
});
}
- state.targets.push(targetObj);
+ targetObj.indexInStateArray = state.targets.push(targetObj) - 1;
+
+ if (fromTargetField === true) {
+ draggableObj.targetField.push(targetObj);
+ }
}
function removeDraggable(draggable) {
@@ -121,6 +198,10 @@ define(['logme'], function (logme) {
draggable.onTarget = null;
draggable.onTargetIndex = null;
+ if (this.type === 'on_drag') {
+ this.draggableObj.numDraggablesOnMe -= 1;
+ }
+
this.updateNumTextEl();
}
@@ -128,6 +209,10 @@ define(['logme'], function (logme) {
draggable.onTarget = this;
draggable.onTargetIndex = this.draggableList.push(draggable) - 1;
+ if (this.type === 'on_drag') {
+ this.draggableObj.numDraggablesOnMe += 1;
+ }
+
this.updateNumTextEl();
}
@@ -183,10 +268,5 @@ define(['logme'], function (logme) {
this.numTextEl.html(this.draggableList.length);
}
}
-});
-
-// 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)
+}); // End-of: define(['logme'], function (logme) {
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {
diff --git a/common/static/js/capa/drag_and_drop/update_input.js b/common/static/js/capa/drag_and_drop/update_input.js
index 04715a3ecf..804b0bed97 100644
--- a/common/static/js/capa/drag_and_drop/update_input.js
+++ b/common/static/js/capa/drag_and_drop/update_input.js
@@ -1,9 +1,4 @@
-// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
-// define() functions from Require JS available inside the anonymous function.
-//
-// See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
(function (requirejs, require, define) {
-
define(['logme'], function (logme) {
return {
'check': check,
@@ -37,7 +32,12 @@ define(['logme'], function (logme) {
(function (c2) {
while (c2 < state.targets[c1].draggableList.length) {
tempObj = {};
- tempObj[state.targets[c1].draggableList[c2].id] = state.targets[c1].id;
+
+ if (state.targets[c1].type === 'base') {
+ tempObj[state.targets[c1].draggableList[c2].id] = state.targets[c1].id;
+ } else {
+ addTargetRecursively(tempObj, state.targets[c1].draggableList[c2], state.targets[c1]);
+ }
draggables.push(tempObj);
tempObj = null;
@@ -50,7 +50,18 @@ define(['logme'], function (logme) {
}(0));
}
- $('#input_' + state.problemId).val(JSON.stringify({'draggables': draggables}));
+ $('#input_' + state.problemId).val(JSON.stringify(draggables));
+ }
+
+ function addTargetRecursively(tempObj, draggable, target) {
+ if (target.type === 'base') {
+ tempObj[draggable.id] = target.id;
+ } else {
+ tempObj[draggable.id] = {};
+ tempObj[draggable.id][target.id] = {};
+
+ addTargetRecursively(tempObj[draggable.id][target.id], target.draggableObj, target.draggableObj.onTarget);
+ }
}
// Check if input has an answer from server. If yes, then position
@@ -59,6 +70,7 @@ define(['logme'], function (logme) {
var inputElVal;
inputElVal = $('#input_' + state.problemId).val();
+
if (inputElVal.length === 0) {
return false;
}
@@ -68,95 +80,147 @@ define(['logme'], function (logme) {
return true;
}
- function getUseTargets(answer) {
- if ($.isArray(answer.draggables) === false) {
- logme('ERROR: answer.draggables is not an array.');
+ function processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i) {
+ var baseDraggableId, baseDraggable, baseTargetId, baseTarget,
+ layeredDraggableId, layeredDraggable, layeredTargetId, layeredTarget,
+ chain;
- return;
- } else if (answer.draggables.length === 0) {
- return;
- }
-
- if ($.isPlainObject(answer.draggables[0]) === false) {
- logme('ERROR: answer.draggables array does not contain objects.');
+ if (depth === 0) {
+ // We are at the lowest depth? The end.
return;
}
- for (c1 in answer.draggables[0]) {
- if (answer.draggables[0].hasOwnProperty(c1) === false) {
- continue;
- }
+ if (answerSortedByDepth.hasOwnProperty(depth) === false) {
+ // We have a depth that ts not valid, we decrease the depth by one.
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth - 1, 0);
- if (typeof answer.draggables[0][c1] === 'string') {
- // use_targets = true;
-
- return true;
- } else if (
- ($.isArray(answer.draggables[0][c1]) === true) &&
- (answer.draggables[0][c1].length === 2)
- ) {
- // use_targets = false;
-
- return false;
- } else {
- logme('ERROR: answer.draggables[0] is inconsidtent.');
-
- return;
- }
+ return;
}
- logme('ERROR: answer.draggables[0] is an empty object.');
+ if (answerSortedByDepth[depth].length <= i) {
+ // We ran out of answers at this depth, go to the next depth down.
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth - 1, 0);
+
+ return;
+ }
+
+ chain = answerSortedByDepth[depth][i];
+
+ baseDraggableId = Object.keys(chain)[0];
+
+ // This is a hack. For now we will work with depths 1 and 3.
+ if (depth === 1) {
+ baseTargetId = chain[baseDraggableId];
+
+ layeredTargetId = null;
+ layeredDraggableId = null;
+
+ // createBaseDraggableOnTarget(state, baseDraggableId, baseTargetId);
+ } else if (depth === 3) {
+ layeredDraggableId = baseDraggableId;
+
+ layeredTargetId = Object.keys(chain[layeredDraggableId])[0];
+
+ baseDraggableId = Object.keys(chain[layeredDraggableId][layeredTargetId])[0];
+
+ baseTargetId = chain[layeredDraggableId][layeredTargetId][baseDraggableId];
+ }
+
+ checkBaseDraggable();
return;
+
+ function checkBaseDraggable() {
+ if ((baseDraggable = getById(state, 'draggables', baseDraggableId, null, false, baseTargetId)) === null) {
+ createBaseDraggableOnTarget(state, baseDraggableId, baseTargetId, true, function () {
+ if ((baseDraggable = getById(state, 'draggables', baseDraggableId, null, false, baseTargetId)) === null) {
+ console.log('ERROR: Could not successfully create a base draggable on a base target.');
+ } else {
+ baseTarget = baseDraggable.onTarget;
+
+ if ((layeredTargetId === null) || (layeredDraggableId === null)) {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ } else {
+ checklayeredDraggable();
+ }
+ }
+ });
+ } else {
+ baseTarget = baseDraggable.onTarget;
+
+ if ((layeredTargetId === null) || (layeredDraggableId === null)) {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ } else {
+ checklayeredDraggable();
+ }
+ }
+ }
+
+ function checklayeredDraggable() {
+ if ((layeredDraggable = getById(state, 'draggables', layeredDraggableId, null, false, layeredTargetId, baseDraggableId, baseTargetId)) === null) {
+ layeredDraggable = getById(state, 'draggables', layeredDraggableId);
+ layeredTarget = null;
+ baseDraggable.targetField.every(function (target) {
+ if (target.id === layeredTargetId) {
+ layeredTarget = target;
+ }
+
+ return true;
+ });
+
+ if ((layeredDraggable !== null) && (layeredTarget !== null)) {
+ layeredDraggable.moveDraggableTo('target', layeredTarget, function () {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ });
+ } else {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ }
+ } else {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ }
+ }
}
- function processAnswerTargets(state, answer) {
- var draggableId, draggable, targetId, target;
+ function createBaseDraggableOnTarget(state, draggableId, targetId, reportError, funcCallback) {
+ var draggable, target;
- (function (c1) {
- while (c1 < answer.draggables.length) {
- for (draggableId in answer.draggables[c1]) {
- if (answer.draggables[c1].hasOwnProperty(draggableId) === false) {
- continue;
- }
-
- if ((draggable = getById(state, 'draggables', draggableId)) === null) {
- logme(
- 'ERROR: In answer there exists a ' +
- 'draggable ID "' + draggableId + '". No ' +
- 'draggable with this ID could be found.'
- );
-
- continue;
- }
-
- targetId = answer.draggables[c1][draggableId];
- if ((target = getById(state, 'targets', targetId)) === null) {
- logme(
- 'ERROR: In answer there exists a target ' +
- 'ID "' + targetId + '". No target with this ' +
- 'ID could be found.'
- );
-
- continue;
- }
-
- draggable.moveDraggableTo('target', target);
- }
-
- c1 += 1;
+ if ((draggable = getById(state, 'draggables', draggableId)) === null) {
+ if (reportError !== false) {
+ logme(
+ 'ERROR: In answer there exists a ' +
+ 'draggable ID "' + draggableId + '". No ' +
+ 'draggable with this ID could be found.'
+ );
}
- }(0));
+
+ return false;
+ }
+
+ if ((target = getById(state, 'targets', targetId)) === null) {
+ if (reportError !== false) {
+ logme(
+ 'ERROR: In answer there exists a target ' +
+ 'ID "' + targetId + '". No target with this ' +
+ 'ID could be found.'
+ );
+ }
+
+ return false;
+ }
+
+ draggable.moveDraggableTo('target', target, funcCallback);
+
+ return true;
}
function processAnswerPositions(state, answer) {
var draggableId, draggable;
(function (c1) {
- while (c1 < answer.draggables.length) {
- for (draggableId in answer.draggables[c1]) {
- if (answer.draggables[c1].hasOwnProperty(draggableId) === false) {
+ while (c1 < answer.length) {
+ for (draggableId in answer[c1]) {
+ if (answer[c1].hasOwnProperty(draggableId) === false) {
continue;
}
@@ -171,8 +235,8 @@ define(['logme'], function (logme) {
}
draggable.moveDraggableTo('XY', {
- 'x': answer.draggables[c1][draggableId][0],
- 'y': answer.draggables[c1][draggableId][1]
+ 'x': answer[c1][draggableId][0],
+ 'y': answer[c1][draggableId][1]
});
}
@@ -182,33 +246,110 @@ define(['logme'], function (logme) {
}
function repositionDraggables(state, answer) {
- if (answer.draggables.length === 0) {
+ var answerSortedByDepth, minDepth, maxDepth;
+
+ answerSortedByDepth = {};
+ minDepth = 1000;
+ maxDepth = 0;
+
+ answer.every(function (chain) {
+ var depth;
+
+ depth = findDepth(chain, 0);
+
+ if (depth < minDepth) {
+ minDepth = depth;
+ }
+ if (depth > maxDepth) {
+ maxDepth = depth;
+ }
+
+ if (answerSortedByDepth.hasOwnProperty(depth) === false) {
+ answerSortedByDepth[depth] = [];
+ }
+
+ answerSortedByDepth[depth].push(chain);
+
+ return true;
+ });
+
+ if (answer.length === 0) {
return;
}
- if (state.config.individualTargets !== getUseTargets(answer)) {
- logme('ERROR: JSON config is not consistent with server response.');
-
+ // For now we support only one case.
+ if ((minDepth < 1) || (maxDepth > 3)) {
return;
}
if (state.config.individualTargets === true) {
- processAnswerTargets(state, answer);
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, maxDepth, 0);
} else if (state.config.individualTargets === false) {
processAnswerPositions(state, answer);
}
}
- function getById(state, type, id) {
+ function findDepth(tempObj, depth) {
+ var i;
+
+ if ($.isPlainObject(tempObj) === false) {
+ return depth;
+ }
+
+ depth += 1;
+
+ for (i in tempObj) {
+ if (tempObj.hasOwnProperty(i) === true) {
+ depth = findDepth(tempObj[i], depth);
+ }
+ }
+
+ return depth;
+ }
+
+ function getById(state, type, id, fromTargetField, inContainer, targetId, baseDraggableId, baseTargetId) {
return (function (c1) {
while (c1 < state[type].length) {
if (type === 'draggables') {
- if ((state[type][c1].id === id) && (state[type][c1].isOriginal === true)) {
- return state[type][c1];
+ if ((targetId !== undefined) && (inContainer === false) && (baseDraggableId !== undefined) && (baseTargetId !== undefined)) {
+ if (
+ (state[type][c1].id === id) &&
+ (state[type][c1].inContainer === false) &&
+ (state[type][c1].onTarget.id === targetId) &&
+ (state[type][c1].onTarget.type === 'on_drag') &&
+ (state[type][c1].onTarget.draggableObj.id === baseDraggableId) &&
+ (state[type][c1].onTarget.draggableObj.onTarget.id === baseTargetId)
+ ) {
+ return state[type][c1];
+ }
+ } else if ((targetId !== undefined) && (inContainer === false)) {
+ if (
+ (state[type][c1].id === id) &&
+ (state[type][c1].inContainer === false) &&
+ (state[type][c1].onTarget.id === targetId)
+ ) {
+ return state[type][c1];
+ }
+ } else {
+ if (inContainer === false) {
+ if ((state[type][c1].id === id) && (state[type][c1].inContainer === false)) {
+ return state[type][c1];
+ }
+ } else {
+ if ((state[type][c1].id === id) && (state[type][c1].inContainer === true)) {
+ return state[type][c1];
+ }
+ }
}
} else { // 'targets'
- if (state[type][c1].id === id) {
- return state[type][c1];
+ if (fromTargetField === true) {
+ if ((state[type][c1].id === id) && (state[type][c1].type === 'on_drag')) {
+ return state[type][c1];
+ }
+ } else {
+ if ((state[type][c1].id === id) && (state[type][c1].type === 'base')) {
+ return state[type][c1];
+ }
}
}
@@ -218,10 +359,5 @@ define(['logme'], function (logme) {
return null;
}(0));
}
-});
-
-// 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)
+}); // End-of: define(['logme'], function (logme) {
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {
diff --git a/common/static/js/capa/jsme/gwt/chrome/chrome.css b/common/static/js/capa/jsme/gwt/chrome/chrome.css
index 9c7bcd627d..b8f084fe05 100644
--- a/common/static/js/capa/jsme/gwt/chrome/chrome.css
+++ b/common/static/js/capa/jsme/gwt/chrome/chrome.css
@@ -12,6 +12,8 @@
* }
*/
+/* Commented out the following that was messing up the CSS in sequential and had no use anyway when jsme is running inside edX platform.
+
body, table td, select {
font-family: Arial Unicode MS, Arial, sans-serif;
font-size: small;
@@ -31,6 +33,7 @@ body {
a, a:visited, a:hover {
color: #0000AA;
}
+*/
/**
* The reference theme can be used to determine when this style sheet has
diff --git a/common/static/js/capa/jsme/gwt/chrome/chrome_rtl.css b/common/static/js/capa/jsme/gwt/chrome/chrome_rtl.css
index 9d316660b1..602afd8c5d 100644
--- a/common/static/js/capa/jsme/gwt/chrome/chrome_rtl.css
+++ b/common/static/js/capa/jsme/gwt/chrome/chrome_rtl.css
@@ -12,6 +12,8 @@
* }
*/
+/* Commented out the following that was messing up the CSS in sequential and had no use anyway when jsme is running inside edX platform.
+
body, table td, select {
font-family: Arial Unicode MS, Arial, sans-serif;
font-size: small;
@@ -31,6 +33,7 @@ body {
a, a:visited, a:hover {
color: #0000AA;
}
+*/
/**
* The reference theme can be used to determine when this style sheet has
diff --git a/common/static/js/capa/jsme/gwt/chrome/mosaic.css b/common/static/js/capa/jsme/gwt/chrome/mosaic.css
index 9b6a242ea3..ccea6a69df 100644
--- a/common/static/js/capa/jsme/gwt/chrome/mosaic.css
+++ b/common/static/js/capa/jsme/gwt/chrome/mosaic.css
@@ -26,6 +26,8 @@
zoom: 1;
}
+/* Commented out the following that was messing up the CSS in sequential and had no use anyway when jsme is running inside edX platform.
+
body {
font-family: arial,sans-serif;
}
@@ -45,6 +47,7 @@ a:visited {
a:active {
color:#ff0000;
}
+*/
/*** Button ***/
diff --git a/common/static/js/capa/jsme/gwt/chrome/mosaic_rtl.css b/common/static/js/capa/jsme/gwt/chrome/mosaic_rtl.css
index dda9c913e9..9b910d66a1 100644
--- a/common/static/js/capa/jsme/gwt/chrome/mosaic_rtl.css
+++ b/common/static/js/capa/jsme/gwt/chrome/mosaic_rtl.css
@@ -26,6 +26,8 @@
zoom: 1;
}
+/* Commented out the following that was messing up the CSS in sequential and had no use anyway when jsme is running inside edX platform.
+
body {
font-family: arial,sans-serif;
}
@@ -45,6 +47,7 @@ a:visited {
a:active {
color:#ff0000;
}
+*/
/*** Button ***/
diff --git a/common/static/js/capa/jsmolcalc/gwt/clean/clean.css b/common/static/js/capa/jsmolcalc/gwt/clean/clean.css
index aa02d5385d..1800c0ee20 100644
--- a/common/static/js/capa/jsmolcalc/gwt/clean/clean.css
+++ b/common/static/js/capa/jsmolcalc/gwt/clean/clean.css
@@ -12,6 +12,8 @@
* }
*/
+/* Commented out the following that was messing up the CSS in sequential and had no use anyway when jsmolcalc is running inside edX platform.
+
body, table td, select, button {
font-family: Arial Unicode MS, Arial, sans-serif;
font-size: small;
@@ -41,6 +43,7 @@ a:hover {
select {
background: white;
}
+*/
/**
* The reference theme can be used to determine when this style sheet has
diff --git a/common/static/js/capa/jsmolcalc/gwt/clean/clean_rtl.css b/common/static/js/capa/jsmolcalc/gwt/clean/clean_rtl.css
index 7e2c695ccf..a80e7bd55f 100644
--- a/common/static/js/capa/jsmolcalc/gwt/clean/clean_rtl.css
+++ b/common/static/js/capa/jsmolcalc/gwt/clean/clean_rtl.css
@@ -12,6 +12,8 @@
* }
*/
+/* Commented out the following that was messing up the CSS in sequential and had no use anyway when jsmolcalc is running inside edX platform.
+
body, table td, select, button {
font-family: Arial Unicode MS, Arial, sans-serif;
font-size: small;
@@ -41,6 +43,7 @@ a:hover {
select {
background: white;
}
+*/
/**
* The reference theme can be used to determine when this style sheet has
diff --git a/common/static/js/capa/symbolic_mathjax_preprocessor.js b/common/static/js/capa/symbolic_mathjax_preprocessor.js
new file mode 100644
index 0000000000..766e5efc03
--- /dev/null
+++ b/common/static/js/capa/symbolic_mathjax_preprocessor.js
@@ -0,0 +1,35 @@
+/* This file defines a processor in between the student's math input
+ (AsciiMath) and what is read by MathJax. It allows for our own
+ customizations, such as use of the syntax "a_b__x" in superscripts, or
+ possibly coloring certain variables, etc&.
+
+ It is used in the definition like the following:
+
+
+
+
+*/
+window.SymbolicMathjaxPreprocessor = function () {
+ this.fn = function (eqn) {
+ // flags and config
+ var superscriptsOn = true;
+
+ if (superscriptsOn) {
+ // find instances of "__" and make them superscripts ("^") and tag them
+ // as such. Specifcally replace instances of "__X" or "__{XYZ}" with
+ // "^{CHAR$1}", marking superscripts as different from powers
+
+ // a zero width space--this is an invisible character that no one would
+ // use, that gets passed through MathJax and to the server
+ var c = "\u200b";
+ eqn = eqn.replace(/__(?:([^\{])|\{([^\}]+)\})/g, '^{' + c + '$1$2}');
+
+ // NOTE: MathJax supports '\class{name}{mathcode}' but not for asciimath
+ // input, which is too bad. This would be preferable to this char tag
+ }
+
+ return eqn;
+ };
+};
diff --git a/common/static/js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.css b/common/static/js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.css
new file mode 100644
index 0000000000..3d9c8844bb
--- /dev/null
+++ b/common/static/js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.css
@@ -0,0 +1,37 @@
+.jvectormap-label {
+ position: absolute;
+ display: none;
+ border: solid 1px #CDCDCD;
+ -webkit-border-radius: 3px;
+ -moz-border-radius: 3px;
+ border-radius: 3px;
+ background: #292929;
+ color: white;
+ font-family: sans-serif, Verdana;
+ font-size: smaller;
+ padding: 3px;
+}
+
+.jvectormap-zoomin, .jvectormap-zoomout {
+ position: absolute;
+ left: 10px;
+ -webkit-border-radius: 3px;
+ -moz-border-radius: 3px;
+ border-radius: 3px;
+ background: #292929;
+ padding: 3px;
+ color: white;
+ width: 10px;
+ height: 10px;
+ cursor: pointer;
+ line-height: 10px;
+ text-align: center;
+}
+
+.jvectormap-zoomin {
+ top: 10px;
+}
+
+.jvectormap-zoomout {
+ top: 30px;
+}
\ No newline at end of file
diff --git a/common/static/js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js b/common/static/js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js
new file mode 100644
index 0000000000..17450a0983
--- /dev/null
+++ b/common/static/js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js
@@ -0,0 +1,7 @@
+/**
+ * jVectorMap version 1.1
+ *
+ * Copyright 2011-2012, Kirill Lebedev
+ * Licensed under the MIT license.
+ *
+ */(function(e){var t={set:{colors:1,values:1,backgroundColor:1,scaleColors:1,normalizeFunction:1,focus:1},get:{selectedRegions:1,selectedMarkers:1,mapObject:1,regionName:1}};e.fn.vectorMap=function(e){var n,r,i,n=this.children(".jvectormap-container").data("mapObject");if(e==="addMap")jvm.WorldMap.maps[arguments[1]]=arguments[2];else{if(!(e!=="set"&&e!=="get"||!t[e][arguments[1]]))return r=arguments[1].charAt(0).toUpperCase()+arguments[1].substr(1),n[e+r].apply(n,Array.prototype.slice.call(arguments,2));e=e||{},e.container=this,n=new jvm.WorldMap(e)}return this}})(jQuery),function(e){function r(t){var n=t||window.event,r=[].slice.call(arguments,1),i=0,s=!0,o=0,u=0;return t=e.event.fix(n),t.type="mousewheel",n.wheelDelta&&(i=n.wheelDelta/120),n.detail&&(i=-n.detail/3),u=i,n.axis!==undefined&&n.axis===n.HORIZONTAL_AXIS&&(u=0,o=-1*i),n.wheelDeltaY!==undefined&&(u=n.wheelDeltaY/120),n.wheelDeltaX!==undefined&&(o=-1*n.wheelDeltaX/120),r.unshift(t,i,o,u),(e.event.dispatch||e.event.handle).apply(this,r)}var t=["DOMMouseScroll","mousewheel"];if(e.event.fixHooks)for(var n=t.length;n;)e.event.fixHooks[t[--n]]=e.event.mouseHooks;e.event.special.mousewheel={setup:function(){if(this.addEventListener)for(var e=t.length;e;)this.addEventListener(t[--e],r,!1);else this.onmousewheel=r},teardown:function(){if(this.removeEventListener)for(var e=t.length;e;)this.removeEventListener(t[--e],r,!1);else this.onmousewheel=null}},e.fn.extend({mousewheel:function(e){return e?this.bind("mousewheel",e):this.trigger("mousewheel")},unmousewheel:function(e){return this.unbind("mousewheel",e)}})}(jQuery);var jvm={inherits:function(e,t){function n(){}n.prototype=t.prototype,e.prototype=new n,e.prototype.constructor=e,e.parentClass=t},mixin:function(e,t){var n;for(n in t.prototype)t.prototype.hasOwnProperty(n)&&(e.prototype[n]=t.prototype[n])},min:function(e){var t=Number.MAX_VALUE,n;if(e instanceof Array)for(n=0;nt&&(t=e[n]);else for(n in e)e[n]>t&&(t=e[n]);return t},keys:function(e){var t=[],n;for(n in e)t.push(n);return t},values:function(e){var t=[],n,r;for(r=0;r')}}catch(e){jvm.VMLElement.prototype.createElement=function(e){return document.createElement("<"+e+' xmlns="urn:schemas-microsoft.com:vml" class="rvml">')}}document.createStyleSheet().addRule(".rvml","behavior:url(#default#VML)"),jvm.VMLElement.VMLInitialized=!0},jvm.VMLElement.prototype.getElementCtr=function(e){return jvm["VML"+e]},jvm.VMLElement.prototype.addClass=function(e){jvm.$(this.node).addClass(e)},jvm.VMLElement.prototype.applyAttr=function(e,t){this.node[e]=t},jvm.VMLElement.prototype.getBBox=function(){var e=jvm.$(this.node);return{x:e.position().left/this.canvas.scale,y:e.position().top/this.canvas.scale,width:e.width()/this.canvas.scale,height:e.height()/this.canvas.scale}},jvm.VMLGroupElement=function(){jvm.VMLGroupElement.parentClass.call(this,"group"),this.node.style.left="0px",this.node.style.top="0px",this.node.coordorigin="0 0"},jvm.inherits(jvm.VMLGroupElement,jvm.VMLElement),jvm.VMLGroupElement.prototype.add=function(e){this.node.appendChild(e.node)},jvm.VMLCanvasElement=function(e,t,n){this.classPrefix="VML",jvm.VMLCanvasElement.parentClass.call(this,"group"),jvm.AbstractCanvasElement.apply(this,arguments),this.node.style.position="absolute"},jvm.inherits(jvm.VMLCanvasElement,jvm.VMLElement),jvm.mixin(jvm.VMLCanvasElement,jvm.AbstractCanvasElement),jvm.VMLCanvasElement.prototype.setSize=function(e,t){var n,r,i,s;this.width=e,this.height=t,this.node.style.width=e+"px",this.node.style.height=t+"px",this.node.coordsize=e+" "+t,this.node.coordorigin="0 0";if(this.rootElement){n=this.rootElement.node.getElementsByTagName("shape");for(i=0,s=n.length;i=0)e-=t[i],i++;return i==this.scale.length-1?e=this.vectorToNum(this.scale[i]):e=this.vectorToNum(this.vectorAdd(this.scale[i],this.vectorMult(this.vectorSubtract(this.scale[i+1],this.scale[i]),e/t[i]))),e},vectorToNum:function(e){var t=0,n;for(n=0;nt&&(t=e[i]),r").css({width:"100%",height:"100%"}).addClass("jvectormap-container"),this.params.container.append(this.container),this.container.data("mapObject",this),this.container.css({position:"relative",overflow:"hidden"}),this.defaultWidth=this.mapData.width,this.defaultHeight=this.mapData.height,this.setBackgroundColor(this.params.backgroundColor),this.onResize=function(){t.setSize()},jvm.$(window).resize(this.onResize);for(n in jvm.WorldMap.apiEvents)this.params[n]&&this.container.bind(jvm.WorldMap.apiEvents[n]+".jvectormap",this.params[n]);this.canvas=new jvm.VectorCanvas(this.container[0],this.width,this.height),"ontouchstart"in window||window.DocumentTouch&&document instanceof DocumentTouch?this.params.bindTouchEvents&&this.bindContainerTouchEvents():this.bindContainerEvents(),this.bindElementEvents(),this.createLabel(),this.bindZoomButtons(),this.createRegions(),this.createMarkers(this.params.markers||{}),this.setSize(),this.params.focusOn&&(typeof this.params.focusOn=="object"?this.setFocus.call(this,this.params.focusOn.scale,this.params.focusOn.x,this.params.focusOn.y):this.setFocus.call(this,this.params.focusOn)),this.params.selectedRegions&&this.setSelectedRegions(this.params.selectedRegions),this.params.selectedMarkers&&this.setSelectedMarkers(this.params.selectedMarkers),this.params.series&&this.createSeries()},jvm.WorldMap.prototype={transX:0,transY:0,scale:1,baseTransX:0,baseTransY:0,baseScale:1,width:0,height:0,setBackgroundColor:function(e){this.container.css("background-color",e)},resize:function(){var e=this.baseScale;this.width/this.height>this.defaultWidth/this.defaultHeight?(this.baseScale=this.height/this.defaultHeight,this.baseTransX=Math.abs(this.width-this.defaultWidth*this.baseScale)/(2*this.baseScale)):(this.baseScale=this.width/this.defaultWidth,this.baseTransY=Math.abs(this.height-this.defaultHeight*this.baseScale)/(2*this.baseScale)),this.scale*=this.baseScale/e,this.transX*=this.baseScale/e,this.transY*=this.baseScale/e},setSize:function(){this.width=this.container.width(),this.height=this.container.height(),this.resize(),this.canvas.setSize(this.width,this.height),this.applyTransform()},reset:function(){var e,t;for(e in this.series)for(t=0;tt?this.transY=t:this.transYe?this.transX=e:this.transXt[1].pageX?o=t[1].pageX+(t[0].pageX-t[1].pageX)/2:o=t[0].pageX+(t[1].pageX-t[0].pageX)/2,t[0].pageY>t[1].pageY?u=t[1].pageY+(t[0].pageY-t[1].pageY)/2:u=t[0].pageY+(t[1].pageY-t[0].pageY)/2),i=e.originalEvent.touches[0].pageX,s=e.originalEvent.touches[0].pageY}),jvm.$(this.container).bind("touchmove",function(e){var t;if(r.scale!=r.baseScale)return e.originalEvent.touches.length==1&&i&&s?(t=e.originalEvent.touches[0],r.transX-=(i-t.pageX)/r.scale,r.transY-=(s-t.pageY)/r.scale,r.applyTransform(),r.label.hide(),i=t.pageX,s=t.pageY):(i=!1,s=!1),!1})},bindElementEvents:function(){var e=this,t;this.container.mousemove(function(){t=!0}),this.container.delegate("[class~='jvectormap-element']","mouseover mouseout",function(t){var n=this,r=jvm.$(this).attr("class").indexOf("jvectormap-region")===-1?"marker":"region",i=r=="region"?jvm.$(this).attr("data-code"):jvm.$(this).attr("data-index"),s=r=="region"?e.regions[i].element:e.markers[i].element,o=r=="region"?e.mapData.paths[i].name:e.markers[i].config.name||"",u=jvm.$.Event(r+"LabelShow.jvectormap"),a=jvm.$.Event(r+"Over.jvectormap");t.type=="mouseover"?(e.container.trigger(a,[i]),a.isDefaultPrevented()||s.setHovered(!0),e.label.text(o),e.container.trigger(u,[e.label,i]),u.isDefaultPrevented()||(e.label.show(),e.labelWidth=e.label.width(),e.labelHeight=e.label.height())):(s.setHovered(!1),e.label.hide(),e.container.trigger(r+"Out.jvectormap",[i]))}),this.container.delegate("[class~='jvectormap-element']","mousedown",function(e){t=!1}),this.container.delegate("[class~='jvectormap-element']","mouseup",function(n){var r=this,i=jvm.$(this).attr("class").indexOf("jvectormap-region")===-1?"marker":"region",s=i=="region"?jvm.$(this).attr("data-code"):jvm.$(this).attr("data-index"),o=jvm.$.Event(i+"Click.jvectormap"),u=i=="region"?e.regions[s].element:e.markers[s].element;if(!t){e.container.trigger(o,[s]);if(i==="region"&&e.params.regionsSelectable||i==="marker"&&e.params.markersSelectable)o.isDefaultPrevented()||(e.params[i+"sSelectableOne"]&&e.clearSelected(i+"s"),u.setSelected(!u.isSelected))}})},bindZoomButtons:function(){var e=this;jvm.$("").addClass("jvectormap-zoomin").text("+").appendTo(this.container),jvm.$("").addClass("jvectormap-zoomout").html("−").appendTo(this.container),this.container.find(".jvectormap-zoomin").click(function(){e.setScale(e.scale*e.params.zoomStep,e.width/2,e.height/2)}),this.container.find(".jvectormap-zoomout").click(function(){e.setScale(e.scale/e.params.zoomStep,e.width/2,e.height/2)})},createLabel:function(){var e=this;this.label=jvm.$("").addClass("jvectormap-label").appendTo(jvm.$("body")),this.container.mousemove(function(t){var n=t.pageX-15-e.labelWidth,r=t.pageY-15-e.labelHeight;n<5&&(n=t.pageX+15),r<5&&(r=t.pageY+15),e.label.is(":visible")&&e.label.css({left:n,top:r})})},setScale:function(e,t,n,r){var i,s=jvm.$.Event("zoom.jvectormap");e>this.params.zoomMax*this.baseScale?e=this.params.zoomMax*this.baseScale:ei[0].x&&ei[0].y&&t0?e.push(this):(t[r](1),o=t[r]()>0,o&&e.push(this),t[r](0))}}),e.length||this.each(function(){"BODY"===this.nodeName&&(e=[this])}),"first"===t.el&&e.length>1&&(e=[e[0]]),e};l.fn.extend({scrollable:function(l){var t=r.call(this,{dir:l});return this.pushStack(t)},firstScrollable:function(l){var t=r.call(this,{el:"first",dir:l});return this.pushStack(t)},smoothScroll:function(e){e=e||{};var o=l.extend({},l.fn.smoothScroll.defaults,e),r=l.smoothScroll.filterPath(location.pathname);return this.unbind("click.smoothscroll").bind("click.smoothscroll",function(e){var n=this,s=l(this),c=o.exclude,i=o.excludeWithin,a=0,f=0,h=!0,u={},d=location.hostname===n.hostname||!n.hostname,m=o.scrollTarget||(l.smoothScroll.filterPath(n.pathname)||r)===r,p=t(n.hash);if(o.scrollTarget||d&&m&&p){for(;h&&c.length>a;)s.is(t(c[a++]))&&(h=!1);for(;h&&i.length>f;)s.closest(i[f++]).length&&(h=!1)}else h=!1;h&&(e.preventDefault(),l.extend(u,o,{scrollTarget:o.scrollTarget||p,link:n}),l.smoothScroll(u))}),this}}),l.smoothScroll=function(t,e){var o,r,n,s,c=0,i="offset",a="scrollTop",f={},h={};"number"==typeof t?(o=l.fn.smoothScroll.defaults,n=t):(o=l.extend({},l.fn.smoothScroll.defaults,t||{}),o.scrollElement&&(i="position","static"==o.scrollElement.css("position")&&o.scrollElement.css("position","relative"))),o=l.extend({link:null},o),a="left"==o.direction?"scrollLeft":a,o.scrollElement?(r=o.scrollElement,c=r[a]()):r=l("html, body").firstScrollable(),o.beforeScroll.call(r,o),n="number"==typeof t?t:e||l(o.scrollTarget)[i]()&&l(o.scrollTarget)[i]()[o.direction]||0,f[a]=n+c+o.offset,s=o.speed,"auto"===s&&(s=f[a]||r.scrollTop(),s/=o.autoCoefficent),h={duration:s,easing:o.easing,complete:function(){o.afterScroll.call(o.link,o)}},o.step&&(h.step=o.step),r.length?r.stop().animate(f,h):o.afterScroll.call(o.link,o)},l.smoothScroll.version=e,l.smoothScroll.filterPath=function(l){return l.replace(/^\//,"").replace(/(index|default).[a-zA-Z]{3,4}$/,"").replace(/\/$/,"")},l.fn.smoothScroll.defaults=o})(jQuery);
\ No newline at end of file
diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss
index 76d52ed930..c1dd5b7f2d 100644
--- a/common/static/sass/_mixins.scss
+++ b/common/static/sass/_mixins.scss
@@ -1,9 +1,12 @@
+// studio - utilities - mixins and extends
+// ====================
+
// font-sizing
@function em($pxval, $base: 16) {
@return #{$pxval / $base}em;
}
-@mixin font-size($sizeValue: 1.6){
+@mixin font-size($sizeValue: 16){
font-size: $sizeValue + px;
font-size: ($sizeValue/10) + rem;
}
@@ -64,4 +67,106 @@
:-ms-input-placeholder {
color: $color;
}
+}
+
+// ====================
+
+// extends - visual
+.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;
+}
+
+// extends - ui
+.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;
+}
+
+.elem-d1 {
+ @include clearfix();
+ @include box-sizing(border-box);
+}
+
+.elem-d2 {
+ @include clearfix();
+ @include box-sizing(border-box);
}
\ No newline at end of file
diff --git a/common/templates/jasmine/base.html b/common/templates/jasmine/base.html
index 96507bdebf..9a1b3bed92 100644
--- a/common/templates/jasmine/base.html
+++ b/common/templates/jasmine/base.html
@@ -13,14 +13,19 @@
+ {% load compressed %}
+ {# static files #}
+ {% for url in suite.static_files %}
+
+ {% endfor %}
+
+ {% compressed_js 'js-test-source' %}
+
{# source files #}
{% for url in suite.js_files %}
{% endfor %}
- {% load compressed %}
- {# static files #}
- {% compressed_js 'js-test-source' %}
{# spec files #}
{% compressed_js 'spec' %}
diff --git a/common/test/data/conditional_and_poll/README b/common/test/data/conditional_and_poll/README
new file mode 100644
index 0000000000..fc95a7c0c9
--- /dev/null
+++ b/common/test/data/conditional_and_poll/README
@@ -0,0 +1,50 @@
+Any place that says "YEAR_SEMESTER" needs to be replaced with something
+in the form "2013_Spring". Take note of this name exactly, you'll need to
+use it everywhere, precisely - capitalization is very important.
+
+See https://github.com/MITx/mitx/blob/master/doc/xml-format.md for more on all this.
+-----------------------
+
+about/: Files that live here will be visible OUTSIDE OF COURSEWARE.
+ YEAR_SEMESTER/
+ end_date.html: Specifies in plain-text the end date of the course
+ overview.html: Text of the overview of the course
+ short_description.html: 10-15 words about the course
+ prerequisites.html: Any prerequisites for the course, or None if there are none.
+
+course/
+ YEAR_SEMESTER.xml: This is your top-level xml page that points at chapters.
+ Can just be for now.
+
+course.xml: This top level file points at a file in roots/. See creating_course.xml.
+
+creating_course.xml: Explains how to create course.xml
+
+info/: Files that live here will be visible on the COURSE LANDING PAGE
+ (Course Info) WITHIN THE COURSEWARE.
+ YEAR_SEMESTER/
+ handouts.html: A list of handouts, or an empty file if there are none
+ (if this file doesn't exist, it displays an error)
+ updates.html: Course updates.
+
+policies/
+ YEAR_SEMESTER/
+ policy.json: See https://github.com/MITx/mitx/blob/master/doc/xml-format.md
+ for more on the fields specified by this file.
+ grading_policy.json: Optional -- you don't need it to get a course off the
+ ground but will eventually. For more info see
+ https://github.com/MITx/mitx/blob/master/doc/course_grading.md
+
+roots/
+ YEAR_SEMESTER.xml: Looks something like
+
+ where ORG in {"MITx", "HarvardX", "BerkeleyX"}
+
+static/
+ See README.
+
+ images/
+ course_image.jpg: You MUST have an image named this to be the background
+ banner image on edx.org
+
+-----------------------
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/README.md b/common/test/data/conditional_and_poll/README.md
new file mode 100644
index 0000000000..7dbfa46a26
--- /dev/null
+++ b/common/test/data/conditional_and_poll/README.md
@@ -0,0 +1,2 @@
+content-harvard-justicex
+========================
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/overview.html b/common/test/data/conditional_and_poll/about/2013_Spring/overview.html
new file mode 100644
index 0000000000..9c49899948
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/overview.html
@@ -0,0 +1,79 @@
+
+
+
+
About ER22x
+
+
Justice is a critical analysis of classical and contemporary theories of justice, including discussion of present-day applications. Topics include affirmative action, income distribution, same-sex marriage, the role of markets, debates about rights (human rights and property rights), arguments for and against equality, dilemmas of loyalty in public and private life. The course invites students to subject their own views on these controversies to critical examination.
+
+
The principle readings for the course are texts by Aristotle, John Locke, Immanuel Kant, John Stuart Mill, and John Rawls. Other assigned readings include writings by contemporary philosophers, court cases, and articles about political controversies that raise philosophical questions.
+
+
+
+
+
+
+
+
Course instructor
+
+
+
+
+
Michael J. Sandel
+
Michael J. Sandel is the Anne T. and Robert M. Bass Professor of Government at Harvard University, where he teaches political philosophy. His course "Justice" has enrolled more than 15,000 Harvard students. Sandel's writings have been published in 21 languages. His books include What Money Can't Buy: The Moral Limits of Markets (2012); Justice: What's the Right Thing to Do? (2009); The Case against Perfection: Ethics in the Age of Genetic Engineering (2007); Public Philosophy: Essays on Morality in Politics (2005); Democracy's Discontent (1996); and Liberalism and the Limits of Justice(1982; 2nd ed., 1998).
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
How much does it cost to take the course?
+
Nothing! The course is free.
+
+
+
+
Does the course have any prerequisites?
+
No. Only an interest in thinking through some of the big ethical and civic questions we face in our everyday lives.
+
+
+
+
Do I need any other materials to take the course?
+
No. As long as you’ve got a computer to access the website, you are ready to take the course.
+
+
+
+
Is there a textbook for the course?
+
All of the course readings that are in the public domain are freely available online, at links provided on the course website. The course can be taken using these free resources alone. For those who wish to purchase a printed version of the assigned readings, an edited volume entitled, Justice: A Reader (ed., Michael Sandel) is available in paperback from Oxford University Press (in bookstores and from online booksellers). Those who would like supplementary readings on the themes of the lectures can find them in Michael Sandel's book Justice: What's the Right Thing to Do?, which is available in various languages throughout the world. This book is not required, and the course can be taken using the free online resources alone.
+
+
+
+
Do I need to watch the lectures at a specific time?
+
No. You can watch the lectures at your leisure.
+
+
+
+
Will I be able to participate in class discussions?
+
Yes, in several ways:
+
+
+
Each lecture invites you to respond to a poll question related to the themes of the lecture. If you respond to the question, you will be presented with a challenge to the opinion you have expressed, and invited to reply to the challenge. You can also, if you wish, comment on the opinions and responses posted by other students in the course, continuing the discussion.
+
+
In addition to the poll question, each class contains a discussion prompt that invites you to offer your view on a controversial question related to the lecture. If you wish, you can respond to this question, and then see what other students have to say about the argument you present. You can also comment on the opinions posted by other students. One aim of the course is to promote reasoned public dialogue about hard moral and political questions.
+
+
Each week, there will be an optional live dialogue enabling students to interact with instructors and participants from around the world.
+
+
+
+
+
Will certificates be awarded?
+
Yes. Online learners who achieve a passing grade in a course can earn a certificate of mastery. These certificates will indicate you have successfully completed the course, but will not include a specific grade. Certificates will be issued by edX under the name of HarvardX, designating the institution from which the course originated.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html b/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html
new file mode 100644
index 0000000000..b0047fa49f
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html
@@ -0,0 +1 @@
+None
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html b/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html
new file mode 100644
index 0000000000..208880c842
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html
@@ -0,0 +1 @@
+JusticeX is an introduction to moral and political philosophy, including discussion of contemporary dilemmas and controversies.
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/video.html b/common/test/data/conditional_and_poll/about/2013_Spring/video.html
new file mode 100644
index 0000000000..0cf427b16c
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/video.html
@@ -0,0 +1 @@
+
\ 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 @@
+
+
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.
[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):
-
-
-
-
-
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 @@
-
-
-
-
- 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
+
+
+##
Number of students who dropped off per day before becoming inactive:
+##
+## % if dropoff_per_day is not None:
+## % if dropoff_per_day['status'] == 'success':
+##
+##
+##
Day
Number of students
+## % for k,v in dropoff_per_day['data'].items():
+##
<%
@@ -226,7 +226,7 @@
%>
% if testcenter_exam_info is not None:
- % if registration is None and testcenter_exam_info.is_registering():
+ % if registration is None and testcenter_exam_info.is_registering():
% endif
- % if not registration.is_accepted and not registration.is_rejected:
+ % if not registration.is_accepted and not registration.is_rejected:
Your registration for the Pearson exam is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.