diff --git a/.pylintrc b/.pylintrc
index 6690bb7df0..9ea1e62ad4 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -41,7 +41,8 @@ disable=
# R0902: Too many instance attributes
# R0903: Too few public methods (1/2)
# R0904: Too many public methods
- W0141,W0142,R0201,R0901,R0902,R0903,R0904
+# R0913: Too many arguments
+ W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
[REPORTS]
@@ -137,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 8c8aed549d..589db4ac56 100644
--- a/cms/djangoapps/contentstore/course_info_model.py
+++ b/cms/djangoapps/contentstore/course_info_model.py
@@ -1,13 +1,15 @@
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
-from lxml import html, etree
+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 = etree.fromstring(course_updates.data)
- except etree.XMLSyntaxError:
- course_html_parsed = etree.fromstring("
subs and then rest of val
course_upd_collection = []
@@ -64,9 +68,11 @@ def update_course_updates(location, update, passed_id=None):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
- course_html_parsed = etree.fromstring(course_updates.data)
- except etree.XMLSyntaxError:
- course_html_parsed = etree.fromstring("")
+ course_html_parsed = html.fromstring(course_updates.data)
+ except:
+ 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,19 @@ def update_course_updates(location, update, passed_id=None):
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record
- course_updates.data = etree.tostring(course_html_parsed)
+ course_updates.data = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.data)
- return {"id" : passed_id,
- "date" : update['date'],
- "content" :update['content']}
+ 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": content}
+
def delete_course_update(location, update, passed_id):
"""
@@ -108,9 +121,11 @@ def delete_course_update(location, update, passed_id):
# 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 = etree.fromstring(course_updates.data)
- except etree.XMLSyntaxError:
- course_html_parsed = etree.fromstring("")
+ course_html_parsed = html.fromstring(course_updates.data)
+ except:
+ 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?
@@ -121,7 +136,7 @@ def delete_course_update(location, update, passed_id):
course_html_parsed.remove(element_to_delete)
# update db record
- course_updates.data = etree.tostring(course_html_parsed)
+ course_updates.data = html.tostring(course_html_parsed)
store = modulestore('direct')
store.update_item(location, course_updates.data)
@@ -132,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 af97709ad0..db7294c14c 100644
--- a/cms/djangoapps/contentstore/features/advanced-settings.feature
+++ b/cms/djangoapps/contentstore/features/advanced-settings.feature
@@ -1,6 +1,6 @@
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 default advanced settings
Given I have opened a new course in Studio
diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py
index 7e86e94a31..16562b6b15 100644
--- a/cms/djangoapps/contentstore/features/advanced-settings.py
+++ b/cms/djangoapps/contentstore/features/advanced-settings.py
@@ -1,9 +1,10 @@
+#pylint: disable=C0111
+#pylint: disable=W0621
+
from lettuce import world, step
from common import *
import time
from terrain.steps import reload_the_page
-from selenium.common.exceptions import WebDriverException
-from selenium.webdriver.support import expected_conditions as EC
from nose.tools import assert_true, assert_false, assert_equal
@@ -18,13 +19,14 @@ 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.css_click(link_css)
@step('I am on the Advanced Course Settings page in Studio$')
@@ -35,24 +37,8 @@ def i_am_on_advanced_course_settings(step):
@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)
- time.sleep(float(1))
- css_click_at(css)
-
-# is_invisible is not returning a boolean, not working
-# try:
-# css_click_at(css)
-# wait_for(is_invisible)
-# except WebDriverException, e:
-# css_click_at(css)
-# wait_for(is_invisible)
+ world.css_click_at(css)
@step(u'I edit the value of a policy key$')
@@ -61,7 +47,7 @@ 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 :)
"""
- e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
+ e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
@@ -85,7 +71,7 @@ def i_see_default_advanced_settings(step):
@step('the settings are alphabetized$')
def they_are_alphabetized(step):
- key_elements = css_find(KEY_CSS)
+ key_elements = world.css_find(KEY_CSS)
all_keys = []
for key in key_elements:
all_keys.append(key.value)
@@ -118,13 +104,13 @@ def assert_policy_entries(expected_keys, 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], css_find(VALUE_CSS)[index].value, "value is incorrect")
+ assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect")
def get_index_of(expected_key):
- for counter in range(len(css_find(KEY_CSS))):
+ for counter in range(len(world.css_find(KEY_CSS))):
# Sometimes get stale reference if I hold on to the array of elements
- key = css_find(KEY_CSS)[counter].value
+ key = world.css_find(KEY_CSS)[counter].value
if key == expected_key:
return counter
@@ -133,14 +119,14 @@ def get_index_of(expected_key):
def get_display_name_value():
index = get_index_of(DISPLAY_NAME_KEY)
- return css_find(VALUE_CSS)[index].value
+ return world.css_find(VALUE_CSS)[index].value
def change_display_name_value(step, new_value):
- e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
+ 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")
\ No newline at end of file
+ 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/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 d04e1a6332..ce5bf36559 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -37,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):
@@ -101,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'])
@@ -131,8 +153,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# make sure the parent no longer points to the child object which was deleted
self.assertFalse(sequential.location.url() in chapter.children)
-
-
def test_about_overrides(self):
'''
This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html
@@ -193,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'])
@@ -293,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()
@@ -514,7 +560,7 @@ class ContentStoreTest(ModuleStoreTestCase):
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
@@ -529,7 +575,7 @@ class ContentStoreTest(ModuleStoreTestCase):
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.assertEqual(timedelta(1), new_module.lms.graceperiod)
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index ecdeca29e7..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
@@ -15,33 +13,13 @@ from models.settings.course_details import (CourseDetails,
from models.settings.course_grading import CourseGradingModel
from contentstore.utils import get_modulestore
-from django.test import TestCase
from .utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
-
-
-# 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)
@@ -269,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):
diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py
index 6a3a1e21f7..80d4f0bbc2 100644
--- a/cms/djangoapps/contentstore/tests/test_course_updates.py
+++ b/cms/djangoapps/contentstore/tests/test_course_updates.py
@@ -1,31 +1,145 @@
+'''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.
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
index 391cac8eca..cad3110574 100644
--- a/cms/xmodule_namespace.py
+++ b/cms/xmodule_namespace.py
@@ -1,14 +1,27 @@
+"""
+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
@@ -24,6 +37,9 @@ class DateTuple(ModelType):
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)
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/request_cache/__init__.py b/common/djangoapps/request_cache/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
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/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/student/tests/__init__.py b/common/djangoapps/student/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
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 902ec82677..8267816e2c 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -311,7 +311,7 @@ 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'):
@@ -325,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":
@@ -369,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.'}))
@@ -392,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)
@@ -400,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 " + \
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 a531f4fd26..768c51b25e 100644
--- a/common/djangoapps/terrain/factories.py
+++ b/common/djangoapps/terrain/factories.py
@@ -1,163 +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
-from xmodule.modulestore.inheritance import own_metadata
+'''
+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.display_name = display_name
-
- new_course.lms.start = 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(), own_metadata(new_course))
-
- 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)
-
- # replace the display name with an optional parameter passed in from the caller
- if display_name is not None:
- new_item.display_name = display_name
-
- store.update_metadata(new_item.location.url(), own_metadata(new_item))
-
- if new_item.location.category not in DETACHED_CATEGORIES:
- store.update_children(parent_location, parent.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 3dcef9b1ed..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/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py
index 42753fc90b..68f80006f6 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,8 +31,6 @@ 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
@@ -70,9 +67,6 @@ 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
@@ -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
@@ -110,21 +109,23 @@ class LoncapaProblem(object):
self.system = system
if self.system is None:
raise Exception()
- self.seed = seed
- 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 {}
+
+ # 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))
+ 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', {})
+
- # TODO: Does this deplete the Linux entropy pool? Is this fast enough?
- if not self.seed:
- self.seed = struct.unpack('i', os.urandom(4))[0]
# Convert startouttext and endouttext to proper
problem_text = re.sub("startouttext\s*/", "text", problem_text)
@@ -188,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):
@@ -237,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
@@ -351,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 {}
@@ -527,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/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index c2babfa479..2febfbd5d2 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -37,18 +37,18 @@ 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 capa.chem import chemcalc
+import xqueue_interface
+from datetime import datetime
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,16 +1010,16 @@ 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']
@@ -865,7 +1032,7 @@ class DragAndDropInput(InputTypeBase):
# 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()
@@ -874,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',
@@ -896,7 +1063,7 @@ class DragAndDropInput(InputTypeBase):
registry.register(DragAndDropInput)
-#--------------------------------------------------------------------------------------------------------------------
+#-------------------------------------------------------------------------
class EditAMoleculeInput(InputTypeBase):
@@ -934,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.
@@ -969,6 +1137,7 @@ registry.register(DesignProtein2dInput)
#-----------------------------------------------------------------------------
+
class EditAGeneInput(InputTypeBase):
"""
An input type for editing a gene. Integrates with the genex java applet.
@@ -1005,6 +1174,7 @@ registry.register(EditAGeneInput)
#---------------------------------------------------------------------
+
class AnnotationInput(InputTypeBase):
"""
Input type for annotations: students can enter some notes or other text
@@ -1037,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()
@@ -1061,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. '''
@@ -1071,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. '''
@@ -1089,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))
@@ -1110,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 6bf98999d8..2035c42661 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -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
@@ -241,14 +247,17 @@ class LoncapaResponse(object):
# callback procedure to a social hint generation system.
if not hintfn in self.context:
msg = 'missing specified hint function %s in script context' % hintfn
- msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '')
+ msg += "\nSee XML source line %s" % getattr(
+ self.xml, 'sourceline', '')
raise LoncapaProblemError(msg)
try:
- self.context[hintfn](self.answer_ids, student_answers, new_cmap, old_cmap)
+ self.context[hintfn](
+ self.answer_ids, student_answers, new_cmap, old_cmap)
except Exception as err:
msg = 'Error %s in evaluating hint function %s' % (err, hintfn)
- msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '')
+ msg += "\nSee XML source line %s" % getattr(
+ self.xml, 'sourceline', '')
raise ResponseError(msg)
return
@@ -270,17 +279,19 @@ class LoncapaResponse(object):
if (self.hint_tag is not None
and hintgroup.find(self.hint_tag) is not None
- and hasattr(self, 'check_hint_condition')):
+ and hasattr(self, 'check_hint_condition')):
rephints = hintgroup.findall(self.hint_tag)
- hints_to_show = self.check_hint_condition(rephints, student_answers)
+ hints_to_show = self.check_hint_condition(
+ rephints, student_answers)
# can be 'on_request' or 'always' (default)
hintmode = hintgroup.get('mode', 'always')
for hintpart in hintgroup.findall('hintpart'):
if hintpart.get('on') in hints_to_show:
hint_text = hintpart.find('text').text
- # make the hint appear after the last answer box in this response
+ # make the hint appear after the last answer box in this
+ # response
aid = self.answer_ids[-1]
new_cmap.set_hint_and_mode(aid, hint_text, hintmode)
log.debug('after hint: new_cmap = %s' % new_cmap)
@@ -340,7 +351,6 @@ class LoncapaResponse(object):
response_msg_div = etree.Element('div')
response_msg_div.text = str(response_msg)
-
# Set the css class of the message
response_msg_div.set("class", "response_message")
@@ -384,20 +394,20 @@ class JavascriptResponse(LoncapaResponse):
# until we decide on exactly how to solve this issue. For now, files are
# manually being compiled to DATA_DIR/js/compiled.
- #latestTimestamp = 0
- #basepath = self.system.filestore.root_path + '/js/'
- #for filename in (self.display_dependencies + [self.display]):
+ # latestTimestamp = 0
+ # basepath = self.system.filestore.root_path + '/js/'
+ # for filename in (self.display_dependencies + [self.display]):
# filepath = basepath + filename
# timestamp = os.stat(filepath).st_mtime
# if timestamp > latestTimestamp:
# latestTimestamp = timestamp
#
- #h = hashlib.md5()
- #h.update(self.answer_id + str(self.display_dependencies))
- #compiled_filename = 'compiled/' + h.hexdigest() + '.js'
- #compiled_filepath = basepath + compiled_filename
+ # h = hashlib.md5()
+ # h.update(self.answer_id + str(self.display_dependencies))
+ # compiled_filename = 'compiled/' + h.hexdigest() + '.js'
+ # compiled_filepath = basepath + compiled_filename
- #if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp:
+ # if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp:
# outfile = open(compiled_filepath, 'w')
# for filename in (self.display_dependencies + [self.display]):
# filepath = basepath + filename
@@ -419,7 +429,7 @@ class JavascriptResponse(LoncapaResponse):
id=self.xml.get('id'))[0]
self.display_xml = self.xml.xpath('//*[@id=$id]//display',
- id=self.xml.get('id'))[0]
+ id=self.xml.get('id'))[0]
self.xml.remove(self.generator_xml)
self.xml.remove(self.grader_xml)
@@ -430,17 +440,20 @@ class JavascriptResponse(LoncapaResponse):
self.display = self.display_xml.get("src")
if self.generator_xml.get("dependencies"):
- self.generator_dependencies = self.generator_xml.get("dependencies").split()
+ self.generator_dependencies = self.generator_xml.get(
+ "dependencies").split()
else:
self.generator_dependencies = []
if self.grader_xml.get("dependencies"):
- self.grader_dependencies = self.grader_xml.get("dependencies").split()
+ self.grader_dependencies = self.grader_xml.get(
+ "dependencies").split()
else:
self.grader_dependencies = []
if self.display_xml.get("dependencies"):
- self.display_dependencies = self.display_xml.get("dependencies").split()
+ self.display_dependencies = self.display_xml.get(
+ "dependencies").split()
else:
self.display_dependencies = []
@@ -461,10 +474,10 @@ class JavascriptResponse(LoncapaResponse):
return subprocess.check_output(subprocess_args, env=self.get_node_env())
-
def generate_problem_state(self):
- generator_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_generator.js'
+ generator_file = os.path.dirname(os.path.normpath(
+ __file__)) + '/javascript_problem_generator.js'
output = self.call_node([generator_file,
self.generator,
json.dumps(self.generator_dependencies),
@@ -478,17 +491,18 @@ class JavascriptResponse(LoncapaResponse):
params = {}
for param in self.xml.xpath('//*[@id=$id]//responseparam',
- id=self.xml.get('id')):
+ id=self.xml.get('id')):
raw_param = param.get("value")
- params[param.get("name")] = json.loads(contextualize_text(raw_param, self.context))
+ params[param.get("name")] = json.loads(
+ contextualize_text(raw_param, self.context))
return params
def prepare_inputfield(self):
for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput',
- id=self.xml.get('id')):
+ id=self.xml.get('id')):
escapedict = {'"': '"'}
@@ -501,7 +515,7 @@ class JavascriptResponse(LoncapaResponse):
escapedict)
inputfield.set("problem_state", encoded_problem_state)
- inputfield.set("display_file", self.display_filename)
+ inputfield.set("display_file", self.display_filename)
inputfield.set("display_class", self.display_class)
def get_score(self, student_answers):
@@ -519,7 +533,8 @@ class JavascriptResponse(LoncapaResponse):
if submission is None or submission == '':
submission = json.dumps(None)
- grader_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_grader.js'
+ grader_file = os.path.dirname(os.path.normpath(
+ __file__)) + '/javascript_problem_grader.js'
outputs = self.call_node([grader_file,
self.grader,
json.dumps(self.grader_dependencies),
@@ -528,8 +543,8 @@ class JavascriptResponse(LoncapaResponse):
json.dumps(self.params)]).split('\n')
all_correct = json.loads(outputs[0].strip())
- evaluation = outputs[1].strip()
- solution = outputs[2].strip()
+ evaluation = outputs[1].strip()
+ solution = outputs[2].strip()
return (all_correct, evaluation, solution)
def get_answers(self):
@@ -539,9 +554,7 @@ class JavascriptResponse(LoncapaResponse):
return {self.answer_id: self.solution}
-
#-----------------------------------------------------------------------------
-
class ChoiceResponse(LoncapaResponse):
"""
This response type is used when the student chooses from a discrete set of
@@ -599,9 +612,10 @@ class ChoiceResponse(LoncapaResponse):
self.assign_choice_names()
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
- id=self.xml.get('id'))
+ id=self.xml.get('id'))
- self.correct_choices = set([choice.get('name') for choice in correct_xml])
+ self.correct_choices = set([choice.get(
+ 'name') for choice in correct_xml])
def assign_choice_names(self):
'''
@@ -654,7 +668,8 @@ class MultipleChoiceResponse(LoncapaResponse):
allowed_inputfields = ['choicegroup']
def setup_response(self):
- # call secondary setup for MultipleChoice questions, to set name attributes
+ # call secondary setup for MultipleChoice questions, to set name
+ # attributes
self.mc_setup_response()
# define correct choices (after calling secondary setup)
@@ -692,7 +707,7 @@ class MultipleChoiceResponse(LoncapaResponse):
# log.debug('%s: student_answers=%s, correct_choices=%s' % (
# unicode(self), student_answers, self.correct_choices))
if (self.answer_id in student_answers
- and student_answers[self.answer_id] in self.correct_choices):
+ and student_answers[self.answer_id] in self.correct_choices):
return CorrectMap(self.answer_id, 'correct')
else:
return CorrectMap(self.answer_id, 'incorrect')
@@ -760,7 +775,8 @@ class OptionResponse(LoncapaResponse):
return cmap
def get_answers(self):
- amap = dict([(af.get('id'), contextualize_text(af.get('correct'), self.context)) for af in self.answer_fields])
+ amap = dict([(af.get('id'), contextualize_text(af.get(
+ 'correct'), self.context)) for af in self.answer_fields])
# log.debug('%s: expected answers=%s' % (unicode(self),amap))
return amap
@@ -780,8 +796,9 @@ class NumericalResponse(LoncapaResponse):
context = self.context
self.correct_answer = contextualize_text(xml.get('answer'), context)
try:
- self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default',
- id=xml.get('id'))[0]
+ self.tolerance_xml = xml.xpath(
+ '//*[@id=$id]//responseparam[@type="tolerance"]/@default',
+ id=xml.get('id'))[0]
self.tolerance = contextualize_text(self.tolerance_xml, context)
except Exception:
self.tolerance = '0'
@@ -798,17 +815,21 @@ class NumericalResponse(LoncapaResponse):
try:
correct_ans = complex(self.correct_answer)
except ValueError:
- log.debug("Content error--answer '{0}' is not a valid complex number".format(self.correct_answer))
- raise StudentInputError("There was a problem with the staff answer to this problem")
+ log.debug("Content error--answer '{0}' is not a valid complex number".format(
+ self.correct_answer))
+ raise StudentInputError(
+ "There was a problem with the staff answer to this problem")
try:
- correct = compare_with_tolerance(evaluator(dict(), dict(), student_answer),
- correct_ans, self.tolerance)
+ correct = compare_with_tolerance(
+ evaluator(dict(), dict(), student_answer),
+ correct_ans, self.tolerance)
# We should catch this explicitly.
# I think this is just pyparsing.ParseException, calc.UndefinedVariable:
# But we'd need to confirm
except:
- # Use the traceback-preserving version of re-raising with a different type
+ # Use the traceback-preserving version of re-raising with a
+ # different type
import sys
type, value, traceback = sys.exc_info()
@@ -837,7 +858,8 @@ class StringResponse(LoncapaResponse):
max_inputfields = 1
def setup_response(self):
- self.correct_answer = contextualize_text(self.xml.get('answer'), self.context).strip()
+ self.correct_answer = contextualize_text(
+ self.xml.get('answer'), self.context).strip()
def get_score(self, student_answers):
'''Grade a string response '''
@@ -846,7 +868,8 @@ class StringResponse(LoncapaResponse):
return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect')
def check_string(self, expected, given):
- if self.xml.get('type') == 'ci': return given.lower() == expected.lower()
+ if self.xml.get('type') == 'ci':
+ return given.lower() == expected.lower()
return given == expected
def check_hint_condition(self, hxml_set, student_answers):
@@ -854,8 +877,10 @@ class StringResponse(LoncapaResponse):
hints_to_show = []
for hxml in hxml_set:
name = hxml.get('name')
- correct_answer = contextualize_text(hxml.get('answer'), self.context).strip()
- if self.check_string(correct_answer, given): hints_to_show.append(name)
+ correct_answer = contextualize_text(
+ hxml.get('answer'), self.context).strip()
+ if self.check_string(correct_answer, given):
+ hints_to_show.append(name)
log.debug('hints_to_show = %s' % hints_to_show)
return hints_to_show
@@ -889,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_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py
index 360fd9f2f6..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):
'''
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/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index e66b1d3495..da8b5b4f96 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -93,6 +93,7 @@ class CapaFields(object):
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, default={})
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)
@@ -188,6 +189,7 @@ class CapaModule(CapaFields, XModule):
'done': self.done,
'correct_map': self.correct_map,
'student_answers': self.student_answers,
+ 'input_state': self.input_state,
'seed': self.seed,
}
@@ -195,6 +197,7 @@ class CapaModule(CapaFields, XModule):
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']
@@ -443,7 +446,8 @@ class CapaModule(CapaFields, 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:
@@ -537,6 +541,43 @@ class CapaModule(CapaFields, XModule):
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.
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index f05f419a03..48fbfcced1 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -8,41 +8,66 @@ 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")
-
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
- "skip_spelling_checks", "due", "graceperiod", "max_score"]
+ "skip_spelling_checks", "due", "graceperiod", "max_score"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
- "student_attempts", "ready_to_reset"]
+ "student_attempts", "ready_to_reset"]
V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES
-VERSION_TUPLES = (
- ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, 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 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)
+ 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)
+ 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)
+ 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 = Integer(help="Current version number", default=DEFAULT_VERSION, 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)
@@ -130,23 +155,10 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
if self.task_states is None:
self.task_states = []
- versions = [i[0] for i in VERSION_TUPLES]
- descriptors = [i[1] for i in VERSION_TUPLES]
- modules = [i[2] for i in VERSION_TUPLES]
- settings_attributes = [i[3] for i in VERSION_TUPLES]
- student_attributes = [i[4] for i in VERSION_TUPLES]
- version_error_string = "Could not find version {0}, using version {1} instead"
+ version_tuple = VERSION_TUPLES[self.version]
- 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)
-
- self.student_attributes = student_attributes[version_index]
- self.settings_attributes = settings_attributes[version_index]
+ self.student_attributes = version_tuple.student_attributes
+ self.settings_attributes = version_tuple.settings_attributes
attributes = self.student_attributes + self.settings_attributes
@@ -154,10 +166,11 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
'rewrite_content_links': self.rewrite_content_links,
}
instance_state = {k: getattr(self, k) for k in attributes}
- self.child_descriptor = descriptors[version_index](self.system)
- self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(self.data), self.system)
- self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
- instance_state=instance_state, static_data=static_data, attributes=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):
diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py
index a9375cae78..b3e0e0e06b 100644
--- a/common/lib/xmodule/xmodule/conditional_module.py
+++ b/common/lib/xmodule/xmodule/conditional_module.py
@@ -40,8 +40,21 @@ class ConditionalModule(ConditionalFields, XModule):
poll_answer - map to `poll_answer` module attribute
voted - map to `voted` module attribute
- tag attributes:
- sources - location id of modules, separated by ';'
+ 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'),
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 7c47e0887a..6f3b8e94c9 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -1,25 +1,22 @@
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
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, ModelType, List, String, Object, Boolean
+from xblock.core import Scope, List, String, Object, Boolean
from .fields import Date
@@ -29,30 +26,30 @@ log = logging.getLogger(__name__)
class StringOrDate(Date):
def from_json(self, value):
"""
- Parse an optional metadata key containing a time: if present, complain
- if it doesn't parse.
- Return None if not present or invalid.
+ Parse an optional metadata key containing a time or a string:
+ if present, assume it's a string if it doesn't parse.
"""
- if value is None:
- return None
-
try:
- return time.strptime(value, self.time_format)
+ 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 to a string
+ Convert a time struct or string to a string.
"""
- if value is None:
- return None
-
try:
- return time.strftime(self.time_format, value)
- except (ValueError, TypeError):
+ 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,
@@ -60,6 +57,7 @@ edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
_cached_toc = {}
+
class Textbook(object):
def __init__(self, title, book_url):
self.title = title
@@ -154,7 +152,7 @@ class CourseFields(object):
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 = StringOrDate(help="Date that this course is advertised to start", 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)
@@ -179,7 +177,7 @@ class CourseFields(object):
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
@@ -367,7 +365,7 @@ class CourseDescriptor(CourseFields, 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:
@@ -541,10 +539,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
announcement = self.announcement
if announcement is not None:
announcement = to_datetime(announcement)
- if self.advertised_start is None or isinstance(self.advertised_start, basestring):
+
+ try:
+ start = dateutil.parser.parse(self.advertised_start)
+ except (ValueError, AttributeError):
start = to_datetime(self.start)
- else:
- start = to_datetime(self.advertised_start)
+
now = to_datetime(time.gmtime())
return announcement, start, now
@@ -635,8 +635,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
@property
def start_date_text(self):
+ 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()
+
+ return result
+
if isinstance(self.advertised_start, basestring):
- return self.advertised_start
+ return try_parse_iso_8601(self.advertised_start)
elif self.advertised_start is None and self.start is None:
return 'TBD'
else:
@@ -675,7 +684,7 @@ class CourseDescriptor(CourseFields, 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)
diff --git a/common/lib/xmodule/xmodule/css/poll/display.scss b/common/lib/xmodule/xmodule/css/poll/display.scss
index cfc03bcf91..82c018a3a0 100644
--- a/common/lib/xmodule/xmodule/css/poll/display.scss
+++ b/common/lib/xmodule/xmodule/css/poll/display.scss
@@ -131,6 +131,7 @@ section.poll_question {
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 {
diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py
index fb80752e56..ea857933fc 100644
--- a/common/lib/xmodule/xmodule/fields.py
+++ b/common/lib/xmodule/xmodule/fields.py
@@ -4,27 +4,36 @@ import re
from datetime import timedelta
from xblock.core import ModelType
+import datetime
+import dateutil.parser
log = logging.getLogger(__name__)
class Date(ModelType):
- time_format = "%Y-%m-%dT%H:%M"
-
- def from_json(self, value):
+ '''
+ 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 value is None:
+ if field is None:
+ return field
+ elif field is "":
return None
-
- try:
- return time.strptime(value, self.time_format)
- except ValueError as e:
- msg = "Field {0} has bad value '{1}': '{2}'".format(
- self._name, value, e)
+ 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
@@ -34,8 +43,11 @@ class Date(ModelType):
"""
if value is None:
return None
-
- return time.strftime(self.time_format, value)
+ 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)?)?$')
@@ -66,4 +78,4 @@ class Timedelta(ModelType):
cur_value = getattr(value, attr, 0)
if cur_value > 0:
values.append("%d %s" % (cur_value, attr))
- return ' '.join(values)
\ No newline at end of file
+ return ' '.join(values)
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/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index b0b8b11aef..907b4bdb16 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -8,7 +8,8 @@ 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 uuid import uuid4
from importlib import import_module
@@ -101,6 +102,7 @@ class MongoKeyValueStore(KeyValueStore):
else:
return False
+
MongoUsage = namedtuple('MongoUsage', 'id, def_id')
@@ -112,7 +114,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
@@ -137,9 +139,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:
@@ -170,8 +176,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location))
module = class_(self, location, model_data)
- if self.metadata_inheritance_tree is not None:
- metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(), {})
+ 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:
@@ -201,16 +207,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
@@ -220,7 +229,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)
@@ -233,7 +243,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
@@ -251,8 +260,11 @@ class MongoModuleStore(ModuleStoreBase):
self.fs_root = path(fs_root)
self.error_tracker = error_tracker
self.render_template = render_template
+ 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
'''
@@ -262,10 +274,15 @@ class MongoModuleStore(ModuleStoreBase):
query = {
'_id.org': location.org,
'_id.course': location.course,
- '_id.category': {'$in': [ 'course', 'chapter', 'sequential', '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)
@@ -282,7 +299,11 @@ class MongoModuleStore(ModuleStoreBase):
# now traverse the tree and compute down the inherited metadata
metadata_to_inherit = {}
+
def _compute_inherited_metadata(url):
+ """
+ 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
@@ -297,7 +318,7 @@ class MongoModuleStore(ModuleStoreBase):
# 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].get('metadata', {}))
@@ -311,33 +332,54 @@ class MongoModuleStore(ModuleStoreBase):
if root is not None:
_compute_inherited_metadata(root)
- return {'parent_metadata': metadata_to_inherit,
- 'timestamp' : datetime.now()}
+ return metadata_to_inherit
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
'''
- key_name = '{0}/{1}'.format(location.org, location.course)
+ 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]
- tree = None
- if self.metadata_inheritance_cache is not None:
- tree = self.metadata_inheritance_cache.get(key_name)
- else:
- # This is to help guard against an accident prod runtime without a cache
- logging.warning('Running MongoModuleStore without metadata_inheritance_cache. This should not happen in production!')
+ # 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.')
- if tree is None or force_refresh:
- tree = self.get_metadata_inheritance_tree(location)
- if self.metadata_inheritance_cache is not None:
- self.metadata_inheritance_cache.set(key_name, tree)
+ 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 clear_cached_metadata_inheritance_tree(self, location):
- key_name = '{0}/{1}'.format(location.org, location.course)
- if self.metadata_inheritance_cache is not None:
- self.metadata_inheritance_cache.delete(key_name)
+ 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):
"""
@@ -364,6 +406,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
@@ -380,7 +425,7 @@ 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
"""
@@ -392,12 +437,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']))
+ 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
@@ -408,7 +450,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'])
@@ -419,7 +461,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):
'''
@@ -508,7 +553,12 @@ class MongoModuleStore(ModuleStoreBase):
source_item['metadata'][key] = uuid4().hex
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
@@ -530,7 +580,7 @@ class MongoModuleStore(ModuleStoreBase):
raise DuplicateItemError(location)
# recompute (and update) the metadata inheritance tree which is cached
- self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
+ self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def fire_updated_modulestore_signal(self, course_id, location):
@@ -563,7 +613,8 @@ class MongoModuleStore(ModuleStoreBase):
raise Exception('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
- raise Exception('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]
@@ -580,6 +631,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)
@@ -606,7 +660,7 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'definition.children': children})
# recompute (and update) the metadata inheritance tree which is cached
- self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
+ self.refresh_cached_metadata_inheritance_tree(Location(location))
# fire signal that we've written to DB
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
@@ -634,9 +688,10 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'metadata': metadata})
# recompute (and update) the metadata inheritance tree which is cached
- self.get_cached_metadata_inheritance_tree(loc, force_refresh = True)
+ self.refresh_cached_metadata_inheritance_tree(loc)
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
+
def delete_item(self, location):
"""
Delete an item from this modulestore
@@ -653,9 +708,12 @@ class MongoModuleStore(ModuleStoreBase):
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
self.update_metadata(course.location, own_metadata(course))
- self.collection.remove({'_id': Location(location).dict()})
+ 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.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
+ self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def get_parent_locations(self, location, course_id):
@@ -677,4 +735,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/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index b842ffe9dd..1a82e1b708 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -25,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')
@@ -43,8 +42,7 @@ class XModuleCourseFactory(Factory):
if display_name is not None:
new_course.display_name = display_name
- new_course.start = gmtime()
-
+ new_course.lms.start = gmtime()
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
@@ -81,21 +79,41 @@ 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)
@@ -103,7 +121,14 @@ class XModuleItemFactory(Factory):
if display_name is not None:
new_item.display_name = display_name
- store.update_metadata(new_item.location.url(), own_metadata(new_item))
+ # 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.children + [new_item.location.url()])
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_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
index fa232596f2..6a4ce5131b 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
@@ -4,6 +4,8 @@ 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
@@ -201,100 +203,127 @@ 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.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
-
-
- 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)))
-
- # 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 hasattr(module, 'data'):
- module_data = module.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 hasattr(module, 'children') and module.children != []:
- store.update_children(module.location, module.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(own_metadata(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)
+
+ 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
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 98a54601de..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
@@ -24,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.
@@ -363,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):
@@ -782,7 +790,7 @@ class CombinedOpenEndedV1Descriptor():
template_dir_name = "combinedopenended"
def __init__(self, system):
- self.system =system
+ self.system = system
@classmethod
def definition_from_xml(cls, xml_object, system):
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 1f84d2ab8c..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
@@ -174,7 +174,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
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
)
@@ -224,7 +224,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
anonymous_student_id +
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)
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 2e49565bec..b9341f0cbe 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
@@ -357,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/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/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index 09c86baf27..55c31ded58 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -183,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)
diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py
index 59099b0dff..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
@@ -89,25 +90,41 @@ class IsNewCourseTestCase(unittest.TestCase):
((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 2013"), (day1, None, "Fall 2012"), self.assertLess),
- ((day1, None, "Spring 2013"), (day1, None, "Fall 2012"), 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 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_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):
@@ -125,7 +142,7 @@ class IsNewCourseTestCase(unittest.TestCase):
descriptor = self.get_dummy_course(start='2013-01-15T12:00')
assert(descriptor.is_newish is True)
- descriptor = self.get_dummy_course(start='2013-03-00T12:00')
+ 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')
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/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee
index a5a1deac10..56525af347 100644
--- a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee
+++ b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee
@@ -128,7 +128,9 @@ if Backbone?
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
- @model.set('pinned', true)
+ @model.set('pinned', true)
+ error: =>
+ $('.admin-pin').text("Pinning not currently available")
unPin: ->
url = @model.urlFor("unPinThread")
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.smooth-scroll.min.js b/common/static/js/vendor/jquery.smooth-scroll.min.js
new file mode 100755
index 0000000000..2af596ee83
--- /dev/null
+++ b/common/static/js/vendor/jquery.smooth-scroll.min.js
@@ -0,0 +1,7 @@
+/*!
+ * Smooth Scroll - v1.4.10 - 2013-03-02
+ * https://github.com/kswedberg/jquery-smooth-scroll
+ * Copyright (c) 2013 Karl Swedberg
+ * Licensed MIT (https://github.com/kswedberg/jquery-smooth-scroll/blob/master/LICENSE-MIT)
+ */
+(function(l){function t(l){return l.replace(/(:|\.)/g,"\\$1")}var e="1.4.10",o={exclude:[],excludeWithin:[],offset:0,direction:"top",scrollElement:null,scrollTarget:null,beforeScroll:function(){},afterScroll:function(){},easing:"swing",speed:400,autoCoefficent:2},r=function(t){var e=[],o=!1,r=t.dir&&"left"==t.dir?"scrollLeft":"scrollTop";return this.each(function(){if(this!=document&&this!=window){var t=l(this);t[r]()>0?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/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/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/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/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 eb5143b782..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 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
-
-
-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