Merge branch 'master' into feature/jkarni/genex
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,4 +27,5 @@ lms/lib/comment_client/python
|
||||
nosetests.xml
|
||||
cover_html/
|
||||
.idea/
|
||||
.redcar/
|
||||
chromedriver.log
|
||||
@@ -1,3 +1 @@
|
||||
from xmodule.templates import update_templates
|
||||
|
||||
update_templates()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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
|
||||
|
||||
Scenario: A course author sees only display_name on a newly created course
|
||||
Given I have opened a new course in Studio
|
||||
When I select the Advanced Settings
|
||||
Then I see only the display name
|
||||
|
||||
Scenario: Test if there are no policy settings without existing UI controls
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I delete the display name
|
||||
Then there are no advanced policy settings
|
||||
And I reload the page
|
||||
Then there are no advanced policy settings
|
||||
|
||||
Scenario: Test cancel editing key name
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the name of a policy key
|
||||
And I press the "Cancel" notification button
|
||||
Then the policy key name is unchanged
|
||||
|
||||
Scenario: Test editing key name
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the name of a policy key
|
||||
And I press the "Save" notification button
|
||||
Then the policy key name is changed
|
||||
|
||||
Scenario: Test cancel editing key value
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
And I press the "Cancel" notification button
|
||||
Then the policy key value is unchanged
|
||||
|
||||
Scenario: Test editing key value
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I edit the value of a policy key
|
||||
And I press the "Save" notification button
|
||||
Then the policy key value is changed
|
||||
|
||||
Scenario: Add new entries, and they appear alphabetically after save
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create New Entries
|
||||
Then they are alphabetized
|
||||
And I reload the page
|
||||
Then they are alphabetized
|
||||
|
||||
Scenario: Test how multi-line input appears
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object
|
||||
Then it is displayed as formatted
|
||||
182
cms/djangoapps/contentstore/features/advanced-settings.py
Normal file
182
cms/djangoapps/contentstore/features/advanced-settings.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
import time
|
||||
|
||||
from nose.tools import assert_equal
|
||||
from nose.tools import assert_true
|
||||
|
||||
"""
|
||||
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
|
||||
"""
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
|
||||
############### 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)
|
||||
link_css = 'li.nav-course-settings-advanced a'
|
||||
css_click(link_css)
|
||||
|
||||
|
||||
@step('I am on the Advanced Course Settings page in Studio$')
|
||||
def i_am_on_advanced_course_settings(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select the Advanced Settings')
|
||||
|
||||
|
||||
# TODO: this is copied from terrain's step.py. Need to figure out how to share that code.
|
||||
@step('I reload the page$')
|
||||
def reload_the_page(step):
|
||||
world.browser.reload()
|
||||
|
||||
|
||||
@step(u'I edit the name of a policy key$')
|
||||
def edit_the_name_of_a_policy_key(step):
|
||||
policy_key_css = 'input.policy-key'
|
||||
e = css_find(policy_key_css).first
|
||||
e.fill('new')
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(step, name):
|
||||
world.browser.click_link_by_text(name)
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
def edit_the_value_of_a_policy_key(step):
|
||||
"""
|
||||
It is hard to figure out how to get into the CodeMirror
|
||||
area, so cheat and do it from the policy key field :)
|
||||
"""
|
||||
policy_key_css = 'input.policy-key'
|
||||
e = css_find(policy_key_css).first
|
||||
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
|
||||
|
||||
|
||||
@step('I delete the display name$')
|
||||
def delete_the_display_name(step):
|
||||
delete_entry(0)
|
||||
click_save()
|
||||
|
||||
|
||||
@step('create New Entries$')
|
||||
def create_new_entries(step):
|
||||
create_entry("z", "apple")
|
||||
create_entry("a", "zebra")
|
||||
click_save()
|
||||
|
||||
|
||||
@step('I create a JSON object$')
|
||||
def create_JSON_object(step):
|
||||
create_entry("json", '{"key": "value", "key_2": "value_2"}')
|
||||
click_save()
|
||||
|
||||
|
||||
############### RESULTS ####################
|
||||
@step('I see only the display name$')
|
||||
def i_see_only_display_name(step):
|
||||
assert_policy_entries(["display_name"], ['"Robot Super Course"'])
|
||||
|
||||
|
||||
@step('there are no advanced policy settings$')
|
||||
def no_policy_settings(step):
|
||||
assert_policy_entries([], [])
|
||||
|
||||
|
||||
@step('they are alphabetized$')
|
||||
def they_are_alphabetized(step):
|
||||
assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"'])
|
||||
|
||||
|
||||
@step('it is displayed as formatted$')
|
||||
def it_is_formatted(step):
|
||||
assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}'])
|
||||
|
||||
|
||||
@step(u'the policy key name is unchanged$')
|
||||
def the_policy_key_name_is_unchanged(step):
|
||||
policy_key_css = 'input.policy-key'
|
||||
e = css_find(policy_key_css).first
|
||||
assert_equal(e.value, 'display_name')
|
||||
|
||||
|
||||
@step(u'the policy key name is changed$')
|
||||
def the_policy_key_name_is_changed(step):
|
||||
policy_key_css = 'input.policy-key'
|
||||
e = css_find(policy_key_css).first
|
||||
assert_equal(e.value, 'new')
|
||||
|
||||
|
||||
@step(u'the policy key value is unchanged$')
|
||||
def the_policy_key_value_is_unchanged(step):
|
||||
policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
|
||||
e = css_find(policy_value_css).first
|
||||
assert_equal(e.value, '"Robot Super Course"')
|
||||
|
||||
|
||||
@step(u'the policy key value is changed$')
|
||||
def the_policy_key_value_is_unchanged(step):
|
||||
policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
|
||||
e = css_find(policy_value_css).first
|
||||
assert_equal(e.value, '"Robot Super Course X"')
|
||||
|
||||
|
||||
############# HELPERS ###############
|
||||
def create_entry(key, value):
|
||||
# Scroll down the page so the button is visible
|
||||
world.scroll_to_bottom()
|
||||
css_click_at('a.new-advanced-policy-item', 10, 10)
|
||||
new_key_css = 'div#__new_advanced_key__ input'
|
||||
new_key_element = css_find(new_key_css).first
|
||||
new_key_element.fill(key)
|
||||
# For some reason have to get the instance for each command (get error that it is no longer attached to the DOM)
|
||||
# Have to do all this because Selenium has a bug that fill does not remove existing text
|
||||
new_value_css = 'div.CodeMirror textarea'
|
||||
css_find(new_value_css).last.fill("")
|
||||
css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE)
|
||||
css_find(new_value_css).last.fill(value)
|
||||
|
||||
|
||||
def delete_entry(index):
|
||||
"""
|
||||
Delete the nth entry where index is 0-based
|
||||
"""
|
||||
css = '.delete-button'
|
||||
assert_true(world.browser.is_element_present_by_css(css, 5))
|
||||
delete_buttons = css_find(css)
|
||||
assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index))
|
||||
delete_buttons[index].click()
|
||||
|
||||
|
||||
def assert_policy_entries(expected_keys, expected_values):
|
||||
assert_entries('.key input', expected_keys)
|
||||
assert_entries('.json', expected_values)
|
||||
|
||||
|
||||
def assert_entries(css, expected_values):
|
||||
webElements = css_find(css)
|
||||
assert_equal(len(expected_values), len(webElements))
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
for counter in range(len(expected_values)):
|
||||
assert_equal(expected_values[counter], css_find(css)[counter].value)
|
||||
|
||||
|
||||
def click_save():
|
||||
css = ".save-button"
|
||||
|
||||
def is_shown(driver):
|
||||
visible = css_find(css).first.visible
|
||||
if visible:
|
||||
# Even when waiting for visible, this fails sporadically. Adding in a small wait.
|
||||
time.sleep(float(1))
|
||||
return visible
|
||||
wait_for(is_shown)
|
||||
css_click(css)
|
||||
|
||||
|
||||
def fill_last_field(value):
|
||||
newValue = css_find('#__new_advanced_key__ input').first
|
||||
newValue.fill(value)
|
||||
@@ -1,12 +1,13 @@
|
||||
from lettuce import world, step
|
||||
from factories import *
|
||||
from django.core.management import call_command
|
||||
from lettuce.django import django_url
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from nose.tools import assert_true
|
||||
from nose.tools import assert_equal
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory
|
||||
from terrain.factories import CourseFactory, GroupFactory
|
||||
import xmodule.modulestore.django
|
||||
from auth.authz import get_user_by_email
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
@@ -44,6 +45,13 @@ def i_press_the_category_delete_icon(step, category):
|
||||
assert False, 'Invalid category: %s' % category
|
||||
css_click(css)
|
||||
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(step):
|
||||
clear_courses()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
####### HELPER FUNCTIONS ##############
|
||||
|
||||
|
||||
@@ -86,13 +94,38 @@ def assert_css_with_text(css, text):
|
||||
|
||||
|
||||
def css_click(css):
|
||||
assert_true(world.browser.is_element_present_by_css(css, 5))
|
||||
world.browser.find_by_css(css).first.click()
|
||||
|
||||
|
||||
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
|
||||
'''
|
||||
assert_true(world.browser.is_element_present_by_css(css, 5))
|
||||
e = world.browser.find_by_css(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):
|
||||
return world.browser.find_by_css(css)
|
||||
|
||||
|
||||
def wait_for(func):
|
||||
WebDriverWait(world.browser.driver, 10).until(func)
|
||||
|
||||
|
||||
def id_find(id):
|
||||
return world.browser.find_by_id(id)
|
||||
|
||||
|
||||
def clear_courses():
|
||||
flush_xmodule_store()
|
||||
|
||||
@@ -129,9 +162,18 @@ def log_into_studio(
|
||||
|
||||
|
||||
def create_a_course():
|
||||
css_click('a.new-course-button')
|
||||
fill_in_course_info()
|
||||
css_click('input.new-course-save')
|
||||
c = 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')
|
||||
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)
|
||||
course_title_css = 'span.course-title'
|
||||
assert_true(world.browser.is_element_present_by_css(course_title_css, 5))
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ Feature: Create Section
|
||||
And I see a release date for my section
|
||||
And I see a link to create a new subsection
|
||||
|
||||
Scenario: Add a new section (with a quote in the name) to a course (bug #216)
|
||||
Given I have opened a new course in Studio
|
||||
When I click the New Section link
|
||||
And I enter a section name with a quote and click save
|
||||
Then I see my section name with a quote on the Courseware page
|
||||
And I click to edit the section name
|
||||
Then I see the complete section name with a quote in the editor
|
||||
|
||||
Scenario: Edit section release date
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(step):
|
||||
clear_courses()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
|
||||
@step('I click the new section link$')
|
||||
def i_click_new_section_link(step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
@@ -19,10 +13,12 @@ def i_click_new_section_link(step):
|
||||
|
||||
@step('I enter the section name and click save$')
|
||||
def i_save_section_name(step):
|
||||
name_css = '.new-section-name'
|
||||
save_css = '.new-section-name-save'
|
||||
css_fill(name_css, 'My Section')
|
||||
css_click(save_css)
|
||||
save_section_name('My Section')
|
||||
|
||||
|
||||
@step('I enter a section name with a quote and click save$')
|
||||
def i_save_section_name_with_quote(step):
|
||||
save_section_name('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I have added a new section$')
|
||||
@@ -46,13 +42,30 @@ def i_save_a_new_section_release_date(step):
|
||||
css_fill(time_css, '12:00am')
|
||||
css_click('a.save-button')
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@step('I see my section on the Courseware page$')
|
||||
def i_see_my_section_on_the_courseware_page(step):
|
||||
section_css = 'span.section-name-span'
|
||||
assert_css_with_text(section_css, 'My Section')
|
||||
see_my_section_on_the_courseware_page('My Section')
|
||||
|
||||
|
||||
@step('I see my section name with a quote on the Courseware page$')
|
||||
def i_see_my_section_name_with_quote_on_the_courseware_page(step):
|
||||
see_my_section_on_the_courseware_page('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I click to edit the section name$')
|
||||
def i_click_to_edit_section_name(step):
|
||||
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_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
|
||||
|
||||
|
||||
@step('the section does not exist$')
|
||||
@@ -94,3 +107,17 @@ def the_section_release_date_is_updated(step):
|
||||
css = 'span.published-status'
|
||||
status_text = world.browser.find_by_css(css).text
|
||||
assert status_text == 'Will Release: 12/25/2013 at 12:00am'
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def see_my_section_on_the_courseware_page(name):
|
||||
section_css = 'span.section-name-span'
|
||||
assert_css_with_text(section_css, name)
|
||||
@@ -9,6 +9,14 @@ Feature: Create Subsection
|
||||
And I enter the subsection name and click save
|
||||
Then I see my subsection on the Courseware page
|
||||
|
||||
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
|
||||
Given I have opened a new course section in Studio
|
||||
When I click the New Subsection link
|
||||
And I enter a subsection name with a quote and click save
|
||||
Then I see my subsection name with a quote on the Courseware page
|
||||
And I click to edit the subsection name
|
||||
Then I see the complete subsection name with a quote in the editor
|
||||
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
@@ -20,28 +21,60 @@ def i_click_the_new_subsection_link(step):
|
||||
|
||||
@step('I enter the subsection name and click save$')
|
||||
def i_save_subsection_name(step):
|
||||
name_css = 'input.new-subsection-name-input'
|
||||
save_css = 'input.new-subsection-name-save'
|
||||
css_fill(name_css, 'Subsection One')
|
||||
css_click(save_css)
|
||||
save_subsection_name('Subsection One')
|
||||
|
||||
|
||||
@step('I enter a subsection name with a quote and click save$')
|
||||
def i_save_subsection_name_with_quote(step):
|
||||
save_subsection_name('Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I click to edit the subsection name$')
|
||||
def i_click_to_edit_subsection_name(step):
|
||||
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"')
|
||||
|
||||
|
||||
@step('I have added a new subsection$')
|
||||
def i_have_added_a_new_subsection(step):
|
||||
add_subsection()
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@step('I see my subsection on the Courseware page$')
|
||||
def i_see_my_subsection_on_the_courseware_page(step):
|
||||
css = 'span.subsection-name'
|
||||
assert world.browser.is_element_present_by_css(css)
|
||||
css = 'span.subsection-name-value'
|
||||
assert_css_with_text(css, 'Subsection One')
|
||||
see_subsection_name('Subsection One')
|
||||
|
||||
|
||||
@step('I see my subsection name with a quote on the Courseware page$')
|
||||
def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
|
||||
see_subsection_name('Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('the subsection does not exist$')
|
||||
def the_subsection_does_not_exist(step):
|
||||
css = 'span.subsection-name'
|
||||
assert world.browser.is_element_not_present_by_css(css)
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
|
||||
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)
|
||||
|
||||
def see_subsection_name(name):
|
||||
css = 'span.subsection-name'
|
||||
assert world.browser.is_element_present_by_css(css)
|
||||
css = 'span.subsection-name-value'
|
||||
assert_css_with_text(css, name)
|
||||
|
||||
@@ -17,22 +17,29 @@ from auth.authz import _delete_course_group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''Delete a MongoDB backed course'''
|
||||
help = '''Delete a MongoDB backed course'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1:
|
||||
raise CommandError("delete_course requires one argument: <location>")
|
||||
if len(args) != 1 and len(args) != 2:
|
||||
raise CommandError("delete_course requires one or more arguments: <location> |commit|")
|
||||
|
||||
loc_str = args[0]
|
||||
|
||||
commit = False
|
||||
if len(args) == 2:
|
||||
commit = args[1] == 'commit'
|
||||
|
||||
if commit:
|
||||
print 'Actually going to delete the course from DB....'
|
||||
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
loc = CourseDescriptor.id_to_location(loc_str)
|
||||
if delete_course(ms, cs, loc) == True:
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
_delete_course_group(loc)
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
loc = CourseDescriptor.id_to_location(loc_str)
|
||||
if delete_course(ms, cs, loc, commit) == True:
|
||||
print 'removing User permissions from course....'
|
||||
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||
if commit:
|
||||
_delete_course_group(loc)
|
||||
|
||||
@@ -13,7 +13,7 @@ def query_yes_no(question, default="yes"):
|
||||
"""
|
||||
valid = {"yes":True, "y":True, "ye":True,
|
||||
"no":False, "n":False}
|
||||
if default == None:
|
||||
if default is None:
|
||||
prompt = " [y/n] "
|
||||
elif default == "yes":
|
||||
prompt = " [Y/n] "
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from xmodule.templates import update_templates
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
'''Imports and updates the Studio component templates from the code pack and put in the DB'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
update_templates()
|
||||
@@ -1,49 +0,0 @@
|
||||
from factory import Factory
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from student.models import (User, UserProfile, Registration,
|
||||
CourseEnrollmentAllowed)
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
|
||||
class UserProfileFactory(Factory):
|
||||
FACTORY_FOR = UserProfile
|
||||
|
||||
user = None
|
||||
name = 'Robot Studio'
|
||||
courseware = 'course.xml'
|
||||
|
||||
|
||||
class RegistrationFactory(Factory):
|
||||
FACTORY_FOR = Registration
|
||||
|
||||
user = None
|
||||
activation_key = uuid4().hex
|
||||
|
||||
|
||||
class UserFactory(Factory):
|
||||
FACTORY_FOR = User
|
||||
|
||||
username = 'robot'
|
||||
email = 'robot@edx.org'
|
||||
password = 'test'
|
||||
first_name = 'Robot'
|
||||
last_name = 'Tester'
|
||||
is_staff = False
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
last_login = datetime.now()
|
||||
date_joined = datetime.now()
|
||||
|
||||
|
||||
class GroupFactory(Factory):
|
||||
FACTORY_FOR = Group
|
||||
|
||||
name = 'test_group'
|
||||
|
||||
|
||||
class CourseEnrollmentAllowedFactory(Factory):
|
||||
FACTORY_FOR = CourseEnrollmentAllowed
|
||||
|
||||
email = 'test@edx.org'
|
||||
course_id = 'edX/test/2012_Fall'
|
||||
@@ -171,7 +171,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
delete_course(ms, cs, location)
|
||||
delete_course(ms, cs, location, commit=True)
|
||||
|
||||
items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
|
||||
self.assertEqual(len(items), 0)
|
||||
@@ -465,7 +465,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
# check for grace period definition which should be defined at the course level
|
||||
self.assertIn('graceperiod', new_module.metadata)
|
||||
|
||||
self.assertEqual(course.metadata['graceperiod'], new_module.metadata['graceperiod'])
|
||||
self.assertEqual(parent.metadata['graceperiod'], new_module.metadata['graceperiod'])
|
||||
|
||||
self.assertEqual(course.metadata['xqa_key'], new_module.metadata['xqa_key'])
|
||||
|
||||
#
|
||||
# now let's define an override at the leaf node level
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import datetime
|
||||
import time
|
||||
import json
|
||||
import calendar
|
||||
import copy
|
||||
from util import converters
|
||||
from util.converters import jsdate_to_time
|
||||
@@ -11,7 +9,6 @@ from django.test.client import Client
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
import xmodule
|
||||
from xmodule.modulestore import Location
|
||||
from cms.djangoapps.models.settings.course_details import (CourseDetails,
|
||||
CourseSettingsEncoder)
|
||||
@@ -22,6 +19,10 @@ from django.test import TestCase
|
||||
from utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from cms.djangoapps.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):
|
||||
@@ -261,3 +262,64 @@ class CourseGradingTest(CourseTestCase):
|
||||
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
def setUp(self):
|
||||
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])
|
||||
|
||||
|
||||
def test_fetch_initial_fields(self):
|
||||
test_model = CourseMetadata.fetch(self.course_location)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
|
||||
test_model = CourseMetadata.fetch(self.fullcourse_location)
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
self.assertIn('showanswer', test_model, 'showanswer field ')
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
def test_update_from_json(self):
|
||||
test_model = CourseMetadata.update_from_json(self.course_location,
|
||||
{ "a" : 1,
|
||||
"b_a_c_h" : { "c" : "test" },
|
||||
"test_text" : "a text string"})
|
||||
self.update_check(test_model)
|
||||
# try fresh fetch to ensure persistence
|
||||
test_model = CourseMetadata.fetch(self.course_location)
|
||||
self.update_check(test_model)
|
||||
# now change some of the existing metadata
|
||||
test_model = CourseMetadata.update_from_json(self.course_location,
|
||||
{ "a" : 2,
|
||||
"display_name" : "jolly roger"})
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
|
||||
self.assertIn('a', test_model, 'Missing revised a metadata field')
|
||||
self.assertEqual(test_model['a'], 2, "a not expected value")
|
||||
|
||||
def update_check(self, test_model):
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('a', test_model, 'Missing new a metadata field')
|
||||
self.assertEqual(test_model['a'], 1, "a not expected value")
|
||||
self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field')
|
||||
self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value")
|
||||
self.assertIn('test_text', test_model, 'Missing test_text metadata field')
|
||||
self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value")
|
||||
|
||||
|
||||
def test_delete_key(self):
|
||||
test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']})
|
||||
# ensure no harm
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
# check for deletion effectiveness
|
||||
self.assertNotIn('showanswer', test_model, 'showanswer field still in')
|
||||
self.assertNotIn('xqa_key', test_model, 'xqa_key field still in')
|
||||
@@ -75,12 +75,20 @@ def get_course_for_item(location):
|
||||
return courses[0]
|
||||
|
||||
|
||||
def get_lms_link_for_item(location, preview=False):
|
||||
def get_lms_link_for_item(location, preview=False, course_id=None):
|
||||
if course_id is None:
|
||||
course_id = get_course_id(location)
|
||||
|
||||
if settings.LMS_BASE is not None:
|
||||
lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format(
|
||||
preview='preview.' if preview else '',
|
||||
lms_base=settings.LMS_BASE,
|
||||
course_id=get_course_id(location),
|
||||
if preview:
|
||||
lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
|
||||
'preview.' + settings.LMS_BASE)
|
||||
else:
|
||||
lms_base = settings.LMS_BASE
|
||||
|
||||
lms_link = "//{lms_base}/courses/{course_id}/jump_to/{location}".format(
|
||||
lms_base=lms_base,
|
||||
course_id=course_id,
|
||||
location=Location(location)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -58,8 +58,8 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\
|
||||
CourseSettingsEncoder
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.djangoapps.contentstore.utils import get_modulestore
|
||||
from lxml import etree
|
||||
from django.shortcuts import redirect
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
|
||||
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
|
||||
|
||||
@@ -68,6 +68,10 @@ log = logging.getLogger(__name__)
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
ADVANCED_COMPONENT_TYPES = ['annotatable','combinedopenended', 'peergrading']
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
@@ -114,7 +118,7 @@ def index(request):
|
||||
"""
|
||||
List all courses available to the logged in user
|
||||
"""
|
||||
courses = modulestore().get_items(['i4x', None, None, 'course', None])
|
||||
courses = modulestore('direct').get_items(['i4x', None, None, 'course', None])
|
||||
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
@@ -132,7 +136,7 @@ def index(request):
|
||||
course.location.org,
|
||||
course.location.course,
|
||||
course.location.name]),
|
||||
get_lms_link_for_item(course.location))
|
||||
get_lms_link_for_item(course.location, course_id=course.location.course_id))
|
||||
for course in courses],
|
||||
'user': request.user,
|
||||
'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff
|
||||
@@ -281,10 +285,31 @@ def edit_unit(request, location):
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy. These modules
|
||||
# should be specified as a list of strings, where the strings are the names of the modules
|
||||
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
|
||||
course_metadata = CourseMetadata.fetch(course.location)
|
||||
course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, [])
|
||||
|
||||
# Set component types according to course policy file
|
||||
component_types = list(COMPONENT_TYPES)
|
||||
if isinstance(course_advanced_keys, list):
|
||||
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
|
||||
if len(course_advanced_keys) > 0:
|
||||
component_types.append(ADVANCED_COMPONENT_CATEGORY)
|
||||
else:
|
||||
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
|
||||
|
||||
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
|
||||
for template in templates:
|
||||
if template.location.category in COMPONENT_TYPES:
|
||||
component_templates[template.location.category].append((
|
||||
category = template.location.category
|
||||
|
||||
if category in course_advanced_keys:
|
||||
category = ADVANCED_COMPONENT_CATEGORY
|
||||
|
||||
if category in component_types:
|
||||
#This is a hack to create categories for different xmodules
|
||||
component_templates[category].append((
|
||||
template.display_name,
|
||||
template.location.url(),
|
||||
'markdown' in template.metadata,
|
||||
@@ -317,8 +342,11 @@ def edit_unit(request, location):
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview='preview.',
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
|
||||
'preview.' + settings.LMS_BASE)
|
||||
|
||||
preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
@@ -365,7 +393,6 @@ def preview_component(request, location):
|
||||
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -682,7 +709,6 @@ def create_draft(request):
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
@@ -712,7 +738,6 @@ def unpublish_unit(request):
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
@@ -901,7 +926,6 @@ def remove_user(request, location):
|
||||
def landing(request, org, course, coursename):
|
||||
return render_to_response('temp-course-landing.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def static_pages(request, org, course, coursename):
|
||||
@@ -1005,7 +1029,6 @@ def edit_tabs(request, org, course, coursename):
|
||||
'components': components
|
||||
})
|
||||
|
||||
|
||||
def not_found(request):
|
||||
return render_to_response('error.html', {'error': '404'})
|
||||
|
||||
@@ -1041,7 +1064,6 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -1128,12 +1150,15 @@ def get_course_settings(request, org, course, name):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseDetails.fetch(location)
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location' : location,
|
||||
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"})
|
||||
})
|
||||
|
||||
@login_required
|
||||
@@ -1159,6 +1184,28 @@ def course_config_graders_page(request, org, course, name):
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location' : location,
|
||||
'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST),
|
||||
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@@ -1191,7 +1238,6 @@ def course_settings_updates(request, org, course, name, section):
|
||||
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -1226,6 +1272,37 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
## NB: expect_json failed on ["key", "key2"] and json payload
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
"""
|
||||
restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
|
||||
the payload is either a key or a list of keys to delete.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
|
||||
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
|
||||
elif real_method == 'DELETE':
|
||||
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json")
|
||||
elif real_method == 'POST' or real_method == 'PUT':
|
||||
# NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
|
||||
return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json")
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -1286,7 +1363,6 @@ def asset_index(request, org, course, name):
|
||||
def edge(request):
|
||||
return render_to_response('university_profiles/edge.html', {})
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
@@ -1342,7 +1418,6 @@ def create_new_course(request):
|
||||
|
||||
return HttpResponse(json.dumps({'id': new_course.location.url()}))
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
# set up the default tabs
|
||||
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
@@ -1360,7 +1435,6 @@ def initialize_course_tabs(course):
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
@@ -1438,7 +1512,6 @@ def import_course(request, org, course, name):
|
||||
course_module.location.name])
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
@@ -1490,7 +1563,6 @@ def export_course(request, org, course, name):
|
||||
'successful_import_redirect_url': ''
|
||||
})
|
||||
|
||||
|
||||
def event(request):
|
||||
'''
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
|
||||
70
cms/djangoapps/models/settings/course_metadata.py
Normal file
70
cms/djangoapps/models/settings/course_metadata.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
|
||||
|
||||
class CourseMetadata(object):
|
||||
'''
|
||||
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
|
||||
The objects have no predefined attrs but instead are obj encodings of the editable metadata.
|
||||
'''
|
||||
# __new_advanced_key__ is used by client not server; so, could argue against it being here
|
||||
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__']
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
"""
|
||||
Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
course = {}
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
for k, v in descriptor.metadata.iteritems():
|
||||
if k not in cls.FILTERED_LIST:
|
||||
course[k] = v
|
||||
|
||||
return course
|
||||
|
||||
@classmethod
|
||||
def update_from_json(cls, course_location, jsondict):
|
||||
"""
|
||||
Decode the json into CourseMetadata and save any changed attrs to the db.
|
||||
|
||||
Ensures none of the fields are in the blacklist.
|
||||
"""
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
for k, v in jsondict.iteritems():
|
||||
# should it be an error if one of the filtered list items is in the payload?
|
||||
if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v):
|
||||
dirty = True
|
||||
descriptor.metadata[k] = v
|
||||
|
||||
if dirty:
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
|
||||
|
||||
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
|
||||
# it persisted correctly
|
||||
return cls.fetch(course_location)
|
||||
|
||||
@classmethod
|
||||
def delete_key(cls, course_location, payload):
|
||||
'''
|
||||
Remove the given metadata key(s) from the course. payload can be a single key or [key..]
|
||||
'''
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
for key in payload['deleteKeys']:
|
||||
if key in descriptor.metadata:
|
||||
del descriptor.metadata[key]
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
|
||||
|
||||
return cls.fetch(course_location)
|
||||
|
||||
@@ -172,6 +172,9 @@ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identi
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
|
||||
# Tracking
|
||||
TRACK_MAX_EVENT = 10000
|
||||
|
||||
# Messages
|
||||
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
|
||||
|
||||
@@ -275,6 +278,10 @@ INSTALLED_APPS = (
|
||||
'auth',
|
||||
'student', # misleading name due to sharing with lms
|
||||
'course_groups', # not used in cms (yet), but tests run
|
||||
|
||||
# tracking
|
||||
'track',
|
||||
|
||||
# For asset pipelining
|
||||
'pipeline',
|
||||
'staticfiles',
|
||||
|
||||
@@ -27,6 +27,9 @@ STATIC_ROOT = TEST_ROOT / "staticfiles"
|
||||
GITHUB_REPO_ROOT = TEST_ROOT / "data"
|
||||
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
|
||||
|
||||
# Makes the tests run much faster...
|
||||
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
|
||||
|
||||
# TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing
|
||||
STATICFILES_DIRS = [
|
||||
COMMON_ROOT / "static",
|
||||
|
||||
16
cms/static/client_templates/advanced_entry.html
Normal file
16
cms/static/client_templates/advanced_entry.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<li class="field-group course-advanced-policy-list-item">
|
||||
<div class="field text key" id="<%= (_.isEmpty(key) ? '__new_advanced_key__' : key) %>">
|
||||
<label for="<%= keyUniqueId %>">Policy Key:</label>
|
||||
<input type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
|
||||
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
|
||||
</div>
|
||||
|
||||
<div class="field text value">
|
||||
<label for="<%= valueUniqueId %>">Policy Value:</label>
|
||||
<textarea class="json text" id="<%= valueUniqueId %>"><%= value %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="#" class="button delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
|
||||
</div>
|
||||
</li>
|
||||
BIN
cms/static/img/large-advanced-icon.png
Normal file
BIN
cms/static/img/large-advanced-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 342 B |
BIN
cms/static/img/large-annotations-icon.png
Normal file
BIN
cms/static/img/large-annotations-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 275 B |
BIN
cms/static/img/large-openended-icon.png
Normal file
BIN
cms/static/img/large-openended-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 379 B |
62
cms/static/js/models/settings/advanced.js
Normal file
62
cms/static/js/models/settings/advanced.js
Normal file
@@ -0,0 +1,62 @@
|
||||
if (!CMS.Models['Settings']) CMS.Models.Settings = {};
|
||||
|
||||
CMS.Models.Settings.Advanced = Backbone.Model.extend({
|
||||
// the key for a newly added policy-- before the user has entered a key value
|
||||
new_key : "__new_advanced_key__",
|
||||
|
||||
defaults: {
|
||||
// the properties are whatever the user types in (in addition to whatever comes originally from the server)
|
||||
},
|
||||
// which keys to send as the deleted keys on next save
|
||||
deleteKeys : [],
|
||||
blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based]
|
||||
|
||||
validate: function (attrs) {
|
||||
var errors = {};
|
||||
for (var key in attrs) {
|
||||
if (key === this.new_key || _.isEmpty(key)) {
|
||||
errors[key] = "A key must be entered.";
|
||||
}
|
||||
else if (_.contains(this.blacklistKeys, key)) {
|
||||
errors[key] = key + " is a reserved keyword or can be edited on another screen";
|
||||
}
|
||||
}
|
||||
if (!_.isEmpty(errors)) return errors;
|
||||
},
|
||||
|
||||
save : function (attrs, options) {
|
||||
// wraps the save call w/ the deletion of the removed keys after we know the saved ones worked
|
||||
options = options ? _.clone(options) : {};
|
||||
// add saveSuccess to the success
|
||||
var success = options.success;
|
||||
options.success = function(model, resp, options) {
|
||||
model.afterSave(model);
|
||||
if (success) success(model, resp, options);
|
||||
};
|
||||
Backbone.Model.prototype.save.call(this, attrs, options);
|
||||
},
|
||||
|
||||
afterSave : function(self) {
|
||||
// remove deleted attrs
|
||||
if (!_.isEmpty(self.deleteKeys)) {
|
||||
// remove the to be deleted keys from the returned model
|
||||
_.each(self.deleteKeys, function(key) { self.unset(key); });
|
||||
// not able to do via backbone since we're not destroying the model
|
||||
$.ajax({
|
||||
url : self.url,
|
||||
// json to and fro
|
||||
contentType : "application/json",
|
||||
dataType : "json",
|
||||
// delete
|
||||
type : 'DELETE',
|
||||
// data
|
||||
data : JSON.stringify({ deleteKeys : self.deleteKeys})
|
||||
})
|
||||
.fail(function(hdr, status, error) { CMS.ServerError(self, "Deleting keys:" + status); })
|
||||
.done(function(data, status, error) {
|
||||
// clear deleteKeys on success
|
||||
self.deleteKeys = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -59,19 +59,14 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
|
||||
// NOTE don't return empty errors as that will be interpreted as an error state
|
||||
},
|
||||
|
||||
url: function() {
|
||||
var location = this.get('location');
|
||||
return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/details';
|
||||
},
|
||||
|
||||
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
|
||||
save_videosource: function(newsource) {
|
||||
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
|
||||
// returns the videosource for the preview which iss the key whose speed is closest to 1
|
||||
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.set({'intro_video': null});
|
||||
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null});
|
||||
// TODO remove all whitespace w/in string
|
||||
else {
|
||||
if (this.get('intro_video') !== newsource) this.set('intro_video', newsource);
|
||||
if (this.get('intro_video') !== newsource) this.save('intro_video', newsource);
|
||||
}
|
||||
|
||||
return this.videosourceSample();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
|
||||
CMS.Models.Settings.CourseSettings = Backbone.Model.extend({
|
||||
// a container for the models representing the n possible tabbed states
|
||||
defaults: {
|
||||
courseLocation: null,
|
||||
details: null,
|
||||
faculty: null,
|
||||
grading: null,
|
||||
problems: null,
|
||||
discussions: null
|
||||
},
|
||||
|
||||
retrieve: function(submodel, callback) {
|
||||
if (this.get(submodel)) callback();
|
||||
else {
|
||||
var cachethis = this;
|
||||
switch (submodel) {
|
||||
case 'details':
|
||||
var details = new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')});
|
||||
details.fetch( {
|
||||
success : function(model) {
|
||||
cachethis.set('details', model);
|
||||
callback(model);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'grading':
|
||||
var grading = new CMS.Models.Settings.CourseGradingPolicy({course_location: this.get('courseLocation')});
|
||||
grading.fetch( {
|
||||
success : function(model) {
|
||||
cachethis.set('grading', model);
|
||||
callback(model);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
if (typeof window.templateLoader == 'function') return;
|
||||
|
||||
var templateLoader = {
|
||||
templateVersion: "0.0.12",
|
||||
templateVersion: "0.0.15",
|
||||
templates: {},
|
||||
loadRemoteTemplate: function(templateName, filename, callback) {
|
||||
if (!this.templates[templateName]) {
|
||||
|
||||
@@ -44,6 +44,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
self.render();
|
||||
}
|
||||
);
|
||||
// when the client refetches the updates as a whole, re-render them
|
||||
this.listenTo(this.collection, 'reset', this.render);
|
||||
},
|
||||
|
||||
render: function () {
|
||||
@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
$(updateEle).empty();
|
||||
var self = this;
|
||||
this.collection.each(function (update) {
|
||||
var newEle = self.template({ updateModel : update });
|
||||
$(updateEle).append(newEle);
|
||||
try {
|
||||
var newEle = self.template({ updateModel : update });
|
||||
$(updateEle).append(newEle);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
this.$el.find(".new-update-form").hide();
|
||||
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
|
||||
@@ -150,7 +156,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
},
|
||||
|
||||
closeEditor: function(self, removePost) {
|
||||
var targetModel = self.collection.getByCid(self.$currentPost.attr('name'));
|
||||
var targetModel = self.collection.get(self.$currentPost.attr('name'));
|
||||
|
||||
if(removePost) {
|
||||
self.$currentPost.remove();
|
||||
@@ -160,8 +166,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
self.$currentPost.removeClass('editing');
|
||||
self.$currentPost.find('.date-display').html(targetModel.get('date'));
|
||||
self.$currentPost.find('.date').val(targetModel.get('date'));
|
||||
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
|
||||
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
|
||||
try {
|
||||
// just in case the content causes an error (embedded js errors)
|
||||
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
|
||||
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
|
||||
} catch (e) {
|
||||
// ignore but handle rest of page
|
||||
}
|
||||
self.$currentPost.find('form').hide();
|
||||
window.$modalCover.unbind('click');
|
||||
window.$modalCover.hide();
|
||||
@@ -172,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
|
||||
// Dereferencing from events to screen elements
|
||||
eventModel: function(event) {
|
||||
// not sure if it should be currentTarget or delegateTarget
|
||||
return this.collection.getByCid($(event.currentTarget).attr("name"));
|
||||
return this.collection.get($(event.currentTarget).attr("name"));
|
||||
},
|
||||
|
||||
modelDom: function(event) {
|
||||
|
||||
305
cms/static/js/views/settings/advanced_view.js
Normal file
305
cms/static/js/views/settings/advanced_view.js
Normal file
@@ -0,0 +1,305 @@
|
||||
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
|
||||
|
||||
CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
|
||||
error_saving : "error_saving",
|
||||
successful_changes: "successful_changes",
|
||||
|
||||
// Model class is CMS.Models.Settings.Advanced
|
||||
events : {
|
||||
'click .delete-button' : "deleteEntry",
|
||||
'click .new-button' : "addEntry",
|
||||
// update model on changes
|
||||
'change .policy-key' : "updateKey",
|
||||
// keypress to catch alpha keys and backspace/delete on some browsers
|
||||
'keypress .policy-key' : "showSaveCancelButtons",
|
||||
// keyup to catch backspace/delete reliably
|
||||
'keyup .policy-key' : "showSaveCancelButtons",
|
||||
'focus :input' : "focusInput",
|
||||
'blur :input' : "blurInput"
|
||||
// TODO enable/disable save based on validation (currently enabled whenever there are changes)
|
||||
},
|
||||
initialize : function() {
|
||||
var self = this;
|
||||
// instantiates an editor template for each update in the collection
|
||||
window.templateLoader.loadRemoteTemplate("advanced_entry",
|
||||
"/static/client_templates/advanced_entry.html",
|
||||
function (raw_template) {
|
||||
self.template = _.template(raw_template);
|
||||
self.render();
|
||||
}
|
||||
);
|
||||
// because these are outside of this.$el, they can't be in the event hash
|
||||
$('.save-button').on('click', this, this.saveView);
|
||||
$('.cancel-button').on('click', this, this.revertView);
|
||||
this.model.on('error', this.handleValidationError, this);
|
||||
},
|
||||
render: function() {
|
||||
// catch potential outside call before template loaded
|
||||
if (!this.template) return this;
|
||||
|
||||
var listEle$ = this.$el.find('.course-advanced-policy-list');
|
||||
listEle$.empty();
|
||||
|
||||
// b/c we've deleted all old fields, clear the map and repopulate
|
||||
this.fieldToSelectorMap = {};
|
||||
this.selectorToField = {};
|
||||
|
||||
// iterate through model and produce key : value editors for each property in model.get
|
||||
var self = this;
|
||||
_.each(_.sortBy(_.keys(this.model.attributes), _.identity),
|
||||
function(key) {
|
||||
listEle$.append(self.renderTemplate(key, self.model.get(key)));
|
||||
});
|
||||
|
||||
var policyValues = listEle$.find('.json');
|
||||
_.each(policyValues, this.attachJSONEditor, this);
|
||||
this.showMessage();
|
||||
return this;
|
||||
},
|
||||
attachJSONEditor : function (textarea) {
|
||||
// Since we are allowing duplicate keys at the moment, it is possible that we will try to attach
|
||||
// JSON Editor to a value that already has one. Therefore only attach if no CodeMirror peer exists.
|
||||
if ( $(textarea).siblings().hasClass('CodeMirror')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var oldValue = $(textarea).val();
|
||||
CodeMirror.fromTextArea(textarea, {
|
||||
mode: "application/json", lineNumbers: false, lineWrapping: false,
|
||||
onChange: function(instance, changeobj) {
|
||||
// this event's being called even when there's no change :-(
|
||||
if (instance.getValue() !== oldValue) self.showSaveCancelButtons();
|
||||
},
|
||||
onFocus : function(mirror) {
|
||||
$(textarea).parent().children('label').addClass("is-focused");
|
||||
},
|
||||
onBlur: function (mirror) {
|
||||
$(textarea).parent().children('label').removeClass("is-focused");
|
||||
var key = $(mirror.getWrapperElement()).closest('.field-group').children('.key').attr('id');
|
||||
var stringValue = $.trim(mirror.getValue());
|
||||
// update CodeMirror to show the trimmed value.
|
||||
mirror.setValue(stringValue);
|
||||
var JSONValue = undefined;
|
||||
try {
|
||||
JSONValue = JSON.parse(stringValue);
|
||||
} catch (e) {
|
||||
// If it didn't parse, try converting non-arrays/non-objects to a String.
|
||||
// But don't convert single-quote strings, which are most likely errors.
|
||||
var firstNonWhite = stringValue.substring(0, 1);
|
||||
if (firstNonWhite !== "{" && firstNonWhite !== "[" && firstNonWhite !== "'") {
|
||||
try {
|
||||
stringValue = '"'+stringValue +'"';
|
||||
JSONValue = JSON.parse(stringValue);
|
||||
mirror.setValue(stringValue);
|
||||
} catch(quotedE) {
|
||||
// TODO: validation error
|
||||
console.log("Error with JSON, even after converting to String.");
|
||||
console.log(quotedE);
|
||||
JSONValue = undefined;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// TODO: validation error
|
||||
console.log("Error with JSON, but will not convert to String.");
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
if (JSONValue !== undefined) {
|
||||
self.clearValidationErrors();
|
||||
self.model.set(key, JSONValue, {validate: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
showMessage: function (type) {
|
||||
this.$el.find(".message-status").removeClass("is-shown");
|
||||
if (type) {
|
||||
if (type === this.error_saving) {
|
||||
this.$el.find(".message-status.error").addClass("is-shown");
|
||||
}
|
||||
else if (type === this.successful_changes) {
|
||||
this.$el.find(".message-status.confirm").addClass("is-shown");
|
||||
this.hideSaveCancelButtons();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// This is the case of the page first rendering, or when Cancel is pressed.
|
||||
this.hideSaveCancelButtons();
|
||||
this.toggleNewButton(true);
|
||||
}
|
||||
},
|
||||
|
||||
showSaveCancelButtons: function(event) {
|
||||
if (!this.buttonsVisible) {
|
||||
if (event && (event.type === 'keypress' || event.type === 'keyup')) {
|
||||
// check whether it's really an altering event: note, String.fromCharCode(keyCode) will
|
||||
// give positive values for control/command/option-letter combos; so, don't use it
|
||||
if (!((event.charCode && String.fromCharCode(event.charCode) !== "") ||
|
||||
// 8 = backspace, 46 = delete
|
||||
event.keyCode === 8 || event.keyCode === 46)) return;
|
||||
}
|
||||
this.$el.find(".message-status").removeClass("is-shown");
|
||||
$('.wrapper-notification').addClass('is-shown');
|
||||
this.buttonsVisible = true;
|
||||
}
|
||||
},
|
||||
|
||||
hideSaveCancelButtons: function() {
|
||||
$('.wrapper-notification').removeClass('is-shown');
|
||||
this.buttonsVisible = false;
|
||||
},
|
||||
|
||||
toggleNewButton: function (enable) {
|
||||
var newButton = this.$el.find(".new-button");
|
||||
if (enable) {
|
||||
newButton.removeClass('disabled');
|
||||
}
|
||||
else {
|
||||
newButton.addClass('disabled');
|
||||
}
|
||||
},
|
||||
|
||||
deleteEntry : function(event) {
|
||||
event.preventDefault();
|
||||
// find out which entry
|
||||
var li$ = $(event.currentTarget).closest('li');
|
||||
// Not data b/c the validation view uses it for a selector
|
||||
var key = $('.key', li$).attr('id');
|
||||
|
||||
delete this.selectorToField[this.fieldToSelectorMap[key]];
|
||||
delete this.fieldToSelectorMap[key];
|
||||
if (key !== this.model.new_key) {
|
||||
this.model.deleteKeys.push(key);
|
||||
this.model.unset(key);
|
||||
}
|
||||
li$.remove();
|
||||
this.showSaveCancelButtons();
|
||||
},
|
||||
saveView : function(event) {
|
||||
// TODO one last verification scan:
|
||||
// call validateKey on each to ensure proper format
|
||||
// check for dupes
|
||||
var self = event.data;
|
||||
self.model.save({},
|
||||
{
|
||||
success : function() {
|
||||
self.render();
|
||||
self.showMessage(self.successful_changes);
|
||||
},
|
||||
error : CMS.ServerError
|
||||
});
|
||||
},
|
||||
revertView : function(event) {
|
||||
var self = event.data;
|
||||
self.model.deleteKeys = [];
|
||||
self.model.clear({silent : true});
|
||||
self.model.fetch({
|
||||
success : function() { self.render(); },
|
||||
error : CMS.ServerError
|
||||
});
|
||||
},
|
||||
addEntry : function() {
|
||||
var listEle$ = this.$el.find('.course-advanced-policy-list');
|
||||
var newEle = this.renderTemplate("", "");
|
||||
listEle$.append(newEle);
|
||||
// need to re-find b/c replaceWith seems to copy rather than use the specific ele instance
|
||||
var policyValueDivs = this.$el.find('#' + this.model.new_key).closest('li').find('.json');
|
||||
// only 1 but hey, let's take advantage of the context mechanism
|
||||
_.each(policyValueDivs, this.attachJSONEditor, this);
|
||||
this.toggleNewButton(false);
|
||||
},
|
||||
updateKey : function(event) {
|
||||
var parentElement = $(event.currentTarget).closest('.key');
|
||||
// old key: either the key as in the model or new_key.
|
||||
// That is, it doesn't change as the val changes until val is accepted.
|
||||
var oldKey = parentElement.attr('id');
|
||||
// TODO: validation of keys with spaces. For now at least trim strings to remove initial and
|
||||
// trailing whitespace
|
||||
var newKey = $.trim($(event.currentTarget).val());
|
||||
if (oldKey !== newKey) {
|
||||
// TODO: is it OK to erase other validation messages?
|
||||
this.clearValidationErrors();
|
||||
|
||||
if (!this.validateKey(oldKey, newKey)) return;
|
||||
|
||||
if (this.model.has(newKey)) {
|
||||
var error = {};
|
||||
error[oldKey] = 'You have already defined "' + newKey + '" in the manual policy definitions.';
|
||||
error[newKey] = "You tried to enter a duplicate of this key.";
|
||||
this.model.trigger("error", this.model, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success
|
||||
// method which is uglier I think?)
|
||||
var newEntryModel = {};
|
||||
// set the new key's value to the old one's
|
||||
newEntryModel[newKey] = (oldKey === this.model.new_key ? '' : this.model.get(oldKey));
|
||||
|
||||
var validation = this.model.validate(newEntryModel);
|
||||
if (validation) {
|
||||
if (_.has(validation, newKey)) {
|
||||
// swap to the key which the map knows about
|
||||
validation[oldKey] = validation[newKey];
|
||||
}
|
||||
this.model.trigger("error", this.model, validation);
|
||||
// abandon update
|
||||
return;
|
||||
}
|
||||
|
||||
// Now safe to actually do the update
|
||||
this.model.set(newEntryModel);
|
||||
|
||||
// update maps
|
||||
var selector = this.fieldToSelectorMap[oldKey];
|
||||
this.selectorToField[selector] = newKey;
|
||||
this.fieldToSelectorMap[newKey] = selector;
|
||||
delete this.fieldToSelectorMap[oldKey];
|
||||
|
||||
if (oldKey !== this.model.new_key) {
|
||||
// mark the old key for deletion and delete from field maps
|
||||
this.model.deleteKeys.push(oldKey);
|
||||
this.model.unset(oldKey) ;
|
||||
}
|
||||
else {
|
||||
// id for the new entry will now be the key value. Enable new entry button.
|
||||
this.toggleNewButton(true);
|
||||
}
|
||||
|
||||
// check for newkey being the name of one which was previously deleted in this session
|
||||
var wasDeleting = this.model.deleteKeys.indexOf(newKey);
|
||||
if (wasDeleting >= 0) {
|
||||
this.model.deleteKeys.splice(wasDeleting, 1);
|
||||
}
|
||||
|
||||
// Update the ID to the new value.
|
||||
parentElement.attr('id', newKey);
|
||||
|
||||
}
|
||||
},
|
||||
validateKey : function(oldKey, newKey) {
|
||||
// model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here
|
||||
// TODO ensure there's no spaces or illegal chars (note some checking for spaces currently done in model's
|
||||
// validate method.
|
||||
return true;
|
||||
},
|
||||
|
||||
renderTemplate: function (key, value) {
|
||||
var newKeyId = _.uniqueId('policy_key_'),
|
||||
newEle = this.template({ key : key, value : JSON.stringify(value, null, 4),
|
||||
keyUniqueId: newKeyId, valueUniqueId: _.uniqueId('policy_value_')});
|
||||
|
||||
this.fieldToSelectorMap[(_.isEmpty(key) ? this.model.new_key : key)] = newKeyId;
|
||||
this.selectorToField[newKeyId] = (_.isEmpty(key) ? this.model.new_key : key);
|
||||
return newEle;
|
||||
},
|
||||
|
||||
focusInput : function(event) {
|
||||
$(event.target).prev().addClass("is-focused");
|
||||
},
|
||||
blurInput : function(event) {
|
||||
$(event.target).prev().removeClass("is-focused");
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
if (!CMS.Views['Settings']) CMS.Views.Settings = {}; // ensure the pseudo pkg exists
|
||||
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
|
||||
|
||||
CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
// Model class is CMS.Models.Settings.CourseDetails
|
||||
events : {
|
||||
"blur input" : "updateModel",
|
||||
"blur textarea" : "updateModel",
|
||||
"change input" : "updateModel",
|
||||
"change textarea" : "updateModel",
|
||||
'click .remove-course-syllabus' : "removeSyllabus",
|
||||
'click .new-course-syllabus' : 'assetSyllabus',
|
||||
'click .remove-course-introduction-video' : "removeVideo",
|
||||
@@ -97,7 +97,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
}
|
||||
var newVal = new Date(date.getTime() + time * 1000);
|
||||
if (!cacheModel.has(fieldName) || cacheModel.get(fieldName).getTime() !== newVal.getTime()) {
|
||||
cacheModel.save(fieldName, newVal, { error: CMS.ServerError});
|
||||
cacheModel.save(fieldName, newVal);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@ if (!CMS.Views['Settings']) CMS.Views.Settings = {}; // ensure the pseudo pkg ex
|
||||
CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
// Model class is CMS.Models.Settings.CourseGradingPolicy
|
||||
events : {
|
||||
"blur input" : "updateModel",
|
||||
"blur textarea" : "updateModel",
|
||||
"blur span[contenteditable=true]" : "updateDesignation",
|
||||
"change input" : "updateModel",
|
||||
"change textarea" : "updateModel",
|
||||
"change span[contenteditable=true]" : "updateDesignation",
|
||||
"click .settings-extra header" : "showSettingsExtras",
|
||||
"click .new-grade-button" : "addNewGrade",
|
||||
"click .remove-button" : "removeGrade",
|
||||
@@ -90,8 +90,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
setGracePeriod : function(event) {
|
||||
event.data.clearValidationErrors();
|
||||
var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
|
||||
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal,
|
||||
{ error : CMS.ServerError});
|
||||
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal);
|
||||
},
|
||||
updateModel : function(event) {
|
||||
if (!this.selectorToField[event.currentTarget.id]) return;
|
||||
@@ -227,8 +226,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
|
||||
return object;
|
||||
},
|
||||
{}),
|
||||
{ error : CMS.ServerError});
|
||||
{}));
|
||||
},
|
||||
|
||||
addNewGrade: function(e) {
|
||||
@@ -310,8 +308,8 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
|
||||
// Model class is CMS.Models.Settings.CourseGrader
|
||||
events : {
|
||||
"blur input" : "updateModel",
|
||||
"blur textarea" : "updateModel",
|
||||
"change input" : "updateModel",
|
||||
"change textarea" : "updateModel",
|
||||
"click .remove-grading-data" : "deleteModel",
|
||||
// would love to move to a general superclass, but event hashes don't inherit in backbone :-(
|
||||
'focus :input' : "inputFocus",
|
||||
|
||||
@@ -10,8 +10,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({
|
||||
errorTemplate : _.template('<span class="message-error"><%= message %></span>'),
|
||||
|
||||
events : {
|
||||
"blur input" : "clearValidationErrors",
|
||||
"blur textarea" : "clearValidationErrors"
|
||||
"change input" : "clearValidationErrors",
|
||||
"change textarea" : "clearValidationErrors"
|
||||
},
|
||||
fieldToSelectorMap : {
|
||||
// Your subclass must populate this w/ all of the model keys and dom selectors
|
||||
|
||||
@@ -1,3 +1,100 @@
|
||||
// notifications
|
||||
.wrapper-notification {
|
||||
@include clearfix();
|
||||
@include box-sizing(border-box);
|
||||
@include transition (bottom 2.0s ease-in-out 5s);
|
||||
@include box-shadow(0 -1px 2px rgba(0,0,0,0.1));
|
||||
position: fixed;
|
||||
bottom: -100px;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
border-top: 1px solid $darkGrey;
|
||||
padding: 20px 40px;
|
||||
|
||||
&.is-shown {
|
||||
bottom: 0;
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
&.wrapper-notification-warning {
|
||||
border-color: shade($yellow, 25%);
|
||||
background: tint($yellow, 25%);
|
||||
}
|
||||
|
||||
&.wrapper-notification-error {
|
||||
border-color: shade($red, 50%);
|
||||
background: tint($red, 20%);
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.wrapper-notification-confirm {
|
||||
border-color: shade($green, 30%);
|
||||
background: tint($green, 40%);
|
||||
color: shade($green, 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.notification {
|
||||
@include box-sizing(border-box);
|
||||
margin: 0 auto;
|
||||
width: flex-grid(12);
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
|
||||
.copy {
|
||||
float: left;
|
||||
width: flex-grid(9, 12);
|
||||
margin-right: flex-gutter();
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: 5px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
width: flex-grid(8, 9);
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
float: right;
|
||||
width: flex-grid(3, 12);
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
// adopted alerts
|
||||
.alert {
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
@@ -254,6 +254,30 @@
|
||||
background: url(../img/html-icon.png) center no-repeat;
|
||||
}
|
||||
|
||||
.large-openended-icon {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-right: 5px;
|
||||
background: url(../img/large-openended-icon.png) center no-repeat;
|
||||
}
|
||||
|
||||
.large-annotations-icon {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-right: 5px;
|
||||
background: url(../img/large-annotations-icon.png) center no-repeat;
|
||||
}
|
||||
|
||||
.large-advanced-icon {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-right: 5px;
|
||||
background: url(../img/large-advanced-icon.png) center no-repeat;
|
||||
}
|
||||
|
||||
.large-textbook-icon {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
|
||||
@@ -14,6 +14,44 @@ body.course.settings {
|
||||
padding: $baseline ($baseline*1.5);
|
||||
}
|
||||
|
||||
// messages - should be synced up with global messages in the future
|
||||
.message {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
display: none;
|
||||
@include border-top-radius(2px);
|
||||
@include box-sizing(border-box);
|
||||
border-bottom: 2px solid $yellow;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
background: $paleYellow;
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: shade($red, 50%);
|
||||
background: tint($red, 20%);
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.confirm {
|
||||
border-color: shade($green, 50%);
|
||||
background: tint($green, 20%);
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// in form - elements
|
||||
.group-settings {
|
||||
margin: 0 0 ($baseline*2) 0;
|
||||
|
||||
@@ -45,7 +83,12 @@ body.course.settings {
|
||||
|
||||
}
|
||||
|
||||
// UI hints/tips/messages
|
||||
// in form -UI hints/tips/messages
|
||||
.instructions {
|
||||
@include font-size(14);
|
||||
margin: 0 0 $baseline 0;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@include transition(color, 0.15s, ease-in-out);
|
||||
@include font-size(13);
|
||||
@@ -576,6 +619,119 @@ body.course.settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// specific fields - advanced settings
|
||||
&.advanced-policies {
|
||||
|
||||
.field-group {
|
||||
margin-bottom: ($baseline*1.5);
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.course-advanced-policy-list-item {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
|
||||
.field {
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@include transition (opacity 0.5s ease-in-out 0s);
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: ($baseline*1.25);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
|
||||
& + .tip {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
input.error {
|
||||
|
||||
& + .tip {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.key, .value {
|
||||
float: left;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: flex-grid(3, 9);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
.value {
|
||||
width: flex-grid(6, 9);
|
||||
}
|
||||
|
||||
.actions {
|
||||
float: left;
|
||||
width: flex-grid(9, 9);
|
||||
|
||||
.delete-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-error {
|
||||
position: absolute;
|
||||
bottom: ($baseline*0.75);
|
||||
}
|
||||
|
||||
// specific to code mirror instance in JSON policy editing, need to sync up with other similar code mirror UIs
|
||||
.CodeMirror {
|
||||
@include font-size(16);
|
||||
@include box-sizing(border-box);
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset);
|
||||
@include linear-gradient($lightGrey, tint($lightGrey, 90%));
|
||||
padding: 5px 8px;
|
||||
border: 1px solid $mediumGrey;
|
||||
border-radius: 2px;
|
||||
background-color: $lightGrey;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: $baseFontColor;
|
||||
outline: 0;
|
||||
|
||||
&.CodeMirror-focused {
|
||||
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
min-height: ($baseline*1.5);
|
||||
max-height: ($baseline*10);
|
||||
}
|
||||
|
||||
// editor color changes just for JSON
|
||||
.CodeMirror-lines {
|
||||
|
||||
.cm-string {
|
||||
color: #cb9c40;
|
||||
}
|
||||
|
||||
pre {
|
||||
line-height: 2.0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
$(document).ready(function(){
|
||||
var course_updates = new CMS.Models.CourseUpdateCollection();
|
||||
course_updates.reset(${course_updates|n});
|
||||
course_updates.urlbase = '${url_base}';
|
||||
course_updates.fetch();
|
||||
|
||||
var course_handouts = new CMS.Models.ModuleInfo({
|
||||
id: '${handouts_location}'
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<article class="subsection-body window" data-id="${subsection.location}">
|
||||
<div class="subsection-name-input">
|
||||
<label>Display Name:</label>
|
||||
<input type="text" value="${subsection.display_name}" class="subsection-display-name-input" data-metadata-name="display_name"/>
|
||||
<input type="text" value="${subsection.display_name | h}" class="subsection-display-name-input" data-metadata-name="display_name"/>
|
||||
</div>
|
||||
<div class="sortable-unit-list">
|
||||
<label>Units:</label>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<div class="inner-wrapper">
|
||||
<article class="import-overview">
|
||||
<div class="description">
|
||||
<h2>Please <a href="https://edge.edx.org/courses/edX/edx101/edX_Studio_Reference/about" target="_blank">read the documentation</a> before attempting an import!</h2>
|
||||
<p><strong>Importing a new course will delete all content currently associated with your course
|
||||
and replace it with the contents of the uploaded file.</strong></p>
|
||||
<p>File uploads must be gzipped tar files (.tar.gz or .tgz) containing, at a minimum, a <code>course.xml</code> file.</p>
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
<h3 class="section-name">
|
||||
<span data-tooltip="Edit this section's name" class="section-name-span">${section.display_name}</span>
|
||||
<form class="section-name-edit" style="display:none">
|
||||
<input type="text" value="${section.display_name}" class="edit-section-name" autocomplete="off"/>
|
||||
<input type="text" value="${section.display_name | h}" class="edit-section-name" autocomplete="off"/>
|
||||
<input type="submit" class="save-button edit-section-name-save" value="Save" />
|
||||
<input type="button" class="cancel-button edit-section-name-cancel" value="Cancel" />
|
||||
</form>
|
||||
|
||||
@@ -23,19 +23,25 @@ from contentstore import utils
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function(){
|
||||
|
||||
|
||||
// hilighting labels when fields are focused in
|
||||
$("form :input").focus(function() {
|
||||
$("label[for='" + this.id + "']").addClass("is-focused");
|
||||
}).blur(function() {
|
||||
$("label").removeClass("is-focused");
|
||||
});
|
||||
|
||||
var editor = new CMS.Views.Settings.Details({
|
||||
el: $('.settings-details'),
|
||||
model: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true})
|
||||
});
|
||||
|
||||
editor.render();
|
||||
var model = new CMS.Models.Settings.CourseDetails();
|
||||
model.urlRoot = '${details_url}';
|
||||
model.fetch({success :
|
||||
function(model) {
|
||||
var editor = new CMS.Views.Settings.Details({
|
||||
el: $('.settings-details'),
|
||||
model: model
|
||||
});
|
||||
|
||||
editor.render();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -205,7 +211,7 @@ from contentstore import utils
|
||||
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">How will these settings be used</h3>
|
||||
<h3 class="title-3">How will these settings be used?</h3>
|
||||
<p>Your course's schedule settings determine when students can enroll in and begin a course as well as when the course.</p>
|
||||
|
||||
<p>Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.</p>
|
||||
@@ -220,6 +226,7 @@ from contentstore import utils
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a></li>
|
||||
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
|
||||
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
124
cms/templates/settings_advanced.html
Normal file
124
cms/templates/settings_advanced.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Advanced Settings</%block>
|
||||
<%block name="bodyclass">is-signedin course advanced settings</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from contentstore import utils
|
||||
%>
|
||||
|
||||
<%block name="jsextra">
|
||||
|
||||
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/settings/advanced.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/settings/advanced_view.js')}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
|
||||
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
|
||||
var advancedModel = new CMS.Models.Settings.Advanced(${advanced_dict | n}, {parse: true});
|
||||
advancedModel.blacklistKeys = ${advanced_blacklist | n};
|
||||
advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
|
||||
|
||||
var editor = new CMS.Views.Settings.Advanced({
|
||||
el: $('.settings-advanced'),
|
||||
model: advancedModel
|
||||
});
|
||||
|
||||
editor.render();
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<header class="page">
|
||||
<span class="title-sub">Settings</span>
|
||||
<h1 class="title-1">Advanced Settings</h1>
|
||||
</header>
|
||||
|
||||
<article class="content-primary" role="main">
|
||||
<form id="settings_advanced" class="settings-advanced" method="post" action="">
|
||||
|
||||
<div class="message message-status confirm">
|
||||
Your policy changes have been saved.
|
||||
</div>
|
||||
|
||||
<div class="message message-status error">
|
||||
There was an error saving your information. Please see below.
|
||||
</div>
|
||||
|
||||
<section class="group-settings advanced-policies">
|
||||
<header>
|
||||
<h2 class="title-2">Manual Policy Definition</h2>
|
||||
<span class="tip">Manually Edit Course Policy Values (JSON Key / Value pairs)</span>
|
||||
</header>
|
||||
|
||||
<p class="instructions"><strong>Warning</strong>: Add only manual policy data that you are familiar
|
||||
with.</p>
|
||||
|
||||
<ul class="list-input course-advanced-policy-list enum">
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="actions">
|
||||
<a href="#" class="button new-button new-advanced-policy-item add-policy-data">
|
||||
<span class="plus-icon white"></span>New Manual Policy
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">How will these settings be used?</h3>
|
||||
<p>Manual policies are JSON-based key and value pairs that allow you add additional settings which edX Studio will use when generating your course.</p>
|
||||
|
||||
<p>Any policies you define here will override any other information you've defined elsewhere in Studio. With this in mind, please be very careful and do not add policies that you are unfamiliar with (both their purpose and their syntax).</p>
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<h3 class="title-3">Other Course Settings</h3>
|
||||
<nav class="nav-related">
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Details & Schedule</a></li>
|
||||
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a></li>
|
||||
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- notification: change has been made and a save is needed -->
|
||||
<div class="wrapper wrapper-notification wrapper-notification-warning">
|
||||
<div class="notification warning">
|
||||
<div class="copy">
|
||||
<i class="ss-icon ss-symbolicons-block icon icon-warning">⚠</i>
|
||||
|
||||
<p><strong>Note: </strong>Your changes will not take effect until you <strong>save your
|
||||
progress</strong>. Take care with key and value formatting, as validation is <strong>not implemented</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<ul>
|
||||
<li><a href="#" class="save-button">Save</a></li>
|
||||
<li><a href="#" class="cancel-button">Cancel</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -126,7 +126,7 @@ from contentstore import utils
|
||||
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">How will these settings be used</h3>
|
||||
<h3 class="title-3">How will these settings be used?</h3>
|
||||
<p>Your grading settings will be used to calculate students grades and performance.</p>
|
||||
|
||||
<p>Overall grade range will be used in students' final grades, which are calculated by the weighting you determine for each custom assignment type.</p>
|
||||
@@ -141,6 +141,7 @@ from contentstore import utils
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Details & Schedule</a></li>
|
||||
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
|
||||
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
<div class="main-column">
|
||||
<article class="unit-body window">
|
||||
<p class="unit-name-input"><label>Display Name:</label><input type="text" value="${unit.display_name}" class="unit-display-name-input" /></p>
|
||||
<p class="unit-name-input"><label>Display Name:</label><input type="text" value="${unit.display_name | h}" class="unit-display-name-input" /></p>
|
||||
<ol class="components">
|
||||
% for id in components:
|
||||
<li class="component" data-id="${id}"/>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<li class="nav-item nav-course-settings-schedule"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Schedule & Details</a></li>
|
||||
<li class="nav-item nav-course-settings-grading"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a></li>
|
||||
<li class="nav-item nav-course-settings-team"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
|
||||
<!-- <li class="nav-item nav-course-settings-advanced"><a href="${reverse('course_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li> -->
|
||||
<li class="nav-item nav-course-settings-advanced"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<h2>High Level Source Editing</h2>
|
||||
</header>
|
||||
|
||||
<form id="hls-form">
|
||||
<form id="hls-form" enctype="multipart/form-data">
|
||||
<section class="source-edit">
|
||||
<textarea name="" data-metadata-name="source_code" class="source-edit-box hls-data" rows="8" cols="40">${metadata['source_code']|h}</textarea>
|
||||
</section>
|
||||
@@ -18,6 +18,9 @@
|
||||
<button type="reset" class="hls-compile">Save & Compile to edX XML</button>
|
||||
<button type="reset" class="hls-save">Save</button>
|
||||
<button type="reset" class="hls-refresh">Refresh</button>
|
||||
<div style="display:none" id="hls-finput"></div>
|
||||
<span id="message"></span>
|
||||
<button type="reset" class="hls-upload" style="float:right">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -25,88 +28,148 @@
|
||||
</section>
|
||||
|
||||
<script type="text/javascript" src="/static/js/vendor/CodeMirror/stex.js"></script>
|
||||
<script type="text/javascript">
|
||||
$('#hls-trig-${hlskey}').leanModal({ top:40, overlay:0.8, closeButton: ".close-button"});
|
||||
<script type = "text/javascript">
|
||||
hlstrig = $('#hls-trig-${hlskey}');
|
||||
hlsmodal = $('#hls-modal-${hlskey}');
|
||||
|
||||
$('#hls-modal-${hlskey}').data('editor',CodeMirror.fromTextArea($('#hls-modal-${hlskey}').find('.hls-data')[0], {lineNumbers: true, mode: 'stex'}));
|
||||
hlstrig.leanModal({
|
||||
top: 40,
|
||||
overlay: 0.8,
|
||||
closeButton: ".close-button"
|
||||
});
|
||||
|
||||
$('#hls-trig-${hlskey}').click(function(){slow_refresh_hls($('#hls-modal-${hlskey}'))})
|
||||
hlsmodal.data('editor', CodeMirror.fromTextArea(hlsmodal.find('.hls-data')[0], {
|
||||
lineNumbers: true,
|
||||
mode: 'stex'
|
||||
}));
|
||||
|
||||
// refresh button
|
||||
$('#hls-trig-${hlskey}').click(function() {
|
||||
|
||||
$('#hls-modal-${hlskey}').find('.hls-refresh').click(function(){refresh_hls($('#hls-modal-${hlskey}'))});
|
||||
## this ought to be done using css instead
|
||||
hlsmodal.attr('style', function(i,s) { return s + ' margin-left:0px !important; left:5%' });
|
||||
|
||||
function refresh_hls(el){
|
||||
el.data('editor').refresh();
|
||||
}
|
||||
## setup file input
|
||||
## need to insert this only after hls triggered, because otherwise it
|
||||
## causes other <form> elements to become multipart/form-data,
|
||||
## thus breaking multiple-choice input forms, for example.
|
||||
$('#hls-finput').append('<input type="file" name="hlsfile" id="hlsfile" />');
|
||||
|
||||
function slow_refresh_hls(el){
|
||||
el.delay(200).queue(function(){
|
||||
refresh_hls(el);
|
||||
$(this).dequeue();
|
||||
});
|
||||
// resize the codemirror box
|
||||
h = el.height();
|
||||
el.find('.CodeMirror-scroll').height(h-100);
|
||||
}
|
||||
var el = $('#hls-modal-${hlskey}');
|
||||
setup_autoupload(el);
|
||||
slow_refresh_hls(el);
|
||||
});
|
||||
|
||||
// compile & save button
|
||||
// file upload button
|
||||
hlsmodal.find('.hls-upload').click(function() {
|
||||
$('#hls-modal-${hlskey}').find('#hlsfile').trigger('click');
|
||||
});
|
||||
|
||||
$('#hls-modal-${hlskey}').find('.hls-compile').click(compile_hls_${hlskey});
|
||||
|
||||
function compile_hls_${hlskey}(){
|
||||
// auto-upload after file is chosen
|
||||
function setup_autoupload(el){
|
||||
el.find('#hlsfile').change(function() {
|
||||
var file = el.find('#hlsfile')[0].files[0];
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
var contents = event.target.result;
|
||||
el.data('editor').setValue(contents);
|
||||
// trigger compile & save
|
||||
el.find('.hls-compile').trigger('click');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
editor = $('#hls-modal-${hlskey}').data('editor')
|
||||
var myquery = { latexin: editor.getValue() };
|
||||
// refresh button
|
||||
hlsmodal.find('.hls-refresh').click(function() {
|
||||
refresh_hls(hlsmodal);
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: '${metadata.get('source_processor_url','https://qisx.mit.edu:5443/latex2edx')}',
|
||||
type: 'GET',
|
||||
contentType: 'application/json',
|
||||
data: escape(JSON.stringify(myquery)),
|
||||
crossDomain: true,
|
||||
dataType: 'jsonp',
|
||||
jsonpCallback: 'process_return_${hlskey}',
|
||||
beforeSend: function (xhr) { xhr.setRequestHeader ("Authorization", "Basic eHFhOmFnYXJ3YWw="); },
|
||||
timeout : 7000,
|
||||
success: function(result) {
|
||||
console.log(result);
|
||||
},
|
||||
error: function() {
|
||||
alert('Error: cannot connect to latex2edx server');
|
||||
console.log('error!');
|
||||
function refresh_hls(el) {
|
||||
el.data('editor').refresh();
|
||||
$('#lean_overlay').hide(); // hide gray overlay
|
||||
}
|
||||
|
||||
function slow_refresh_hls(el) {
|
||||
el.delay(200).queue(function() {
|
||||
refresh_hls(el);
|
||||
$(this).dequeue();
|
||||
});
|
||||
// resize the codemirror box
|
||||
var h = el.height();
|
||||
el.find('.CodeMirror-scroll').height(h - 100);
|
||||
}
|
||||
|
||||
// compile & save button
|
||||
hlsmodal.find('.hls-compile').click(function() {
|
||||
var el = $('#hls-modal-${hlskey}');
|
||||
compile_hls(el);
|
||||
});
|
||||
|
||||
// connect to server using POST (requires cross-domain-access)
|
||||
|
||||
function compile_hls(el) {
|
||||
var editor = el.data('editor')
|
||||
var hlsdata = editor.getValue();
|
||||
|
||||
$.ajax({
|
||||
url: "https://studio-input-filter.mitx.mit.edu/latex2edx?raw=1",
|
||||
type: "POST",
|
||||
data: "" + hlsdata,
|
||||
crossDomain: true,
|
||||
processData: false,
|
||||
success: function(data) {
|
||||
xml = data.xml;
|
||||
if (xml.length == 0) {
|
||||
alert('Conversion failed! error:' + data.message);
|
||||
} else {
|
||||
el.closest('.component').find('.CodeMirror-wrap')[0].CodeMirror.setValue(xml);
|
||||
save_hls(el);
|
||||
}
|
||||
});
|
||||
// $('#hls-modal-${hlskey}').hide();
|
||||
}
|
||||
|
||||
function process_return_${hlskey}(datadict){
|
||||
// datadict is json of array with "xml" and "message"
|
||||
// if "xml" value is '' then the conversion failed
|
||||
xml = datadict.xml;
|
||||
console.log('xml:');
|
||||
console.log(xml);
|
||||
if (xml.length==0){
|
||||
alert('Conversion failed! error:'+ datadict.message);
|
||||
}else{
|
||||
set_raw_edit_box(xml,'${hlskey}');
|
||||
save_hls($('#hls-modal-${hlskey}'));
|
||||
},
|
||||
error: function() {
|
||||
alert('Error: cannot connect to latex2edx server');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function process_return_${hlskey}(datadict) {
|
||||
// datadict is json of array with "xml" and "message"
|
||||
// if "xml" value is '' then the conversion failed
|
||||
var xml = datadict.xml;
|
||||
if (xml.length == 0) {
|
||||
alert('Conversion failed! error:' + datadict.message);
|
||||
} else {
|
||||
set_raw_edit_box(xml, '${hlskey}');
|
||||
save_hls($('#hls-modal-${hlskey}'));
|
||||
}
|
||||
}
|
||||
|
||||
function set_raw_edit_box(data,key){
|
||||
// get the codemirror editor for the raw-edit-box
|
||||
// it's a CodeMirror-wrap class element
|
||||
$('#hls-modal-'+key).closest('.component').find('.CodeMirror-wrap')[0].CodeMirror.setValue(data);
|
||||
}
|
||||
|
||||
// save button
|
||||
function set_raw_edit_box(data, key) {
|
||||
// get the codemirror editor for the raw-edit-box
|
||||
// it's a CodeMirror-wrap class element
|
||||
$('#hls-modal-' + key).closest('.component').find('.CodeMirror-wrap')[0].CodeMirror.setValue(data);
|
||||
}
|
||||
|
||||
$('#hls-modal-${hlskey}').find('.hls-save').click(function(){save_hls($('#hls-modal-${hlskey}'))});
|
||||
|
||||
function save_hls(el){
|
||||
el.find('.hls-data').val(el.data('editor').getValue());
|
||||
el.closest('.component').find('.save-button').click();
|
||||
}
|
||||
// save button
|
||||
|
||||
hlsmodal.find('.hls-save').click(function() {
|
||||
save_hls(hlsmodal);
|
||||
});
|
||||
|
||||
function save_hls(el) {
|
||||
el.find('.hls-data').val(el.data('editor').getValue());
|
||||
el.closest('.component').find('.save-button').click();
|
||||
}
|
||||
|
||||
## add upload and download links / buttons to component edit box
|
||||
hlsmodal.closest('.component').find('.component-actions').append('<div id="link-${hlskey}" style="float:right;"></div>');
|
||||
$('#link-${hlskey}').html('<a class="upload-button standard" id="upload-${hlskey}">upload</a>');
|
||||
$('#upload-${hlskey}').click(function() {
|
||||
hlsmodal.closest('.component').find('.edit-button').trigger('click'); // open up editor window
|
||||
$('#hls-trig-${hlskey}').trigger('click'); // open up HLS editor window
|
||||
hlsmodal.find('#hlsfile').trigger('click');
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -47,6 +47,10 @@ urlpatterns = ('',
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$', 'contentstore.views.course_grader_updates', name='course_settings'),
|
||||
# This is the URL to initially render the course advanced settings.
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$', '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<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$', 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'),
|
||||
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'),
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ forums, and to the cohort admin views.
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import Http404
|
||||
import logging
|
||||
import random
|
||||
|
||||
from courseware import courses
|
||||
from student.models import get_user_by_username_or_email
|
||||
@@ -64,7 +65,23 @@ def is_commentable_cohorted(course_id, commentable_id):
|
||||
ans))
|
||||
return ans
|
||||
|
||||
|
||||
def get_cohorted_commentables(course_id):
|
||||
"""
|
||||
Given a course_id return a list of strings representing cohorted commentables
|
||||
"""
|
||||
|
||||
course = courses.get_course_by_id(course_id)
|
||||
|
||||
if not course.is_cohorted:
|
||||
# this is the easy case :)
|
||||
ans = []
|
||||
else:
|
||||
ans = course.cohorted_discussions
|
||||
|
||||
return ans
|
||||
|
||||
|
||||
def get_cohort(user, course_id):
|
||||
"""
|
||||
Given a django User and a course_id, return the user's cohort in that
|
||||
@@ -96,9 +113,30 @@ def get_cohort(user, course_id):
|
||||
group_type=CourseUserGroup.COHORT,
|
||||
users__id=user.id)
|
||||
except CourseUserGroup.DoesNotExist:
|
||||
# TODO: add auto-cohorting logic here once we know what that will be.
|
||||
# Didn't find the group. We'll go on to create one if needed.
|
||||
pass
|
||||
|
||||
if not course.auto_cohort:
|
||||
return None
|
||||
|
||||
choices = course.auto_cohort_groups
|
||||
if len(choices) == 0:
|
||||
# Nowhere to put user
|
||||
log.warning("Course %s is auto-cohorted, but there are no"
|
||||
" auto_cohort_groups specified",
|
||||
course_id)
|
||||
return None
|
||||
|
||||
# Put user in a random group, creating it if needed
|
||||
group_name = random.choice(choices)
|
||||
group, created = CourseUserGroup.objects.get_or_create(
|
||||
course_id=course_id,
|
||||
group_type=CourseUserGroup.COHORT,
|
||||
name=group_name)
|
||||
|
||||
user.course_groups.add(group)
|
||||
return group
|
||||
|
||||
|
||||
def get_course_cohorts(course_id):
|
||||
"""
|
||||
|
||||
@@ -47,7 +47,10 @@ class TestCohorts(django.test.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def config_course_cohorts(course, discussions,
|
||||
cohorted, cohorted_discussions=None):
|
||||
cohorted,
|
||||
cohorted_discussions=None,
|
||||
auto_cohort=None,
|
||||
auto_cohort_groups=None):
|
||||
"""
|
||||
Given a course with no discussion set up, add the discussions and set
|
||||
the cohort config appropriately.
|
||||
@@ -59,6 +62,9 @@ class TestCohorts(django.test.TestCase):
|
||||
cohorted: bool.
|
||||
cohorted_discussions: optional list of topic names. If specified,
|
||||
converts them to use the same ids as topic names.
|
||||
auto_cohort: optional bool.
|
||||
auto_cohort_groups: optional list of strings
|
||||
(names of groups to put students into).
|
||||
|
||||
Returns:
|
||||
Nothing -- modifies course in place.
|
||||
@@ -76,6 +82,12 @@ class TestCohorts(django.test.TestCase):
|
||||
if cohorted_discussions is not None:
|
||||
d["cohorted_discussions"] = [to_id(name)
|
||||
for name in cohorted_discussions]
|
||||
|
||||
if auto_cohort is not None:
|
||||
d["auto_cohort"] = auto_cohort
|
||||
if auto_cohort_groups is not None:
|
||||
d["auto_cohort_groups"] = auto_cohort_groups
|
||||
|
||||
course.metadata["cohort_config"] = d
|
||||
|
||||
|
||||
@@ -89,12 +101,9 @@ class TestCohorts(django.test.TestCase):
|
||||
|
||||
|
||||
def test_get_cohort(self):
|
||||
# Need to fix this, but after we're testing on staging. (Looks like
|
||||
# problem is that when get_cohort internally tries to look up the
|
||||
# course.id, it fails, even though we loaded it through the modulestore.
|
||||
|
||||
# Proper fix: give all tests a standard modulestore that uses the test
|
||||
# dir.
|
||||
"""
|
||||
Make sure get_cohort() does the right thing when the course is cohorted
|
||||
"""
|
||||
course = modulestore().get_course("edX/toy/2012_Fall")
|
||||
self.assertEqual(course.id, "edX/toy/2012_Fall")
|
||||
self.assertFalse(course.is_cohorted)
|
||||
@@ -122,6 +131,54 @@ class TestCohorts(django.test.TestCase):
|
||||
self.assertEquals(get_cohort(other_user, course.id), None,
|
||||
"other_user shouldn't have a cohort")
|
||||
|
||||
def test_auto_cohorting(self):
|
||||
"""
|
||||
Make sure get_cohort() does the right thing when the course is auto_cohorted
|
||||
"""
|
||||
course = modulestore().get_course("edX/toy/2012_Fall")
|
||||
self.assertEqual(course.id, "edX/toy/2012_Fall")
|
||||
self.assertFalse(course.is_cohorted)
|
||||
|
||||
user1 = User.objects.create(username="test", email="a@b.com")
|
||||
user2 = User.objects.create(username="test2", email="a2@b.com")
|
||||
user3 = User.objects.create(username="test3", email="a3@b.com")
|
||||
|
||||
cohort = CourseUserGroup.objects.create(name="TestCohort",
|
||||
course_id=course.id,
|
||||
group_type=CourseUserGroup.COHORT)
|
||||
|
||||
# user1 manually added to a cohort
|
||||
cohort.users.add(user1)
|
||||
|
||||
# Make the course auto cohorted...
|
||||
self.config_course_cohorts(course, [], cohorted=True,
|
||||
auto_cohort=True,
|
||||
auto_cohort_groups=["AutoGroup"])
|
||||
|
||||
self.assertEquals(get_cohort(user1, course.id).id, cohort.id,
|
||||
"user1 should stay put")
|
||||
|
||||
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
|
||||
"user2 should be auto-cohorted")
|
||||
|
||||
# Now make the group list empty
|
||||
self.config_course_cohorts(course, [], cohorted=True,
|
||||
auto_cohort=True,
|
||||
auto_cohort_groups=[])
|
||||
|
||||
self.assertEquals(get_cohort(user3, course.id), None,
|
||||
"No groups->no auto-cohorting")
|
||||
|
||||
# Now make it different
|
||||
self.config_course_cohorts(course, [], cohorted=True,
|
||||
auto_cohort=True,
|
||||
auto_cohort_groups=["OtherGroup"])
|
||||
|
||||
self.assertEquals(get_cohort(user3, course.id).name, "OtherGroup",
|
||||
"New list->new group")
|
||||
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
|
||||
"user2 should still be in originally placed cohort")
|
||||
|
||||
|
||||
def test_get_course_cohorts(self):
|
||||
course1_id = 'a/b/c'
|
||||
|
||||
@@ -115,7 +115,7 @@ def get_date_for_press(publish_date):
|
||||
|
||||
def press(request):
|
||||
json_articles = cache.get("student_press_json_articles")
|
||||
if json_articles == None:
|
||||
if json_articles is None:
|
||||
if hasattr(settings, 'RSS_URL'):
|
||||
content = urllib.urlopen(settings.PRESS_URL).read()
|
||||
json_articles = json.loads(content)
|
||||
@@ -301,7 +301,7 @@ def change_enrollment(request):
|
||||
action = request.POST.get("enrollment_action", "")
|
||||
|
||||
course_id = request.POST.get("course_id", None)
|
||||
if course_id == None:
|
||||
if course_id is None:
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'error': 'There was an error receiving the course id.'}))
|
||||
|
||||
@@ -385,7 +385,7 @@ def login_user(request, error=""):
|
||||
try:
|
||||
login(request, user)
|
||||
if request.POST.get('remember') == 'true':
|
||||
request.session.set_expiry(None) # or change to 604800 for 7 days
|
||||
request.session.set_expiry(604800)
|
||||
log.debug("Setting user session to never expire")
|
||||
else:
|
||||
request.session.set_expiry(0)
|
||||
@@ -554,7 +554,7 @@ def create_account(request, post_override=None):
|
||||
try:
|
||||
validate_slug(post_vars['username'])
|
||||
except ValidationError:
|
||||
js['value'] = "Username should only consist of A-Z and 0-9.".format(field=a)
|
||||
js['value'] = "Username should only consist of A-Z and 0-9, with no spaces.".format(field=a)
|
||||
js['field'] = 'username'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
@@ -1203,7 +1203,7 @@ def _get_news(top=None):
|
||||
"Return the n top news items on settings.RSS_URL"
|
||||
|
||||
feed_data = cache.get("students_index_rss_feed_data")
|
||||
if feed_data == None:
|
||||
if feed_data is None:
|
||||
if hasattr(settings, 'RSS_URL'):
|
||||
feed_data = urllib.urlopen(settings.RSS_URL).read()
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -8,6 +9,12 @@ from uuid import uuid4
|
||||
from xmodule.timeparse import stringify_time
|
||||
|
||||
|
||||
class GroupFactory(Factory):
|
||||
FACTORY_FOR = Group
|
||||
|
||||
name = 'staff_MITx/999/Robot_Super_Course'
|
||||
|
||||
|
||||
class UserProfileFactory(Factory):
|
||||
FACTORY_FOR = UserProfile
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from lettuce import world, step
|
||||
from factories import *
|
||||
from django.core.management import call_command
|
||||
from lettuce.django import django_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import CourseEnrollment
|
||||
from urllib import quote_plus
|
||||
@@ -21,6 +19,11 @@ def wait(step, seconds):
|
||||
time.sleep(float(seconds))
|
||||
|
||||
|
||||
@step('I reload the page$')
|
||||
def reload_the_page(step):
|
||||
world.browser.reload()
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the homepage$')
|
||||
def i_visit_the_homepage(step):
|
||||
world.browser.visit(django_url('/'))
|
||||
@@ -105,6 +108,11 @@ def i_am_an_edx_user(step):
|
||||
|
||||
#### helper functions
|
||||
|
||||
@world.absorb
|
||||
def scroll_to_bottom():
|
||||
# Maximize the browser
|
||||
world.browser.execute_script("window.scrollTo(0, screen.height);")
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_user(uname):
|
||||
|
||||
@@ -510,7 +510,9 @@ class LoncapaProblem(object):
|
||||
|
||||
# let each Response render itself
|
||||
if problemtree in self.responders:
|
||||
return self.responders[problemtree].render_html(self._extract_html)
|
||||
overall_msg = self.correct_map.get_overall_message()
|
||||
return self.responders[problemtree].render_html(self._extract_html,
|
||||
response_msg=overall_msg)
|
||||
|
||||
# let each custom renderer render itself:
|
||||
if problemtree.tag in customrender.registry.registered_tags():
|
||||
|
||||
@@ -27,6 +27,7 @@ class CorrectMap(object):
|
||||
self.cmap = dict()
|
||||
self.items = self.cmap.items
|
||||
self.keys = self.cmap.keys
|
||||
self.overall_message = ""
|
||||
self.set(*args, **kwargs)
|
||||
|
||||
def __getitem__(self, *args, **kwargs):
|
||||
@@ -104,16 +105,21 @@ class CorrectMap(object):
|
||||
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
|
||||
|
||||
def get_queuetime_str(self, answer_id):
|
||||
return self.cmap[answer_id]['queuestate']['time']
|
||||
if self.cmap[answer_id]['queuestate']:
|
||||
return self.cmap[answer_id]['queuestate']['time']
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_npoints(self, answer_id):
|
||||
npoints = self.get_property(answer_id, 'npoints')
|
||||
if npoints is not None:
|
||||
return npoints
|
||||
elif self.is_correct(answer_id):
|
||||
return 1
|
||||
# if not correct and no points have been assigned, return 0
|
||||
return 0
|
||||
""" Return the number of points for an answer:
|
||||
If the answer is correct, return the assigned
|
||||
number of points (default: 1 point)
|
||||
Otherwise, return 0 points """
|
||||
if self.is_correct(answer_id):
|
||||
npoints = self.get_property(answer_id, 'npoints')
|
||||
return npoints if npoints is not None else 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def set_property(self, answer_id, property, value):
|
||||
if answer_id in self.cmap:
|
||||
@@ -153,3 +159,15 @@ class CorrectMap(object):
|
||||
if not isinstance(other_cmap, CorrectMap):
|
||||
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
|
||||
self.cmap.update(other_cmap.get_dict())
|
||||
self.set_overall_message(other_cmap.get_overall_message())
|
||||
|
||||
|
||||
def set_overall_message(self, message_str):
|
||||
""" Set a message that applies to the question as a whole,
|
||||
rather than to individual inputs. """
|
||||
self.overall_message = str(message_str) if message_str else ""
|
||||
|
||||
def get_overall_message(self):
|
||||
""" Retrieve a message that applies to the question as a whole.
|
||||
If no message is available, returns the empty string """
|
||||
return self.overall_message
|
||||
|
||||
@@ -174,13 +174,14 @@ class LoncapaResponse(object):
|
||||
'''
|
||||
return sum(self.maxpoints.values())
|
||||
|
||||
def render_html(self, renderer):
|
||||
def render_html(self, renderer, response_msg=''):
|
||||
'''
|
||||
Return XHTML Element tree representation of this Response.
|
||||
|
||||
Arguments:
|
||||
|
||||
- renderer : procedure which produces HTML given an ElementTree
|
||||
- response_msg: a message displayed at the end of the Response
|
||||
'''
|
||||
# render ourself as a <span> + our content
|
||||
tree = etree.Element('span')
|
||||
@@ -195,6 +196,11 @@ class LoncapaResponse(object):
|
||||
if item_xhtml is not None:
|
||||
tree.append(item_xhtml)
|
||||
tree.tail = self.xml.tail
|
||||
|
||||
# Add a <div> for the message at the end of the response
|
||||
if response_msg:
|
||||
tree.append(self._render_response_msg_html(response_msg))
|
||||
|
||||
return tree
|
||||
|
||||
def evaluate_answers(self, student_answers, old_cmap):
|
||||
@@ -319,6 +325,29 @@ class LoncapaResponse(object):
|
||||
def __unicode__(self):
|
||||
return u'LoncapaProblem Response %s' % self.xml.tag
|
||||
|
||||
def _render_response_msg_html(self, response_msg):
|
||||
""" Render a <div> for a message that applies to the entire response.
|
||||
|
||||
*response_msg* is a string, which may contain XHTML markup
|
||||
|
||||
Returns an etree element representing the response message <div> """
|
||||
# First try wrapping the text in a <div> and parsing
|
||||
# it as an XHTML tree
|
||||
try:
|
||||
response_msg_div = etree.XML('<div>%s</div>' % str(response_msg))
|
||||
|
||||
# If we can't do that, create the <div> and set the message
|
||||
# as the text of the <div>
|
||||
except:
|
||||
response_msg_div = etree.Element('div')
|
||||
response_msg_div.text = str(response_msg)
|
||||
|
||||
|
||||
# Set the css class of the message <div>
|
||||
response_msg_div.set("class", "response_message")
|
||||
|
||||
return response_msg_div
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
@@ -965,6 +994,7 @@ def sympy_check2():
|
||||
# not expecting 'unknown's
|
||||
correct = ['unknown'] * len(idset)
|
||||
messages = [''] * len(idset)
|
||||
overall_message = ""
|
||||
|
||||
# put these in the context of the check function evaluator
|
||||
# note that this doesn't help the "cfn" version - only the exec version
|
||||
@@ -996,6 +1026,10 @@ def sympy_check2():
|
||||
# the list of messages to be filled in by the check function
|
||||
'messages': messages,
|
||||
|
||||
# a message that applies to the entire response
|
||||
# instead of a particular input
|
||||
'overall_message': overall_message,
|
||||
|
||||
# any options to be passed to the cfn
|
||||
'options': self.xml.get('options'),
|
||||
'testdat': 'hello world',
|
||||
@@ -1010,6 +1044,7 @@ def sympy_check2():
|
||||
exec self.code in self.context['global_context'], self.context
|
||||
correct = self.context['correct']
|
||||
messages = self.context['messages']
|
||||
overall_message = self.context['overall_message']
|
||||
except Exception as err:
|
||||
print "oops in customresponse (code) error %s" % err
|
||||
print "context = ", self.context
|
||||
@@ -1044,34 +1079,100 @@ def sympy_check2():
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("oops in customresponse (cfn) error %s" % err)
|
||||
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
|
||||
|
||||
if type(ret) == dict:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret['msg']
|
||||
|
||||
if 1:
|
||||
# try to clean up message html
|
||||
msg = '<html>' + msg + '</html>'
|
||||
msg = msg.replace('<', '<')
|
||||
#msg = msg.replace('<','<')
|
||||
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
|
||||
pretty_print=True)
|
||||
#msg = etree.tostring(fromstring_bs(msg),pretty_print=True)
|
||||
msg = msg.replace(' ', '')
|
||||
#msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7
|
||||
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
|
||||
# One kind of dictionary the check function can return has the
|
||||
# form {'ok': BOOLEAN, 'msg': STRING}
|
||||
# If there are multiple inputs, they all get marked
|
||||
# to the same correct/incorrect value
|
||||
if 'ok' in ret:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret.get('msg', None)
|
||||
msg = self.clean_message_html(msg)
|
||||
|
||||
messages[0] = msg
|
||||
# If there is only one input, apply the message to that input
|
||||
# Otherwise, apply the message to the whole problem
|
||||
if len(idset) > 1:
|
||||
overall_message = msg
|
||||
else:
|
||||
messages[0] = msg
|
||||
|
||||
|
||||
# Another kind of dictionary the check function can return has
|
||||
# the form:
|
||||
# {'overall_message': STRING,
|
||||
# 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] }
|
||||
#
|
||||
# This allows the function to return an 'overall message'
|
||||
# that applies to the entire problem, as well as correct/incorrect
|
||||
# status and messages for individual inputs
|
||||
elif 'input_list' in ret:
|
||||
overall_message = ret.get('overall_message', '')
|
||||
input_list = ret['input_list']
|
||||
|
||||
correct = []
|
||||
messages = []
|
||||
for input_dict in input_list:
|
||||
correct.append('correct' if input_dict['ok'] else 'incorrect')
|
||||
msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None
|
||||
messages.append(msg)
|
||||
|
||||
# Otherwise, we do not recognize the dictionary
|
||||
# Raise an exception
|
||||
else:
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("CustomResponse: check function returned an invalid dict")
|
||||
|
||||
# The check function can return a boolean value,
|
||||
# indicating whether all inputs should be marked
|
||||
# correct or incorrect
|
||||
else:
|
||||
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset)
|
||||
|
||||
# build map giving "correct"ness of the answer(s)
|
||||
correct_map = CorrectMap()
|
||||
|
||||
overall_message = self.clean_message_html(overall_message)
|
||||
correct_map.set_overall_message(overall_message)
|
||||
|
||||
for k in range(len(idset)):
|
||||
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
|
||||
correct_map.set(idset[k], correct[k], msg=messages[k],
|
||||
npoints=npoints)
|
||||
return correct_map
|
||||
|
||||
def clean_message_html(self, msg):
|
||||
|
||||
# If *msg* is an empty string, then the code below
|
||||
# will return "</html>". To avoid this, we first check
|
||||
# that *msg* is a non-empty string.
|
||||
if msg:
|
||||
|
||||
# When we parse *msg* using etree, there needs to be a root
|
||||
# element, so we wrap the *msg* text in <html> tags
|
||||
msg = '<html>' + msg + '</html>'
|
||||
|
||||
# Replace < characters
|
||||
msg = msg.replace('<', '<')
|
||||
|
||||
# Use etree to prettify the HTML
|
||||
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
|
||||
pretty_print=True)
|
||||
|
||||
msg = msg.replace(' ', '')
|
||||
|
||||
# Remove the <html> tags we introduced earlier, so we're
|
||||
# left with just the prettified message markup
|
||||
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
|
||||
|
||||
# Strip leading and trailing whitespace
|
||||
return msg.strip()
|
||||
|
||||
# If we start with an empty string, then return an empty string
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_answers(self):
|
||||
'''
|
||||
Give correct answer expected for this response.
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
<form class="choicegroup capa_inputtype" id="inputtype_${id}">
|
||||
<div class="indicator_container">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
% for choice_id, choice_description in choices:
|
||||
<label for="input_${id}_${choice_id}"> <input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
|
||||
<label for="input_${id}_${choice_id}">
|
||||
|
||||
% if choice_id in value:
|
||||
<span class="indicator_container">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
</span>
|
||||
% else:
|
||||
<span class="indicator_container"> </span>
|
||||
% endif
|
||||
|
||||
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
|
||||
% if choice_id in value:
|
||||
checked="true"
|
||||
% endif
|
||||
/> ${choice_description} </label>
|
||||
/>
|
||||
|
||||
${choice_description}
|
||||
|
||||
</label>
|
||||
|
||||
% endfor
|
||||
<span id="answer_${id}"></span>
|
||||
</fieldset>
|
||||
|
||||
668
common/lib/capa/capa/tests/response_xml_factory.py
Normal file
668
common/lib/capa/capa/tests/response_xml_factory.py
Normal file
@@ -0,0 +1,668 @@
|
||||
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
|
||||
create_input_element to produce XML of particular response types"""
|
||||
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
@abstractmethod
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Subclasses override to return an etree element
|
||||
representing the capa response XML
|
||||
(e.g. <numericalresponse>).
|
||||
|
||||
The tree should NOT contain any input elements
|
||||
(such as <textline />) as these will be added later."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Subclasses override this to return an etree element
|
||||
representing the capa input XML (such as <textline />)"""
|
||||
return None
|
||||
|
||||
def build_xml(self, **kwargs):
|
||||
""" Construct an XML string for a capa response
|
||||
based on **kwargs.
|
||||
|
||||
**kwargs is a dictionary that will be passed
|
||||
to create_response_element() and create_input_element().
|
||||
See the subclasses below for other keyword arguments
|
||||
you can specify.
|
||||
|
||||
For all response types, **kwargs can contain:
|
||||
|
||||
*question_text*: The text of the question to display,
|
||||
wrapped in <p> tags.
|
||||
|
||||
*explanation_text*: The detailed explanation that will
|
||||
be shown if the user answers incorrectly.
|
||||
|
||||
*script*: The embedded Python script (a string)
|
||||
|
||||
*num_responses*: The number of responses to create [DEFAULT: 1]
|
||||
|
||||
*num_inputs*: The number of input elements
|
||||
to create [DEFAULT: 1]
|
||||
|
||||
Returns a string representation of the XML tree.
|
||||
"""
|
||||
|
||||
# Retrieve keyward arguments
|
||||
question_text = kwargs.get('question_text', '')
|
||||
explanation_text = kwargs.get('explanation_text', '')
|
||||
script = kwargs.get('script', None)
|
||||
num_responses = kwargs.get('num_responses', 1)
|
||||
num_inputs = kwargs.get('num_inputs', 1)
|
||||
|
||||
# The root is <problem>
|
||||
root = etree.Element("problem")
|
||||
|
||||
# Add a script if there is one
|
||||
if script:
|
||||
script_element = etree.SubElement(root, "script")
|
||||
script_element.set("type", "loncapa/python")
|
||||
script_element.text = str(script)
|
||||
|
||||
# The problem has a child <p> with question text
|
||||
question = etree.SubElement(root, "p")
|
||||
question.text = question_text
|
||||
|
||||
# Add the response(s)
|
||||
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)
|
||||
if not (None == input_element):
|
||||
response_element.append(input_element)
|
||||
|
||||
# The problem has an explanation of the solution
|
||||
if explanation_text:
|
||||
explanation = etree.SubElement(root, "solution")
|
||||
explanation_div = etree.SubElement(explanation, "div")
|
||||
explanation_div.set("class", "detailed-solution")
|
||||
explanation_div.text = explanation_text
|
||||
|
||||
return etree.tostring(root)
|
||||
|
||||
@staticmethod
|
||||
def textline_input_xml(**kwargs):
|
||||
""" Create a <textline/> XML element
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*math_display*: If True, then includes a MathJax display of user input
|
||||
|
||||
*size*: An integer representing the width of the text line
|
||||
"""
|
||||
math_display = kwargs.get('math_display', False)
|
||||
size = kwargs.get('size', None)
|
||||
|
||||
input_element = etree.Element('textline')
|
||||
|
||||
if math_display:
|
||||
input_element.set('math', '1')
|
||||
|
||||
if size:
|
||||
input_element.set('size', str(size))
|
||||
|
||||
return input_element
|
||||
|
||||
@staticmethod
|
||||
def choicegroup_input_xml(**kwargs):
|
||||
""" Create a <choicegroup> XML element
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*choice_type*: Can be "checkbox", "radio", or "multiple"
|
||||
|
||||
*choices*: List of True/False values indicating whether
|
||||
a particular choice is correct or not.
|
||||
Users must choose *all* correct options in order
|
||||
to be marked correct.
|
||||
DEFAULT: [True]
|
||||
|
||||
*choice_names": List of strings identifying the choices.
|
||||
If specified, you must ensure that
|
||||
len(choice_names) == len(choices)
|
||||
"""
|
||||
# Names of group elements
|
||||
group_element_names = {'checkbox': 'checkboxgroup',
|
||||
'radio': 'radiogroup',
|
||||
'multiple': 'choicegroup' }
|
||||
|
||||
# Retrieve **kwargs
|
||||
choices = kwargs.get('choices', [True])
|
||||
choice_type = kwargs.get('choice_type', 'multiple')
|
||||
choice_names = kwargs.get('choice_names', [None] * len(choices))
|
||||
|
||||
# Create the <choicegroup>, <checkboxgroup>, or <radiogroup> element
|
||||
assert(choice_type in group_element_names)
|
||||
group_element = etree.Element(group_element_names[choice_type])
|
||||
|
||||
# Create the <choice> elements
|
||||
for (correct_val, name) in zip(choices, choice_names):
|
||||
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
|
||||
if name:
|
||||
choice_element.set("name", str(name))
|
||||
|
||||
return group_element
|
||||
|
||||
|
||||
class NumericalResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <numericalresponse> XML trees """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <numericalresponse> XML element.
|
||||
Uses **kwarg keys:
|
||||
|
||||
*answer*: The correct answer (e.g. "5")
|
||||
|
||||
*tolerance*: The tolerance within which a response
|
||||
is considered correct. Can be a decimal (e.g. "0.01")
|
||||
or percentage (e.g. "2%")
|
||||
"""
|
||||
|
||||
answer = kwargs.get('answer', None)
|
||||
tolerance = kwargs.get('tolerance', None)
|
||||
|
||||
response_element = etree.Element('numericalresponse')
|
||||
|
||||
if answer:
|
||||
response_element.set('answer', str(answer))
|
||||
|
||||
if tolerance:
|
||||
responseparam_element = etree.SubElement(response_element, 'responseparam')
|
||||
responseparam_element.set('type', 'tolerance')
|
||||
responseparam_element.set('default', str(tolerance))
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
|
||||
class CustomResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <customresponse> XML trees """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <customresponse> XML element.
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*cfn*: the Python code to run. Can be inline code,
|
||||
or the name of a function defined in earlier <script> tags.
|
||||
|
||||
Should have the form: cfn(expect, answer_given, student_answers)
|
||||
where expect is a value (see below),
|
||||
answer_given is a single value (for 1 input)
|
||||
or a list of values (for multiple inputs),
|
||||
and student_answers is a dict of answers by input ID.
|
||||
|
||||
*expect*: The value passed to the function cfn
|
||||
|
||||
*answer*: Inline script that calculates the answer
|
||||
"""
|
||||
|
||||
# Retrieve **kwargs
|
||||
cfn = kwargs.get('cfn', None)
|
||||
expect = kwargs.get('expect', None)
|
||||
answer = kwargs.get('answer', None)
|
||||
|
||||
# Create the response element
|
||||
response_element = etree.Element("customresponse")
|
||||
|
||||
if cfn:
|
||||
response_element.set('cfn', str(cfn))
|
||||
|
||||
if expect:
|
||||
response_element.set('expect', str(expect))
|
||||
|
||||
if answer:
|
||||
answer_element = etree.SubElement(response_element, "answer")
|
||||
answer_element.text = str(answer)
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
|
||||
class SchematicResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <schematicresponse> XML trees """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <schematicresponse> XML element.
|
||||
|
||||
Uses *kwargs*:
|
||||
|
||||
*answer*: The Python script used to evaluate the answer.
|
||||
"""
|
||||
answer_script = kwargs.get('answer', None)
|
||||
|
||||
# Create the <schematicresponse> element
|
||||
response_element = etree.Element("schematicresponse")
|
||||
|
||||
# Insert the <answer> script if one is provided
|
||||
if answer_script:
|
||||
answer_element = etree.SubElement(response_element, "answer")
|
||||
answer_element.set("type", "loncapa/python")
|
||||
answer_element.text = str(answer_script)
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <schematic> XML element.
|
||||
|
||||
Although <schematic> can have several attributes,
|
||||
(*height*, *width*, *parts*, *analyses*, *submit_analysis*, and *initial_value*),
|
||||
none of them are used in the capa module.
|
||||
For testing, we create a bare-bones version of <schematic>."""
|
||||
return etree.Element("schematic")
|
||||
|
||||
class CodeResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <coderesponse> XML trees """
|
||||
|
||||
def build_xml(self, **kwargs):
|
||||
# Since we are providing an <answer> tag,
|
||||
# we should override the default behavior
|
||||
# of including a <solution> tag as well
|
||||
kwargs['explanation_text'] = None
|
||||
return super(CodeResponseXMLFactory, self).build_xml(**kwargs)
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <coderesponse> 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
|
||||
[DEFAULT: "This is the correct answer!"]
|
||||
*grader_payload*: A JSON-encoded string sent to the grader
|
||||
[DEFAULT: empty dict string]
|
||||
"""
|
||||
# Get **kwargs
|
||||
initial_display = kwargs.get("initial_display", "Enter code here")
|
||||
answer_display = kwargs.get("answer_display", "This is the correct answer!")
|
||||
grader_payload = kwargs.get("grader_payload", '{}')
|
||||
|
||||
# Create the <coderesponse> element
|
||||
response_element = etree.Element("coderesponse")
|
||||
codeparam_element = etree.SubElement(response_element, "codeparam")
|
||||
|
||||
# Set the initial display text
|
||||
initial_element = etree.SubElement(codeparam_element, "initial_display")
|
||||
initial_element.text = str(initial_display)
|
||||
|
||||
# Set the answer display text
|
||||
answer_element = etree.SubElement(codeparam_element, "answer_display")
|
||||
answer_element.text = str(answer_display)
|
||||
|
||||
# Set the grader payload string
|
||||
grader_element = etree.SubElement(codeparam_element, "grader_payload")
|
||||
grader_element.text = str(grader_payload)
|
||||
|
||||
# Create the input within the response
|
||||
input_element = etree.SubElement(response_element, "textbox")
|
||||
input_element.set("mode", "python")
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
# Since we create this in create_response_element(),
|
||||
# return None here
|
||||
return None
|
||||
|
||||
class ChoiceResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <choiceresponse> XML trees """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <choiceresponse> element """
|
||||
return etree.Element("choiceresponse")
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create a <checkboxgroup> element."""
|
||||
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
|
||||
|
||||
|
||||
class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <formularesponse> XML trees """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <formularesponse> element.
|
||||
|
||||
*sample_dict*: A dictionary of the form:
|
||||
{ VARIABLE_NAME: (MIN, MAX), ....}
|
||||
|
||||
This specifies the range within which
|
||||
to numerically sample each variable to check
|
||||
student answers.
|
||||
[REQUIRED]
|
||||
|
||||
*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]
|
||||
|
||||
*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
|
||||
called calculated_answer)
|
||||
[REQUIRED]
|
||||
|
||||
*hints*: List of (hint_prompt, hint_name, hint_text) tuples
|
||||
Where *hint_prompt* is the formula for which we show the hint,
|
||||
*hint_name* is an internal identifier for the hint,
|
||||
and *hint_text* is the text we show for the hint.
|
||||
"""
|
||||
# Retrieve kwargs
|
||||
sample_dict = kwargs.get("sample_dict", None)
|
||||
num_samples = kwargs.get("num_samples", None)
|
||||
tolerance = kwargs.get("tolerance", 0.01)
|
||||
answer = kwargs.get("answer", None)
|
||||
hint_list = kwargs.get("hints", None)
|
||||
|
||||
assert(answer)
|
||||
assert(sample_dict and num_samples)
|
||||
|
||||
# Create the <formularesponse> element
|
||||
response_element = etree.Element("formularesponse")
|
||||
|
||||
# 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")
|
||||
responseparam_element.set("type", "tolerance")
|
||||
responseparam_element.set("default", str(tolerance))
|
||||
|
||||
# Set the answer
|
||||
response_element.set("answer", str(answer))
|
||||
|
||||
# Include hints, if specified
|
||||
if hint_list:
|
||||
hintgroup_element = etree.SubElement(response_element, "hintgroup")
|
||||
|
||||
for (hint_prompt, hint_name, hint_text) in hint_list:
|
||||
|
||||
# For each hint, create a <formulahint> element
|
||||
formulahint_element = etree.SubElement(hintgroup_element, "formulahint")
|
||||
|
||||
# We could sample a different range, but for simplicity,
|
||||
# we use the same sample string for the hints
|
||||
# that we used previously.
|
||||
formulahint_element.set("samples", sample_str)
|
||||
|
||||
formulahint_element.set("answer", str(hint_prompt))
|
||||
formulahint_element.set("name", str(hint_name))
|
||||
|
||||
# For each hint, create a <hintpart> element
|
||||
# corresponding to the <formulahint>
|
||||
hintpart_element = etree.SubElement(hintgroup_element, "hintpart")
|
||||
hintpart_element.set("on", str(hint_name))
|
||||
text_element = etree.SubElement(hintpart_element, "text")
|
||||
text_element.text = str(hint_text)
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
def _sample_str(self, sample_dict, num_samples, tolerance):
|
||||
# Loncapa uses a special format for sample strings:
|
||||
# "x,y,z@4,5,3:10,12,8#4" means plug in values for (x,y,z)
|
||||
# from within the box defined by points (4,5,3) and (10,12,8)
|
||||
# The "#4" means to repeat 4 times.
|
||||
variables = [str(v) for v in sample_dict.keys()]
|
||||
low_range_vals = [str(f[0]) for f in sample_dict.values()]
|
||||
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) +
|
||||
"#" + str(num_samples))
|
||||
return sample_str
|
||||
|
||||
class ImageResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <imageresponse> XML """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <imageresponse> element."""
|
||||
return etree.Element("imageresponse")
|
||||
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <imageinput> element.
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*src*: URL for the image file [DEFAULT: "/static/image.jpg"]
|
||||
|
||||
*width*: Width of the image [DEFAULT: 100]
|
||||
|
||||
*height*: Height of the image [DEFAULT: 100]
|
||||
|
||||
*rectangle*: String representing the rectangles the user should select.
|
||||
|
||||
Take the form "(x1,y1)-(x2,y2)", where the two (x,y)
|
||||
tuples define the corners of the rectangle.
|
||||
|
||||
Can include multiple rectangles separated by a semicolon, e.g.
|
||||
"(490,11)-(556,98);(242,202)-(296,276)"
|
||||
|
||||
*regions*: String representing the regions a user can select
|
||||
|
||||
Take the form "[ [[x1,y1], [x2,y2], [x3,y3]],
|
||||
[[x1,y1], [x2,y2], [x3,y3]] ]"
|
||||
(Defines two regions, each with 3 points)
|
||||
|
||||
REQUIRED: Either *rectangle* or *region* (or both)
|
||||
"""
|
||||
|
||||
# Get the **kwargs
|
||||
src = kwargs.get("src", "/static/image.jpg")
|
||||
width = kwargs.get("width", 100)
|
||||
height = kwargs.get("height", 100)
|
||||
rectangle = kwargs.get('rectangle', None)
|
||||
regions = kwargs.get('regions', None)
|
||||
|
||||
assert(rectangle or regions)
|
||||
|
||||
# Create the <imageinput> element
|
||||
input_element = etree.Element("imageinput")
|
||||
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)
|
||||
|
||||
if regions:
|
||||
input_element.set("regions", regions)
|
||||
|
||||
return input_element
|
||||
|
||||
class JavascriptResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <javascriptresponse> XML """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <javascriptresponse> element.
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*generator_src*: Name of the JS file to generate the problem.
|
||||
*grader_src*: Name of the JS file to grade the problem.
|
||||
*display_class*: Name of the class used to display the problem
|
||||
*display_src*: Name of the JS file used to display the problem
|
||||
*param_dict*: Dictionary of parameters to pass to the JS
|
||||
"""
|
||||
# Get **kwargs
|
||||
generator_src = kwargs.get("generator_src", None)
|
||||
grader_src = kwargs.get("grader_src", None)
|
||||
display_class = kwargs.get("display_class", None)
|
||||
display_src = kwargs.get("display_src", None)
|
||||
param_dict = kwargs.get("param_dict", {})
|
||||
|
||||
# Both display_src and display_class given,
|
||||
# or neither given
|
||||
assert((display_src and display_class) or
|
||||
(not display_src and not display_class))
|
||||
|
||||
# Create the <javascriptresponse> element
|
||||
response_element = etree.Element("javascriptresponse")
|
||||
|
||||
if generator_src:
|
||||
generator_element = etree.SubElement(response_element, "generator")
|
||||
generator_element.set("src", str(generator_src))
|
||||
|
||||
if grader_src:
|
||||
grader_element = etree.SubElement(response_element, "grader")
|
||||
grader_element.set("src", str(grader_src))
|
||||
|
||||
if display_class and display_src:
|
||||
display_element = etree.SubElement(response_element, "display")
|
||||
display_element.set("class", str(display_class))
|
||||
display_element.set("src", str(display_src))
|
||||
|
||||
for (param_name, param_val) in param_dict.items():
|
||||
responseparam_element = etree.SubElement(response_element, "responseparam")
|
||||
responseparam_element.set("name", str(param_name))
|
||||
responseparam_element.set("value", str(param_val))
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <javascriptinput> element """
|
||||
return etree.Element("javascriptinput")
|
||||
|
||||
class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <multiplechoiceresponse> XML """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <multiplechoiceresponse> element"""
|
||||
return etree.Element('multiplechoiceresponse')
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <choicegroup> element"""
|
||||
kwargs['choice_type'] = 'multiple'
|
||||
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
|
||||
|
||||
class TrueFalseResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <truefalseresponse> XML """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <truefalseresponse> element"""
|
||||
return etree.Element('truefalseresponse')
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <choicegroup> element"""
|
||||
kwargs['choice_type'] = 'multiple'
|
||||
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
|
||||
|
||||
class OptionResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <optionresponse> XML"""
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create the <optionresponse> element"""
|
||||
return etree.Element("optionresponse")
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create the <optioninput> element.
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*options*: a list of possible options the user can choose from [REQUIRED]
|
||||
You must specify at least 2 options.
|
||||
*correct_option*: the correct choice from the list of options [REQUIRED]
|
||||
"""
|
||||
|
||||
options_list = kwargs.get('options', None)
|
||||
correct_option = kwargs.get('correct_option', None)
|
||||
|
||||
assert(options_list and correct_option)
|
||||
assert(len(options_list) > 1)
|
||||
assert(correct_option in options_list)
|
||||
|
||||
# Create the <optioninput> element
|
||||
optioninput_element = etree.Element("optioninput")
|
||||
|
||||
# Set the "options" attribute
|
||||
# Format: "('first', 'second', 'third')"
|
||||
options_attr_string = ",".join(["'%s'" % str(o) for o in options_list])
|
||||
options_attr_string = "(%s)" % options_attr_string
|
||||
optioninput_element.set('options', options_attr_string)
|
||||
|
||||
# Set the "correct" attribute
|
||||
optioninput_element.set('correct', str(correct_option))
|
||||
|
||||
return optioninput_element
|
||||
|
||||
|
||||
class StringResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <stringresponse> XML """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <stringresponse> XML element.
|
||||
|
||||
Uses **kwargs:
|
||||
|
||||
*answer*: The correct answer (a string) [REQUIRED]
|
||||
|
||||
*case_sensitive*: Whether the response is case-sensitive (True/False)
|
||||
[DEFAULT: True]
|
||||
|
||||
*hints*: List of (hint_prompt, hint_name, hint_text) tuples
|
||||
Where *hint_prompt* is the string for which we show the hint,
|
||||
*hint_name* is an internal identifier for the hint,
|
||||
and *hint_text* is the text we show for the hint.
|
||||
"""
|
||||
# Retrieve the **kwargs
|
||||
answer = kwargs.get("answer", None)
|
||||
case_sensitive = kwargs.get("case_sensitive", True)
|
||||
hint_list = kwargs.get('hints', None)
|
||||
assert(answer)
|
||||
|
||||
# Create the <stringresponse> element
|
||||
response_element = etree.Element("stringresponse")
|
||||
|
||||
# Set the answer attribute
|
||||
response_element.set("answer", str(answer))
|
||||
|
||||
# Set the case sensitivity
|
||||
response_element.set("type", "cs" if case_sensitive else "ci")
|
||||
|
||||
# Add the hints if specified
|
||||
if hint_list:
|
||||
hintgroup_element = etree.SubElement(response_element, "hintgroup")
|
||||
for (hint_prompt, hint_name, hint_text) in hint_list:
|
||||
stringhint_element = etree.SubElement(hintgroup_element, "stringhint")
|
||||
stringhint_element.set("answer", str(hint_prompt))
|
||||
stringhint_element.set("name", str(hint_name))
|
||||
|
||||
hintpart_element = etree.SubElement(hintgroup_element, "hintpart")
|
||||
hintpart_element.set("on", str(hint_name))
|
||||
|
||||
hint_text_element = etree.SubElement(hintpart_element, "text")
|
||||
hint_text_element.text = str(hint_text)
|
||||
|
||||
return response_element
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
152
common/lib/capa/capa/tests/test_correctmap.py
Normal file
152
common/lib/capa/capa/tests/test_correctmap.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import unittest
|
||||
from capa.correctmap import CorrectMap
|
||||
import datetime
|
||||
|
||||
class CorrectMapTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cmap = CorrectMap()
|
||||
|
||||
def test_set_input_properties(self):
|
||||
|
||||
# Set the correctmap properties for two inputs
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={'key':'secretstring',
|
||||
'time':'20130228100026'})
|
||||
|
||||
self.cmap.set(answer_id='2_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None,
|
||||
msg=None,
|
||||
hint=None,
|
||||
hintmode=None,
|
||||
queuestate=None)
|
||||
|
||||
# Assert that each input has the expected properties
|
||||
self.assertTrue(self.cmap.is_correct('1_2_1'))
|
||||
self.assertFalse(self.cmap.is_correct('2_2_1'))
|
||||
|
||||
self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct')
|
||||
self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect')
|
||||
|
||||
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
|
||||
self.assertEqual(self.cmap.get_npoints('2_2_1'), 0)
|
||||
|
||||
self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message')
|
||||
self.assertEqual(self.cmap.get_msg('2_2_1'), None)
|
||||
|
||||
self.assertEqual(self.cmap.get_hint('1_2_1'), 'Test hint')
|
||||
self.assertEqual(self.cmap.get_hint('2_2_1'), None)
|
||||
|
||||
self.assertEqual(self.cmap.get_hintmode('1_2_1'), 'always')
|
||||
self.assertEqual(self.cmap.get_hintmode('2_2_1'), None)
|
||||
|
||||
self.assertTrue(self.cmap.is_queued('1_2_1'))
|
||||
self.assertFalse(self.cmap.is_queued('2_2_1'))
|
||||
|
||||
self.assertEqual(self.cmap.get_queuetime_str('1_2_1'), '20130228100026')
|
||||
self.assertEqual(self.cmap.get_queuetime_str('2_2_1'), None)
|
||||
|
||||
self.assertTrue(self.cmap.is_right_queuekey('1_2_1', 'secretstring'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', 'invalidstr'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', ''))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', None))
|
||||
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'secretstring'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'invalidstr'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', ''))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None))
|
||||
|
||||
|
||||
def test_get_npoints(self):
|
||||
# Set the correctmap properties for 4 inputs
|
||||
# 1) correct, 5 points
|
||||
# 2) correct, None points
|
||||
# 3) incorrect, 5 points
|
||||
# 4) incorrect, None points
|
||||
# 5) correct, 0 points
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5)
|
||||
|
||||
self.cmap.set(answer_id='2_2_1',
|
||||
correctness='correct',
|
||||
npoints=None)
|
||||
|
||||
self.cmap.set(answer_id='3_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=5)
|
||||
|
||||
self.cmap.set(answer_id='4_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None)
|
||||
|
||||
self.cmap.set(answer_id='5_2_1',
|
||||
correctness='correct',
|
||||
npoints=0)
|
||||
|
||||
# Assert that we get the expected points
|
||||
# If points assigned and correct --> npoints
|
||||
# If no points assigned and correct --> 1 point
|
||||
# Otherwise --> 0 points
|
||||
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
|
||||
self.assertEqual(self.cmap.get_npoints('2_2_1'), 1)
|
||||
self.assertEqual(self.cmap.get_npoints('3_2_1'), 0)
|
||||
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
|
||||
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
|
||||
|
||||
|
||||
def test_set_overall_message(self):
|
||||
|
||||
# Default is an empty string string
|
||||
self.assertEqual(self.cmap.get_overall_message(), "")
|
||||
|
||||
# Set a message that applies to the whole question
|
||||
self.cmap.set_overall_message("Test message")
|
||||
|
||||
# Retrieve the message
|
||||
self.assertEqual(self.cmap.get_overall_message(), "Test message")
|
||||
|
||||
# Setting the message to None --> empty string
|
||||
self.cmap.set_overall_message(None)
|
||||
self.assertEqual(self.cmap.get_overall_message(), "")
|
||||
|
||||
def test_update_from_correctmap(self):
|
||||
# Initialize a CorrectMap with some properties
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={'key':'secretstring',
|
||||
'time':'20130228100026'})
|
||||
|
||||
self.cmap.set_overall_message("Test message")
|
||||
|
||||
# Create a second cmap, then update it to have the same properties
|
||||
# as the first cmap
|
||||
other_cmap = CorrectMap()
|
||||
other_cmap.update(self.cmap)
|
||||
|
||||
# Assert that it has all the same properties
|
||||
self.assertEqual(other_cmap.get_overall_message(),
|
||||
self.cmap.get_overall_message())
|
||||
|
||||
self.assertEqual(other_cmap.get_dict(),
|
||||
self.cmap.get_dict())
|
||||
|
||||
|
||||
def test_update_from_invalid(self):
|
||||
# Should get an exception if we try to update() a CorrectMap
|
||||
# with a non-CorrectMap value
|
||||
invalid_list = [None, "string", 5, datetime.datetime.today()]
|
||||
|
||||
for invalid in invalid_list:
|
||||
with self.assertRaises(Exception):
|
||||
self.cmap.update(invalid)
|
||||
@@ -1,59 +0,0 @@
|
||||
<problem>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
@@ -1,40 +0,0 @@
|
||||
<problem>
|
||||
<choiceresponse>
|
||||
<radiogroup>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</radiogroup>
|
||||
</choiceresponse>
|
||||
<choiceresponse>
|
||||
<radiogroup>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</radiogroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
@@ -1,33 +0,0 @@
|
||||
<problem>
|
||||
<text>
|
||||
<h2>Code response</h2>
|
||||
|
||||
<p>
|
||||
</p>
|
||||
|
||||
<text>
|
||||
Write a program to compute the square of a number
|
||||
<coderesponse tests="repeat:2,generate">
|
||||
<textbox rows="10" cols="70" mode="python"/>
|
||||
<codeparam>
|
||||
<initial_display>def square(x):</initial_display>
|
||||
<answer_display>answer</answer_display>
|
||||
<grader_payload>grader stuff</grader_payload>
|
||||
</codeparam>
|
||||
</coderesponse>
|
||||
</text>
|
||||
|
||||
<text>
|
||||
Write a program to compute the square of a number
|
||||
<coderesponse tests="repeat:2,generate">
|
||||
<textbox rows="10" cols="70" mode="python"/>
|
||||
<codeparam>
|
||||
<initial_display>def square(x):</initial_display>
|
||||
<answer_display>answer</answer_display>
|
||||
<grader_payload>grader stuff</grader_payload>
|
||||
</codeparam>
|
||||
</coderesponse>
|
||||
</text>
|
||||
|
||||
</text>
|
||||
</problem>
|
||||
@@ -1,101 +0,0 @@
|
||||
<problem>
|
||||
<text>
|
||||
<h2>Code response</h2>
|
||||
|
||||
<p>
|
||||
</p>
|
||||
|
||||
<text>
|
||||
Write a program to compute the square of a number
|
||||
<coderesponse tests="repeat:2,generate">
|
||||
<textbox rows="10" cols="70" mode="python"/>
|
||||
<answer><![CDATA[
|
||||
initial_display = """
|
||||
def square(n):
|
||||
"""
|
||||
|
||||
answer = """
|
||||
def square(n):
|
||||
return n**2
|
||||
"""
|
||||
|
||||
preamble = """
|
||||
import sys, time
|
||||
"""
|
||||
|
||||
test_program = """
|
||||
import random
|
||||
import operator
|
||||
|
||||
def testSquare(n = None):
|
||||
if n is None:
|
||||
n = random.randint(2, 20)
|
||||
print 'Test is: square(%d)'%n
|
||||
return str(square(n))
|
||||
|
||||
def main():
|
||||
f = os.fdopen(3,'w')
|
||||
test = int(sys.argv[1])
|
||||
rndlist = map(int,os.getenv('rndlist').split(','))
|
||||
random.seed(rndlist[0])
|
||||
if test == 1: f.write(testSquare(0))
|
||||
elif test == 2: f.write(testSquare(1))
|
||||
else: f.write(testSquare())
|
||||
f.close()
|
||||
|
||||
main()
|
||||
sys.exit(0)
|
||||
"""
|
||||
]]>
|
||||
</answer>
|
||||
</coderesponse>
|
||||
</text>
|
||||
|
||||
<text>
|
||||
Write a program to compute the cube of a number
|
||||
<coderesponse tests="repeat:2,generate">
|
||||
<textbox rows="10" cols="70" mode="python"/>
|
||||
<answer><![CDATA[
|
||||
initial_display = """
|
||||
def cube(n):
|
||||
"""
|
||||
|
||||
answer = """
|
||||
def cube(n):
|
||||
return n**3
|
||||
"""
|
||||
|
||||
preamble = """
|
||||
import sys, time
|
||||
"""
|
||||
|
||||
test_program = """
|
||||
import random
|
||||
import operator
|
||||
|
||||
def testCube(n = None):
|
||||
if n is None:
|
||||
n = random.randint(2, 20)
|
||||
print 'Test is: cube(%d)'%n
|
||||
return str(cube(n))
|
||||
|
||||
def main():
|
||||
f = os.fdopen(3,'w')
|
||||
test = int(sys.argv[1])
|
||||
rndlist = map(int,os.getenv('rndlist').split(','))
|
||||
random.seed(rndlist[0])
|
||||
if test == 1: f.write(testCube(0))
|
||||
elif test == 2: f.write(testCube(1))
|
||||
else: f.write(testCube())
|
||||
f.close()
|
||||
|
||||
main()
|
||||
sys.exit(0)
|
||||
"""
|
||||
]]>
|
||||
</answer>
|
||||
</coderesponse>
|
||||
</text>
|
||||
|
||||
</text>
|
||||
</problem>
|
||||
@@ -0,0 +1 @@
|
||||
This file is used to test converting file handles to filenames in the capa utility module
|
||||
@@ -1,45 +0,0 @@
|
||||
<problem>
|
||||
<script type="loncapa/python">
|
||||
# from loncapa import *
|
||||
x1 = 4 # lc_random(2,4,1)
|
||||
y1 = 5 # lc_random(3,7,1)
|
||||
|
||||
x2 = 10 # lc_random(x1+1,9,1)
|
||||
y2 = 20 # lc_random(y1+1,15,1)
|
||||
|
||||
m = (y2-y1)/(x2-x1)
|
||||
b = y1 - m*x1
|
||||
answer = "%s*x+%s" % (m,b)
|
||||
answer = answer.replace('+-','-')
|
||||
|
||||
inverted_m = (x2-x1)/(y2-y1)
|
||||
inverted_b = b
|
||||
wrongans = "%s*x+%s" % (inverted_m,inverted_b)
|
||||
wrongans = wrongans.replace('+-','-')
|
||||
</script>
|
||||
|
||||
<text>
|
||||
<p>Hints can be provided to students, based on the last response given, as well as the history of responses given. Here is an example of a hint produced by a Formula Response problem.</p>
|
||||
|
||||
<p>
|
||||
What is the equation of the line which passess through ($x1,$y1) and
|
||||
($x2,$y2)?</p>
|
||||
|
||||
<p>The correct answer is <tt>$answer</tt>. A common error is to invert the equation for the slope. Enter <tt>
|
||||
$wrongans</tt> to see a hint.</p>
|
||||
|
||||
</text>
|
||||
|
||||
<formularesponse samples="x@-5:5#11" id="11" answer="$answer">
|
||||
<responseparam description="Numerical Tolerance" type="tolerance" default="0.001" name="tol" />
|
||||
<text>y = <textline size="25" /></text>
|
||||
<hintgroup>
|
||||
<formulahint samples="x@-5:5#11" answer="$wrongans" name="inversegrad">
|
||||
</formulahint>
|
||||
<hintpart on="inversegrad">
|
||||
<text>You have inverted the slope in the question.</text>
|
||||
</hintpart>
|
||||
</hintgroup>
|
||||
</formularesponse>
|
||||
</problem>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<problem>
|
||||
<text><p>
|
||||
Two skiers are on frictionless black diamond ski slopes.
|
||||
Hello</p></text>
|
||||
|
||||
<imageresponse max="1" loncapaid="11">
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)"/>
|
||||
<text>Click on the image where the top skier will stop momentarily if the top skier starts from rest.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(242,202)-(296,276)"/>
|
||||
<text>Click on the image where the lower skier will stop momentarily if the lower skier starts from rest.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98);(242,202)-(296,276)"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98);(242,202)-(296,276)"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98);(242,202)-(296,276)"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<hintgroup showoncorrect="no">
|
||||
<text><p>Use conservation of energy.</p></text>
|
||||
</hintgroup>
|
||||
</imageresponse>
|
||||
|
||||
|
||||
<imageresponse max="1" loncapaid="12">
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)" regions='[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]'/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]]]"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[10,10], [30,30], [15, 15]]"/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[10,10], [30,30], [10, 30], [30, 10]]"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<hintgroup showoncorrect="no">
|
||||
<text><p>Use conservation of energy.</p></text>
|
||||
</hintgroup>
|
||||
</imageresponse>
|
||||
|
||||
|
||||
</problem>
|
||||
@@ -1,13 +0,0 @@
|
||||
<problem>
|
||||
|
||||
<javascriptresponse>
|
||||
<generator src="test_problem_generator.js"/>
|
||||
<grader src="test_problem_grader.js"/>
|
||||
<display class="TestProblemDisplay" src="test_problem_display.js"/>
|
||||
<responseparam name="value" value="4"/>
|
||||
<javascriptinput>
|
||||
</javascriptinput>
|
||||
</javascriptresponse>
|
||||
|
||||
</problem>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var MinimaxProblemDisplay, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
MinimaxProblemDisplay = (function(_super) {
|
||||
|
||||
__extends(MinimaxProblemDisplay, _super);
|
||||
|
||||
function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
|
||||
this.state = state;
|
||||
this.submission = submission;
|
||||
this.evaluation = evaluation;
|
||||
this.container = container;
|
||||
this.submissionField = submissionField;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters);
|
||||
}
|
||||
|
||||
MinimaxProblemDisplay.prototype.render = function() {};
|
||||
|
||||
MinimaxProblemDisplay.prototype.createSubmission = function() {
|
||||
var id, value, _ref, _results;
|
||||
this.newSubmission = {};
|
||||
if (this.submission != null) {
|
||||
_ref = this.submission;
|
||||
_results = [];
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
_results.push(this.newSubmission[id] = value);
|
||||
}
|
||||
return _results;
|
||||
}
|
||||
};
|
||||
|
||||
MinimaxProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return this.newSubmission;
|
||||
};
|
||||
|
||||
return MinimaxProblemDisplay;
|
||||
|
||||
})(XProblemDisplay);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.TestProblemDisplay = TestProblemDisplay;
|
||||
|
||||
}).call(this);
|
||||
;
|
||||
@@ -1,50 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var MinimaxProblemDisplay, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
MinimaxProblemDisplay = (function(_super) {
|
||||
|
||||
__extends(MinimaxProblemDisplay, _super);
|
||||
|
||||
function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
|
||||
this.state = state;
|
||||
this.submission = submission;
|
||||
this.evaluation = evaluation;
|
||||
this.container = container;
|
||||
this.submissionField = submissionField;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters);
|
||||
}
|
||||
|
||||
MinimaxProblemDisplay.prototype.render = function() {};
|
||||
|
||||
MinimaxProblemDisplay.prototype.createSubmission = function() {
|
||||
var id, value, _ref, _results;
|
||||
this.newSubmission = {};
|
||||
if (this.submission != null) {
|
||||
_ref = this.submission;
|
||||
_results = [];
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
_results.push(this.newSubmission[id] = value);
|
||||
}
|
||||
return _results;
|
||||
}
|
||||
};
|
||||
|
||||
MinimaxProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return this.newSubmission;
|
||||
};
|
||||
|
||||
return MinimaxProblemDisplay;
|
||||
|
||||
})(XProblemDisplay);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.TestProblemDisplay = TestProblemDisplay;
|
||||
|
||||
}).call(this);
|
||||
;
|
||||
@@ -1,21 +0,0 @@
|
||||
<problem>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup>
|
||||
<choice correct="false" >
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false" >
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true" >
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
@@ -1,21 +0,0 @@
|
||||
<problem>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup>
|
||||
<choice correct="false" name="foil1">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false" name="foil2">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice correct="true" name="foil3">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false" name="foil4">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice correct="false" name="foil5">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
@@ -1,63 +0,0 @@
|
||||
<problem>
|
||||
<text>
|
||||
<p>
|
||||
Why do bicycles benefit from having larger wheels when going up a bump as shown in the picture? <br/>
|
||||
Assume that for both bicycles:<br/>
|
||||
1.) The tires have equal air pressure.<br/>
|
||||
2.) The bicycles never leave the contact with the bump.<br/>
|
||||
3.) The bicycles have the same mass. The bicycle tires (regardless of size) have the same mass.<br/>
|
||||
</p>
|
||||
</text>
|
||||
<optionresponse texlayout="horizontal" max="10" randomize="yes">
|
||||
<ul>
|
||||
<li>
|
||||
<text>
|
||||
<p>The bicycles with larger wheels have more time to go over the bump. This decreases the magnitude of the force needed to lift the bicycle.</p>
|
||||
</text>
|
||||
<optioninput name="Foil1" location="random" options="('True','False')" correct="True">
|
||||
</optioninput>
|
||||
</li>
|
||||
<li>
|
||||
<text>
|
||||
<p>The bicycles with larger wheels always have a smaller vertical displacement regardless of speed.</p>
|
||||
</text>
|
||||
<optioninput name="Foil2" location="random" options="('True','False')" correct="False">
|
||||
</optioninput>
|
||||
</li>
|
||||
<li>
|
||||
<text>
|
||||
<p>The bicycles with larger wheels experience a force backward with less magnitude for the same amount of time.</p>
|
||||
</text>
|
||||
<optioninput name="Foil3" location="random" options="('True','False')" correct="False">
|
||||
</optioninput>
|
||||
</li>
|
||||
<li>
|
||||
<text>
|
||||
<p>The bicycles with larger wheels experience a force backward with less magnitude for a greater amount of time.</p>
|
||||
</text>
|
||||
<optioninput name="Foil4" location="random" options="('True','False')" correct="True">
|
||||
</optioninput>
|
||||
</li>
|
||||
<li>
|
||||
<text>
|
||||
<p>The bicycles with larger wheels have more kinetic energy turned into gravitational potential energy.</p>
|
||||
</text>
|
||||
<optioninput name="Foil5" location="random" options="('True','False')" correct="False">
|
||||
</optioninput>
|
||||
</li>
|
||||
<li>
|
||||
<text>
|
||||
<p>The bicycles with larger wheels have more rotational kinetic energy, so the horizontal velocity of the biker changes less.</p>
|
||||
</text>
|
||||
<optioninput name="Foil6" location="random" options="('True','False')" correct="False">
|
||||
</optioninput>
|
||||
</li>
|
||||
</ul>
|
||||
<hintgroup showoncorrect="no">
|
||||
<text>
|
||||
<br/>
|
||||
<br/>
|
||||
</text>
|
||||
</hintgroup>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
@@ -1,25 +0,0 @@
|
||||
<problem >
|
||||
<text><h2>Example: String Response Problem</h2>
|
||||
<br/>
|
||||
</text>
|
||||
|
||||
<text>Which US state has Lansing as its capital?</text>
|
||||
<stringresponse answer="Michigan" type="ci">
|
||||
<textline size="20" />
|
||||
<hintgroup>
|
||||
<stringhint answer="wisconsin" type="cs" name="wisc">
|
||||
</stringhint>
|
||||
<stringhint answer="minnesota" type="cs" name="minn">
|
||||
</stringhint>
|
||||
<hintpart on="wisc">
|
||||
<text>The state capital of Wisconsin is Madison.</text>
|
||||
</hintpart>
|
||||
<hintpart on="minn">
|
||||
<text>The state capital of Minnesota is St. Paul.</text>
|
||||
</hintpart>
|
||||
<hintpart on="default">
|
||||
<text>The state you are looking for is also known as the 'Great Lakes State'</text>
|
||||
</hintpart>
|
||||
</hintgroup>
|
||||
</stringresponse>
|
||||
</problem>
|
||||
@@ -1,29 +0,0 @@
|
||||
<problem>
|
||||
<text>
|
||||
<h2>Example: Symbolic Math Response Problem</h2>
|
||||
|
||||
<p>
|
||||
A symbolic math response problem presents one or more symbolic math
|
||||
input fields for input. Correctness of input is evaluated based on
|
||||
the symbolic properties of the expression entered. The student enters
|
||||
text, but sees a proper symbolic rendition of the entered formula, in
|
||||
real time, next to the input box.
|
||||
</p>
|
||||
|
||||
<p>This is a correct answer which may be entered below: </p>
|
||||
<p><tt>cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]</tt></p>
|
||||
|
||||
<script>
|
||||
from symmath import *
|
||||
</script>
|
||||
<text>Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax]
|
||||
and give the resulting \(2 \times 2\) matrix. <br/>
|
||||
Your input should be typed in as a list of lists, eg <tt>[[1,2],[3,4]]</tt>. <br/>
|
||||
[mathjax]U=[/mathjax] <symbolicresponse cfn="symmath_check" answer="[[cos(theta),I*sin(theta)],[I*sin(theta),cos(theta)]]" options="matrix,imaginaryi" id="filenamedogi0VpEBOWedxsymmathresponse_1" state="unsubmitted">
|
||||
<textline size="80" math="1" response_id="2" answer_id="1" id="filenamedogi0VpEBOWedxsymmathresponse_2_1"/>
|
||||
</symbolicresponse>
|
||||
<br/>
|
||||
</text>
|
||||
|
||||
</text>
|
||||
</problem>
|
||||
@@ -1,21 +0,0 @@
|
||||
<problem>
|
||||
<truefalseresponse max="10" randomize="yes">
|
||||
<choicegroup>
|
||||
<choice location="random" correct="true" name="foil1">
|
||||
<startouttext />This is foil One.<endouttext />
|
||||
</choice>
|
||||
<choice location="random" correct="true" name="foil2">
|
||||
<startouttext />This is foil Two.<endouttext />
|
||||
</choice>
|
||||
<choice location="random" correct="false" name="foil3">
|
||||
<startouttext />This is foil Three.<endouttext />
|
||||
</choice>
|
||||
<choice location="random" correct="false" name="foil4">
|
||||
<startouttext />This is foil Four.<endouttext />
|
||||
</choice>
|
||||
<choice location="random" correct="false" name="foil5">
|
||||
<startouttext />This is foil Five.<endouttext />
|
||||
</choice>
|
||||
</choicegroup>
|
||||
</truefalseresponse>
|
||||
</problem>
|
||||
195
common/lib/capa/capa/tests/test_html_render.py
Normal file
195
common/lib/capa/capa/tests/test_html_render.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import unittest
|
||||
from lxml import etree
|
||||
import os
|
||||
import textwrap
|
||||
import json
|
||||
import mock
|
||||
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
|
||||
from . import test_system
|
||||
|
||||
class CapaHtmlRenderTest(unittest.TestCase):
|
||||
|
||||
def test_include_html(self):
|
||||
# Create a test file to include
|
||||
self._create_test_file('test_include.xml',
|
||||
'<test>Test include</test>')
|
||||
|
||||
# Generate some XML with an <include>
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<include file="test_include.xml"/>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the include file was embedded in the problem
|
||||
test_element = rendered_html.find("test")
|
||||
self.assertEqual(test_element.tag, "test")
|
||||
self.assertEqual(test_element.text, "Test include")
|
||||
|
||||
|
||||
def test_process_outtext(self):
|
||||
# Generate some XML with <startouttext /> and <endouttext />
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<startouttext/>Test text<endouttext/>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the <startouttext /> and <endouttext />
|
||||
# were converted to <span></span> tags
|
||||
span_element = rendered_html.find('span')
|
||||
self.assertEqual(span_element.text, 'Test text')
|
||||
|
||||
def test_render_script(self):
|
||||
# Generate some XML with a <script> tag
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<script>test=True</script>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the script element has been removed from the rendered HTML
|
||||
script_element = rendered_html.find('script')
|
||||
self.assertEqual(None, script_element)
|
||||
|
||||
def test_render_response_xml(self):
|
||||
# Generate some XML for a string response
|
||||
kwargs = {'question_text': "Test question",
|
||||
'explanation_text': "Test explanation",
|
||||
'answer': 'Test answer',
|
||||
'hints': [('test prompt', 'test_hint', 'test hint text')]}
|
||||
xml_str = StringResponseXMLFactory().build_xml(**kwargs)
|
||||
|
||||
# Mock out the template renderer
|
||||
test_system.render_template = mock.Mock()
|
||||
test_system.render_template.return_value = "<div>Input Template Render</div>"
|
||||
|
||||
# Create the problem and render the HTML
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect problem has been turned into a <div>
|
||||
self.assertEqual(rendered_html.tag, "div")
|
||||
|
||||
# Expect question text is in a <p> child
|
||||
question_element = rendered_html.find("p")
|
||||
self.assertEqual(question_element.text, "Test question")
|
||||
|
||||
# Expect that the response has been turned into a <span>
|
||||
response_element = rendered_html.find("span")
|
||||
self.assertEqual(response_element.tag, "span")
|
||||
|
||||
# Expect that the response <span>
|
||||
# that contains a <div> for the textline
|
||||
textline_element = response_element.find("div")
|
||||
self.assertEqual(textline_element.text, 'Input Template Render')
|
||||
|
||||
# Expect a child <div> for the solution
|
||||
# with the rendered template
|
||||
solution_element = rendered_html.find("div")
|
||||
self.assertEqual(solution_element.text, 'Input Template Render')
|
||||
|
||||
# Expect that the template renderer was called with the correct
|
||||
# arguments, once for the textline input and once for
|
||||
# the solution
|
||||
expected_textline_context = {'status': 'unsubmitted',
|
||||
'value': '',
|
||||
'preprocessor': None,
|
||||
'msg': '',
|
||||
'inline': False,
|
||||
'hidden': False,
|
||||
'do_math': False,
|
||||
'id': '1_2_1',
|
||||
'size': None}
|
||||
|
||||
expected_solution_context = {'id': '1_solution_1'}
|
||||
|
||||
expected_calls = [mock.call('textline.html', expected_textline_context),
|
||||
mock.call('solutionspan.html', expected_solution_context)]
|
||||
|
||||
self.assertEqual(test_system.render_template.call_args_list,
|
||||
expected_calls)
|
||||
|
||||
|
||||
def test_render_response_with_overall_msg(self):
|
||||
# CustomResponse script that sets an overall_message
|
||||
script=textwrap.dedent("""
|
||||
def check_func(*args):
|
||||
msg = '<p>Test message 1<br /></p><p>Test message 2</p>'
|
||||
return {'overall_message': msg,
|
||||
'input_list': [ {'ok': True, 'msg': '' } ] }
|
||||
""")
|
||||
|
||||
# Generate some XML for a CustomResponse
|
||||
kwargs = {'script':script, 'cfn': 'check_func'}
|
||||
xml_str = CustomResponseXMLFactory().build_xml(**kwargs)
|
||||
|
||||
# Create the problem and render the html
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Grade the problem
|
||||
correctmap = problem.grade_answers({'1_2_1': 'test'})
|
||||
|
||||
# Render the html
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
|
||||
# Expect that there is a <div> within the response <div>
|
||||
# with css class response_message
|
||||
msg_div_element = rendered_html.find(".//div[@class='response_message']")
|
||||
self.assertEqual(msg_div_element.tag, "div")
|
||||
self.assertEqual(msg_div_element.get('class'), "response_message")
|
||||
|
||||
# Expect that the <div> contains our message (as part of the XML tree)
|
||||
msg_p_elements = msg_div_element.findall('p')
|
||||
self.assertEqual(msg_p_elements[0].tag, "p")
|
||||
self.assertEqual(msg_p_elements[0].text, "Test message 1")
|
||||
|
||||
self.assertEqual(msg_p_elements[1].tag, "p")
|
||||
self.assertEqual(msg_p_elements[1].text, "Test message 2")
|
||||
|
||||
|
||||
def test_substitute_python_vars(self):
|
||||
# Generate some XML with Python variables defined in a script
|
||||
# and used later as attributes
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<script>test="TEST"</script>
|
||||
<span attr="$test"></span>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem and render the HTML
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the variable $test has been replaced with its value
|
||||
span_element = rendered_html.find('span')
|
||||
self.assertEqual(span_element.get('attr'), "TEST")
|
||||
|
||||
def _create_test_file(self, path, content_str):
|
||||
test_fp = test_system.filestore.open(path, "w")
|
||||
test_fp.write(content_str)
|
||||
test_fp.close()
|
||||
|
||||
self.addCleanup(lambda: os.remove(test_fp.name))
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
from nose.plugins.skip import SkipTest
|
||||
import os
|
||||
import unittest
|
||||
import textwrap
|
||||
|
||||
from . import test_system
|
||||
|
||||
@@ -16,93 +17,151 @@ 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."""
|
||||
|
||||
xml_factory_class = None
|
||||
|
||||
class MultiChoiceTest(unittest.TestCase):
|
||||
def test_MC_grade(self):
|
||||
multichoice_file = os.path.dirname(__file__) + "/test_files/multichoice.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(multichoice_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'choice_foil3'}
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
false_answers = {'1_2_1': 'choice_foil2'}
|
||||
self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
def setUp(self):
|
||||
if self.xml_factory_class:
|
||||
self.xml_factory = self.xml_factory_class()
|
||||
|
||||
def test_MC_bare_grades(self):
|
||||
multichoice_file = os.path.dirname(__file__) + "/test_files/multi_bare.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(multichoice_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'choice_2'}
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
false_answers = {'1_2_1': 'choice_1'}
|
||||
self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
def build_problem(self, **kwargs):
|
||||
xml = self.xml_factory.build_xml(**kwargs)
|
||||
return lcp.LoncapaProblem(xml, '1', system=test_system)
|
||||
|
||||
def test_TF_grade(self):
|
||||
truefalse_file = os.path.dirname(__file__) + "/test_files/truefalse.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(truefalse_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': ['choice_foil2', 'choice_foil1']}
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
false_answers = {'1_2_1': ['choice_foil1']}
|
||||
self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
false_answers = {'1_2_1': ['choice_foil1', 'choice_foil3']}
|
||||
self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
false_answers = {'1_2_1': ['choice_foil3']}
|
||||
self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
false_answers = {'1_2_1': ['choice_foil1', 'choice_foil2', 'choice_foil3']}
|
||||
self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
def assert_grade(self, problem, submission, expected_correctness):
|
||||
input_dict = {'1_2_1': submission}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness)
|
||||
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
class MultiChoiceResponseTest(ResponseTest):
|
||||
from response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
xml_factory_class = MultipleChoiceResponseXMLFactory
|
||||
|
||||
def test_multiple_choice_grade(self):
|
||||
problem = self.build_problem(choices=[False, True, False])
|
||||
|
||||
# Ensure that we get the expected grades
|
||||
self.assert_grade(problem, 'choice_0', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_1', 'correct')
|
||||
self.assert_grade(problem, 'choice_2', 'incorrect')
|
||||
|
||||
def test_named_multiple_choice_grade(self):
|
||||
problem = self.build_problem(choices=[False, True, False],
|
||||
choice_names=["foil_1", "foil_2", "foil_3"])
|
||||
|
||||
# Ensure that we get the expected grades
|
||||
self.assert_grade(problem, 'choice_foil_1', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_foil_2', 'correct')
|
||||
self.assert_grade(problem, 'choice_foil_3', 'incorrect')
|
||||
|
||||
|
||||
class ImageResponseTest(unittest.TestCase):
|
||||
def test_ir_grade(self):
|
||||
imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system)
|
||||
# testing regions only
|
||||
correct_answers = {
|
||||
#regions
|
||||
'1_2_1': '(490,11)-(556,98)',
|
||||
'1_2_2': '(242,202)-(296,276)',
|
||||
'1_2_3': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_4': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_5': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
#testing regions and rectanges
|
||||
'1_3_1': 'rectangle="(490,11)-(556,98)" \
|
||||
regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_2': 'rectangle="(490,11)-(556,98)" \
|
||||
regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_3': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_4': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_5': 'regions="[[[10,10], [20,10], [20, 30]]]"',
|
||||
'1_3_6': 'regions="[[10,10], [30,30], [15, 15]]"',
|
||||
'1_3_7': 'regions="[[10,10], [30,30], [10, 30], [30, 10]]"',
|
||||
}
|
||||
test_answers = {
|
||||
'1_2_1': '[500,20]',
|
||||
'1_2_2': '[250,300]',
|
||||
'1_2_3': '[500,20]',
|
||||
'1_2_4': '[250,250]',
|
||||
'1_2_5': '[10,10]',
|
||||
class TrueFalseResponseTest(ResponseTest):
|
||||
from response_xml_factory import TrueFalseResponseXMLFactory
|
||||
xml_factory_class = TrueFalseResponseXMLFactory
|
||||
|
||||
'1_3_1': '[500,20]',
|
||||
'1_3_2': '[15,15]',
|
||||
'1_3_3': '[500,20]',
|
||||
'1_3_4': '[115,115]',
|
||||
'1_3_5': '[15,15]',
|
||||
'1_3_6': '[20,20]',
|
||||
'1_3_7': '[20,15]',
|
||||
}
|
||||
def test_true_false_grade(self):
|
||||
problem = self.build_problem(choices=[False, True, True])
|
||||
|
||||
# regions
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_3'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_4'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_5'), 'incorrect')
|
||||
# Check the results
|
||||
# Mark correct if and only if ALL (and only) correct choices selected
|
||||
self.assert_grade(problem, 'choice_0', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_1', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_2', 'incorrect')
|
||||
self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_0', 'choice_2'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_1', 'choice_2'], 'correct')
|
||||
|
||||
# regions and rectangles
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_2'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_3'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_4'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_5'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_6'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_7'), 'correct')
|
||||
# Invalid choices should be marked incorrect (we have no choice 3)
|
||||
self.assert_grade(problem, 'choice_3', 'incorrect')
|
||||
self.assert_grade(problem, 'not_a_choice', 'incorrect')
|
||||
|
||||
def test_named_true_false_grade(self):
|
||||
problem = self.build_problem(choices=[False, True, True],
|
||||
choice_names=['foil_1','foil_2','foil_3'])
|
||||
|
||||
# Check the results
|
||||
# Mark correct if and only if ALL (and only) correct chocies selected
|
||||
self.assert_grade(problem, 'choice_foil_1', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_foil_2', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_foil_3', 'incorrect')
|
||||
self.assert_grade(problem, ['choice_foil_1', 'choice_foil_2', 'choice_foil_3'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_foil_1', 'choice_foil_3'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_foil_1', 'choice_foil_2'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_foil_2', 'choice_foil_3'], 'correct')
|
||||
|
||||
# Invalid choices should be marked incorrect
|
||||
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
|
||||
|
||||
def test_rectangle_grade(self):
|
||||
# Define a rectangle with corners (10,10) and (20,20)
|
||||
problem = self.build_problem(rectangle="(10,10)-(20,20)")
|
||||
|
||||
# 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]"]
|
||||
incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
def test_multiple_rectangles_grade(self):
|
||||
# Define two rectangles
|
||||
rectangle_str = "(10,10)-(20,20);(100,100)-(200,200)"
|
||||
|
||||
# Expect that only points inside the rectangles are marked correct
|
||||
problem = self.build_problem(rectangle=rectangle_str)
|
||||
correct_inputs = ["[12,19]", "[120, 130]"]
|
||||
incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]",
|
||||
"[50,55]", "[300, 14]", "[120, 400]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
def test_region_grade(self):
|
||||
# Define a triangular region with corners (0,0), (5,10), and (0, 10)
|
||||
region_str = "[ [1,1], [5,10], [0,10] ]"
|
||||
|
||||
# Expect that only points inside the triangle are marked correct
|
||||
problem = self.build_problem(regions=region_str)
|
||||
correct_inputs = ["[2,4]", "[1,3]"]
|
||||
incorrect_inputs = ["[0,0]", "[3,5]", "[5,15]", "[30, 12]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
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]]]"
|
||||
|
||||
# Expect that only points inside the regions are marked correct
|
||||
problem = self.build_problem(regions=region_str)
|
||||
correct_inputs = ["[15,12]", "[110,112]"]
|
||||
incorrect_inputs = ["[0,0]", "[600,300]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
def test_region_and_rectangle_grade(self):
|
||||
rectangle_str = "(100,100)-(200,200)"
|
||||
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)
|
||||
correct_inputs = ["[13,12]", "[110,112]"]
|
||||
incorrect_inputs = ["[0,0]", "[600,300]"]
|
||||
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
|
||||
|
||||
|
||||
class SymbolicResponseTest(unittest.TestCase):
|
||||
@@ -195,60 +254,165 @@ class SymbolicResponseTest(unittest.TestCase):
|
||||
self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
|
||||
|
||||
class OptionResponseTest(unittest.TestCase):
|
||||
'''
|
||||
Run this with
|
||||
class OptionResponseTest(ResponseTest):
|
||||
from response_xml_factory import OptionResponseXMLFactory
|
||||
xml_factory_class = OptionResponseXMLFactory
|
||||
|
||||
python manage.py test courseware.OptionResponseTest
|
||||
'''
|
||||
def test_or_grade(self):
|
||||
optionresponse_file = os.path.dirname(__file__) + "/test_files/optionresponse.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(optionresponse_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'True',
|
||||
'1_2_2': 'False'}
|
||||
test_answers = {'1_2_1': 'True',
|
||||
'1_2_2': 'True',
|
||||
}
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect')
|
||||
def test_grade(self):
|
||||
problem = self.build_problem(options=["first", "second", "third"],
|
||||
correct_option="second")
|
||||
|
||||
# Assert that we get the expected grades
|
||||
self.assert_grade(problem, "first", "incorrect")
|
||||
self.assert_grade(problem, "second", "correct")
|
||||
self.assert_grade(problem, "third", "incorrect")
|
||||
|
||||
# Options not in the list should be marked incorrect
|
||||
self.assert_grade(problem, "invalid_option", "incorrect")
|
||||
|
||||
|
||||
class FormulaResponseWithHintTest(unittest.TestCase):
|
||||
'''
|
||||
Test Formula response problem with a hint
|
||||
This problem also uses calc.
|
||||
'''
|
||||
def test_or_grade(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/formularesponse_with_hint.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': '2.5*x-5.0'}
|
||||
test_answers = {'1_2_1': '0.4*x-5.0'}
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
cmap = test_lcp.grade_answers(test_answers)
|
||||
self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertTrue('You have inverted' in cmap.get_hint('1_2_1'))
|
||||
class FormulaResponseTest(ResponseTest):
|
||||
from response_xml_factory import FormulaResponseXMLFactory
|
||||
xml_factory_class = FormulaResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
# Sample variables x and y in the range [-10, 10]
|
||||
sample_dict = {'x': (-10, 10), 'y': (-10, 10)}
|
||||
|
||||
# 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")
|
||||
|
||||
# Expect an equivalent formula to be marked correct
|
||||
# 2x - x + y + y = x + 2y
|
||||
input_formula = "2*x - x + y + y"
|
||||
self.assert_grade(problem, input_formula, "correct")
|
||||
|
||||
# Expect an incorrect formula to be marked incorrect
|
||||
# x + y != x + 2y
|
||||
input_formula = "x + y"
|
||||
self.assert_grade(problem, input_formula, "incorrect")
|
||||
|
||||
def test_hint(self):
|
||||
# Sample variables x and y in the range [-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')]
|
||||
|
||||
|
||||
class StringResponseWithHintTest(unittest.TestCase):
|
||||
'''
|
||||
Test String response problem with a hint
|
||||
'''
|
||||
def test_or_grade(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/stringresponse_with_hint.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'Michigan'}
|
||||
test_answers = {'1_2_1': 'Minnesota'}
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
cmap = test_lcp.grade_answers(test_answers)
|
||||
self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertTrue('St. Paul' in cmap.get_hint('1_2_1'))
|
||||
# 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)
|
||||
|
||||
# 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')
|
||||
|
||||
# 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')
|
||||
|
||||
|
||||
class CodeResponseTest(unittest.TestCase):
|
||||
'''
|
||||
Test CodeResponse
|
||||
TODO: Add tests for external grader messages
|
||||
'''
|
||||
def test_script(self):
|
||||
# Calculate the answer using a script
|
||||
script = "calculated_ans = 'x+x'"
|
||||
|
||||
# Sample x in the range [-10,10]
|
||||
sample_dict = {'x': (-10, 10)}
|
||||
|
||||
# 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)
|
||||
|
||||
# Expect that the inputs are graded correctly
|
||||
self.assert_grade(problem, '2*x', 'correct')
|
||||
self.assert_grade(problem, '3*x', 'incorrect')
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Exact string should be correct
|
||||
self.assert_grade(problem, "Second", "correct")
|
||||
|
||||
# Other strings and the lowercase version of the string are incorrect
|
||||
self.assert_grade(problem, "Other String", "incorrect")
|
||||
self.assert_grade(problem, "second", "incorrect")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
problem = self.build_problem(answer="Second", case_sensitive=False)
|
||||
|
||||
# Both versions of the string should be allowed, regardless
|
||||
# of capitalization
|
||||
self.assert_grade(problem, "Second", "correct")
|
||||
self.assert_grade(problem, "second", "correct")
|
||||
|
||||
# Other strings are not allowed
|
||||
self.assert_grade(problem, "Other String", "incorrect")
|
||||
|
||||
def test_hints(self):
|
||||
hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"),
|
||||
("minnesota", "minn", "The state capital of Minnesota is St. Paul")]
|
||||
|
||||
problem = self.build_problem(answer="Michigan",
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
# We should NOT get a hint for Michigan (the correct answer)
|
||||
input_dict = {'1_2_1': 'Michigan'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'), "")
|
||||
|
||||
# We should NOT get a hint for any other string
|
||||
input_dict = {'1_2_1': 'California'}
|
||||
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
|
||||
|
||||
def setUp(self):
|
||||
super(CodeResponseTest, self).setUp()
|
||||
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def make_queuestate(key, time):
|
||||
timestr = datetime.strftime(time, dateformat)
|
||||
@@ -258,171 +422,487 @@ class CodeResponseTest(unittest.TestCase):
|
||||
"""
|
||||
Simple test of whether LoncapaProblem knows when it's been queued
|
||||
"""
|
||||
problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml")
|
||||
with open(problem_file) as input_file:
|
||||
test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system)
|
||||
|
||||
answer_ids = sorted(test_lcp.get_question_answers())
|
||||
answer_ids = sorted(self.problem.get_question_answers())
|
||||
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the unqueued state
|
||||
cmap = CorrectMap()
|
||||
for answer_id in answer_ids:
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=None))
|
||||
test_lcp.correct_map.update(cmap)
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the unqueued state
|
||||
cmap = CorrectMap()
|
||||
for answer_id in answer_ids:
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=None))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
self.assertEquals(test_lcp.is_queued(), False)
|
||||
self.assertEquals(self.problem.is_queued(), False)
|
||||
|
||||
# Now we queue the LCP
|
||||
cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuestate = CodeResponseTest.make_queuestate(i, datetime.now())
|
||||
cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
test_lcp.correct_map.update(cmap)
|
||||
# Now we queue the LCP
|
||||
cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuestate = CodeResponseTest.make_queuestate(i, datetime.now())
|
||||
cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
self.assertEquals(test_lcp.is_queued(), True)
|
||||
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
|
||||
'''
|
||||
problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml")
|
||||
with open(problem_file) as input_file:
|
||||
test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system)
|
||||
answer_ids = sorted(self.problem.get_question_answers())
|
||||
|
||||
answer_ids = sorted(test_lcp.get_question_answers())
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the queued state
|
||||
old_cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now())
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the queued state
|
||||
old_cmap = CorrectMap()
|
||||
# Message format common to external graders
|
||||
grader_msg = '<span>MESSAGE</span>' # Must be valid XML
|
||||
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
|
||||
|
||||
xserver_msgs = {'correct': correct_score_msg,
|
||||
'incorrect': incorrect_score_msg, }
|
||||
|
||||
# Incorrect queuekey, state should not be updated
|
||||
for correctness in ['correct', 'incorrect']:
|
||||
self.problem.correct_map = CorrectMap()
|
||||
self.problem.correct_map.update(old_cmap) # Deep copy
|
||||
|
||||
self.problem.update_score(xserver_msgs[correctness], queuekey=0)
|
||||
self.assertEquals(self.problem.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison
|
||||
|
||||
for answer_id in answer_ids:
|
||||
self.assertTrue(self.problem.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered
|
||||
|
||||
# Correct queuekey, state should be updated
|
||||
for correctness in ['correct', 'incorrect']:
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now())
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
self.problem.correct_map = CorrectMap()
|
||||
self.problem.correct_map.update(old_cmap)
|
||||
|
||||
# Message format common to external graders
|
||||
grader_msg = '<span>MESSAGE</span>' # Must be valid XML
|
||||
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
|
||||
new_cmap = CorrectMap()
|
||||
new_cmap.update(old_cmap)
|
||||
npoints = 1 if correctness == 'correct' else 0
|
||||
new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None)
|
||||
|
||||
xserver_msgs = {'correct': correct_score_msg,
|
||||
'incorrect': incorrect_score_msg, }
|
||||
self.problem.update_score(xserver_msgs[correctness], queuekey=1000 + i)
|
||||
self.assertEquals(self.problem.correct_map.get_dict(), new_cmap.get_dict())
|
||||
|
||||
# Incorrect queuekey, state should not be updated
|
||||
for correctness in ['correct', 'incorrect']:
|
||||
test_lcp.correct_map = CorrectMap()
|
||||
test_lcp.correct_map.update(old_cmap) # Deep copy
|
||||
|
||||
test_lcp.update_score(xserver_msgs[correctness], queuekey=0)
|
||||
self.assertEquals(test_lcp.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison
|
||||
|
||||
for answer_id in answer_ids:
|
||||
self.assertTrue(test_lcp.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered
|
||||
|
||||
# Correct queuekey, state should be updated
|
||||
for correctness in ['correct', 'incorrect']:
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
test_lcp.correct_map = CorrectMap()
|
||||
test_lcp.correct_map.update(old_cmap)
|
||||
|
||||
new_cmap = CorrectMap()
|
||||
new_cmap.update(old_cmap)
|
||||
npoints = 1 if correctness == 'correct' else 0
|
||||
new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None)
|
||||
|
||||
test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i)
|
||||
self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict())
|
||||
|
||||
for j, test_id in enumerate(answer_ids):
|
||||
if j == i:
|
||||
self.assertFalse(test_lcp.correct_map.is_queued(test_id)) # Should be dequeued, message delivered
|
||||
else:
|
||||
self.assertTrue(test_lcp.correct_map.is_queued(test_id)) # Should be queued, message undelivered
|
||||
for j, test_id in enumerate(answer_ids):
|
||||
if j == i:
|
||||
self.assertFalse(self.problem.correct_map.is_queued(test_id)) # Should be dequeued, message delivered
|
||||
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
|
||||
'''
|
||||
problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml")
|
||||
with open(problem_file) as input_file:
|
||||
test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system)
|
||||
answer_ids = sorted(self.problem.get_question_answers())
|
||||
|
||||
answer_ids = sorted(test_lcp.get_question_answers())
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the unqueued state
|
||||
cmap = CorrectMap()
|
||||
for answer_id in answer_ids:
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=None))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the unqueued state
|
||||
cmap = CorrectMap()
|
||||
for answer_id in answer_ids:
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=None))
|
||||
test_lcp.correct_map.update(cmap)
|
||||
self.assertEquals(self.problem.get_recentmost_queuetime(), None)
|
||||
|
||||
self.assertEquals(test_lcp.get_recentmost_queuetime(), None)
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the queued state
|
||||
cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
latest_timestamp = datetime.now()
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp)
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the queued state
|
||||
cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
latest_timestamp = datetime.now()
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp)
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate))
|
||||
test_lcp.correct_map.update(cmap)
|
||||
# Queue state only tracks up to second
|
||||
latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat)
|
||||
|
||||
# Queue state only tracks up to second
|
||||
latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat)
|
||||
self.assertEquals(self.problem.get_recentmost_queuetime(), latest_timestamp)
|
||||
|
||||
self.assertEquals(test_lcp.get_recentmost_queuetime(), latest_timestamp)
|
||||
def test_convert_files_to_filenames(self):
|
||||
'''
|
||||
Test whether file objects are converted to filenames without altering other structures
|
||||
'''
|
||||
problem_file = os.path.join(os.path.dirname(__file__), "test_files/filename_convert_test.txt")
|
||||
with open(problem_file) as fp:
|
||||
answers_with_file = {'1_2_1': 'String-based answer',
|
||||
'1_3_1': ['answer1', 'answer2', 'answer3'],
|
||||
'1_4_1': [fp, fp]}
|
||||
answers_converted = convert_files_to_filenames(answers_with_file)
|
||||
self.assertEquals(answers_converted['1_2_1'], 'String-based answer')
|
||||
self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3'])
|
||||
self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name])
|
||||
|
||||
def test_convert_files_to_filenames(self):
|
||||
'''
|
||||
Test whether file objects are converted to filenames without altering other structures
|
||||
'''
|
||||
problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml")
|
||||
with open(problem_file) as fp:
|
||||
answers_with_file = {'1_2_1': 'String-based answer',
|
||||
'1_3_1': ['answer1', 'answer2', 'answer3'],
|
||||
'1_4_1': [fp, fp]}
|
||||
answers_converted = convert_files_to_filenames(answers_with_file)
|
||||
self.assertEquals(answers_converted['1_2_1'], 'String-based answer')
|
||||
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])
|
||||
|
||||
# Check that we get the expected results
|
||||
self.assert_grade(problem, 'choice_0', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_1', 'correct')
|
||||
self.assert_grade(problem, 'choice_2', 'incorrect')
|
||||
|
||||
# No choice 3 exists --> mark incorrect
|
||||
self.assert_grade(problem, 'choice_3', 'incorrect')
|
||||
|
||||
|
||||
class ChoiceResponseTest(unittest.TestCase):
|
||||
def test_checkbox_group_grade(self):
|
||||
problem = self.build_problem(choice_type='checkbox',
|
||||
choices=[False, True, True])
|
||||
|
||||
def test_cr_rb_grade(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/choiceresponse_radio.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'choice_2',
|
||||
'1_3_1': ['choice_2', 'choice_3']}
|
||||
test_answers = {'1_2_1': 'choice_2',
|
||||
'1_3_1': 'choice_2',
|
||||
}
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect')
|
||||
# Check that we get the expected results
|
||||
# (correct if and only if BOTH correct choices chosen)
|
||||
self.assert_grade(problem, ['choice_1', 'choice_2'], 'correct')
|
||||
self.assert_grade(problem, 'choice_1', 'incorrect')
|
||||
self.assert_grade(problem, 'choice_2', 'incorrect')
|
||||
self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect')
|
||||
self.assert_grade(problem, ['choice_0', 'choice_2'], 'incorrect')
|
||||
|
||||
def test_cr_cb_grade(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/choiceresponse_checkbox.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': 'choice_2',
|
||||
'1_3_1': ['choice_2', 'choice_3'],
|
||||
'1_4_1': ['choice_2', 'choice_3']}
|
||||
test_answers = {'1_2_1': 'choice_2',
|
||||
'1_3_1': 'choice_2',
|
||||
'1_4_1': ['choice_2', 'choice_3'],
|
||||
}
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_4_1'), 'correct')
|
||||
# No choice 3 exists --> mark incorrect
|
||||
self.assert_grade(problem, 'choice_3', 'incorrect')
|
||||
|
||||
|
||||
class JavascriptResponseTest(unittest.TestCase):
|
||||
class JavascriptResponseTest(ResponseTest):
|
||||
from response_xml_factory import JavascriptResponseXMLFactory
|
||||
xml_factory_class = JavascriptResponseXMLFactory
|
||||
|
||||
def test_jr_grade(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/javascriptresponse.xml"
|
||||
def test_grade(self):
|
||||
# Compile coffee files into javascript used by the response
|
||||
coffee_file_path = os.path.dirname(__file__) + "/test_files/js/*.coffee"
|
||||
os.system("coffee -c %s" % (coffee_file_path))
|
||||
test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': json.dumps({0: 4})}
|
||||
incorrect_answers = {'1_2_1': json.dumps({0: 5})}
|
||||
|
||||
self.assertEquals(test_lcp.grade_answers(incorrect_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
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'})
|
||||
|
||||
# Test that we get graded correctly
|
||||
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
|
||||
xml_factory_class = NumericalResponseXMLFactory
|
||||
|
||||
def test_grade_exact(self):
|
||||
problem = self.build_problem(question_text="What is 2 + 2?",
|
||||
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)
|
||||
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%")
|
||||
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)
|
||||
|
||||
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)
|
||||
correct_responses = ["2", "2.0"]
|
||||
incorrect_responses = ["", "2.01", "1.99", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
class CustomResponseTest(ResponseTest):
|
||||
from response_xml_factory import CustomResponseXMLFactory
|
||||
xml_factory_class = CustomResponseXMLFactory
|
||||
|
||||
def test_inline_code(self):
|
||||
|
||||
# For inline code, we directly modify global context variables
|
||||
# 'answers' is a list of answers provided to us
|
||||
# 'correct' is a list we fill in with True/False
|
||||
# 'expect' is given to us (if provided in the XML)
|
||||
inline_script = """correct[0] = 'correct' if (answers['1_2_1'] == expect) else 'incorrect'"""
|
||||
problem = self.build_problem(answer=inline_script, expect="42")
|
||||
|
||||
# Check results
|
||||
self.assert_grade(problem, '42', 'correct')
|
||||
self.assert_grade(problem, '0', 'incorrect')
|
||||
|
||||
def test_inline_message(self):
|
||||
|
||||
# Inline code can update the global messages list
|
||||
# to pass messages to the CorrectMap for a particular input
|
||||
# The code can also set the global overall_message (str)
|
||||
# to pass a message that applies to the whole response
|
||||
inline_script = textwrap.dedent("""
|
||||
messages[0] = "Test Message"
|
||||
overall_message = "Overall message"
|
||||
""")
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
correctmap = problem.grade_answers(input_dict)
|
||||
|
||||
# Check that the message for the particular input was received
|
||||
input_msg = correctmap.get_msg('1_2_1')
|
||||
self.assertEqual(input_msg, "Test Message")
|
||||
|
||||
# Check that the overall message (for the whole response) was received
|
||||
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:
|
||||
#
|
||||
# 'expect' is the expect attribute of the <customresponse>
|
||||
#
|
||||
# 'answer_given' is the answer the student gave (if there is just one input)
|
||||
# or an ordered list of answers (if there are multiple inputs)
|
||||
#
|
||||
#
|
||||
# The function should return a dict of the form
|
||||
# { 'ok': BOOL, 'msg': STRING }
|
||||
#
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'ok': answer_given == expect, 'msg': 'Message text'}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func", expect="42")
|
||||
|
||||
# Correct answer
|
||||
input_dict = {'1_2_1': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_1')
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
|
||||
self.assertEqual(correctness, 'correct')
|
||||
self.assertEqual(msg, "Message text")
|
||||
|
||||
# Incorrect answer
|
||||
input_dict = {'1_2_1': '0'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_1')
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
self.assertEqual(msg, "Message text")
|
||||
|
||||
def test_function_code_multiple_input_no_msg(self):
|
||||
|
||||
# Check functions also have the option of returning
|
||||
# a single boolean value
|
||||
# If true, mark all the inputs correct
|
||||
# If false, mark all the inputs incorrect
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return (answer_given[0] == expect and
|
||||
answer_given[1] == expect)
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func",
|
||||
expect="42", num_inputs=2)
|
||||
|
||||
# Correct answer -- expect both inputs marked correct
|
||||
input_dict = {'1_2_1': '42', '1_2_2': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_1')
|
||||
self.assertEqual(correctness, 'correct')
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_2')
|
||||
self.assertEqual(correctness, 'correct')
|
||||
|
||||
# One answer incorrect -- expect both inputs marked incorrect
|
||||
input_dict = {'1_2_1': '0', '1_2_2': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_1')
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_2')
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
|
||||
|
||||
def test_function_code_multiple_inputs(self):
|
||||
|
||||
# If the <customresponse> has multiple inputs associated with it,
|
||||
# the check function can return a dict of the form:
|
||||
#
|
||||
# {'overall_message': STRING,
|
||||
# 'input_list': [{'ok': BOOL, 'msg': STRING}, ...] }
|
||||
#
|
||||
# 'overall_message' is displayed at the end of the response
|
||||
#
|
||||
# 'input_list' contains dictionaries representing the correctness
|
||||
# and message for each input.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
check1 = (int(answer_given[0]) == 1)
|
||||
check2 = (int(answer_given[1]) == 2)
|
||||
check3 = (int(answer_given[2]) == 3)
|
||||
return {'overall_message': 'Overall message',
|
||||
'input_list': [
|
||||
{'ok': check1, 'msg': 'Feedback 1'},
|
||||
{'ok': check2, 'msg': 'Feedback 2'},
|
||||
{'ok': check3, 'msg': 'Feedback 3'} ] }
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script,
|
||||
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' }
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Expect that we receive the overall message (for the whole response)
|
||||
self.assertEqual(correct_map.get_overall_message(), "Overall message")
|
||||
|
||||
# Expect that the inputs were graded individually
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
|
||||
|
||||
# Expect that we received messages for each individual input
|
||||
self.assertEqual(correct_map.get_msg('1_2_1'), 'Feedback 1')
|
||||
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
|
||||
#
|
||||
# The sample script below marks the problem as correct
|
||||
# if and only if it receives answer_given=[1,2,3]
|
||||
# (or string values ['1','2','3'])
|
||||
#
|
||||
# Since we return a dict describing the status of one input,
|
||||
# we expect that the same 'ok' value is applied to each
|
||||
# of the inputs.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
check1 = (int(answer_given[0]) == 1)
|
||||
check2 = (int(answer_given[1]) == 2)
|
||||
check3 = (int(answer_given[2]) == 3)
|
||||
return {'ok': (check1 and check2 and check3),
|
||||
'msg': 'Message text'}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script,
|
||||
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' }
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Everything marked incorrect
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_2'), 'incorrect')
|
||||
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' }
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Everything marked incorrect
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
|
||||
|
||||
# Message is interpreted as an "overall message"
|
||||
self.assertEqual(correct_map.get_overall_message(), 'Message text')
|
||||
|
||||
def test_script_exception(self):
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
raise Exception("Test")
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(Exception):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
def test_invalid_dict_exception(self):
|
||||
|
||||
# Construct a script that passes back an invalid dict format
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'invalid': 'test'}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(Exception):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
|
||||
class SchematicResponseTest(ResponseTest):
|
||||
from response_xml_factory import SchematicResponseXMLFactory
|
||||
xml_factory_class = SchematicResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
|
||||
# Most of the schematic-specific work is handled elsewhere
|
||||
# (in client-side JavaScript)
|
||||
# The <schematicresponse> is responsible only for executing the
|
||||
# Python code in <answer> with *submission* (list)
|
||||
# in the global context.
|
||||
|
||||
# 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']"
|
||||
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) }
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Expect that the problem is graded as true
|
||||
# (That is, our script verifies that the context
|
||||
# is what we expect)
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
@@ -111,7 +111,7 @@ class DragAndDrop(object):
|
||||
Returns: bool.
|
||||
'''
|
||||
for draggable in self.excess_draggables:
|
||||
if not self.excess_draggables[draggable]:
|
||||
if self.excess_draggables[draggable]:
|
||||
return False # user answer has more draggables than correct answer
|
||||
|
||||
# Number of draggables in user_groups may be differ that in
|
||||
@@ -304,8 +304,13 @@ class DragAndDrop(object):
|
||||
|
||||
user_answer = json.loads(user_answer)
|
||||
|
||||
# check if we have draggables that are not in correct answer:
|
||||
self.excess_draggables = {}
|
||||
# This dictionary will hold a key for each draggable the user placed on
|
||||
# the image. The value is True if that draggable is not mentioned in any
|
||||
# correct_answer entries. If the draggable is mentioned in at least one
|
||||
# correct_answer entry, the value is False.
|
||||
# default to consider every user answer excess until proven otherwise.
|
||||
self.excess_draggables = dict((users_draggable.keys()[0],True)
|
||||
for users_draggable in user_answer['draggables'])
|
||||
|
||||
# create identical data structures from user answer and correct answer
|
||||
for i in xrange(0, len(correct_answer)):
|
||||
@@ -322,11 +327,8 @@ class DragAndDrop(object):
|
||||
self.user_groups[groupname].append(draggable_name)
|
||||
self.user_positions[groupname]['user'].append(
|
||||
draggable_dict[draggable_name])
|
||||
self.excess_draggables[draggable_name] = True
|
||||
else:
|
||||
self.excess_draggables[draggable_name] = \
|
||||
self.excess_draggables.get(draggable_name, False)
|
||||
|
||||
# proved that this is not excess
|
||||
self.excess_draggables[draggable_name] = False
|
||||
|
||||
def grade(user_input, correct_answer):
|
||||
""" Creates DragAndDrop instance from user_input and correct_answer and
|
||||
|
||||
@@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
|
||||
correct_answer = {'1': 't1', 'name_with_icon': 't2'}
|
||||
self.assertTrue(draganddrop.grade(user_input, correct_answer))
|
||||
|
||||
def test_expect_no_actions_wrong(self):
|
||||
user_input = '{"draggables": [{"1": "t1"}, \
|
||||
{"name_with_icon": "t2"}]}'
|
||||
correct_answer = []
|
||||
self.assertFalse(draganddrop.grade(user_input, correct_answer))
|
||||
|
||||
def test_expect_no_actions_right(self):
|
||||
user_input = '{"draggables": []}'
|
||||
correct_answer = []
|
||||
self.assertTrue(draganddrop.grade(user_input, correct_answer))
|
||||
|
||||
|
||||
def test_targets_false(self):
|
||||
user_input = '{"draggables": [{"1": "t1"}, \
|
||||
{"name_with_icon": "t2"}]}'
|
||||
|
||||
@@ -4,5 +4,5 @@ setup(
|
||||
name="capa",
|
||||
version="0.1",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
install_requires=['distribute', 'pyparsing'],
|
||||
install_requires=['distribute==0.6.34', 'pyparsing==1.5.6'],
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ class CapaModule(XModule):
|
||||
self.close_date = self.display_due_date
|
||||
|
||||
max_attempts = self.metadata.get('attempts', None)
|
||||
if max_attempts:
|
||||
if max_attempts is not None:
|
||||
self.max_attempts = int(max_attempts)
|
||||
else:
|
||||
self.max_attempts = None
|
||||
@@ -247,117 +247,176 @@ class CapaModule(XModule):
|
||||
'progress': Progress.to_js_status_str(self.get_progress())
|
||||
})
|
||||
|
||||
def check_button_name(self):
|
||||
"""
|
||||
Determine the name for the "check" button.
|
||||
Usually it is just "Check", but if this is the student's
|
||||
final attempt, change the name to "Final Check"
|
||||
"""
|
||||
if self.max_attempts is not None:
|
||||
final_check = (self.attempts >= self.max_attempts - 1)
|
||||
else:
|
||||
final_check = False
|
||||
|
||||
return "Final Check" if final_check else "Check"
|
||||
|
||||
def should_show_check_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Check" button.
|
||||
"""
|
||||
submitted_without_reset = (self.is_completed() and self.rerandomize == "always")
|
||||
|
||||
# If the problem is closed (past due / too many attempts)
|
||||
# then we do NOT show the "check" button
|
||||
# Also, do not show the "check" button if we're waiting
|
||||
# for the user to reset a randomized problem
|
||||
if self.closed() or submitted_without_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def should_show_reset_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Reset" button.
|
||||
"""
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
|
||||
if self.rerandomize in ["always", "onreset"]:
|
||||
|
||||
# If the problem is closed (and not a survey question with max_attempts==0),
|
||||
# then do NOT show the reset button.
|
||||
# If the problem hasn't been submitted yet, then do NOT show
|
||||
# the reset button.
|
||||
if (self.closed() and not is_survey_question) or not self.is_completed():
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
# Only randomized problems need a "reset" button
|
||||
else:
|
||||
return False
|
||||
|
||||
def should_show_save_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Save" button.
|
||||
"""
|
||||
|
||||
# If the user has forced the save button to display,
|
||||
# then show it as long as the problem is not closed
|
||||
# (past due / too many attempts)
|
||||
if self.force_save_button == "true":
|
||||
return not self.closed()
|
||||
else:
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
needs_reset = self.is_completed() and self.rerandomize == "always"
|
||||
|
||||
# If the problem is closed (and not a survey question with max_attempts==0),
|
||||
# then do NOT show the reset button
|
||||
# If we're waiting for the user to reset a randomized problem
|
||||
# then do NOT show the reset button
|
||||
if (self.closed() and not is_survey_question) or needs_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def handle_problem_html_error(self, err):
|
||||
"""
|
||||
Change our problem to a dummy problem containing
|
||||
a warning message to display to users.
|
||||
|
||||
Returns the HTML to show to users
|
||||
|
||||
*err* is the Exception encountered while rendering the problem HTML.
|
||||
"""
|
||||
log.exception(err)
|
||||
|
||||
# TODO (vshnayder): another switch on DEBUG.
|
||||
if self.system.DEBUG:
|
||||
msg = (
|
||||
'[courseware.capa.capa_module] <font size="+1" color="red">'
|
||||
'Failed to generate HTML for problem %s</font>' %
|
||||
(self.location.url()))
|
||||
msg += '<p>Error:</p><p><pre>%s</pre></p>' % str(err).replace('<', '<')
|
||||
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '<')
|
||||
html = msg
|
||||
|
||||
# We're in non-debug mode, and possibly even in production. We want
|
||||
# to avoid bricking of problem as much as possible
|
||||
else:
|
||||
|
||||
# Presumably, student submission has corrupted LoncapaProblem HTML.
|
||||
# First, pull down all student answers
|
||||
student_answers = self.lcp.student_answers
|
||||
answer_ids = student_answers.keys()
|
||||
|
||||
# Some inputtypes, such as dynamath, have additional "hidden" state that
|
||||
# is not exposed to the student. Keep those hidden
|
||||
# TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
|
||||
hidden_state_keywords = ['dynamath']
|
||||
for answer_id in answer_ids:
|
||||
for hidden_state_keyword in hidden_state_keywords:
|
||||
if answer_id.find(hidden_state_keyword) >= 0:
|
||||
student_answers.pop(answer_id)
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
state=None, # Tabula rasa
|
||||
seed=self.seed, system=self.system)
|
||||
|
||||
# Prepend a scary warning to the student
|
||||
warning = '<div class="capa_reset">'\
|
||||
'<h2>Warning: The problem has been reset to its initial state!</h2>'\
|
||||
'The problem\'s state was corrupted by an invalid submission. ' \
|
||||
'The submission consisted of:'\
|
||||
'<ul>'
|
||||
for student_answer in student_answers.values():
|
||||
if student_answer != '':
|
||||
warning += '<li>' + cgi.escape(student_answer) + '</li>'
|
||||
warning += '</ul>'\
|
||||
'If this error persists, please contact the course staff.'\
|
||||
'</div>'
|
||||
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
log.exception(err)
|
||||
raise
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def get_problem_html(self, encapsulate=True):
|
||||
'''Return html for the problem. Adds check, reset, save buttons
|
||||
as necessary based on the problem config and state.'''
|
||||
|
||||
try:
|
||||
html = self.lcp.get_html()
|
||||
|
||||
# If we cannot construct the problem HTML,
|
||||
# then generate an error message instead.
|
||||
except Exception, err:
|
||||
log.exception(err)
|
||||
html = self.handle_problem_html_error(err)
|
||||
|
||||
# TODO (vshnayder): another switch on DEBUG.
|
||||
if self.system.DEBUG:
|
||||
msg = (
|
||||
'[courseware.capa.capa_module] <font size="+1" color="red">'
|
||||
'Failed to generate HTML for problem %s</font>' %
|
||||
(self.location.url()))
|
||||
msg += '<p>Error:</p><p><pre>%s</pre></p>' % str(err).replace('<', '<')
|
||||
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '<')
|
||||
html = msg
|
||||
else:
|
||||
# We're in non-debug mode, and possibly even in production. We want
|
||||
# to avoid bricking of problem as much as possible
|
||||
|
||||
# Presumably, student submission has corrupted LoncapaProblem HTML.
|
||||
# First, pull down all student answers
|
||||
student_answers = self.lcp.student_answers
|
||||
answer_ids = student_answers.keys()
|
||||
|
||||
# Some inputtypes, such as dynamath, have additional "hidden" state that
|
||||
# is not exposed to the student. Keep those hidden
|
||||
# TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
|
||||
hidden_state_keywords = ['dynamath']
|
||||
for answer_id in answer_ids:
|
||||
for hidden_state_keyword in hidden_state_keywords:
|
||||
if answer_id.find(hidden_state_keyword) >= 0:
|
||||
student_answers.pop(answer_id)
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
state=None, # Tabula rasa
|
||||
seed=self.seed, system=self.system)
|
||||
|
||||
# Prepend a scary warning to the student
|
||||
warning = '<div class="capa_reset">'\
|
||||
'<h2>Warning: The problem has been reset to its initial state!</h2>'\
|
||||
'The problem\'s state was corrupted by an invalid submission. ' \
|
||||
'The submission consisted of:'\
|
||||
'<ul>'
|
||||
for student_answer in student_answers.values():
|
||||
if student_answer != '':
|
||||
warning += '<li>' + cgi.escape(student_answer) + '</li>'
|
||||
warning += '</ul>'\
|
||||
'If this error persists, please contact the course staff.'\
|
||||
'</div>'
|
||||
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
log.exception(err)
|
||||
raise
|
||||
# The convention is to pass the name of the check button
|
||||
# if we want to show a check button, and False otherwise
|
||||
# This works because non-empty strings evaluate to True
|
||||
if self.should_show_check_button():
|
||||
check_button = self.check_button_name()
|
||||
else:
|
||||
check_button = False
|
||||
|
||||
content = {'name': self.display_name,
|
||||
'html': html,
|
||||
'weight': self.descriptor.weight,
|
||||
}
|
||||
|
||||
# We using strings as truthy values, because the terminology of the
|
||||
# check button is context-specific.
|
||||
|
||||
# Put a "Check" button if unlimited attempts or still some left
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts - 1:
|
||||
check_button = "Check"
|
||||
else:
|
||||
# Will be final check so let user know that
|
||||
check_button = "Final Check"
|
||||
|
||||
reset_button = True
|
||||
save_button = True
|
||||
|
||||
# If we're after deadline, or user has exhausted attempts,
|
||||
# question is read-only.
|
||||
if self.closed():
|
||||
check_button = False
|
||||
reset_button = False
|
||||
save_button = False
|
||||
|
||||
# User submitted a problem, and hasn't reset. We don't want
|
||||
# more submissions.
|
||||
if self.lcp.done and self.rerandomize == "always":
|
||||
check_button = False
|
||||
save_button = False
|
||||
|
||||
# Only show the reset button if pressing it will show different values
|
||||
if self.rerandomize not in ["always", "onreset"]:
|
||||
reset_button = False
|
||||
|
||||
# User hasn't submitted an answer yet -- we don't want resets
|
||||
if not self.lcp.done:
|
||||
reset_button = False
|
||||
|
||||
# We may not need a "save" button if infinite number of attempts and
|
||||
# non-randomized. The problem author can force it. It's a bit weird for
|
||||
# randomization to control this; should perhaps be cleaned up.
|
||||
if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"):
|
||||
save_button = False
|
||||
|
||||
context = {'problem': content,
|
||||
'id': self.id,
|
||||
'check_button': check_button,
|
||||
'reset_button': reset_button,
|
||||
'save_button': save_button,
|
||||
'reset_button': self.should_show_reset_button(),
|
||||
'save_button': self.should_show_save_button(),
|
||||
'answer_available': self.answer_available(),
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'attempts_used': self.attempts,
|
||||
@@ -413,7 +472,7 @@ class CapaModule(XModule):
|
||||
|
||||
def closed(self):
|
||||
''' Is the student still allowed to submit answers? '''
|
||||
if self.attempts == self.max_attempts:
|
||||
if self.max_attempts is not None and self.attempts >= self.max_attempts:
|
||||
return True
|
||||
if self.is_past_due():
|
||||
return True
|
||||
@@ -429,6 +488,11 @@ class CapaModule(XModule):
|
||||
# used by conditional module
|
||||
return self.attempts > 0
|
||||
|
||||
def is_correct(self):
|
||||
"""True if full points"""
|
||||
d = self.get_score()
|
||||
return d['score'] == d['total']
|
||||
|
||||
def answer_available(self):
|
||||
'''
|
||||
Is the user allowed to see an answer?
|
||||
@@ -449,6 +513,9 @@ class CapaModule(XModule):
|
||||
return self.lcp.done
|
||||
elif self.show_answer == 'closed':
|
||||
return self.closed()
|
||||
elif self.show_answer == 'finished':
|
||||
return self.closed() or self.is_correct()
|
||||
|
||||
elif self.show_answer == 'past_due':
|
||||
return self.is_past_due()
|
||||
elif self.show_answer == 'always':
|
||||
@@ -514,21 +581,61 @@ class CapaModule(XModule):
|
||||
def make_dict_of_responses(get):
|
||||
'''Make dictionary of student responses (aka "answers")
|
||||
get is POST dictionary.
|
||||
|
||||
The *get* dict has keys of the form 'x_y', which are mapped
|
||||
to key 'y' in the returned dict. For example,
|
||||
'input_1_2_3' would be mapped to '1_2_3' in the returned dict.
|
||||
|
||||
Some inputs always expect a list in the returned dict
|
||||
(e.g. checkbox inputs). The convention is that
|
||||
keys in the *get* dict that end with '[]' will always
|
||||
have list values in the returned dict.
|
||||
For example, if the *get* dict contains {'input_1[]': 'test' }
|
||||
then the output dict would contain {'1': ['test'] }
|
||||
(the value is a list).
|
||||
|
||||
Raises an exception if:
|
||||
|
||||
A key in the *get* dictionary does not contain >= 1 underscores
|
||||
(e.g. "input" is invalid; "input_1" is valid)
|
||||
|
||||
Two keys end up with the same name in the returned dict.
|
||||
(e.g. 'input_1' and 'input_1[]', which both get mapped
|
||||
to 'input_1' in the returned dict)
|
||||
'''
|
||||
answers = dict()
|
||||
for key in get:
|
||||
# e.g. input_resistor_1 ==> resistor_1
|
||||
_, _, name = key.partition('_')
|
||||
|
||||
# This allows for answers which require more than one value for
|
||||
# the same form input (e.g. checkbox inputs). The convention is that
|
||||
# if the name ends with '[]' (which looks like an array), then the
|
||||
# answer will be an array.
|
||||
if not name.endswith('[]'):
|
||||
answers[name] = get[key]
|
||||
# If key has no underscores, then partition
|
||||
# will return (key, '', '')
|
||||
# We detect this and raise an error
|
||||
if name is '':
|
||||
raise ValueError("%s must contain at least one underscore" % str(key))
|
||||
|
||||
else:
|
||||
name = name[:-2]
|
||||
answers[name] = get.getlist(key)
|
||||
# This allows for answers which require more than one value for
|
||||
# the same form input (e.g. checkbox inputs). The convention is that
|
||||
# if the name ends with '[]' (which looks like an array), then the
|
||||
# answer will be an array.
|
||||
is_list_key = name.endswith('[]')
|
||||
name = name[:-2] if is_list_key else name
|
||||
|
||||
if is_list_key:
|
||||
if type(get[key]) is list:
|
||||
val = get[key]
|
||||
else:
|
||||
val = [get[key]]
|
||||
else:
|
||||
val = get[key]
|
||||
|
||||
# If the name already exists, then we don't want
|
||||
# to override it. Raise an error instead
|
||||
if name in answers:
|
||||
raise ValueError("Key %s already exists in answers dict" % str(name))
|
||||
else:
|
||||
answers[name] = val
|
||||
|
||||
return answers
|
||||
|
||||
@@ -536,7 +643,7 @@ class CapaModule(XModule):
|
||||
''' Checks whether answers to a problem are correct, and
|
||||
returns a map of correct/incorrect answers:
|
||||
|
||||
{'success' : bool,
|
||||
{'success' : 'correct' | 'incorrect' | AJAX alert msg string,
|
||||
'contents' : html}
|
||||
'''
|
||||
event_info = dict()
|
||||
@@ -595,11 +702,11 @@ class CapaModule(XModule):
|
||||
# 'success' will always be incorrect
|
||||
event_info['correct_map'] = correct_map.get_dict()
|
||||
event_info['success'] = success
|
||||
event_info['attempts'] = self.attempts
|
||||
event_info['attempts'] = self.attempts
|
||||
self.system.track_function('save_problem_check', event_info)
|
||||
|
||||
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.system.psychometrics_handler(self.get_instance_state())
|
||||
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.system.psychometrics_handler(self.get_instance_state())
|
||||
|
||||
# render problem into HTML
|
||||
html = self.get_problem_html(encapsulate=False)
|
||||
@@ -622,11 +729,11 @@ class CapaModule(XModule):
|
||||
event_info['answers'] = answers
|
||||
|
||||
# Too late. Cannot submit
|
||||
if self.closed():
|
||||
if self.closed() and not self.max_attempts==0:
|
||||
event_info['failure'] = 'closed'
|
||||
self.system.track_function('save_problem_fail', event_info)
|
||||
return {'success': False,
|
||||
'error': "Problem is closed"}
|
||||
'msg': "Problem is closed"}
|
||||
|
||||
# Problem submitted. Student should reset before saving
|
||||
# again.
|
||||
@@ -634,19 +741,27 @@ class CapaModule(XModule):
|
||||
event_info['failure'] = 'done'
|
||||
self.system.track_function('save_problem_fail', event_info)
|
||||
return {'success': False,
|
||||
'error': "Problem needs to be reset prior to save."}
|
||||
'msg': "Problem needs to be reset prior to save"}
|
||||
|
||||
self.lcp.student_answers = answers
|
||||
|
||||
# TODO: should this be save_problem_fail? Looks like success to me...
|
||||
self.system.track_function('save_problem_fail', event_info)
|
||||
return {'success': True}
|
||||
self.system.track_function('save_problem_success', event_info)
|
||||
msg = "Your answers have been saved"
|
||||
if not self.max_attempts==0:
|
||||
msg += " but not graded. Hit 'Check' to grade them."
|
||||
return {'success': True,
|
||||
'msg': msg}
|
||||
|
||||
def reset_problem(self, get):
|
||||
''' Changes problem state to unfinished -- removes student answers,
|
||||
and causes problem to rerender itself.
|
||||
|
||||
Returns problem html as { 'html' : html-string }.
|
||||
Returns a dictionary of the form:
|
||||
{'success': True/False,
|
||||
'html': Problem HTML string }
|
||||
|
||||
If an error occurs, the dictionary will also have an
|
||||
'error' key containing an error message.
|
||||
'''
|
||||
event_info = dict()
|
||||
event_info['old_state'] = self.lcp.get_state()
|
||||
@@ -669,6 +784,7 @@ class CapaModule(XModule):
|
||||
# reset random number generator seed (note the self.lcp.get_state()
|
||||
# in next line)
|
||||
self.lcp.seed = None
|
||||
|
||||
|
||||
self.lcp = LoncapaProblem(self.definition['data'],
|
||||
self.location.html_id(), self.lcp.get_state(),
|
||||
@@ -677,7 +793,8 @@ class CapaModule(XModule):
|
||||
event_info['new_state'] = self.lcp.get_state()
|
||||
self.system.track_function('reset_problem', event_info)
|
||||
|
||||
return {'html': self.get_problem_html(encapsulate=False)}
|
||||
return { 'success': True,
|
||||
'html': self.get_problem_html(encapsulate=False)}
|
||||
|
||||
|
||||
class CapaDescriptor(RawDescriptor):
|
||||
|
||||
@@ -4,9 +4,8 @@ from lxml import etree
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .editing_module import EditingDescriptor
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
@@ -134,7 +133,7 @@ class CombinedOpenEndedModule(XModule):
|
||||
}
|
||||
|
||||
self.child_descriptor = descriptors[version_index](self.system)
|
||||
self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['xml_string']), self.system)
|
||||
self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']), self.system)
|
||||
self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
|
||||
instance_state = json.dumps(instance_state), metadata = self.metadata, static_data= static_data)
|
||||
|
||||
@@ -165,11 +164,11 @@ class CombinedOpenEndedModule(XModule):
|
||||
return self.child_module.display_name
|
||||
|
||||
|
||||
class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
class CombinedOpenEndedDescriptor(RawDescriptor):
|
||||
"""
|
||||
Module for adding combined open ended questions
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
module_class = CombinedOpenEndedModule
|
||||
filename_extension = "xml"
|
||||
|
||||
@@ -177,35 +176,3 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
has_score = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the individual tasks, the rubric, and the prompt, and parse
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
|
||||
return {'xml_string' : etree.tostring(xml_object), 'metadata' : xml_object.attrib}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('combinedopenended')
|
||||
|
||||
def add_child(k):
|
||||
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
|
||||
child_node = etree.fromstring(child_str)
|
||||
elt.append(child_node)
|
||||
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
|
||||
@@ -35,7 +35,8 @@ class StaticContent(object):
|
||||
@staticmethod
|
||||
def compute_location(org, course, name, revision=None, is_thumbnail=False):
|
||||
name = name.replace('/', '_')
|
||||
return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail', Location.clean(name), revision])
|
||||
return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail',
|
||||
Location.clean_keeping_underscores(name), revision])
|
||||
|
||||
def get_id(self):
|
||||
return StaticContent.get_id_from_location(self.location)
|
||||
|
||||
@@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
|
||||
# disable the syllabus content for courses that do not provide a syllabus
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
self._grading_policy = {}
|
||||
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
|
||||
|
||||
self.test_center_exams = []
|
||||
@@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
grading_policy.update(course_policy)
|
||||
|
||||
# Here is where we should parse any configurations, so that we can fail early
|
||||
grading_policy['RAW_GRADER'] = grading_policy['GRADER'] # used for cms access
|
||||
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
|
||||
self._grading_policy = grading_policy
|
||||
|
||||
|
||||
# Use setters so that side effecting to .definitions works
|
||||
self.raw_grader = grading_policy['GRADER'] # used for cms access
|
||||
self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
@classmethod
|
||||
def read_grading_policy(cls, paths, system):
|
||||
@@ -319,7 +318,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
return self._grading_policy['GRADER']
|
||||
return grader_from_conf(self.raw_grader)
|
||||
|
||||
@property
|
||||
def raw_grader(self):
|
||||
@@ -378,6 +377,28 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
return bool(config.get("cohorted"))
|
||||
|
||||
@property
|
||||
def auto_cohort(self):
|
||||
"""
|
||||
Return whether the course is auto-cohorted.
|
||||
"""
|
||||
if not self.is_cohorted:
|
||||
return False
|
||||
|
||||
return bool(self.metadata.get("cohort_config", {}).get(
|
||||
"auto_cohort", False))
|
||||
|
||||
@property
|
||||
def auto_cohort_groups(self):
|
||||
"""
|
||||
Return the list of groups to put students into. Returns [] if not
|
||||
specified. Returns specified list even if is_cohorted and/or auto_cohort are
|
||||
false.
|
||||
"""
|
||||
return self.metadata.get("cohort_config", {}).get(
|
||||
"auto_cohort_groups", [])
|
||||
|
||||
|
||||
@property
|
||||
def top_level_discussion_topic_ids(self):
|
||||
"""
|
||||
@@ -714,7 +735,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
def get_test_center_exam(self, exam_series_code):
|
||||
exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code]
|
||||
return exams[0] if len(exams) == 1 else None
|
||||
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.display_name
|
||||
|
||||
@@ -40,7 +40,7 @@ section.problem {
|
||||
@include clearfix;
|
||||
|
||||
label.choicegroup_correct{
|
||||
text:after{
|
||||
&:after{
|
||||
content: url('../images/correct-icon.png');
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ section.problem {
|
||||
background: url('../images/correct-icon.png') center center no-repeat;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
top: 3px;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ section.problem {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
common/lib/xmodule/xmodule/css/foldit/leaderboard.scss
Normal file
20
common/lib/xmodule/xmodule/css/foldit/leaderboard.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
$leaderboard: #F4F4F4;
|
||||
|
||||
section.foldit {
|
||||
div.folditchallenge {
|
||||
table {
|
||||
border: 1px solid lighten($leaderboard, 10%);
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th {
|
||||
background: $leaderboard;
|
||||
color: darken($leaderboard, 25%);
|
||||
}
|
||||
td {
|
||||
background: lighten($leaderboard, 3%);
|
||||
border-bottom: 1px solid #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class FolditModule(XModule):
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
# ooh look--I'm lazy, so hardcoding the 7.00x required level.
|
||||
# If we need it generalized, can pull from the xml later
|
||||
self.required_level = 4
|
||||
self.required_sublevel = 5
|
||||
"""
|
||||
|
||||
Example:
|
||||
<foldit show_basic_score="true"
|
||||
required_level="4"
|
||||
required_sublevel="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
req_level = self.metadata.get("required_level")
|
||||
req_sublevel = self.metadata.get("required_sublevel")
|
||||
|
||||
# default to what Spring_7012x uses
|
||||
self.required_level = req_level if req_level else 4
|
||||
self.required_sublevel = req_sublevel if req_sublevel else 5
|
||||
|
||||
def parse_due_date():
|
||||
"""
|
||||
@@ -66,6 +79,14 @@ class FolditModule(XModule):
|
||||
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
|
||||
key=lambda d: (d['set'], d['subset']))
|
||||
|
||||
def puzzle_leaders(self, n=10):
|
||||
"""
|
||||
Returns a list of n pairs (user, score) corresponding to the top
|
||||
scores; the pairs are in descending order of score.
|
||||
"""
|
||||
from foldit.models import Score
|
||||
|
||||
return [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
@@ -75,15 +96,47 @@ class FolditModule(XModule):
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
showbasic = (self.metadata.get("show_basic_score").lower() == "true")
|
||||
showleader = (self.metadata.get("show_leaderboard").lower() == "true")
|
||||
context = {
|
||||
'due': self.due_str,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
'top_scores': self.puzzle_leaders(),
|
||||
'show_basic': showbasic,
|
||||
'show_leader': showleader,
|
||||
'folditbasic': self.get_basicpuzzles_html(),
|
||||
'folditchallenge': self.get_challenge_html()
|
||||
}
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
|
||||
def get_basicpuzzles_html(self):
|
||||
"""
|
||||
Render html for the basic puzzle section.
|
||||
"""
|
||||
goal_level = '{0}-{1}'.format(
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
context = {
|
||||
'due': self.due_str,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
}
|
||||
return self.system.render_template('folditbasic.html', context)
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
def get_challenge_html(self):
|
||||
"""
|
||||
Render html for challenge (i.e., the leaderboard)
|
||||
"""
|
||||
|
||||
context = {
|
||||
'top_scores': self.puzzle_leaders()}
|
||||
|
||||
return self.system.render_template('folditchallenge.html', context)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
@@ -97,9 +150,10 @@ class FolditModule(XModule):
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
class FolditDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding open ended response questions to courses
|
||||
Module for adding Foldit problems to courses
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = FolditModule
|
||||
@@ -119,6 +173,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
For now, don't need anything from the xml
|
||||
Get the xml_object's attributes.
|
||||
"""
|
||||
return {}
|
||||
return {'metadata': xml_object.attrib}
|
||||
|
||||
@@ -119,13 +119,13 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
|
||||
|
||||
<p>Enter the numerical value of Pi:</p>
|
||||
<numericalresponse answer="3.14159 ">
|
||||
<numericalresponse answer="3.14159">
|
||||
<responseparam type="tolerance" default=".02" />
|
||||
<textline />
|
||||
</numericalresponse>
|
||||
|
||||
<p>Enter the approximate value of 502*9:</p>
|
||||
<numericalresponse answer="4518 ">
|
||||
<numericalresponse answer="4518">
|
||||
<responseparam type="tolerance" default="15%" />
|
||||
<textline />
|
||||
</numericalresponse>
|
||||
@@ -147,6 +147,20 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
it 'will convert 0 as a numerical response (instead of string response)', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""
|
||||
Enter 0 with a tolerance:
|
||||
= 0 +- .02
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>Enter 0 with a tolerance:</p>
|
||||
<numericalresponse answer="0">
|
||||
<responseparam type="tolerance" default=".02" />
|
||||
<textline />
|
||||
</numericalresponse>
|
||||
|
||||
|
||||
</problem>""")
|
||||
it 'converts multiple choice to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
|
||||
|
||||
@@ -262,9 +262,8 @@ class @Problem
|
||||
save: =>
|
||||
Logger.log 'problem_save', @answers
|
||||
$.postWithPrefix "#{@url}/problem_save", @answers, (response) =>
|
||||
if response.success
|
||||
saveMessage = "Your answers have been saved but not graded. Hit 'Check' to grade them."
|
||||
@gentle_alert saveMessage
|
||||
saveMessage = response.msg
|
||||
@gentle_alert saveMessage
|
||||
@updateProgress response
|
||||
|
||||
refreshMath: (event, element) =>
|
||||
|
||||
@@ -4,7 +4,47 @@ class @Rubric
|
||||
@initialize: (location) ->
|
||||
$('.rubric').data("location", location)
|
||||
$('input[class="score-selection"]').change @tracking_callback
|
||||
# set up the hotkeys
|
||||
$(window).unbind('keydown', @keypress_callback)
|
||||
$(window).keydown @keypress_callback
|
||||
# display the 'current' carat
|
||||
@categories = $('.rubric-category')
|
||||
@category = $(@categories.first())
|
||||
@category.prepend('> ')
|
||||
@category_index = 0
|
||||
|
||||
|
||||
@keypress_callback: (event) =>
|
||||
# don't try to do this when user is typing in a text input
|
||||
if $(event.target).is('input, textarea')
|
||||
return
|
||||
# for when we select via top row
|
||||
if event.which >= 48 and event.which <= 57
|
||||
selected = event.which - 48
|
||||
# for when we select via numpad
|
||||
else if event.which >= 96 and event.which <= 105
|
||||
selected = event.which - 96
|
||||
# we don't want to do anything since we haven't pressed a number
|
||||
else
|
||||
return
|
||||
|
||||
# if we actually have a current category (not past the end)
|
||||
if(@category_index <= @categories.length)
|
||||
# find the valid selections for this category
|
||||
inputs = $("input[name='score-selection-#{@category_index}']")
|
||||
max_score = inputs.length - 1
|
||||
|
||||
if selected > max_score or selected < 0
|
||||
return
|
||||
inputs.filter("input[value=#{selected}]").click()
|
||||
|
||||
# move to the next category
|
||||
old_category_text = @category.html().substring(5)
|
||||
@category.html(old_category_text)
|
||||
@category_index++
|
||||
@category = $(@categories[@category_index])
|
||||
@category.prepend('> ')
|
||||
|
||||
@tracking_callback: (event) ->
|
||||
target_selection = $(event.target).val()
|
||||
# chop off the beginning of the name so that we can get the number of the category
|
||||
@@ -49,6 +89,7 @@ class @CombinedOpenEnded
|
||||
constructor: (element) ->
|
||||
@element=element
|
||||
@reinitialize(element)
|
||||
$(window).keydown @keydown_handler
|
||||
|
||||
reinitialize: (element) ->
|
||||
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
|
||||
@@ -306,6 +347,7 @@ class @CombinedOpenEnded
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
Rubric.initialize(@location)
|
||||
@answer_area.html(response.student_response)
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@@ -318,6 +360,11 @@ class @CombinedOpenEnded
|
||||
else
|
||||
@errors_area.html(@out_of_sync_message)
|
||||
|
||||
keydown_handler: (e) =>
|
||||
# only do anything when the key pressed is the 'enter' key
|
||||
if e.which == 13 && @child_state == 'assessing' && Rubric.check_complete()
|
||||
@save_assessment(e)
|
||||
|
||||
save_assessment: (event) =>
|
||||
event.preventDefault()
|
||||
if @child_state == 'assessing' && Rubric.check_complete()
|
||||
|
||||
@@ -210,6 +210,9 @@ class @PeerGradingProblem
|
||||
@calibration_interstitial_page_button = $('.calibration-interstitial-page-button')
|
||||
@flag_student_checkbox = $('.flag-checkbox')
|
||||
@answer_unknown_checkbox = $('.answer-unknown-checkbox')
|
||||
|
||||
$(window).keydown @keydown_handler
|
||||
|
||||
@collapse_question()
|
||||
|
||||
Collapsible.setCollapsibles(@content_panel)
|
||||
@@ -251,9 +254,6 @@ class @PeerGradingProblem
|
||||
fetch_submission_essay: () =>
|
||||
@backend.post('get_next_submission', {location: @location}, @render_submission)
|
||||
|
||||
gentle_alert: (msg) =>
|
||||
@grading_message.fadeIn()
|
||||
@grading_message.html("<p>" + msg + "</p>")
|
||||
|
||||
construct_data: () ->
|
||||
data =
|
||||
@@ -337,6 +337,14 @@ class @PeerGradingProblem
|
||||
@show_submit_button()
|
||||
@grade = Rubric.get_total_score()
|
||||
|
||||
keydown_handler: (event) =>
|
||||
if event.which == 13 && @submit_button.is(':visible')
|
||||
if @calibration
|
||||
@submit_calibration_essay()
|
||||
else
|
||||
@submit_grade()
|
||||
|
||||
|
||||
|
||||
|
||||
##########
|
||||
@@ -473,6 +481,10 @@ class @PeerGradingProblem
|
||||
# And now hook up an event handler again
|
||||
$("input[class='score-selection']").change @graded_callback
|
||||
|
||||
gentle_alert: (msg) =>
|
||||
@grading_message.fadeIn()
|
||||
@grading_message.html("<p>" + msg + "</p>")
|
||||
|
||||
collapse_question: () =>
|
||||
@prompt_container.slideToggle()
|
||||
@prompt_container.toggleClass('open')
|
||||
|
||||
@@ -231,13 +231,14 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
|
||||
// replace string and numerical
|
||||
xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) {
|
||||
var string;
|
||||
var params = /(.*?)\+\-\s*(.*?$)/.exec(p);
|
||||
if(parseFloat(p)) {
|
||||
var floatValue = parseFloat(p);
|
||||
if(!isNaN(floatValue)) {
|
||||
var params = /(.*?)\+\-\s*(.*?$)/.exec(p);
|
||||
if(params) {
|
||||
string = '<numericalresponse answer="' + params[1] + '">\n';
|
||||
string = '<numericalresponse answer="' + floatValue + '">\n';
|
||||
string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n';
|
||||
} else {
|
||||
string = '<numericalresponse answer="' + p + '">\n';
|
||||
string = '<numericalresponse answer="' + floatValue + '">\n';
|
||||
}
|
||||
string += ' <textline />\n';
|
||||
string += '</numericalresponse>\n\n';
|
||||
|
||||
@@ -23,6 +23,15 @@ URL_RE = re.compile("""
|
||||
(@(?P<revision>[^/]+))?
|
||||
""", re.VERBOSE)
|
||||
|
||||
MISSING_SLASH_URL_RE = re.compile("""
|
||||
(?P<tag>[^:]+):/
|
||||
(?P<org>[^/]+)/
|
||||
(?P<course>[^/]+)/
|
||||
(?P<category>[^/]+)/
|
||||
(?P<name>[^@]+)
|
||||
(@(?P<revision>[^/]+))?
|
||||
""", re.VERBOSE)
|
||||
|
||||
# TODO (cpennington): We should decide whether we want to expand the
|
||||
# list of valid characters in a location
|
||||
INVALID_CHARS = re.compile(r"[^\w.-]")
|
||||
@@ -62,6 +71,17 @@ class Location(_LocationBase):
|
||||
"""
|
||||
return Location._clean(value, INVALID_CHARS)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def clean_keeping_underscores(value):
|
||||
"""
|
||||
Return value, replacing INVALID_CHARS, but not collapsing multiple '_' chars.
|
||||
This for cleaning asset names, as the YouTube ID's may have underscores in them, and we need the
|
||||
transcript asset name to match. In the future we may want to change the behavior of _clean.
|
||||
"""
|
||||
return INVALID_CHARS.sub('_', value)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def clean_for_url_name(value):
|
||||
"""
|
||||
@@ -164,12 +184,16 @@ class Location(_LocationBase):
|
||||
if isinstance(location, basestring):
|
||||
match = URL_RE.match(location)
|
||||
if match is None:
|
||||
log.debug('location is instance of %s but no URL match' % basestring)
|
||||
raise InvalidLocationError(location)
|
||||
else:
|
||||
groups = match.groupdict()
|
||||
check_dict(groups)
|
||||
return _LocationBase.__new__(_cls, **groups)
|
||||
# cdodge:
|
||||
# check for a dropped slash near the i4x:// element of the location string. This can happen with some
|
||||
# redirects (e.g. edx.org -> www.edx.org which I think happens in Nginx)
|
||||
match = MISSING_SLASH_URL_RE.match(location)
|
||||
if match is None:
|
||||
log.debug('location is instance of %s but no URL match' % basestring)
|
||||
raise InvalidLocationError(location)
|
||||
groups = match.groupdict()
|
||||
check_dict(groups)
|
||||
return _LocationBase.__new__(_cls, **groups)
|
||||
elif isinstance(location, (list, tuple)):
|
||||
if len(location) not in (5, 6):
|
||||
log.debug('location has wrong length')
|
||||
|
||||
@@ -64,7 +64,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
|
||||
location = Location(location)
|
||||
json_data = self.module_data.get(location)
|
||||
if json_data is None:
|
||||
return self.modulestore.get_item(location)
|
||||
module = self.modulestore.get_item(location)
|
||||
if module is not None:
|
||||
# update our own cache after going to the DB to get cache miss
|
||||
self.module_data.update(module.system.module_data)
|
||||
return module
|
||||
else:
|
||||
# load the module and apply the inherited metadata
|
||||
try:
|
||||
@@ -157,10 +161,15 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
'''
|
||||
|
||||
# get all collections in the course, this query should not return any leaf nodes
|
||||
query = { '_id.org' : location.org,
|
||||
'_id.course' : location.course,
|
||||
'_id.revision' : None,
|
||||
'definition.children':{'$ne': []}
|
||||
query = {
|
||||
'_id.org': location.org,
|
||||
'_id.course': location.course,
|
||||
'$or': [
|
||||
{"_id.category":"course"},
|
||||
{"_id.category":"chapter"},
|
||||
{"_id.category":"sequential"},
|
||||
{"_id.category":"vertical"}
|
||||
]
|
||||
}
|
||||
# we just want the Location, children, and metadata
|
||||
record_filter = {'_id':1,'definition.children':1,'metadata':1}
|
||||
@@ -279,6 +288,13 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
resource_fs = OSFS(root)
|
||||
|
||||
metadata_inheritance_tree = None
|
||||
|
||||
# if we are loading a course object, there is no parent to inherit the metadata from
|
||||
# so don't bother getting it
|
||||
if item['location']['category'] != 'course':
|
||||
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']), 300)
|
||||
|
||||
# TODO (cdodge): When the 'split module store' work has been completed, we should remove
|
||||
# the 'metadata_inheritance_tree' parameter
|
||||
system = CachingDescriptorSystem(
|
||||
@@ -288,7 +304,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
resource_fs,
|
||||
self.error_tracker,
|
||||
self.render_template,
|
||||
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']), 60)
|
||||
metadata_inheritance_tree = metadata_inheritance_tree
|
||||
)
|
||||
return system.load_item(item['location'])
|
||||
|
||||
|
||||
@@ -5,128 +5,132 @@ from xmodule.modulestore.mongo import MongoModuleStore
|
||||
|
||||
|
||||
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
|
||||
# check to see if the dest_location exists as an empty course
|
||||
# we need an empty course because the app layers manage the permissions and users
|
||||
if not modulestore.has_item(dest_location):
|
||||
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
|
||||
|
||||
# verify that the dest_location really is an empty course, which means only one
|
||||
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
|
||||
|
||||
if len(dest_modules) != 1:
|
||||
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
|
||||
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
for module in modules:
|
||||
original_loc = Location(module.location)
|
||||
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course, name=dest_location.name)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
if 'data' in module.definition:
|
||||
modulestore.update_item(module.location, module.definition['data'])
|
||||
|
||||
# repoint children
|
||||
if 'children' in module.definition:
|
||||
new_children = []
|
||||
for child_loc_url in module.definition['children']:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
new_children = new_children + [child_loc.url()]
|
||||
|
||||
modulestore.update_children(module.location, new_children)
|
||||
|
||||
# save metadata
|
||||
modulestore.update_metadata(module.location, module.metadata)
|
||||
|
||||
# now iterate through all of the assets and clone them
|
||||
# first the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
content = contentstore.find(thumb_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
|
||||
# now iterate through all of the assets, also updating the thumbnail pointer
|
||||
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
content = contentstore.find(asset_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
# be sure to update the pointer to the thumbnail
|
||||
if content.thumbnail_location is not None:
|
||||
content.thumbnail_location = content.thumbnail_location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_course(modulestore, contentstore, source_location):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
# check to see if the dest_location exists as an empty course
|
||||
# we need an empty course because the app layers manage the permissions and users
|
||||
if not modulestore.has_item(dest_location):
|
||||
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
|
||||
|
||||
# first delete all of the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
id = StaticContent.get_id_from_location(thumb_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
contentstore.delete(id)
|
||||
# verify that the dest_location really is an empty course, which means only one
|
||||
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
|
||||
|
||||
# then delete all of the assets
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
contentstore.delete(id)
|
||||
if len(dest_modules) != 1:
|
||||
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
|
||||
|
||||
# then delete all course modules
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
|
||||
for module in modules:
|
||||
if module.category != 'course': # save deleting the course module for last
|
||||
print "Deleting {0}...".format(module.location)
|
||||
modulestore.delete_item(module.location)
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
|
||||
# finally delete the top-level course module itself
|
||||
print "Deleting {0}...".format(source_location)
|
||||
modulestore.delete_item(source_location)
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
return True
|
||||
for module in modules:
|
||||
original_loc = Location(module.location)
|
||||
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course, name=dest_location.name)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
if 'data' in module.definition:
|
||||
modulestore.update_item(module.location, module.definition['data'])
|
||||
|
||||
# repoint children
|
||||
if 'children' in module.definition:
|
||||
new_children = []
|
||||
for child_loc_url in module.definition['children']:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
new_children = new_children + [child_loc.url()]
|
||||
|
||||
modulestore.update_children(module.location, new_children)
|
||||
|
||||
# save metadata
|
||||
modulestore.update_metadata(module.location, module.metadata)
|
||||
|
||||
# now iterate through all of the assets and clone them
|
||||
# first the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
content = contentstore.find(thumb_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
|
||||
# now iterate through all of the assets, also updating the thumbnail pointer
|
||||
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
content = contentstore.find(asset_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
# be sure to update the pointer to the thumbnail
|
||||
if content.thumbnail_location is not None:
|
||||
content.thumbnail_location = content.thumbnail_location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_course(modulestore, contentstore, source_location, commit = False):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
|
||||
# first delete all of the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
id = StaticContent.get_id_from_location(thumb_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
if commit:
|
||||
contentstore.delete(id)
|
||||
|
||||
# then delete all of the assets
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
if commit:
|
||||
contentstore.delete(id)
|
||||
|
||||
# then delete all course modules
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
for module in modules:
|
||||
if module.category != 'course': # save deleting the course module for last
|
||||
print "Deleting {0}...".format(module.location)
|
||||
if commit:
|
||||
modulestore.delete_item(module.location)
|
||||
|
||||
# finally delete the top-level course module itself
|
||||
print "Deleting {0}...".format(source_location)
|
||||
if commit:
|
||||
modulestore.delete_item(source_location)
|
||||
|
||||
return True
|
||||
|
||||
@@ -7,47 +7,47 @@ from json import dumps
|
||||
|
||||
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir):
|
||||
|
||||
course = modulestore.get_item(course_location)
|
||||
course = modulestore.get_item(course_location)
|
||||
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
|
||||
xml = course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
xml = course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
|
||||
# export the static assets
|
||||
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
|
||||
# export the static assets
|
||||
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
|
||||
|
||||
# export the static tabs
|
||||
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
|
||||
# export the static tabs
|
||||
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
|
||||
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
|
||||
# export the grading policy
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
|
||||
if 'grading_policy' in course.definition['data']:
|
||||
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
|
||||
grading_policy.write(dumps(course.definition['data']['grading_policy']))
|
||||
# export the grading policy
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
|
||||
if 'grading_policy' in course.definition['data']:
|
||||
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
|
||||
grading_policy.write(dumps(course.definition['data']['grading_policy']))
|
||||
|
||||
# export all of the course metadata in policy.json
|
||||
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
|
||||
policy = {}
|
||||
policy = {'course/' + course.location.name: course.metadata}
|
||||
course_policy.write(dumps(policy))
|
||||
# export all of the course metadata in policy.json
|
||||
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
|
||||
policy = {}
|
||||
policy = {'course/' + course.location.name: course.metadata}
|
||||
course_policy.write(dumps(policy))
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix=''):
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
|
||||
if len(items) > 0:
|
||||
item_dir = export_fs.makeopendir(dirname)
|
||||
for item in items:
|
||||
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
|
||||
item_file.write(item.definition['data'].encode('utf8'))
|
||||
if len(items) > 0:
|
||||
item_dir = export_fs.makeopendir(dirname)
|
||||
for item in items:
|
||||
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
|
||||
item_file.write(item.definition['data'].encode('utf8'))
|
||||
|
||||
@@ -79,6 +79,9 @@ class CombinedOpenEndedV1Module():
|
||||
INTERMEDIATE_DONE = 'intermediate_done'
|
||||
DONE = 'done'
|
||||
|
||||
#Where the templates live for this problem
|
||||
TEMPLATE_DIR = "combinedopenended"
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs):
|
||||
|
||||
@@ -343,7 +346,7 @@ class CombinedOpenEndedV1Module():
|
||||
Output: rendered html
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
def get_html_nonsystem(self):
|
||||
@@ -354,7 +357,7 @@ class CombinedOpenEndedV1Module():
|
||||
Output: HTML rendered directly via Mako
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
def get_html_base(self):
|
||||
@@ -531,7 +534,7 @@ class CombinedOpenEndedV1Module():
|
||||
'task_name' : 'Scored Rubric',
|
||||
'class_name' : 'combined-rubric-container'
|
||||
}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_legend(self, get):
|
||||
@@ -543,7 +546,7 @@ class CombinedOpenEndedV1Module():
|
||||
context = {
|
||||
'legend_list' : LEGEND_LIST,
|
||||
}
|
||||
html = self.system.render_template('combined_open_ended_legend.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_results(self, get):
|
||||
@@ -574,7 +577,7 @@ class CombinedOpenEndedV1Module():
|
||||
'submission_id' : ri['submission_ids'][i],
|
||||
}
|
||||
context_list.append(context)
|
||||
feedback_table = self.system.render_template('open_ended_result_table.html', {
|
||||
feedback_table = self.system.render_template('{0}/open_ended_result_table.html'.format(self.TEMPLATE_DIR), {
|
||||
'context_list' : context_list,
|
||||
'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT,
|
||||
'human_grader_types' : HUMAN_GRADER_TYPE,
|
||||
@@ -586,7 +589,7 @@ class CombinedOpenEndedV1Module():
|
||||
'task_name' : "Feedback",
|
||||
'class_name' : "result-container",
|
||||
}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_status_ajax(self, get):
|
||||
@@ -700,7 +703,7 @@ class CombinedOpenEndedV1Module():
|
||||
'legend_list' : LEGEND_LIST,
|
||||
'render_via_ajax' : render_via_ajax,
|
||||
}
|
||||
status_html = self.system.render_template("combined_open_ended_status.html", context)
|
||||
status_html = self.system.render_template("{0}/combined_open_ended_status.html".format(self.TEMPLATE_DIR), context)
|
||||
|
||||
return status_html
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ class RubricParsingError(Exception):
|
||||
|
||||
class CombinedOpenEndedRubric(object):
|
||||
|
||||
TEMPLATE_DIR = "combinedopenended/openended"
|
||||
|
||||
def __init__ (self, system, view_only = False):
|
||||
self.has_score = False
|
||||
self.view_only = view_only
|
||||
@@ -57,9 +59,9 @@ class CombinedOpenEndedRubric(object):
|
||||
rubric_scores = [cat['score'] for cat in rubric_categories]
|
||||
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
|
||||
max_score = max(max_scores)
|
||||
rubric_template = 'open_ended_rubric.html'
|
||||
rubric_template = '{0}/open_ended_rubric.html'.format(self.TEMPLATE_DIR)
|
||||
if self.view_only:
|
||||
rubric_template = 'open_ended_view_only_rubric.html'
|
||||
rubric_template = '{0}/open_ended_view_only_rubric.html'.format(self.TEMPLATE_DIR)
|
||||
html = self.system.render_template(rubric_template,
|
||||
{'categories': rubric_categories,
|
||||
'has_score': self.has_score,
|
||||
@@ -207,7 +209,7 @@ class CombinedOpenEndedRubric(object):
|
||||
for grader_type in tuple[3]:
|
||||
rubric_categories[i]['options'][j]['grader_types'].append(grader_type)
|
||||
|
||||
html = self.system.render_template('open_ended_combined_rubric.html',
|
||||
html = self.system.render_template('{0}/open_ended_combined_rubric.html'.format(self.TEMPLATE_DIR),
|
||||
{'categories': rubric_categories,
|
||||
'has_score': True,
|
||||
'view_only': True,
|
||||
|
||||
@@ -40,6 +40,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
</openended>
|
||||
"""
|
||||
|
||||
TEMPLATE_DIR = "combinedopenended/openended"
|
||||
|
||||
def setup_response(self, system, location, definition, descriptor):
|
||||
"""
|
||||
Sets up the response type.
|
||||
@@ -397,10 +399,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
rubric_scores = rubric_dict['rubric_scores']
|
||||
|
||||
if not response_items['success']:
|
||||
return system.render_template("open_ended_error.html",
|
||||
return system.render_template("{0}/open_ended_error.html".format(self.TEMPLATE_DIR),
|
||||
{'errors': feedback})
|
||||
|
||||
feedback_template = system.render_template("open_ended_feedback.html", {
|
||||
feedback_template = system.render_template("{0}/open_ended_feedback.html".format(self.TEMPLATE_DIR), {
|
||||
'grader_type': response_items['grader_type'],
|
||||
'score': "{0} / {1}".format(response_items['score'], self.max_score()),
|
||||
'feedback': feedback,
|
||||
@@ -558,7 +560,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
@return: Rendered html
|
||||
"""
|
||||
context = {'msg': feedback, 'id': "1", 'rows': 50, 'cols': 50}
|
||||
html = system.render_template('open_ended_evaluation.html', context)
|
||||
html = system.render_template('{0}/open_ended_evaluation.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
def handle_ajax(self, dispatch, get, system):
|
||||
@@ -692,7 +694,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
'eta_message' : eta_string,
|
||||
}
|
||||
html = system.render_template('open_ended.html', context)
|
||||
html = system.render_template('{0}/open_ended.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from xmodule.stringify import stringify_children
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from capa.util import *
|
||||
from peer_grading_service import PeerGradingService
|
||||
from peer_grading_service import PeerGradingService, MockPeerGradingService
|
||||
import controller_query_service
|
||||
|
||||
from datetime import datetime
|
||||
@@ -106,8 +106,14 @@ class OpenEndedChild(object):
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
self._max_score = static_data['max_score']
|
||||
self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system)
|
||||
self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,system)
|
||||
if system.open_ended_grading_interface:
|
||||
self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system)
|
||||
self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,system)
|
||||
else:
|
||||
self.peer_gs = MockPeerGradingService()
|
||||
self.controller_qs = None
|
||||
|
||||
|
||||
|
||||
self.system = system
|
||||
|
||||
@@ -461,11 +467,14 @@ class OpenEndedChild(object):
|
||||
return success, allowed_to_submit, error_message
|
||||
|
||||
def get_eta(self):
|
||||
response = self.controller_qs.check_for_eta(self.location_string)
|
||||
try:
|
||||
response = json.loads(response)
|
||||
except:
|
||||
pass
|
||||
if self.controller_qs:
|
||||
response = self.controller_qs.check_for_eta(self.location_string)
|
||||
try:
|
||||
response = json.loads(response)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
return ""
|
||||
|
||||
success = response['success']
|
||||
if isinstance(success, basestring):
|
||||
|
||||
@@ -32,6 +32,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
</selfassessment>
|
||||
"""
|
||||
|
||||
TEMPLATE_DIR = "combinedopenended/selfassessment"
|
||||
|
||||
def setup_response(self, system, location, definition, descriptor):
|
||||
"""
|
||||
Sets up the module
|
||||
@@ -68,7 +70,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
html = system.render_template('self_assessment_prompt.html', context)
|
||||
html = system.render_template('{0}/self_assessment_prompt.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
|
||||
@@ -129,7 +131,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
#This is a dev_facing_error
|
||||
raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state))
|
||||
|
||||
return system.render_template('self_assessment_rubric.html', context)
|
||||
return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context)
|
||||
|
||||
def get_hint_html(self, system):
|
||||
"""
|
||||
@@ -155,7 +157,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
#This is a dev_facing_error
|
||||
raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state))
|
||||
|
||||
return system.render_template('self_assessment_hint.html', context)
|
||||
return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context)
|
||||
|
||||
|
||||
def save_answer(self, get, system):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user