Merge remote-tracking branch 'origin/master' into feature/alex/poll-merged
Conflicts: cms/djangoapps/contentstore/views.py common/lib/xmodule/xmodule/course_module.py common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py common/lib/xmodule/xmodule/peer_grading_module.py
This commit is contained in:
@@ -1,3 +1 @@
|
||||
from xmodule.templates import update_templates
|
||||
|
||||
update_templates()
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
@@ -12,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$')
|
||||
@@ -45,8 +48,24 @@ def i_save_a_new_section_release_date(step):
|
||||
|
||||
@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$')
|
||||
@@ -88,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)
|
||||
|
||||
@@ -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'
|
||||
@@ -72,6 +72,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']
|
||||
|
||||
@@ -290,10 +294,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.lms.display_name,
|
||||
template.location.url(),
|
||||
hasattr(template, 'markdown') and template.markdown != '',
|
||||
@@ -1107,6 +1132,7 @@ def module_info(request, module_location):
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
@@ -1122,12 +1148,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
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 |
@@ -59,11 +59,6 @@ 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -30,13 +30,18 @@ from contentstore import utils
|
||||
}).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>
|
||||
|
||||
@@ -512,7 +512,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.
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest):
|
||||
|
||||
# Inline code can update the global messages list
|
||||
# to pass messages to the CorrectMap for a particular input
|
||||
inline_script = """messages[0] = "Test Message" """
|
||||
# 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'}
|
||||
msg = problem.grade_answers(input_dict).get_msg('1_2_1')
|
||||
self.assertEqual(msg, "Test Message")
|
||||
correctmap = problem.grade_answers(input_dict)
|
||||
|
||||
def test_function_code(self):
|
||||
# Check that the message for the particular input was received
|
||||
input_msg = correctmap.get_msg('1_2_1')
|
||||
self.assertEqual(input_msg, "Test Message")
|
||||
|
||||
# For function code, we pass in three arguments:
|
||||
# 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)
|
||||
#
|
||||
# 'student_answers' is a dictionary of answers by input ID
|
||||
#
|
||||
#
|
||||
# The function should return a dict of the form
|
||||
# { 'ok': BOOL, 'msg': STRING }
|
||||
#
|
||||
script = """def check_func(expect, answer_given, student_answers):
|
||||
return {'ok': answer_given == expect, 'msg': 'Message text'}"""
|
||||
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")
|
||||
|
||||
@@ -698,7 +712,7 @@ class CustomResponseTest(ResponseTest):
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
|
||||
self.assertEqual(correctness, 'correct')
|
||||
self.assertEqual(msg, "Message text\n")
|
||||
self.assertEqual(msg, "Message text")
|
||||
|
||||
# Incorrect answer
|
||||
input_dict = {'1_2_1': '0'}
|
||||
@@ -708,19 +722,108 @@ class CustomResponseTest(ResponseTest):
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
self.assertEqual(msg, "Message text\n")
|
||||
self.assertEqual(msg, "Message text")
|
||||
|
||||
def test_multiple_inputs(self):
|
||||
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'])
|
||||
script = """def check_func(expect, answer_given, student_answers):
|
||||
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'}"""
|
||||
#
|
||||
# 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)
|
||||
@@ -743,6 +846,37 @@ class CustomResponseTest(ResponseTest):
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -225,7 +225,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.grading_policy)
|
||||
|
||||
self.test_center_exams = []
|
||||
@@ -295,9 +295,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):
|
||||
@@ -390,7 +390,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):
|
||||
|
||||
@@ -71,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):
|
||||
"""
|
||||
|
||||
@@ -139,7 +139,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:
|
||||
|
||||
@@ -77,6 +77,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):
|
||||
|
||||
@@ -334,7 +337,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):
|
||||
@@ -345,7 +348,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):
|
||||
@@ -522,7 +525,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):
|
||||
@@ -534,7 +537,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):
|
||||
@@ -565,7 +568,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,
|
||||
@@ -577,7 +580,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):
|
||||
@@ -691,7 +694,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
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@ import logging
|
||||
from lxml import etree
|
||||
|
||||
from xmodule.capa_module import ComplexEncoder
|
||||
from xmodule.editing_module import EditingDescriptor
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.stringify import stringify_children
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xblock.core import List, Integer, String, Scope
|
||||
import openendedchild
|
||||
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
@@ -33,27 +30,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
</selfassessment>
|
||||
"""
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
REQUEST_HINT = 'request_hint'
|
||||
DONE = 'done'
|
||||
|
||||
student_answers = List(scope=Scope.student_state, default=[])
|
||||
scores = List(scope=Scope.student_state, default=[])
|
||||
hints = List(scope=Scope.student_state, default=[])
|
||||
state = String(scope=Scope.student_state, default=INITIAL)
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
max_score = Integer(scope=Scope.settings, default=openendedchild.MAX_SCORE)
|
||||
max_attempts = Integer(scope=Scope.settings, default=openendedchild.MAX_ATTEMPTS)
|
||||
|
||||
attempts = Integer(scope=Scope.student_state, default=0)
|
||||
rubric = String(scope=Scope.content)
|
||||
prompt = String(scope=Scope.content)
|
||||
submitmessage = String(scope=Scope.content)
|
||||
hintprompt = String(scope=Scope.content)
|
||||
TEMPLATE_DIR = "combinedopenended/selfassessment"
|
||||
|
||||
def setup_response(self, system, location, definition, descriptor):
|
||||
"""
|
||||
@@ -91,7 +68,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
|
||||
|
||||
|
||||
@@ -152,7 +129,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):
|
||||
"""
|
||||
@@ -178,7 +155,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):
|
||||
|
||||
@@ -460,6 +460,9 @@ class PeerGradingModule(XModule):
|
||||
#This is a student_facing_error
|
||||
error_text = "Could not get list of problems to peer grade. Please notify course staff."
|
||||
success = False
|
||||
except:
|
||||
log.exception("Could not contact peer grading service.")
|
||||
success = False
|
||||
|
||||
|
||||
def _find_corresponding_module_for_location(location):
|
||||
@@ -562,3 +565,40 @@ class PeerGradingDescriptor(RawDescriptor):
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "peer_grading"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the individual tasks, the rubric, and the prompt, and parse
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
expected_children = []
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
#This is a staff_facing_error
|
||||
raise ValueError("Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(child))
|
||||
|
||||
def parse_task(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {}, []
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('peergrading')
|
||||
return elt
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Open Ended Response
|
||||
max_attempts: 1
|
||||
max_score: 1
|
||||
is_graded: False
|
||||
version: 1
|
||||
display_name: Open Ended Response
|
||||
skip_spelling_checks: False
|
||||
accept_file_upload: False
|
||||
data: |
|
||||
<combinedopenended>
|
||||
<rubric>
|
||||
<rubric>
|
||||
<category>
|
||||
<description>Category 1</description>
|
||||
<option>
|
||||
The response does not incorporate what is needed for a one response.
|
||||
</option>
|
||||
<option>
|
||||
The response is correct for category 1.
|
||||
</option>
|
||||
</category>
|
||||
</rubric>
|
||||
</rubric>
|
||||
<prompt>
|
||||
<p>Why is the sky blue?</p>
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment/>
|
||||
</task>
|
||||
<task>
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="2">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "peer_grading.conf", "problem_id" : "700x/Demo"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
|
||||
|
||||
children: []
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Peer Grading Interface
|
||||
attempts: 1
|
||||
use_for_single_location: False
|
||||
link_to_location: None
|
||||
is_graded: False
|
||||
max_grade: 1
|
||||
data: |
|
||||
<peergrading>
|
||||
</peergrading>
|
||||
|
||||
children: []
|
||||
@@ -19,9 +19,14 @@ class ContentTest(unittest.TestCase):
|
||||
|
||||
content = StaticContent('loc', 'name', 'content_type', 'data')
|
||||
self.assertIsNone(content.thumbnail_location)
|
||||
def test_generate_thumbnail_nonimage(self):
|
||||
def test_generate_thumbnail_image(self):
|
||||
contentStore = ContentStore()
|
||||
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters.jpg'), None)
|
||||
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters__.jpg'), None)
|
||||
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
|
||||
self.assertIsNone(thumbnail_content)
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg'), thumbnail_file_location)
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location)
|
||||
def test_compute_location(self):
|
||||
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
|
||||
# still happen.
|
||||
asset_location = StaticContent.compute_location('mitX', '400', 'subs__1eo_jXvZnE .srt.sjson')
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'400', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location)
|
||||
|
||||
72
common/static/js/vendor/backbone-min.js
vendored
72
common/static/js/vendor/backbone-min.js
vendored
@@ -1,40 +1,42 @@
|
||||
// Backbone.js 0.9.2
|
||||
// Backbone.js 0.9.10
|
||||
|
||||
// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
|
||||
// Backbone may be freely distributed under the MIT license.
|
||||
// For all details and documentation:
|
||||
// http://backbonejs.org
|
||||
(function(){var k=this,y=k.Backbone,z=Array.prototype.splice,g;g="undefined"!==typeof exports?exports:k.Backbone={};g.VERSION="0.9.2";var f=k._;!f&&"undefined"!==typeof require&&(f=require("underscore"));g.$=k.jQuery||k.Zepto||k.ender;g.noConflict=function(){k.Backbone=y;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var p=/\s+/,h=g.Events={on:function(a,b,c){var d,e;if(!b)return this;a=a.split(p);for(d=this._callbacks||(this._callbacks={});e=a.shift();)e=d[e]||(d[e]=[]),e.push(b,c);return this},
|
||||
off:function(a,b,c){var d,e,m;if(!(e=this._callbacks))return this;if(!a&&!b&&!c)return delete this._callbacks,this;for(a=a?a.split(p):f.keys(e);d=a.shift();)if(!(m=e[d])||!b&&!c)delete e[d];else for(d=m.length-2;0<=d;d-=2)b&&m[d]!==b||c&&m[d+1]!==c||m.splice(d,2);return this},trigger:function(a){var b,c,d,e,f,g,j;if(!(c=this._callbacks))return this;j=[];a=a.split(p);e=1;for(f=arguments.length;e<f;e++)j[e-1]=arguments[e];for(;b=a.shift();){if(g=c.all)g=g.slice();if(d=c[b])d=d.slice();if(d){e=0;for(f=
|
||||
d.length;e<f;e+=2)d[e].apply(d[e+1]||this,j)}if(g){b=[b].concat(j);e=0;for(f=g.length;e<f;e+=2)g[e].apply(g[e+1]||this,b)}}return this}};h.bind=h.on;h.unbind=h.off;var o=g.Model=function(a,b){var c;a||(a={});b&&b.collection&&(this.collection=b.collection);b&&b.parse&&(a=this.parse(a));if(c=l(this,"defaults"))a=f.extend({},c,a);this.attributes={};this._escapedAttributes={};this.cid=f.uniqueId("c");this.changed={};this._silent={};this._pending={};this.set(a,{silent:!0});this.changed={};this._silent=
|
||||
{};this._pending={};this._previousAttributes=f.clone(this.attributes);this.initialize.apply(this,arguments)};f.extend(o.prototype,h,{changed:null,_silent:null,_pending:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},sync:function(){return g.sync.apply(this,arguments)},get:function(a){return this.attributes[a]},escape:function(a){var b;if(b=this._escapedAttributes[a])return b;b=this.get(a);return this._escapedAttributes[a]=f.escape(null==b?"":""+b)},
|
||||
has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c||(c={});if(!d)return this;d instanceof o&&(d=d.attributes);if(c.unset)for(e in d)d[e]=void 0;if(!this._validate(d,c))return!1;this.idAttribute in d&&(this.id=d[this.idAttribute]);var b=c.changes={},g=this.attributes,i=this._escapedAttributes,j=this._previousAttributes||{};for(e in d){a=d[e];if(!f.isEqual(g[e],a)||c.unset&&f.has(g,e))delete i[e],(c.silent?this._silent:b)[e]=!0;c.unset?
|
||||
delete g[e]:g[e]=a;!f.isEqual(j[e],a)||f.has(g,e)!==f.has(j,e)?(this.changed[e]=a,c.silent||(this._pending[e]=!0)):(delete this.changed[e],delete this._pending[e])}c.silent||this.change(c);return this},unset:function(a,b){b=f.extend({},b,{unset:!0});return this.set(a,null,b)},clear:function(a){a=f.extend({},a,{unset:!0});return this.set(f.clone(this.attributes),a)},fetch:function(a){var a=a?f.clone(a):{},b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&c(b,d,a);b.trigger("sync",
|
||||
b,d,a)};a.error=g.wrapError(a.error,b,a);return this.sync("read",this,a)},save:function(a,b,c){var d,e,m;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c=c?f.clone(c):{};if(c.wait){if(!this._validate(d,c))return!1;e=f.clone(this.attributes)}a=f.extend({},c,{silent:!0});if(d&&!this.set(d,c.wait?a:c)||!d&&!this.isValid())return!1;var i=this,j=c.success;c.success=function(a,b,e){m=true;b=i.parse(a,e);c.wait&&(b=f.extend(d||{},b));if(!i.set(b,c))return false;j&&j(i,a,c);i.trigger("sync",i,a,c)};c.error=
|
||||
g.wrapError(c.error,i,c);b=this.sync(this.isNew()?"create":"update",this,c);!m&&c.wait&&(this.clear(a),this.set(e,a));return b},destroy:function(a){var a=a?f.clone(a):{},b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};a.success=function(e){(a.wait||b.isNew())&&d();c&&c(b,e,a);b.isNew()||b.trigger("sync",b,e,a)};if(this.isNew())return a.success(),!1;a.error=g.wrapError(a.error,b,a);var e=this.sync("delete",this,a);a.wait||d();return e},url:function(){var a=l(this,"urlRoot")||
|
||||
l(this.collection,"url")||s();return this.isNew()?a:a+("/"===a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return null==this.id},change:function(a){a||(a={});var b=this._changing;this._changing=!0;for(var c in this._silent)this._pending[c]=!0;var d=f.extend({},a.changes,this._silent);this._silent={};for(c in d)this.trigger("change:"+c,this,this.get(c),a);if(b)return this;for(;!f.isEmpty(this._pending);){this._pending=
|
||||
{};this.trigger("change",this,a);for(c in this.changed)!this._pending[c]&&!this._silent[c]&&delete this.changed[c];this._previousAttributes=f.clone(this.attributes)}this._changing=!1;return this},hasChanged:function(a){return null==a?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._previousAttributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return null==
|
||||
a||!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},isValid:function(){return!this.validate||!this.validate(this.attributes)},_validate:function(a,b){if(b.silent||!this.validate)return!0;var a=f.extend({},this.attributes,a),c=this.validate(a,b);if(!c)return!0;b&&b.error?b.error(this,c,b):this.trigger("error",this,c,b);return!1}});var q=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);void 0!==b.comparator&&
|
||||
(this.comparator=b.comparator);this._reset();this.initialize.apply(this,arguments);a&&(b.parse&&(a=this.parse(a)),this.reset(a,{silent:!0,parse:b.parse}))};f.extend(q.prototype,h,{model:o,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},sync:function(){return g.sync.apply(this,arguments)},add:function(a,b){var c,d,e,g,i,j={},k={},h=[];b||(b={});a=f.isArray(a)?a.slice():[a];c=0;for(d=a.length;c<d;c++){if(!(e=a[c]=this._prepareModel(a[c],b)))throw Error("Can't add an invalid model to a collection");
|
||||
g=e.cid;i=e.id;j[g]||this._byCid[g]||null!=i&&(k[i]||this._byId[i])?h.push(c):j[g]=k[i]=e}for(c=h.length;c--;)h[c]=a.splice(h[c],1)[0];c=0;for(d=a.length;c<d;c++)(e=a[c]).on("all",this._onModelEvent,this),this._byCid[e.cid]=e,null!=e.id&&(this._byId[e.id]=e);this.length+=d;z.apply(this.models,[null!=b.at?b.at:this.models.length,0].concat(a));if(b.merge){c=0;for(d=h.length;c<d;c++)(e=this._byId[h[c].id])&&e.set(h[c],b)}this.comparator&&null==b.at&&this.sort({silent:!0});if(b.silent)return this;c=0;
|
||||
for(d=this.models.length;c<d;c++)if(j[(e=this.models[c]).cid])b.index=c,e.trigger("add",e,this,b);return this},remove:function(a,b){var c,d,e,g;b||(b={});a=f.isArray(a)?a.slice():[a];c=0;for(d=a.length;c<d;c++)if(g=this.getByCid(a[c])||this.get(a[c]))delete this._byId[g.id],delete this._byCid[g.cid],e=this.indexOf(g),this.models.splice(e,1),this.length--,b.silent||(b.index=e,g.trigger("remove",g,this,b)),this._removeReference(g);return this},push:function(a,b){a=this._prepareModel(a,b);this.add(a,
|
||||
b);return a},pop:function(a){var b=this.at(this.length-1);this.remove(b,a);return b},unshift:function(a,b){a=this._prepareModel(a,b);this.add(a,f.extend({at:0},b));return a},shift:function(a){var b=this.at(0);this.remove(b,a);return b},slice:function(a,b){return this.models.slice(a,b)},get:function(a){return null==a?void 0:this._byId[null!=a.id?a.id:a]},getByCid:function(a){return a&&this._byCid[a.cid||a]},at:function(a){return this.models[a]},where:function(a){return f.isEmpty(a)?[]:this.filter(function(b){for(var c in a)if(a[c]!==
|
||||
b.get(c))return!1;return!0})},sort:function(a){a||(a={});if(!this.comparator)throw Error("Cannot sort a set without a comparator");var b=f.bind(this.comparator,this);1===this.comparator.length?this.models=this.sortBy(b):this.models.sort(b);a.silent||this.trigger("reset",this,a);return this},pluck:function(a){return f.map(this.models,function(b){return b.get(a)})},reset:function(a,b){a||(a=[]);b||(b={});for(var c=0,d=this.models.length;c<d;c++)this._removeReference(this.models[c]);this._reset();this.add(a,
|
||||
f.extend({silent:!0},b));b.silent||this.trigger("reset",this,b);return this},fetch:function(a){a=a?f.clone(a):{};void 0===a.parse&&(a.parse=!0);var b=this,c=a.success;a.success=function(d,e,f){b[a.add?"add":"reset"](b.parse(d,f),a);c&&c(b,d,a);b.trigger("sync",b,d,a)};a.error=g.wrapError(a.error,b,a);return this.sync("read",this,a)},create:function(a,b){var c=this,b=b?f.clone(b):{},a=this._prepareModel(a,b);if(!a)return!1;b.wait||c.add(a,b);var d=b.success;b.success=function(a,b,f){f.wait&&c.add(a,
|
||||
f);d&&d(a,b,f)};a.save(null,b);return a},parse:function(a){return a},clone:function(){return new this.constructor(this.models)},chain:function(){return f(this.models).chain()},_reset:function(){this.length=0;this.models=[];this._byId={};this._byCid={}},_prepareModel:function(a,b){if(a instanceof o)return a.collection||(a.collection=this),a;b||(b={});b.collection=this;var c=new this.model(a,b);return!c._validate(c.attributes,b)?!1:c},_removeReference:function(a){this===a.collection&&delete a.collection;
|
||||
a.off("all",this._onModelEvent,this)},_onModelEvent:function(a,b,c,d){("add"===a||"remove"===a)&&c!==this||("destroy"===a&&this.remove(b,d),b&&a==="change:"+b.idAttribute&&(delete this._byId[b.previous(b.idAttribute)],null!=b.id&&(this._byId[b.id]=b)),this.trigger.apply(this,arguments))}});f.each("forEach each map collect reduce foldl inject reduceRight foldr find detect filter select reject every all some any include contains invoke max min sortBy sortedIndex toArray size first head take initial rest tail last without indexOf shuffle lastIndexOf isEmpty groupBy".split(" "),
|
||||
function(a){q.prototype[a]=function(){return f[a].apply(f,[this.models].concat(f.toArray(arguments)))}});var t=g.Router=function(a){a||(a={});a.routes&&(this.routes=a.routes);this._bindRoutes();this.initialize.apply(this,arguments)},A=/:\w+/g,B=/\*\w+/g,C=/[-[\]{}()+?.,\\^$|#\s]/g;f.extend(t.prototype,h,{initialize:function(){},route:function(a,b,c){g.history||(g.history=new n);f.isRegExp(a)||(a=this._routeToRegExp(a));c||(c=this[b]);g.history.route(a,f.bind(function(d){d=this._extractParameters(a,
|
||||
d);c&&c.apply(this,d);this.trigger.apply(this,["route:"+b].concat(d));g.history.trigger("route",this,b,d)},this));return this},navigate:function(a,b){g.history.navigate(a,b)},_bindRoutes:function(){if(this.routes){var a=[],b;for(b in this.routes)a.unshift([b,this.routes[b]]);b=0;for(var c=a.length;b<c;b++)this.route(a[b][0],a[b][1],this[a[b][1]])}},_routeToRegExp:function(a){a=a.replace(C,"\\$&").replace(A,"([^/]+)").replace(B,"(.*?)");return RegExp("^"+a+"$")},_extractParameters:function(a,b){return a.exec(b).slice(1)}});
|
||||
var n=g.History=function(a){this.handlers=[];f.bindAll(this,"checkUrl");this.location=a&&a.location||k.location;this.history=a&&a.history||k.history},r=/^[#\/]/,D=/msie [\w.]+/,u=/\/$/;n.started=!1;f.extend(n.prototype,h,{interval:50,getHash:function(a){return(a=(a||this).location.href.match(/#(.*)$/))?a[1]:""},getFragment:function(a,b){if(null==a)if(this._hasPushState||!this._wantsHashChange||b){var a=this.location.pathname,c=this.options.root.replace(u,"");a.indexOf(c)||(a=a.substr(c.length))}else a=
|
||||
this.getHash();return decodeURIComponent(a.replace(r,""))},start:function(a){if(n.started)throw Error("Backbone.history has already been started");n.started=!0;this.options=f.extend({},{root:"/"},this.options,a);this._wantsHashChange=!1!==this.options.hashChange;this._wantsPushState=!!this.options.pushState;this._hasPushState=!(!this.options.pushState||!this.history||!this.history.pushState);var a=this.getFragment(),b=document.documentMode,b=D.exec(navigator.userAgent.toLowerCase())&&(!b||7>=b);u.test(this.options.root)||
|
||||
(this.options.root+="/");b&&this._wantsHashChange&&(this.iframe=g.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo("body")[0].contentWindow,this.navigate(a));this._hasPushState?g.$(window).bind("popstate",this.checkUrl):this._wantsHashChange&&"onhashchange"in window&&!b?g.$(window).bind("hashchange",this.checkUrl):this._wantsHashChange&&(this._checkUrlInterval=setInterval(this.checkUrl,this.interval));this.fragment=a;a=this.location;b=a.pathname.replace(/[^/]$/,"$&/")===this.options.root&&
|
||||
!a.search;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),this.location.replace(this.options.root+this.location.search+"#"+this.fragment),!0;this._wantsPushState&&(this._hasPushState&&b&&a.hash)&&(this.fragment=this.getHash().replace(r,""),this.history.replaceState({},document.title,a.protocol+"//"+a.host+this.options.root+this.fragment));if(!this.options.silent)return this.loadUrl()},stop:function(){g.$(window).unbind("popstate",
|
||||
this.checkUrl).unbind("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);n.started=!1},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a===this.fragment&&this.iframe&&(a=this.getFragment(this.getHash(this.iframe)));if(a===this.fragment)return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(this.getHash())},loadUrl:function(a){var b=this.fragment=this.getFragment(a);return f.any(this.handlers,function(a){if(a.route.test(b))return a.callback(b),
|
||||
!0})},navigate:function(a,b){if(!n.started)return!1;if(!b||!0===b)b={trigger:b};var c=(a||"").replace(r,"");if(this.fragment!==c){this.fragment=c;var d=(0!==c.indexOf(this.options.root)?this.options.root:"")+c;if(this._hasPushState)this.history[b.replace?"replaceState":"pushState"]({},document.title,d);else if(this._wantsHashChange)this._updateHash(this.location,c,b.replace),this.iframe&&c!==this.getFragment(this.getHash(this.iframe))&&(b.replace||this.iframe.document.open().close(),this._updateHash(this.iframe.location,
|
||||
c,b.replace));else return this.location.assign(d);b.trigger&&this.loadUrl(a)}},_updateHash:function(a,b,c){c?a.replace(a.href.replace(/(javascript:|#).*$/,"")+"#"+b):a.hash=b}});var v=g.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.initialize.apply(this,arguments);this.delegateEvents()},E=/^(\S+)\s*(.*)$/,w="model collection el id attributes className tagName".split(" ");f.extend(v.prototype,h,{tagName:"div",$:function(a){return this.$el.find(a)},initialize:function(){},
|
||||
render:function(){return this},dispose:function(){this.undelegateEvents();this.model&&this.model.off(null,null,this);this.collection&&this.collection.off(null,null,this);return this},remove:function(){this.dispose();this.$el.remove();return this},make:function(a,b,c){a=document.createElement(a);b&&g.$(a).attr(b);null!=c&&g.$(a).html(c);return a},setElement:function(a,b){this.$el&&this.undelegateEvents();this.$el=a instanceof g.$?a:g.$(a);this.el=this.$el[0];this.$delegateElement=this.$el;!1!==b&&
|
||||
this.delegateEvents();return this},delegateEvents:function(a){if(a||(a=l(this,"events"))){this.undelegateEvents();for(var b in a){var c=a[b];f.isFunction(c)||(c=this[a[b]]);if(!c)throw Error('Method "'+a[b]+'" does not exist');var d=b.match(E),e=d[1],d=d[2],c=f.bind(c,this),e=e+(".delegateEvents"+this.cid);""===d?this.$delegateElement.bind(e,c):this.$delegateElement.delegate(d,e,c)}}},undelegateEvents:function(){this.$el.unbind(".delegateEvents"+this.cid)},_configure:function(a){this.options&&(a=
|
||||
f.extend({},this.options,a));for(var b=0,c=w.length;b<c;b++){var d=w[b];a[d]&&(this[d]=a[d])}this.options=a},_ensureElement:function(){if(this.el)this.setElement(this.el,!1);else{var a=f.extend({},l(this,"attributes"));this.id&&(a.id=l(this,"id"));this.className&&(a["class"]=l(this,"className"));this.setElement(this.make(l(this,"tagName"),a),!1)}}});o.extend=q.extend=t.extend=v.extend=function(a,b){var c=this,d;d=a&&a.hasOwnProperty("constructor")?a.constructor:function(){c.apply(this,arguments)};
|
||||
f.extend(d,c);x.prototype=c.prototype;d.prototype=new x;a&&f.extend(d.prototype,a);b&&f.extend(d,b);d.prototype.constructor=d;d.__super__=c.prototype;return d};var F={create:"POST",update:"PUT","delete":"DELETE",read:"GET"};g.sync=function(a,b,c){var d=F[a];c||(c={});var e={type:d,dataType:"json"};c.url||(e.url=l(b,"url")||s());if(!c.data&&b&&("create"===a||"update"===a))e.contentType="application/json",e.data=JSON.stringify(b);g.emulateJSON&&(e.contentType="application/x-www-form-urlencoded",e.data=
|
||||
e.data?{model:e.data}:{});if(g.emulateHTTP&&("PUT"===d||"DELETE"===d))g.emulateJSON&&(e.data._method=d),e.type="POST",e.beforeSend=function(a){a.setRequestHeader("X-HTTP-Method-Override",d)};"GET"!==e.type&&!g.emulateJSON&&(e.processData=!1);return g.ajax(f.extend(e,c))};g.ajax=function(){return g.$.ajax.apply(g.$,arguments)};g.wrapError=function(a,b,c){return function(d,e){e=d===b?e:d;a?a(b,e,c):b.trigger("error",b,e,c)}};var x=function(){},l=function(a,b){return!a||!a[b]?null:f.isFunction(a[b])?
|
||||
a[b]():a[b]},s=function(){throw Error('A "url" property or function must be specified');}}).call(this);
|
||||
(function(){var n=this,B=n.Backbone,h=[],C=h.push,u=h.slice,D=h.splice,g;g="undefined"!==typeof exports?exports:n.Backbone={};g.VERSION="0.9.10";var f=n._;!f&&"undefined"!==typeof require&&(f=require("underscore"));g.$=n.jQuery||n.Zepto||n.ender;g.noConflict=function(){n.Backbone=B;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var v=/\s+/,q=function(a,b,c,d){if(!c)return!0;if("object"===typeof c)for(var e in c)a[b].apply(a,[e,c[e]].concat(d));else if(v.test(c)){c=c.split(v);e=0;for(var f=c.length;e<
|
||||
f;e++)a[b].apply(a,[c[e]].concat(d))}else return!0},w=function(a,b){var c,d=-1,e=a.length;switch(b.length){case 0:for(;++d<e;)(c=a[d]).callback.call(c.ctx);break;case 1:for(;++d<e;)(c=a[d]).callback.call(c.ctx,b[0]);break;case 2:for(;++d<e;)(c=a[d]).callback.call(c.ctx,b[0],b[1]);break;case 3:for(;++d<e;)(c=a[d]).callback.call(c.ctx,b[0],b[1],b[2]);break;default:for(;++d<e;)(c=a[d]).callback.apply(c.ctx,b)}},h=g.Events={on:function(a,b,c){if(!q(this,"on",a,[b,c])||!b)return this;this._events||(this._events=
|
||||
{});(this._events[a]||(this._events[a]=[])).push({callback:b,context:c,ctx:c||this});return this},once:function(a,b,c){if(!q(this,"once",a,[b,c])||!b)return this;var d=this,e=f.once(function(){d.off(a,e);b.apply(this,arguments)});e._callback=b;this.on(a,e,c);return this},off:function(a,b,c){var d,e,t,g,j,l,k,h;if(!this._events||!q(this,"off",a,[b,c]))return this;if(!a&&!b&&!c)return this._events={},this;g=a?[a]:f.keys(this._events);j=0;for(l=g.length;j<l;j++)if(a=g[j],d=this._events[a]){t=[];if(b||
|
||||
c){k=0;for(h=d.length;k<h;k++)e=d[k],(b&&b!==e.callback&&b!==e.callback._callback||c&&c!==e.context)&&t.push(e)}this._events[a]=t}return this},trigger:function(a){if(!this._events)return this;var b=u.call(arguments,1);if(!q(this,"trigger",a,b))return this;var c=this._events[a],d=this._events.all;c&&w(c,b);d&&w(d,arguments);return this},listenTo:function(a,b,c){var d=this._listeners||(this._listeners={}),e=a._listenerId||(a._listenerId=f.uniqueId("l"));d[e]=a;a.on(b,"object"===typeof b?this:c,this);
|
||||
return this},stopListening:function(a,b,c){var d=this._listeners;if(d){if(a)a.off(b,"object"===typeof b?this:c,this),!b&&!c&&delete d[a._listenerId];else{"object"===typeof b&&(c=this);for(var e in d)d[e].off(b,c,this);this._listeners={}}return this}}};h.bind=h.on;h.unbind=h.off;f.extend(g,h);var r=g.Model=function(a,b){var c,d=a||{};this.cid=f.uniqueId("c");this.attributes={};b&&b.collection&&(this.collection=b.collection);b&&b.parse&&(d=this.parse(d,b)||{});if(c=f.result(this,"defaults"))d=f.defaults({},
|
||||
d,c);this.set(d,b);this.changed={};this.initialize.apply(this,arguments)};f.extend(r.prototype,h,{changed:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},sync:function(){return g.sync.apply(this,arguments)},get:function(a){return this.attributes[a]},escape:function(a){return f.escape(this.get(a))},has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e,g,p,j,l,k;if(null==a)return this;"object"===typeof a?(e=a,c=b):(e={})[a]=b;c||(c={});
|
||||
if(!this._validate(e,c))return!1;g=c.unset;p=c.silent;a=[];j=this._changing;this._changing=!0;j||(this._previousAttributes=f.clone(this.attributes),this.changed={});k=this.attributes;l=this._previousAttributes;this.idAttribute in e&&(this.id=e[this.idAttribute]);for(d in e)b=e[d],f.isEqual(k[d],b)||a.push(d),f.isEqual(l[d],b)?delete this.changed[d]:this.changed[d]=b,g?delete k[d]:k[d]=b;if(!p){a.length&&(this._pending=!0);b=0;for(d=a.length;b<d;b++)this.trigger("change:"+a[b],this,k[a[b]],c)}if(j)return this;
|
||||
if(!p)for(;this._pending;)this._pending=!1,this.trigger("change",this,c);this._changing=this._pending=!1;return this},unset:function(a,b){return this.set(a,void 0,f.extend({},b,{unset:!0}))},clear:function(a){var b={},c;for(c in this.attributes)b[c]=void 0;return this.set(b,f.extend({},a,{unset:!0}))},hasChanged:function(a){return null==a?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._changing?
|
||||
this._previousAttributes:this.attributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return null==a||!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},fetch:function(a){a=a?f.clone(a):{};void 0===a.parse&&(a.parse=!0);var b=a.success;a.success=function(a,d,e){if(!a.set(a.parse(d,e),e))return!1;b&&b(a,d,e)};return this.sync("read",this,a)},save:function(a,b,c){var d,e,g=this.attributes;
|
||||
null==a||"object"===typeof a?(d=a,c=b):(d={})[a]=b;if(d&&(!c||!c.wait)&&!this.set(d,c))return!1;c=f.extend({validate:!0},c);if(!this._validate(d,c))return!1;d&&c.wait&&(this.attributes=f.extend({},g,d));void 0===c.parse&&(c.parse=!0);e=c.success;c.success=function(a,b,c){a.attributes=g;var k=a.parse(b,c);c.wait&&(k=f.extend(d||{},k));if(f.isObject(k)&&!a.set(k,c))return!1;e&&e(a,b,c)};a=this.isNew()?"create":c.patch?"patch":"update";"patch"===a&&(c.attrs=d);a=this.sync(a,this,c);d&&c.wait&&(this.attributes=
|
||||
g);return a},destroy:function(a){a=a?f.clone(a):{};var b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};a.success=function(a,b,e){(e.wait||a.isNew())&&d();c&&c(a,b,e)};if(this.isNew())return a.success(this,null,a),!1;var e=this.sync("delete",this,a);a.wait||d();return e},url:function(){var a=f.result(this,"urlRoot")||f.result(this.collection,"url")||x();return this.isNew()?a:a+("/"===a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},
|
||||
isNew:function(){return null==this.id},isValid:function(a){return!this.validate||!this.validate(this.attributes,a)},_validate:function(a,b){if(!b.validate||!this.validate)return!0;a=f.extend({},this.attributes,a);var c=this.validationError=this.validate(a,b)||null;if(!c)return!0;this.trigger("invalid",this,c,b||{});return!1}});var s=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);void 0!==b.comparator&&(this.comparator=b.comparator);this.models=[];this._reset();this.initialize.apply(this,
|
||||
arguments);a&&this.reset(a,f.extend({silent:!0},b))};f.extend(s.prototype,h,{model:r,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},sync:function(){return g.sync.apply(this,arguments)},add:function(a,b){a=f.isArray(a)?a.slice():[a];b||(b={});var c,d,e,g,p,j,l,k,h,m;l=[];k=b.at;h=this.comparator&&null==k&&!1!=b.sort;m=f.isString(this.comparator)?this.comparator:null;c=0;for(d=a.length;c<d;c++)(e=this._prepareModel(g=a[c],b))?(p=this.get(e))?b.merge&&(p.set(g===
|
||||
e?e.attributes:g,b),h&&(!j&&p.hasChanged(m))&&(j=!0)):(l.push(e),e.on("all",this._onModelEvent,this),this._byId[e.cid]=e,null!=e.id&&(this._byId[e.id]=e)):this.trigger("invalid",this,g,b);l.length&&(h&&(j=!0),this.length+=l.length,null!=k?D.apply(this.models,[k,0].concat(l)):C.apply(this.models,l));j&&this.sort({silent:!0});if(b.silent)return this;c=0;for(d=l.length;c<d;c++)(e=l[c]).trigger("add",e,this,b);j&&this.trigger("sort",this,b);return this},remove:function(a,b){a=f.isArray(a)?a.slice():[a];
|
||||
b||(b={});var c,d,e,g;c=0;for(d=a.length;c<d;c++)if(g=this.get(a[c]))delete this._byId[g.id],delete this._byId[g.cid],e=this.indexOf(g),this.models.splice(e,1),this.length--,b.silent||(b.index=e,g.trigger("remove",g,this,b)),this._removeReference(g);return this},push:function(a,b){a=this._prepareModel(a,b);this.add(a,f.extend({at:this.length},b));return a},pop:function(a){var b=this.at(this.length-1);this.remove(b,a);return b},unshift:function(a,b){a=this._prepareModel(a,b);this.add(a,f.extend({at:0},
|
||||
b));return a},shift:function(a){var b=this.at(0);this.remove(b,a);return b},slice:function(a,b){return this.models.slice(a,b)},get:function(a){if(null!=a)return this._idAttr||(this._idAttr=this.model.prototype.idAttribute),this._byId[a.id||a.cid||a[this._idAttr]||a]},at:function(a){return this.models[a]},where:function(a){return f.isEmpty(a)?[]:this.filter(function(b){for(var c in a)if(a[c]!==b.get(c))return!1;return!0})},sort:function(a){if(!this.comparator)throw Error("Cannot sort a set without a comparator");
|
||||
a||(a={});f.isString(this.comparator)||1===this.comparator.length?this.models=this.sortBy(this.comparator,this):this.models.sort(f.bind(this.comparator,this));a.silent||this.trigger("sort",this,a);return this},pluck:function(a){return f.invoke(this.models,"get",a)},update:function(a,b){b=f.extend({add:!0,merge:!0,remove:!0},b);b.parse&&(a=this.parse(a,b));var c,d,e,g,h=[],j=[],l={};f.isArray(a)||(a=a?[a]:[]);if(b.add&&!b.remove)return this.add(a,b);d=0;for(e=a.length;d<e;d++)c=a[d],g=this.get(c),
|
||||
b.remove&&g&&(l[g.cid]=!0),(b.add&&!g||b.merge&&g)&&h.push(c);if(b.remove){d=0;for(e=this.models.length;d<e;d++)c=this.models[d],l[c.cid]||j.push(c)}j.length&&this.remove(j,b);h.length&&this.add(h,b);return this},reset:function(a,b){b||(b={});b.parse&&(a=this.parse(a,b));for(var c=0,d=this.models.length;c<d;c++)this._removeReference(this.models[c]);b.previousModels=this.models.slice();this._reset();a&&this.add(a,f.extend({silent:!0},b));b.silent||this.trigger("reset",this,b);return this},fetch:function(a){a=
|
||||
a?f.clone(a):{};void 0===a.parse&&(a.parse=!0);var b=a.success;a.success=function(a,d,e){a[e.update?"update":"reset"](d,e);b&&b(a,d,e)};return this.sync("read",this,a)},create:function(a,b){b=b?f.clone(b):{};if(!(a=this._prepareModel(a,b)))return!1;b.wait||this.add(a,b);var c=this,d=b.success;b.success=function(a,b,f){f.wait&&c.add(a,f);d&&d(a,b,f)};a.save(null,b);return a},parse:function(a){return a},clone:function(){return new this.constructor(this.models)},_reset:function(){this.length=0;this.models.length=
|
||||
0;this._byId={}},_prepareModel:function(a,b){if(a instanceof r)return a.collection||(a.collection=this),a;b||(b={});b.collection=this;var c=new this.model(a,b);return!c._validate(a,b)?!1:c},_removeReference:function(a){this===a.collection&&delete a.collection;a.off("all",this._onModelEvent,this)},_onModelEvent:function(a,b,c,d){("add"===a||"remove"===a)&&c!==this||("destroy"===a&&this.remove(b,d),b&&a==="change:"+b.idAttribute&&(delete this._byId[b.previous(b.idAttribute)],null!=b.id&&(this._byId[b.id]=
|
||||
b)),this.trigger.apply(this,arguments))},sortedIndex:function(a,b,c){b||(b=this.comparator);var d=f.isFunction(b)?b:function(a){return a.get(b)};return f.sortedIndex(this.models,a,d,c)}});f.each("forEach each map collect reduce foldl inject reduceRight foldr find detect filter select reject every all some any include contains invoke max min toArray size first head take initial rest tail drop last without indexOf shuffle lastIndexOf isEmpty chain".split(" "),function(a){s.prototype[a]=function(){var b=
|
||||
u.call(arguments);b.unshift(this.models);return f[a].apply(f,b)}});f.each(["groupBy","countBy","sortBy"],function(a){s.prototype[a]=function(b,c){var d=f.isFunction(b)?b:function(a){return a.get(b)};return f[a](this.models,d,c)}});var y=g.Router=function(a){a||(a={});a.routes&&(this.routes=a.routes);this._bindRoutes();this.initialize.apply(this,arguments)},E=/\((.*?)\)/g,F=/(\(\?)?:\w+/g,G=/\*\w+/g,H=/[\-{}\[\]+?.,\\\^$|#\s]/g;f.extend(y.prototype,h,{initialize:function(){},route:function(a,b,c){f.isRegExp(a)||
|
||||
(a=this._routeToRegExp(a));c||(c=this[b]);g.history.route(a,f.bind(function(d){d=this._extractParameters(a,d);c&&c.apply(this,d);this.trigger.apply(this,["route:"+b].concat(d));this.trigger("route",b,d);g.history.trigger("route",this,b,d)},this));return this},navigate:function(a,b){g.history.navigate(a,b);return this},_bindRoutes:function(){if(this.routes)for(var a,b=f.keys(this.routes);null!=(a=b.pop());)this.route(a,this.routes[a])},_routeToRegExp:function(a){a=a.replace(H,"\\$&").replace(E,"(?:$1)?").replace(F,
|
||||
function(a,c){return c?a:"([^/]+)"}).replace(G,"(.*?)");return RegExp("^"+a+"$")},_extractParameters:function(a,b){return a.exec(b).slice(1)}});var m=g.History=function(){this.handlers=[];f.bindAll(this,"checkUrl");"undefined"!==typeof window&&(this.location=window.location,this.history=window.history)},z=/^[#\/]|\s+$/g,I=/^\/+|\/+$/g,J=/msie [\w.]+/,K=/\/$/;m.started=!1;f.extend(m.prototype,h,{interval:50,getHash:function(a){return(a=(a||this).location.href.match(/#(.*)$/))?a[1]:""},getFragment:function(a,
|
||||
b){if(null==a)if(this._hasPushState||!this._wantsHashChange||b){a=this.location.pathname;var c=this.root.replace(K,"");a.indexOf(c)||(a=a.substr(c.length))}else a=this.getHash();return a.replace(z,"")},start:function(a){if(m.started)throw Error("Backbone.history has already been started");m.started=!0;this.options=f.extend({},{root:"/"},this.options,a);this.root=this.options.root;this._wantsHashChange=!1!==this.options.hashChange;this._wantsPushState=!!this.options.pushState;this._hasPushState=!(!this.options.pushState||
|
||||
!this.history||!this.history.pushState);a=this.getFragment();var b=document.documentMode,b=J.exec(navigator.userAgent.toLowerCase())&&(!b||7>=b);this.root=("/"+this.root+"/").replace(I,"/");b&&this._wantsHashChange&&(this.iframe=g.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo("body")[0].contentWindow,this.navigate(a));if(this._hasPushState)g.$(window).on("popstate",this.checkUrl);else if(this._wantsHashChange&&"onhashchange"in window&&!b)g.$(window).on("hashchange",this.checkUrl);
|
||||
else this._wantsHashChange&&(this._checkUrlInterval=setInterval(this.checkUrl,this.interval));this.fragment=a;a=this.location;b=a.pathname.replace(/[^\/]$/,"$&/")===this.root;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),this.location.replace(this.root+this.location.search+"#"+this.fragment),!0;this._wantsPushState&&(this._hasPushState&&b&&a.hash)&&(this.fragment=this.getHash().replace(z,""),this.history.replaceState({},document.title,
|
||||
this.root+this.fragment+a.search));if(!this.options.silent)return this.loadUrl()},stop:function(){g.$(window).off("popstate",this.checkUrl).off("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);m.started=!1},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a===this.fragment&&this.iframe&&(a=this.getFragment(this.getHash(this.iframe)));if(a===this.fragment)return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(this.getHash())},
|
||||
loadUrl:function(a){var b=this.fragment=this.getFragment(a);return f.any(this.handlers,function(a){if(a.route.test(b))return a.callback(b),!0})},navigate:function(a,b){if(!m.started)return!1;if(!b||!0===b)b={trigger:b};a=this.getFragment(a||"");if(this.fragment!==a){this.fragment=a;var c=this.root+a;if(this._hasPushState)this.history[b.replace?"replaceState":"pushState"]({},document.title,c);else if(this._wantsHashChange)this._updateHash(this.location,a,b.replace),this.iframe&&a!==this.getFragment(this.getHash(this.iframe))&&
|
||||
(b.replace||this.iframe.document.open().close(),this._updateHash(this.iframe.location,a,b.replace));else return this.location.assign(c);b.trigger&&this.loadUrl(a)}},_updateHash:function(a,b,c){c?(c=a.href.replace(/(javascript:|#).*$/,""),a.replace(c+"#"+b)):a.hash="#"+b}});g.history=new m;var A=g.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.initialize.apply(this,arguments);this.delegateEvents()},L=/^(\S+)\s*(.*)$/,M="model collection el id attributes className tagName events".split(" ");
|
||||
f.extend(A.prototype,h,{tagName:"div",$:function(a){return this.$el.find(a)},initialize:function(){},render:function(){return this},remove:function(){this.$el.remove();this.stopListening();return this},setElement:function(a,b){this.$el&&this.undelegateEvents();this.$el=a instanceof g.$?a:g.$(a);this.el=this.$el[0];!1!==b&&this.delegateEvents();return this},delegateEvents:function(a){if(a||(a=f.result(this,"events"))){this.undelegateEvents();for(var b in a){var c=a[b];f.isFunction(c)||(c=this[a[b]]);
|
||||
if(!c)throw Error('Method "'+a[b]+'" does not exist');var d=b.match(L),e=d[1],d=d[2],c=f.bind(c,this),e=e+(".delegateEvents"+this.cid);if(""===d)this.$el.on(e,c);else this.$el.on(e,d,c)}}},undelegateEvents:function(){this.$el.off(".delegateEvents"+this.cid)},_configure:function(a){this.options&&(a=f.extend({},f.result(this,"options"),a));f.extend(this,f.pick(a,M));this.options=a},_ensureElement:function(){if(this.el)this.setElement(f.result(this,"el"),!1);else{var a=f.extend({},f.result(this,"attributes"));
|
||||
this.id&&(a.id=f.result(this,"id"));this.className&&(a["class"]=f.result(this,"className"));a=g.$("<"+f.result(this,"tagName")+">").attr(a);this.setElement(a,!1)}}});var N={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};g.sync=function(a,b,c){var d=N[a];f.defaults(c||(c={}),{emulateHTTP:g.emulateHTTP,emulateJSON:g.emulateJSON});var e={type:d,dataType:"json"};c.url||(e.url=f.result(b,"url")||x());if(null==c.data&&b&&("create"===a||"update"===a||"patch"===a))e.contentType="application/json",
|
||||
e.data=JSON.stringify(c.attrs||b.toJSON(c));c.emulateJSON&&(e.contentType="application/x-www-form-urlencoded",e.data=e.data?{model:e.data}:{});if(c.emulateHTTP&&("PUT"===d||"DELETE"===d||"PATCH"===d)){e.type="POST";c.emulateJSON&&(e.data._method=d);var h=c.beforeSend;c.beforeSend=function(a){a.setRequestHeader("X-HTTP-Method-Override",d);if(h)return h.apply(this,arguments)}}"GET"!==e.type&&!c.emulateJSON&&(e.processData=!1);var m=c.success;c.success=function(a){m&&m(b,a,c);b.trigger("sync",b,a,c)};
|
||||
var j=c.error;c.error=function(a){j&&j(b,a,c);b.trigger("error",b,a,c)};a=c.xhr=g.ajax(f.extend(e,c));b.trigger("request",b,a,c);return a};g.ajax=function(){return g.$.ajax.apply(g.$,arguments)};r.extend=s.extend=y.extend=A.extend=m.extend=function(a,b){var c=this,d;d=a&&f.has(a,"constructor")?a.constructor:function(){return c.apply(this,arguments)};f.extend(d,c,b);var e=function(){this.constructor=d};e.prototype=c.prototype;d.prototype=new e;a&&f.extend(d.prototype,a);d.__super__=c.prototype;return d};
|
||||
var x=function(){throw Error('A "url" property or function must be specified');}}).call(this);
|
||||
|
||||
@@ -357,6 +357,8 @@ Supported fields at the course level
|
||||
* `cohorted_discussions`: list of discussions that should be cohorted. Any not specified in this list are not cohorted.
|
||||
* `auto_cohort`: Truthy.
|
||||
* `auto_cohort_groups`: `["group name 1", "group name 2", ...]` If `cohorted` and `auto_cohort` is true, automatically put each student into a random group from the `auto_cohort_groups` list, creating the group if needed.
|
||||
* - `pdf_textbooks`
|
||||
- have pdf-based textbooks on tabs in the courseware. See below for details on config.
|
||||
|
||||
|
||||
Available metadata
|
||||
@@ -508,13 +510,15 @@ If you want to customize the courseware tabs displayed for your course, specify
|
||||
"url_slug": "news",
|
||||
"name": "Exciting news"
|
||||
},
|
||||
{"type": "textbooks"}
|
||||
{"type": "textbooks"},
|
||||
{"type": "pdf_textbooks"}
|
||||
]
|
||||
|
||||
* If you specify any tabs, you must specify all tabs. They will appear in the order given.
|
||||
* The first two tabs must have types `"courseware"` and `"course_info"`, in that order, or the course will not load.
|
||||
* The `courseware` tab never has a name attribute -- it's always rendered as "Courseware" for consistency between courses.
|
||||
* The `textbooks` tab will actually generate one tab per textbook, using the textbook titles as names.
|
||||
* The `pdf_textbooks` tab will actually generate one tab per pdf_textbook. The tab name is found in the pdf textbook definition.
|
||||
* For static tabs, the `url_slug` will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist.
|
||||
* An Instructor tab will be automatically added at the end for course staff users.
|
||||
|
||||
@@ -527,13 +531,15 @@ If you want to customize the courseware tabs displayed for your course, specify
|
||||
* - `course_info`
|
||||
- Parameter `name`.
|
||||
* - `wiki`
|
||||
- arameter `name`.
|
||||
- Parameter `name`.
|
||||
* - `discussion`
|
||||
- Parameter `name`.
|
||||
* - `external_link`
|
||||
- Parameters `name`, `link`.
|
||||
* - `textbooks`
|
||||
- No parameters--generates tab names from book titles.
|
||||
* - `pdf_textbooks`
|
||||
- No parameters--generates tab names from pdf book definition. (See discussion below for configuration.)
|
||||
* - `progress`
|
||||
- Parameter `name`.
|
||||
* - `static_tab`
|
||||
@@ -541,6 +547,39 @@ If you want to customize the courseware tabs displayed for your course, specify
|
||||
* - `staff_grading`
|
||||
- No parameters. If specified, displays the staff grading tab for instructors.
|
||||
|
||||
*********
|
||||
Textbooks
|
||||
*********
|
||||
Support is currently provided for image-based and PDF-based textbooks.
|
||||
|
||||
Image-based Textbooks
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
TBD.
|
||||
|
||||
PDF-based Textbooks
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
"pdf_textbooks": [
|
||||
{"tab_title": "Textbook 1",
|
||||
"url": "https://www.example.com/book1.pdf" },
|
||||
{"tab_title": "Textbook 2",
|
||||
"chapters": [
|
||||
{ "title": "Chapter 1", "url": "https://www.example.com/Chapter1.pdf" },
|
||||
{ "title": "Chapter 2", "url": "https://www.example.com/Chapter2.pdf" },
|
||||
{ "title": "Chapter 3", "url": "https://www.example.com/Chapter3.pdf" },
|
||||
{ "title": "Chapter 4", "url": "https://www.example.com/Chapter4.pdf" },
|
||||
{ "title": "Chapter 5", "url": "https://www.example.com/Chapter5.pdf" },
|
||||
{ "title": "Chapter 6", "url": "https://www.example.com/Chapter6.pdf" },
|
||||
{ "title": "Chapter 7", "url": "https://www.example.com/Chapter7.pdf" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
*************************************
|
||||
Other file locations (info and about)
|
||||
*************************************
|
||||
|
||||
142
doc/public/course_data_formats/custom_response.rst
Normal file
142
doc/public/course_data_formats/custom_response.rst
Normal file
@@ -0,0 +1,142 @@
|
||||
####################################
|
||||
CustomResponse XML and Python Script
|
||||
####################################
|
||||
|
||||
This document explains how to write a CustomResponse problem. CustomResponse
|
||||
problems execute Python script to check student answers and provide hints.
|
||||
|
||||
There are two general ways to create a CustomResponse problem:
|
||||
|
||||
|
||||
*****************
|
||||
Answer tag format
|
||||
*****************
|
||||
One format puts the Python code in an ``<answer>`` tag:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<problem>
|
||||
<p>What is the sum of 2 and 3?</p>
|
||||
|
||||
<customresponse expect="5">
|
||||
<textline math="1" />
|
||||
</customresponse>
|
||||
|
||||
<answer>
|
||||
# Python script goes here
|
||||
</answer>
|
||||
</problem>
|
||||
|
||||
|
||||
The Python script interacts with these variables in the global context:
|
||||
* ``answers``: An ordered list of answers the student provided.
|
||||
For example, if the student answered ``6``, then ``answers[0]`` would
|
||||
equal ``6``.
|
||||
* ``expect``: The value of the ``expect`` attribute of ``<customresponse>``
|
||||
(if provided).
|
||||
* ``correct``: An ordered list of strings indicating whether the
|
||||
student answered the question correctly. Valid values are
|
||||
``"correct"``, ``"incorrect"``, and ``"unknown"``. You can set these
|
||||
values in the script.
|
||||
* ``messages``: An ordered list of message strings that will be displayed
|
||||
beneath each input. You can use this to provide hints to users.
|
||||
For example ``messages[0] = "The capital of California is Sacramento"``
|
||||
would display that message beneath the first input of the response.
|
||||
* ``overall_message``: A string that will be displayed beneath the
|
||||
entire problem. You can use this to provide a hint that applies
|
||||
to the entire problem rather than a particular input.
|
||||
|
||||
Example of a checking script:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
if answers[0] == expect:
|
||||
correct[0] = 'correct'
|
||||
overall_message = 'Good job!'
|
||||
else:
|
||||
correct[0] = 'incorrect'
|
||||
messages[0] = 'This answer is incorrect'
|
||||
overall_message = 'Please try again'
|
||||
|
||||
**Important**: Python is picky about indentation. Within the ``<answer>`` tag,
|
||||
you must begin your script with no indentation.
|
||||
|
||||
*****************
|
||||
Script tag format
|
||||
*****************
|
||||
The other way to create a CustomResponse is to put a "checking function"
|
||||
in a ``<script>`` tag, then use the ``cfn`` attribute of the
|
||||
``<customresponse>`` tag:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<problem>
|
||||
<p>What is the sum of 2 and 3?</p>
|
||||
|
||||
<customresponse cfn="check_func" expect="5">
|
||||
<textline math="1" />
|
||||
</customresponse>
|
||||
|
||||
<script type="loncapa/python">
|
||||
def check_func(expect, ans):
|
||||
# Python script goes here
|
||||
</script>
|
||||
</problem>
|
||||
|
||||
|
||||
**Important**: Python is picky about indentation. Within the ``<script>`` tag,
|
||||
the ``def check_func(expect, ans):`` line must have no indentation.
|
||||
|
||||
The check function accepts two arguments:
|
||||
* ``expect`` is the value of the ``expect`` attribute of ``<customresponse>``
|
||||
(if provided)
|
||||
* ``answer`` is either:
|
||||
|
||||
* The value of the answer the student provided, if there is only one input.
|
||||
* An ordered list of answers the student provided, if there
|
||||
are multiple inputs.
|
||||
|
||||
There are several ways that the check function can indicate whether the student
|
||||
succeeded. The check function can return any of the following:
|
||||
|
||||
* ``True``: Indicates that the student answered correctly for all inputs.
|
||||
* ``False``: Indicates that the student answered incorrectly.
|
||||
All inputs will be marked incorrect.
|
||||
* A dictionary of the form: ``{ 'ok': True, 'msg': 'Message' }``
|
||||
If the dictionary's value for ``ok`` is set to ``True``, all inputs are
|
||||
marked correct; if it is set to ``False``, all inputs are marked incorrect.
|
||||
The ``msg`` is displayed beneath all inputs, and it may contain
|
||||
XHTML markup.
|
||||
* A dictionary of the form
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
|
||||
{ 'overall_message': 'Overall message',
|
||||
'input_list': [
|
||||
{ 'ok': True, 'msg': 'Feedback for input 1'},
|
||||
{ 'ok': False, 'msg': 'Feedback for input 2'},
|
||||
... ] }
|
||||
|
||||
The last form is useful for responses that contain multiple inputs.
|
||||
It allows you to provide feedback for each input individually,
|
||||
as well as a message that applies to the entire response.
|
||||
|
||||
Example of a checking function:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
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'} ] }
|
||||
|
||||
The function checks that the user entered ``1`` for the first input,
|
||||
``2`` for the second input, and ``3`` for the third input.
|
||||
It provides feedback messages for each individual input, as well
|
||||
as a message displayed beneath the entire problem.
|
||||
@@ -24,6 +24,7 @@ Specific Problem Types
|
||||
|
||||
course_data_formats/drag_and_drop/drag_and_drop_input.rst
|
||||
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
|
||||
course_data_formats/custom_response.rst
|
||||
|
||||
|
||||
Internal Data Formats
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import unittest
|
||||
import logging
|
||||
import time
|
||||
from mock import Mock
|
||||
from mock import Mock, MagicMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from factories import CourseEnrollmentAllowedFactory
|
||||
from xmodule.timeparse import parse_time
|
||||
from xmodule.x_module import XModule, XModuleDescriptor
|
||||
import courseware.access as access
|
||||
from factories import CourseEnrollmentAllowedFactory
|
||||
|
||||
|
||||
class AccessTestCase(TestCase):
|
||||
|
||||
170
lms/djangoapps/courseware/tests/test_module_render.py
Normal file
170
lms/djangoapps/courseware/tests/test_module_render.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import logging
|
||||
from mock import MagicMock, patch
|
||||
import json
|
||||
import factory
|
||||
import unittest
|
||||
from nose.tools import set_trace
|
||||
|
||||
from django.http import Http404, HttpResponse, HttpRequest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
import courseware.module_render as render
|
||||
from xmodule.modulestore.django import modulestore, _MODULESTORES
|
||||
from xmodule.seq_module import SequenceModule
|
||||
from courseware.tests.tests import PageLoader
|
||||
from student.models import Registration
|
||||
from courseware.model_data import ModelDataCache
|
||||
|
||||
from factories import UserFactory
|
||||
|
||||
|
||||
class Stub:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
def xml_store_config(data_dir):
|
||||
return {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
'OPTIONS': {
|
||||
'data_dir': data_dir,
|
||||
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class ModuleRenderTestCase(PageLoader):
|
||||
def setUp(self):
|
||||
self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview']
|
||||
self._MODULESTORES = {}
|
||||
self.course_id = 'edX/toy/2012_Fall'
|
||||
self.toy_course = modulestore().get_course(self.course_id)
|
||||
|
||||
def test_get_module(self):
|
||||
self.assertIsNone(render.get_module('dummyuser', None,
|
||||
'invalid location', None, None))
|
||||
|
||||
def test_modx_dispatch(self):
|
||||
self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy',
|
||||
'invalid Location', 'dummy')
|
||||
mock_request = MagicMock()
|
||||
mock_request.FILES.keys.return_value = ['file_id']
|
||||
mock_request.FILES.getlist.return_value = ['file'] * (settings.MAX_FILEUPLOADS_PER_INPUT + 1)
|
||||
self.assertEquals(render.modx_dispatch(mock_request, 'dummy', self.location,
|
||||
'dummy').content,
|
||||
json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' %
|
||||
settings.MAX_FILEUPLOADS_PER_INPUT}))
|
||||
mock_request_2 = MagicMock()
|
||||
mock_request_2.FILES.keys.return_value = ['file_id']
|
||||
inputfile = Stub()
|
||||
inputfile.size = 1 + settings.STUDENT_FILEUPLOAD_MAX_SIZE
|
||||
inputfile.name = 'name'
|
||||
filelist = [inputfile]
|
||||
mock_request_2.FILES.getlist.return_value = filelist
|
||||
self.assertEquals(render.modx_dispatch(mock_request_2, 'dummy', self.location,
|
||||
'dummy').content,
|
||||
json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %
|
||||
(inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))}))
|
||||
mock_request_3 = MagicMock()
|
||||
mock_request_3.POST.copy.return_value = {}
|
||||
mock_request_3.FILES = False
|
||||
mock_request_3.user = UserFactory()
|
||||
inputfile_2 = Stub()
|
||||
inputfile_2.size = 1
|
||||
inputfile_2.name = 'name'
|
||||
self.assertRaises(ItemNotFoundError, render.modx_dispatch,
|
||||
mock_request_3, 'dummy', self.location, 'toy')
|
||||
self.assertRaises(Http404, render.modx_dispatch, mock_request_3, 'dummy',
|
||||
self.location, self.course_id)
|
||||
mock_request_3.POST.copy.return_value = {'position': 1}
|
||||
self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position',
|
||||
self.location, self.course_id), HttpResponse)
|
||||
|
||||
def test_get_score_bucket(self):
|
||||
self.assertEquals(render.get_score_bucket(0, 10), 'incorrect')
|
||||
self.assertEquals(render.get_score_bucket(1, 10), 'partial')
|
||||
self.assertEquals(render.get_score_bucket(10, 10), 'correct')
|
||||
# get_score_bucket calls error cases 'incorrect'
|
||||
self.assertEquals(render.get_score_bucket(11, 10), 'incorrect')
|
||||
self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect')
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestTOC(TestCase):
|
||||
"""Check the Table of Contents for a course"""
|
||||
def setUp(self):
|
||||
self._MODULESTORES = {}
|
||||
|
||||
# Toy courses should be loaded
|
||||
self.course_name = 'edX/toy/2012_Fall'
|
||||
self.toy_course = modulestore().get_course(self.course_name)
|
||||
self.portal_user = UserFactory()
|
||||
|
||||
def test_toc_toy_from_chapter(self):
|
||||
chapter = 'Overview'
|
||||
chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter)
|
||||
factory = RequestFactory()
|
||||
request = factory.get(chapter_url)
|
||||
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
|
||||
self.toy_course.id, self.portal_user, self.toy_course, depth=2)
|
||||
|
||||
expected = ([{'active': True, 'sections':
|
||||
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
|
||||
'format': u'Lecture Sequence', 'due': '', 'active': False},
|
||||
{'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False},
|
||||
{'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False},
|
||||
{'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False}],
|
||||
'url_name': 'Overview', 'display_name': u'Overview'},
|
||||
{'active': False, 'sections':
|
||||
[{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False}],
|
||||
'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
|
||||
|
||||
actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None, model_data_cache)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_toc_toy_from_section(self):
|
||||
chapter = 'Overview'
|
||||
chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter)
|
||||
section = 'Welcome'
|
||||
factory = RequestFactory()
|
||||
request = factory.get(chapter_url)
|
||||
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
|
||||
self.toy_course.id, self.portal_user, self.toy_course, depth=2)
|
||||
|
||||
expected = ([{'active': True, 'sections':
|
||||
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
|
||||
'format': u'Lecture Sequence', 'due': '', 'active': False},
|
||||
{'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True,
|
||||
'format': '', 'due': '', 'active': True},
|
||||
{'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False},
|
||||
{'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False}],
|
||||
'url_name': 'Overview', 'display_name': u'Overview'},
|
||||
{'active': False, 'sections':
|
||||
[{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True,
|
||||
'format': '', 'due': '', 'active': False}],
|
||||
'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
|
||||
|
||||
actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section, model_data_cache)
|
||||
self.assertEqual(expected, actual)
|
||||
57
lms/djangoapps/courseware/tests/test_progress.py
Normal file
57
lms/djangoapps/courseware/tests/test_progress.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from django.test import TestCase
|
||||
from courseware import progress
|
||||
from mock import MagicMock
|
||||
|
||||
|
||||
class ProgessTests(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
self.d = dict({'duration_total': 0,
|
||||
'duration_watched': 0,
|
||||
'done': True,
|
||||
'questions_correct': 4,
|
||||
'questions_incorrect': 0,
|
||||
'questions_total': 0})
|
||||
|
||||
self.c = progress.completion()
|
||||
self.c2 = progress.completion()
|
||||
self.c2.dict = dict({'duration_total': 0,
|
||||
'duration_watched': 0,
|
||||
'done': True,
|
||||
'questions_correct': 2,
|
||||
'questions_incorrect': 1,
|
||||
'questions_total': 0})
|
||||
|
||||
self.cplusc2 = dict({'duration_total': 0,
|
||||
'duration_watched': 0,
|
||||
'done': True,
|
||||
'questions_correct': 2,
|
||||
'questions_incorrect': 1,
|
||||
'questions_total': 0})
|
||||
|
||||
self.oth = dict({'duration_total': 0,
|
||||
'duration_watched': 0,
|
||||
'done': True,
|
||||
'questions_correct': 4,
|
||||
'questions_incorrect': 0,
|
||||
'questions_total': 7})
|
||||
|
||||
self.x = MagicMock()
|
||||
self.x.dict = self.oth
|
||||
|
||||
self.d_oth = {'duration_total': 0,
|
||||
'duration_watched': 0,
|
||||
'done': True,
|
||||
'questions_correct': 4,
|
||||
'questions_incorrect': 0,
|
||||
'questions_total': 7}
|
||||
|
||||
def test_getitem(self):
|
||||
self.assertEqual(self.c.__getitem__('duration_watched'), 0)
|
||||
|
||||
def test_setitem(self):
|
||||
self.c.__setitem__('questions_correct', 4)
|
||||
self.assertEqual(str(self.c), str(self.d))
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEqual(self.c.__repr__(), str(progress.completion()))
|
||||
126
lms/djangoapps/courseware/tests/test_views.py
Normal file
126
lms/djangoapps/courseware/tests/test_views.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
from mock import MagicMock, patch
|
||||
import datetime
|
||||
import factory
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from django.test import TestCase
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.modulestore.django import modulestore, _MODULESTORES
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError,\
|
||||
ItemNotFoundError, NoPathToItem
|
||||
import courseware.views as views
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from factories import UserFactory
|
||||
|
||||
|
||||
class Stub():
|
||||
pass
|
||||
|
||||
|
||||
# This part is required for modulestore() to work properly
|
||||
def xml_store_config(data_dir):
|
||||
return {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
'OPTIONS': {
|
||||
'data_dir': data_dir,
|
||||
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestJumpTo(TestCase):
|
||||
"""Check the jumpto link for a course"""
|
||||
def setUp(self):
|
||||
self._MODULESTORES = {}
|
||||
|
||||
# Toy courses should be loaded
|
||||
self.course_name = 'edX/toy/2012_Fall'
|
||||
self.toy_course = modulestore().get_course('edX/toy/2012_Fall')
|
||||
|
||||
def test_jumpto_invalid_location(self):
|
||||
location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None)
|
||||
jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location)
|
||||
expected = 'courses/edX/toy/2012_Fall/courseware/Overview/'
|
||||
response = self.client.get(jumpto_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_jumpto_from_chapter(self):
|
||||
location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview')
|
||||
jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location)
|
||||
expected = 'courses/edX/toy/2012_Fall/courseware/Overview/'
|
||||
response = self.client.get(jumpto_url)
|
||||
self.assertRedirects(response, expected, status_code=302, target_status_code=302)
|
||||
|
||||
|
||||
class ViewsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(username='dummy', password='123456',
|
||||
email='test@mit.edu')
|
||||
self.date = datetime.datetime(2013, 1, 22)
|
||||
self.course_id = 'edX/toy/2012_Fall'
|
||||
self.enrollment = CourseEnrollment.objects.get_or_create(user=self.user,
|
||||
course_id=self.course_id,
|
||||
created=self.date)[0]
|
||||
self.location = ['tag', 'org', 'course', 'category', 'name']
|
||||
self._MODULESTORES = {}
|
||||
# This is a CourseDescriptor object
|
||||
self.toy_course = modulestore().get_course('edX/toy/2012_Fall')
|
||||
self.request_factory = RequestFactory()
|
||||
chapter = 'Overview'
|
||||
self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter)
|
||||
|
||||
def test_user_groups(self):
|
||||
# depreciated function
|
||||
mock_user = MagicMock()
|
||||
mock_user.is_authenticated.return_value = False
|
||||
self.assertEquals(views.user_groups(mock_user), [])
|
||||
|
||||
def test_get_current_child(self):
|
||||
self.assertIsNone(views.get_current_child(Stub()))
|
||||
mock_xmodule = MagicMock()
|
||||
mock_xmodule.position = -1
|
||||
mock_xmodule.get_display_items.return_value = ['one', 'two']
|
||||
self.assertEquals(views.get_current_child(mock_xmodule), 'one')
|
||||
mock_xmodule_2 = MagicMock()
|
||||
mock_xmodule_2.position = 3
|
||||
mock_xmodule_2.get_display_items.return_value = []
|
||||
self.assertIsNone(views.get_current_child(mock_xmodule_2))
|
||||
|
||||
def test_redirect_to_course_position(self):
|
||||
mock_module = MagicMock()
|
||||
mock_module.descriptor.id = 'Underwater Basketweaving'
|
||||
mock_module.position = 3
|
||||
mock_module.get_display_items.return_value = []
|
||||
self.assertRaises(Http404, views.redirect_to_course_position,
|
||||
mock_module)
|
||||
|
||||
def test_registered_for_course(self):
|
||||
self.assertFalse(views.registered_for_course('Basketweaving', None))
|
||||
mock_user = MagicMock()
|
||||
mock_user.is_authenticated.return_value = False
|
||||
self.assertFalse(views.registered_for_course('dummy', mock_user))
|
||||
mock_course = MagicMock()
|
||||
mock_course.id = self.course_id
|
||||
self.assertTrue(views.registered_for_course(mock_course, self.user))
|
||||
|
||||
def test_jump_to_invalid(self):
|
||||
request = self.request_factory.get(self.chapter_url)
|
||||
self.assertRaisesRegexp(Http404, 'Invalid location', views.jump_to,
|
||||
request, 'bar', ())
|
||||
self.assertRaisesRegexp(Http404, 'No data*', views.jump_to, request,
|
||||
'dummy', self.location)
|
||||
@@ -53,46 +53,46 @@ def registration(email):
|
||||
|
||||
def mongo_store_config(data_dir):
|
||||
return {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': data_dir,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': data_dir,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def draft_mongo_store_config(data_dir):
|
||||
return {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': data_dir,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
|
||||
'OPTIONS': {
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'host': 'localhost',
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': data_dir,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def xml_store_config(data_dir):
|
||||
return {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
'OPTIONS': {
|
||||
'data_dir': data_dir,
|
||||
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
'OPTIONS': {
|
||||
'data_dir': data_dir,
|
||||
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
|
||||
@@ -115,8 +115,7 @@ class ActivateLoginTestCase(TestCase):
|
||||
'Response status code was {0} instead of 302'.format(response.status_code))
|
||||
url = response['Location']
|
||||
|
||||
e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(
|
||||
expected_url)
|
||||
e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url)
|
||||
if not (e_scheme or e_netloc):
|
||||
expected_url = urlunsplit(('http', 'testserver', e_path,
|
||||
e_query, e_fragment))
|
||||
@@ -211,7 +210,7 @@ class PageLoader(ActivateLoginTestCase):
|
||||
resp = self.client.post('/change_enrollment', {
|
||||
'enrollment_action': 'enroll',
|
||||
'course_id': course.id,
|
||||
})
|
||||
})
|
||||
return parse_json(resp)
|
||||
|
||||
def try_enroll(self, course):
|
||||
@@ -230,11 +229,10 @@ class PageLoader(ActivateLoginTestCase):
|
||||
resp = self.client.post('/change_enrollment', {
|
||||
'enrollment_action': 'unenroll',
|
||||
'course_id': course.id,
|
||||
})
|
||||
})
|
||||
data = parse_json(resp)
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
|
||||
def check_for_get_code(self, code, url):
|
||||
"""
|
||||
Check that we got the expected code when accessing url via GET.
|
||||
@@ -246,7 +244,6 @@ class PageLoader(ActivateLoginTestCase):
|
||||
.format(resp.status_code, url, code))
|
||||
return resp
|
||||
|
||||
|
||||
def check_for_post_code(self, code, url, data={}):
|
||||
"""
|
||||
Check that we got the expected code when accessing url via POST.
|
||||
@@ -258,12 +255,8 @@ class PageLoader(ActivateLoginTestCase):
|
||||
.format(resp.status_code, url, code))
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
def check_pages_load(self, module_store):
|
||||
"""Make all locations in course load"""
|
||||
|
||||
|
||||
# enroll in the course before trying to access pages
|
||||
courses = module_store.get_courses()
|
||||
self.assertEqual(len(courses), 1)
|
||||
@@ -316,7 +309,7 @@ class PageLoader(ActivateLoginTestCase):
|
||||
msg = str(resp.status_code)
|
||||
|
||||
if resp.status_code != 200:
|
||||
msg = "ERROR " + msg + ": " + descriptor.location.url()
|
||||
msg = "ERROR " + msg + ": " + descriptor.location.url()
|
||||
all_ok = False
|
||||
num_bad += 1
|
||||
elif resp.redirect_chain[0][1] != 302:
|
||||
@@ -344,7 +337,6 @@ class PageLoader(ActivateLoginTestCase):
|
||||
self.assertTrue(all_ok)
|
||||
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestCoursesLoadTestCase_XmlModulestore(PageLoader):
|
||||
'''Check that all pages in test courses load properly'''
|
||||
@@ -355,21 +347,21 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoader):
|
||||
|
||||
def test_toy_course_loads(self):
|
||||
module_store = XMLModuleStore(
|
||||
TEST_DATA_DIR,
|
||||
default_class='xmodule.hidden_module.HiddenDescriptor',
|
||||
course_dirs=['toy'],
|
||||
load_error_modules=True,
|
||||
)
|
||||
TEST_DATA_DIR,
|
||||
default_class='xmodule.hidden_module.HiddenDescriptor',
|
||||
course_dirs=['toy'],
|
||||
load_error_modules=True,
|
||||
)
|
||||
|
||||
self.check_pages_load(module_store)
|
||||
|
||||
def test_full_course_loads(self):
|
||||
module_store = XMLModuleStore(
|
||||
TEST_DATA_DIR,
|
||||
default_class='xmodule.hidden_module.HiddenDescriptor',
|
||||
course_dirs=['full'],
|
||||
load_error_modules=True,
|
||||
)
|
||||
TEST_DATA_DIR,
|
||||
default_class='xmodule.hidden_module.HiddenDescriptor',
|
||||
course_dirs=['full'],
|
||||
load_error_modules=True,
|
||||
)
|
||||
self.check_pages_load(module_store)
|
||||
|
||||
|
||||
@@ -525,7 +517,6 @@ class TestViewAuth(PageLoader):
|
||||
print 'checking for 404 on {0}'.format(url)
|
||||
self.check_for_get_code(404, url)
|
||||
|
||||
|
||||
# now also make the instructor staff
|
||||
u = user(self.instructor)
|
||||
u.is_staff = True
|
||||
@@ -536,7 +527,6 @@ class TestViewAuth(PageLoader):
|
||||
print 'checking for 200 on {0}'.format(url)
|
||||
self.check_for_get_code(200, url)
|
||||
|
||||
|
||||
def run_wrapped(self, test):
|
||||
"""
|
||||
test.py turns off start dates. Enable them.
|
||||
@@ -552,7 +542,6 @@ class TestViewAuth(PageLoader):
|
||||
finally:
|
||||
settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD
|
||||
|
||||
|
||||
def test_dark_launch(self):
|
||||
"""Make sure that before course start, students can't access course
|
||||
pages, but instructors can"""
|
||||
@@ -646,7 +635,6 @@ class TestViewAuth(PageLoader):
|
||||
url = reverse_urls(['courseware'], course)[0]
|
||||
self.check_for_get_code(302, url)
|
||||
|
||||
|
||||
# First, try with an enrolled student
|
||||
print '=== Testing student access....'
|
||||
self.login(self.student, self.password)
|
||||
@@ -761,7 +749,6 @@ class TestViewAuth(PageLoader):
|
||||
self.assertTrue(has_access(student_user, self.toy, 'load'))
|
||||
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestCourseGrader(PageLoader):
|
||||
"""Check that a course gets graded properly"""
|
||||
@@ -832,13 +819,12 @@ class TestCourseGrader(PageLoader):
|
||||
kwargs={
|
||||
'course_id': self.graded_course.id,
|
||||
'location': problem_location,
|
||||
'dispatch': 'problem_check', }
|
||||
)
|
||||
'dispatch': 'problem_check', })
|
||||
|
||||
resp = self.client.post(modx_url, {
|
||||
'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0],
|
||||
'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1],
|
||||
})
|
||||
})
|
||||
print "modx_url", modx_url, "responses", responses
|
||||
print "resp", resp
|
||||
|
||||
@@ -854,8 +840,7 @@ class TestCourseGrader(PageLoader):
|
||||
kwargs={
|
||||
'course_id': self.graded_course.id,
|
||||
'location': problem_location,
|
||||
'dispatch': 'problem_reset', }
|
||||
)
|
||||
'dispatch': 'problem_reset', })
|
||||
|
||||
resp = self.client.post(modx_url)
|
||||
return resp
|
||||
|
||||
@@ -102,6 +102,9 @@ def get_current_child(xmodule):
|
||||
children. If xmodule has no position or is out of bounds, return the first child.
|
||||
Returns None only if there are no children at all.
|
||||
"""
|
||||
if not hasattr(xmodule, 'position'):
|
||||
return None
|
||||
|
||||
if xmodule.position is None:
|
||||
pos = 0
|
||||
else:
|
||||
@@ -304,6 +307,10 @@ def index(request, course_id, chapter=None, section=None,
|
||||
# Specifically asked-for section doesn't exist
|
||||
raise Http404
|
||||
|
||||
# cdodge: this looks silly, but let's refetch the section_descriptor with depth=None
|
||||
# which will prefetch the children more efficiently than doing a recursive load
|
||||
section_descriptor = modulestore().get_instance(course.id, section_descriptor.location, depth=None)
|
||||
|
||||
# Load all descendants of the section, because we're going to display its
|
||||
# html, which in general will need all of its children
|
||||
section_model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
from datetime import datetime
|
||||
from django.http import Http404
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from django.db import connection
|
||||
|
||||
from student.models import CourseEnrollment, CourseEnrollmentAllowed
|
||||
from django.contrib.auth.models import User
|
||||
@@ -12,16 +13,18 @@ def dictfetchall(cursor):
|
||||
'''Returns a list of all rows from a cursor as a column: result dict.
|
||||
Borrowed from Django documentation'''
|
||||
desc = cursor.description
|
||||
table=[]
|
||||
table = []
|
||||
table.append([col[0] for col in desc])
|
||||
table = table + cursor.fetchall()
|
||||
print "Table: " + str(table)
|
||||
|
||||
# ensure response from db is a list, not a tuple (which is returned
|
||||
# by MySQL backed django instances)
|
||||
rows_from_cursor=cursor.fetchall()
|
||||
table = table + [list(row) for row in rows_from_cursor]
|
||||
return table
|
||||
|
||||
def SQL_query_to_list(cursor, query_string):
|
||||
cursor.execute(query_string)
|
||||
raw_result=dictfetchall(cursor)
|
||||
print raw_result
|
||||
return raw_result
|
||||
|
||||
def dashboard(request):
|
||||
@@ -50,7 +53,6 @@ def dashboard(request):
|
||||
results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count()
|
||||
|
||||
# establish a direct connection to the database (for executing raw SQL)
|
||||
from django.db import connection
|
||||
cursor = connection.cursor()
|
||||
|
||||
# define the queries that will generate our user-facing tables
|
||||
|
||||
@@ -549,11 +549,6 @@ def instructor_dashboard(request, course_id):
|
||||
msg += "Error! Failed to un-enroll student with email '%s'\n" % student
|
||||
msg += str(err) + '\n'
|
||||
|
||||
elif action == 'Un-enroll ALL students':
|
||||
|
||||
ret = _do_enroll_students(course, course_id, '', overload=True)
|
||||
datatable = ret['datatable']
|
||||
|
||||
elif action == 'Enroll multiple students':
|
||||
|
||||
students = request.POST.get('enroll_multiple', '')
|
||||
|
||||
@@ -96,14 +96,24 @@ def peer_grading(request, course_id):
|
||||
Show a peer grading interface
|
||||
'''
|
||||
|
||||
#Get the current course
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
course_id_parts = course.id.split("/")
|
||||
course_id_norun = "/".join(course_id_parts[0:2])
|
||||
pg_location = "i4x://" + course_id_norun + "/peergrading/init"
|
||||
false_dict = [False,"False", "false", "FALSE"]
|
||||
|
||||
#Reverse the base course url
|
||||
base_course_url = reverse('courses')
|
||||
try:
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, pg_location)
|
||||
#TODO: This will not work with multiple runs of a course. Make it work. The last key in the Location passed
|
||||
#to get_items is called revision. Is this the same as run?
|
||||
#Get the peer grading modules currently in the course
|
||||
items = modulestore().get_items(['i4x', None, course_id_parts[1], 'peergrading', None])
|
||||
#See if any of the modules are centralized modules (ie display info from multiple problems)
|
||||
items = [i for i in items if i.metadata.get("use_for_single_location", True) in false_dict]
|
||||
#Get the first one
|
||||
item_location = items[0].location
|
||||
#Generate a url for the first module and redirect the user to it
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, item_location)
|
||||
problem_url = generate_problem_url(problem_url_parts, base_course_url)
|
||||
|
||||
return HttpResponseRedirect(problem_url)
|
||||
|
||||
@@ -246,7 +246,6 @@ function goto( mode)
|
||||
<p>
|
||||
Student Email: <input type="text" name="enstudent"> <input type="submit" name="action" value="Un-enroll student">
|
||||
<input type="submit" name="action" value="Enroll student">
|
||||
<input type="submit" name="action" value="Un-enroll ALL students">
|
||||
<hr width="40%" style="align:left">
|
||||
|
||||
%if settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
% for i, entry in enumerate(history_entries):
|
||||
<hr/>
|
||||
<div>
|
||||
<b>#${len(history_entries) - i}</b>: ${entry.created} UTC</br>
|
||||
<b>#${len(history_entries) - i}</b>: ${entry.created} (${TIME_ZONE} time)</br>
|
||||
Score: ${entry.grade} / ${entry.max_grade}
|
||||
<pre>
|
||||
${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h}
|
||||
|
||||
7
rakefile
7
rakefile
@@ -448,6 +448,13 @@ namespace :cms do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :cms do
|
||||
desc "Imports all the templates from the code pack"
|
||||
task :update_templates do
|
||||
sh(django_admin(:cms, :dev, :update_templates))
|
||||
end
|
||||
end
|
||||
|
||||
namespace :cms do
|
||||
desc "Import course data within the given DATA_DIR variable"
|
||||
task :xlint do
|
||||
|
||||
Reference in New Issue
Block a user