Merge branch 'master' into feature/btalbot/studio-alerts
This commit is contained in:
@@ -1,3 +1 @@
|
||||
from xmodule.templates import update_templates
|
||||
|
||||
update_templates()
|
||||
|
||||
@@ -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()
|
||||
@@ -1112,6 +1112,7 @@ def module_info(request, module_location):
|
||||
else:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
@@ -1127,12 +1128,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -554,7 +554,7 @@ def create_account(request, post_override=None):
|
||||
try:
|
||||
validate_slug(post_vars['username'])
|
||||
except ValidationError:
|
||||
js['value'] = "Username should only consist of A-Z and 0-9.".format(field=a)
|
||||
js['value'] = "Username should only consist of A-Z and 0-9, with no spaces.".format(field=a)
|
||||
js['field'] = 'username'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
|
||||
@@ -510,7 +510,9 @@ class LoncapaProblem(object):
|
||||
|
||||
# let each Response render itself
|
||||
if problemtree in self.responders:
|
||||
return self.responders[problemtree].render_html(self._extract_html)
|
||||
overall_msg = self.correct_map.get_overall_message()
|
||||
return self.responders[problemtree].render_html(self._extract_html,
|
||||
response_msg=overall_msg)
|
||||
|
||||
# let each custom renderer render itself:
|
||||
if problemtree.tag in customrender.registry.registered_tags():
|
||||
|
||||
@@ -27,6 +27,7 @@ class CorrectMap(object):
|
||||
self.cmap = dict()
|
||||
self.items = self.cmap.items
|
||||
self.keys = self.cmap.keys
|
||||
self.overall_message = ""
|
||||
self.set(*args, **kwargs)
|
||||
|
||||
def __getitem__(self, *args, **kwargs):
|
||||
@@ -104,16 +105,21 @@ class CorrectMap(object):
|
||||
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
|
||||
|
||||
def get_queuetime_str(self, answer_id):
|
||||
return self.cmap[answer_id]['queuestate']['time']
|
||||
if self.cmap[answer_id]['queuestate']:
|
||||
return self.cmap[answer_id]['queuestate']['time']
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_npoints(self, answer_id):
|
||||
npoints = self.get_property(answer_id, 'npoints')
|
||||
if npoints is not None:
|
||||
return npoints
|
||||
elif self.is_correct(answer_id):
|
||||
return 1
|
||||
# if not correct and no points have been assigned, return 0
|
||||
return 0
|
||||
""" Return the number of points for an answer:
|
||||
If the answer is correct, return the assigned
|
||||
number of points (default: 1 point)
|
||||
Otherwise, return 0 points """
|
||||
if self.is_correct(answer_id):
|
||||
npoints = self.get_property(answer_id, 'npoints')
|
||||
return npoints if npoints is not None else 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def set_property(self, answer_id, property, value):
|
||||
if answer_id in self.cmap:
|
||||
@@ -153,3 +159,15 @@ class CorrectMap(object):
|
||||
if not isinstance(other_cmap, CorrectMap):
|
||||
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
|
||||
self.cmap.update(other_cmap.get_dict())
|
||||
self.set_overall_message(other_cmap.get_overall_message())
|
||||
|
||||
|
||||
def set_overall_message(self, message_str):
|
||||
""" Set a message that applies to the question as a whole,
|
||||
rather than to individual inputs. """
|
||||
self.overall_message = str(message_str) if message_str else ""
|
||||
|
||||
def get_overall_message(self):
|
||||
""" Retrieve a message that applies to the question as a whole.
|
||||
If no message is available, returns the empty string """
|
||||
return self.overall_message
|
||||
|
||||
@@ -174,13 +174,14 @@ class LoncapaResponse(object):
|
||||
'''
|
||||
return sum(self.maxpoints.values())
|
||||
|
||||
def render_html(self, renderer):
|
||||
def render_html(self, renderer, response_msg=''):
|
||||
'''
|
||||
Return XHTML Element tree representation of this Response.
|
||||
|
||||
Arguments:
|
||||
|
||||
- renderer : procedure which produces HTML given an ElementTree
|
||||
- response_msg: a message displayed at the end of the Response
|
||||
'''
|
||||
# render ourself as a <span> + our content
|
||||
tree = etree.Element('span')
|
||||
@@ -195,6 +196,11 @@ class LoncapaResponse(object):
|
||||
if item_xhtml is not None:
|
||||
tree.append(item_xhtml)
|
||||
tree.tail = self.xml.tail
|
||||
|
||||
# Add a <div> for the message at the end of the response
|
||||
if response_msg:
|
||||
tree.append(self._render_response_msg_html(response_msg))
|
||||
|
||||
return tree
|
||||
|
||||
def evaluate_answers(self, student_answers, old_cmap):
|
||||
@@ -319,6 +325,29 @@ class LoncapaResponse(object):
|
||||
def __unicode__(self):
|
||||
return u'LoncapaProblem Response %s' % self.xml.tag
|
||||
|
||||
def _render_response_msg_html(self, response_msg):
|
||||
""" Render a <div> for a message that applies to the entire response.
|
||||
|
||||
*response_msg* is a string, which may contain XHTML markup
|
||||
|
||||
Returns an etree element representing the response message <div> """
|
||||
# First try wrapping the text in a <div> and parsing
|
||||
# it as an XHTML tree
|
||||
try:
|
||||
response_msg_div = etree.XML('<div>%s</div>' % str(response_msg))
|
||||
|
||||
# If we can't do that, create the <div> and set the message
|
||||
# as the text of the <div>
|
||||
except:
|
||||
response_msg_div = etree.Element('div')
|
||||
response_msg_div.text = str(response_msg)
|
||||
|
||||
|
||||
# Set the css class of the message <div>
|
||||
response_msg_div.set("class", "response_message")
|
||||
|
||||
return response_msg_div
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
@@ -965,6 +994,7 @@ def sympy_check2():
|
||||
# not expecting 'unknown's
|
||||
correct = ['unknown'] * len(idset)
|
||||
messages = [''] * len(idset)
|
||||
overall_message = ""
|
||||
|
||||
# put these in the context of the check function evaluator
|
||||
# note that this doesn't help the "cfn" version - only the exec version
|
||||
@@ -996,6 +1026,10 @@ def sympy_check2():
|
||||
# the list of messages to be filled in by the check function
|
||||
'messages': messages,
|
||||
|
||||
# a message that applies to the entire response
|
||||
# instead of a particular input
|
||||
'overall_message': overall_message,
|
||||
|
||||
# any options to be passed to the cfn
|
||||
'options': self.xml.get('options'),
|
||||
'testdat': 'hello world',
|
||||
@@ -1010,6 +1044,7 @@ def sympy_check2():
|
||||
exec self.code in self.context['global_context'], self.context
|
||||
correct = self.context['correct']
|
||||
messages = self.context['messages']
|
||||
overall_message = self.context['overall_message']
|
||||
except Exception as err:
|
||||
print "oops in customresponse (code) error %s" % err
|
||||
print "context = ", self.context
|
||||
@@ -1044,34 +1079,100 @@ def sympy_check2():
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("oops in customresponse (cfn) error %s" % err)
|
||||
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
|
||||
|
||||
if type(ret) == dict:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret['msg']
|
||||
|
||||
if 1:
|
||||
# try to clean up message html
|
||||
msg = '<html>' + msg + '</html>'
|
||||
msg = msg.replace('<', '<')
|
||||
#msg = msg.replace('<','<')
|
||||
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
|
||||
pretty_print=True)
|
||||
#msg = etree.tostring(fromstring_bs(msg),pretty_print=True)
|
||||
msg = msg.replace(' ', '')
|
||||
#msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7
|
||||
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
|
||||
# One kind of dictionary the check function can return has the
|
||||
# form {'ok': BOOLEAN, 'msg': STRING}
|
||||
# If there are multiple inputs, they all get marked
|
||||
# to the same correct/incorrect value
|
||||
if 'ok' in ret:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret.get('msg', None)
|
||||
msg = self.clean_message_html(msg)
|
||||
|
||||
messages[0] = msg
|
||||
# If there is only one input, apply the message to that input
|
||||
# Otherwise, apply the message to the whole problem
|
||||
if len(idset) > 1:
|
||||
overall_message = msg
|
||||
else:
|
||||
messages[0] = msg
|
||||
|
||||
|
||||
# Another kind of dictionary the check function can return has
|
||||
# the form:
|
||||
# {'overall_message': STRING,
|
||||
# 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] }
|
||||
#
|
||||
# This allows the function to return an 'overall message'
|
||||
# that applies to the entire problem, as well as correct/incorrect
|
||||
# status and messages for individual inputs
|
||||
elif 'input_list' in ret:
|
||||
overall_message = ret.get('overall_message', '')
|
||||
input_list = ret['input_list']
|
||||
|
||||
correct = []
|
||||
messages = []
|
||||
for input_dict in input_list:
|
||||
correct.append('correct' if input_dict['ok'] else 'incorrect')
|
||||
msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None
|
||||
messages.append(msg)
|
||||
|
||||
# Otherwise, we do not recognize the dictionary
|
||||
# Raise an exception
|
||||
else:
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("CustomResponse: check function returned an invalid dict")
|
||||
|
||||
# The check function can return a boolean value,
|
||||
# indicating whether all inputs should be marked
|
||||
# correct or incorrect
|
||||
else:
|
||||
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset)
|
||||
|
||||
# build map giving "correct"ness of the answer(s)
|
||||
correct_map = CorrectMap()
|
||||
|
||||
overall_message = self.clean_message_html(overall_message)
|
||||
correct_map.set_overall_message(overall_message)
|
||||
|
||||
for k in range(len(idset)):
|
||||
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
|
||||
correct_map.set(idset[k], correct[k], msg=messages[k],
|
||||
npoints=npoints)
|
||||
return correct_map
|
||||
|
||||
def clean_message_html(self, msg):
|
||||
|
||||
# If *msg* is an empty string, then the code below
|
||||
# will return "</html>". To avoid this, we first check
|
||||
# that *msg* is a non-empty string.
|
||||
if msg:
|
||||
|
||||
# When we parse *msg* using etree, there needs to be a root
|
||||
# element, so we wrap the *msg* text in <html> tags
|
||||
msg = '<html>' + msg + '</html>'
|
||||
|
||||
# Replace < characters
|
||||
msg = msg.replace('<', '<')
|
||||
|
||||
# Use etree to prettify the HTML
|
||||
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
|
||||
pretty_print=True)
|
||||
|
||||
msg = msg.replace(' ', '')
|
||||
|
||||
# Remove the <html> tags we introduced earlier, so we're
|
||||
# left with just the prettified message markup
|
||||
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
|
||||
|
||||
# Strip leading and trailing whitespace
|
||||
return msg.strip()
|
||||
|
||||
# If we start with an empty string, then return an empty string
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_answers(self):
|
||||
'''
|
||||
Give correct answer expected for this response.
|
||||
|
||||
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
|
||||
|
||||
@@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
|
||||
# disable the syllabus content for courses that do not provide a syllabus
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
self._grading_policy = {}
|
||||
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
|
||||
|
||||
self.test_center_exams = []
|
||||
@@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
grading_policy.update(course_policy)
|
||||
|
||||
# Here is where we should parse any configurations, so that we can fail early
|
||||
grading_policy['RAW_GRADER'] = grading_policy['GRADER'] # used for cms access
|
||||
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
|
||||
self._grading_policy = grading_policy
|
||||
|
||||
|
||||
# Use setters so that side effecting to .definitions works
|
||||
self.raw_grader = grading_policy['GRADER'] # used for cms access
|
||||
self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
@classmethod
|
||||
def read_grading_policy(cls, paths, system):
|
||||
@@ -319,7 +318,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
return self._grading_policy['GRADER']
|
||||
return grader_from_conf(self.raw_grader)
|
||||
|
||||
@property
|
||||
def raw_grader(self):
|
||||
|
||||
20
common/lib/xmodule/xmodule/css/foldit/leaderboard.scss
Normal file
20
common/lib/xmodule/xmodule/css/foldit/leaderboard.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
$leaderboard: #F4F4F4;
|
||||
|
||||
section.foldit {
|
||||
div.folditchallenge {
|
||||
table {
|
||||
border: 1px solid lighten($leaderboard, 10%);
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th {
|
||||
background: $leaderboard;
|
||||
color: darken($leaderboard, 25%);
|
||||
}
|
||||
td {
|
||||
background: lighten($leaderboard, 3%);
|
||||
border-bottom: 1px solid #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class FolditModule(XModule):
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
# ooh look--I'm lazy, so hardcoding the 7.00x required level.
|
||||
# If we need it generalized, can pull from the xml later
|
||||
self.required_level = 4
|
||||
self.required_sublevel = 5
|
||||
"""
|
||||
|
||||
Example:
|
||||
<foldit show_basic_score="true"
|
||||
required_level="4"
|
||||
required_sublevel="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
req_level = self.metadata.get("required_level")
|
||||
req_sublevel = self.metadata.get("required_sublevel")
|
||||
|
||||
# default to what Spring_7012x uses
|
||||
self.required_level = req_level if req_level else 4
|
||||
self.required_sublevel = req_sublevel if req_sublevel else 5
|
||||
|
||||
def parse_due_date():
|
||||
"""
|
||||
@@ -66,6 +79,14 @@ class FolditModule(XModule):
|
||||
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
|
||||
key=lambda d: (d['set'], d['subset']))
|
||||
|
||||
def puzzle_leaders(self, n=10):
|
||||
"""
|
||||
Returns a list of n pairs (user, score) corresponding to the top
|
||||
scores; the pairs are in descending order of score.
|
||||
"""
|
||||
from foldit.models import Score
|
||||
|
||||
return [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
@@ -75,15 +96,47 @@ class FolditModule(XModule):
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
showbasic = (self.metadata.get("show_basic_score").lower() == "true")
|
||||
showleader = (self.metadata.get("show_leaderboard").lower() == "true")
|
||||
context = {
|
||||
'due': self.due_str,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
'top_scores': self.puzzle_leaders(),
|
||||
'show_basic': showbasic,
|
||||
'show_leader': showleader,
|
||||
'folditbasic': self.get_basicpuzzles_html(),
|
||||
'folditchallenge': self.get_challenge_html()
|
||||
}
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
|
||||
def get_basicpuzzles_html(self):
|
||||
"""
|
||||
Render html for the basic puzzle section.
|
||||
"""
|
||||
goal_level = '{0}-{1}'.format(
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
context = {
|
||||
'due': self.due_str,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
}
|
||||
return self.system.render_template('folditbasic.html', context)
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
def get_challenge_html(self):
|
||||
"""
|
||||
Render html for challenge (i.e., the leaderboard)
|
||||
"""
|
||||
|
||||
context = {
|
||||
'top_scores': self.puzzle_leaders()}
|
||||
|
||||
return self.system.render_template('folditchallenge.html', context)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
@@ -97,9 +150,10 @@ class FolditModule(XModule):
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
class FolditDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding open ended response questions to courses
|
||||
Module for adding Foldit problems to courses
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = FolditModule
|
||||
@@ -119,6 +173,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
For now, don't need anything from the xml
|
||||
Get the xml_object's attributes.
|
||||
"""
|
||||
return {}
|
||||
return {'metadata': xml_object.attrib}
|
||||
|
||||
@@ -64,7 +64,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
|
||||
location = Location(location)
|
||||
json_data = self.module_data.get(location)
|
||||
if json_data is None:
|
||||
return self.modulestore.get_item(location)
|
||||
module = self.modulestore.get_item(location)
|
||||
if module is not None:
|
||||
# update our own cache after going to the DB to get cache miss
|
||||
self.module_data.update(module.system.module_data)
|
||||
return module
|
||||
else:
|
||||
# load the module and apply the inherited metadata
|
||||
try:
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
|
||||
@@ -270,7 +270,7 @@ def progress_summary(student, request, course, student_module_cache):
|
||||
# would be simpler
|
||||
course_module = get_module(student, request,
|
||||
course.location, student_module_cache,
|
||||
course.id)
|
||||
course.id, depth=None)
|
||||
if not course_module:
|
||||
# This student must not have access to the course.
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'StudentModuleHistory'
|
||||
db.create_table('courseware_studentmodulehistory', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('student_module', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['courseware.StudentModule'])),
|
||||
('version', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('created', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('state', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
|
||||
('grade', self.gf('django.db.models.fields.FloatField')(null=True, blank=True)),
|
||||
('max_grade', self.gf('django.db.models.fields.FloatField')(null=True, blank=True)),
|
||||
))
|
||||
db.send_create_signal('courseware', ['StudentModuleHistory'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'StudentModuleHistory'
|
||||
db.delete_table('courseware_studentmodulehistory')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'courseware.offlinecomputedgrade': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.offlinecomputedgradelog': {
|
||||
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
|
||||
},
|
||||
'courseware.studentmodule': {
|
||||
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.studentmodulehistory': {
|
||||
'Meta': {'object_name': 'StudentModuleHistory'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
|
||||
'version': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['courseware']
|
||||
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
|
||||
# Changing field 'StudentModuleHistory.version'
|
||||
db.alter_column('courseware_studentmodulehistory', 'version', self.gf('django.db.models.fields.CharField')(max_length=255, null=True))
|
||||
|
||||
def backwards(self, orm):
|
||||
|
||||
# User chose to not deal with backwards NULL issues for 'StudentModuleHistory.version'
|
||||
raise RuntimeError("Cannot reverse this migration. 'StudentModuleHistory.version' and its values cannot be restored.")
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'courseware.offlinecomputedgrade': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.offlinecomputedgradelog': {
|
||||
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
|
||||
},
|
||||
'courseware.studentmodule': {
|
||||
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.studentmodulehistory': {
|
||||
'Meta': {'object_name': 'StudentModuleHistory'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
|
||||
'version': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'db_index': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['courseware']
|
||||
@@ -12,8 +12,10 @@ file and check it in at the same time as your model changes. To do that,
|
||||
ASSUMPTIONS: modules have unique IDs, even across different module_types
|
||||
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
class StudentModule(models.Model):
|
||||
"""
|
||||
@@ -60,6 +62,37 @@ class StudentModule(models.Model):
|
||||
self.student.username, self.module_state_key, str(self.state)[:20]])
|
||||
|
||||
|
||||
class StudentModuleHistory(models.Model):
|
||||
"""Keeps a complete history of state changes for a given XModule for a given
|
||||
Student. Right now, we restrict this to problems so that the table doesn't
|
||||
explode in size."""
|
||||
|
||||
HISTORY_SAVING_TYPES = {'problem'}
|
||||
|
||||
class Meta:
|
||||
get_latest_by = "created"
|
||||
|
||||
student_module = models.ForeignKey(StudentModule, db_index=True)
|
||||
version = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
|
||||
# This should be populated from the modified field in StudentModule
|
||||
created = models.DateTimeField(db_index=True)
|
||||
state = models.TextField(null=True, blank=True)
|
||||
grade = models.FloatField(null=True, blank=True)
|
||||
max_grade = models.FloatField(null=True, blank=True)
|
||||
|
||||
@receiver(post_save, sender=StudentModule)
|
||||
def save_history(sender, instance, **kwargs):
|
||||
if instance.module_type in StudentModuleHistory.HISTORY_SAVING_TYPES:
|
||||
history_entry = StudentModuleHistory(student_module=instance,
|
||||
version=None,
|
||||
created=instance.modified,
|
||||
state=instance.state,
|
||||
grade=instance.grade,
|
||||
max_grade=instance.max_grade)
|
||||
history_entry.save()
|
||||
|
||||
|
||||
# TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ from functools import partial
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
#from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
@@ -20,7 +21,7 @@ from courseware.access import has_access
|
||||
from courseware.courses import (get_courses, get_course_with_access,
|
||||
get_courses_by_university, sort_by_announcement)
|
||||
import courseware.tabs as tabs
|
||||
from courseware.models import StudentModule, StudentModuleCache
|
||||
from courseware.models import StudentModule, StudentModuleCache, StudentModuleHistory
|
||||
from module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor
|
||||
|
||||
from django_comment_client.utils import get_discussion_title
|
||||
@@ -306,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_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
|
||||
@@ -570,7 +575,7 @@ def progress(request, course_id, student_id=None):
|
||||
|
||||
Course staff are allowed to see the progress of students in their class.
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
course = get_course_with_access(request.user, course_id, 'load', depth=None)
|
||||
staff_access = has_access(request.user, course, 'staff')
|
||||
|
||||
if student_id is None or student_id == request.user.id:
|
||||
@@ -590,7 +595,7 @@ def progress(request, course_id, student_id=None):
|
||||
student = User.objects.prefetch_related("groups").get(id=student.id)
|
||||
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
|
||||
course_id, student, course)
|
||||
course_id, student, course, depth=None)
|
||||
|
||||
courseware_summary = grades.progress_summary(student, request, course,
|
||||
student_module_cache)
|
||||
@@ -608,3 +613,48 @@ def progress(request, course_id, student_id=None):
|
||||
context.update()
|
||||
|
||||
return render_to_response('courseware/progress.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def submission_history(request, course_id, student_username, location):
|
||||
"""Render an HTML fragment (meant for inclusion elsewhere) that renders a
|
||||
history of all state changes made by this user for this problem location.
|
||||
Right now this only works for problems because that's all
|
||||
StudentModuleHistory records.
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
staff_access = has_access(request.user, course, 'staff')
|
||||
|
||||
# Permission Denied if they don't have staff access and are trying to see
|
||||
# somebody else's submission history.
|
||||
if (student_username != request.user.username) and (not staff_access):
|
||||
raise PermissionDenied
|
||||
|
||||
try:
|
||||
student = User.objects.get(username=student_username)
|
||||
student_module = StudentModule.objects.get(course_id=course_id,
|
||||
module_state_key=location,
|
||||
student_id=student.id)
|
||||
except User.DoesNotExist:
|
||||
return HttpResponse("User {0} does not exist.".format(student_username))
|
||||
except StudentModule.DoesNotExist:
|
||||
return HttpResponse("{0} has never accessed problem {1}"
|
||||
.format(student_username, location))
|
||||
|
||||
history_entries = StudentModuleHistory.objects \
|
||||
.filter(student_module=student_module).order_by('-created')
|
||||
|
||||
# If no history records exist, let's force a save to get history started.
|
||||
if not history_entries:
|
||||
student_module.save()
|
||||
history_entries = StudentModuleHistory.objects \
|
||||
.filter(student_module=student_module).order_by('-created')
|
||||
|
||||
context = {
|
||||
'history_entries': history_entries,
|
||||
'username': student.username,
|
||||
'location': location,
|
||||
'course_id': course_id
|
||||
}
|
||||
|
||||
return render_to_response('courseware/submission_history.html', context)
|
||||
|
||||
@@ -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
|
||||
|
||||
111
lms/djangoapps/foldit/migrations/0001_initial.py
Normal file
111
lms/djangoapps/foldit/migrations/0001_initial.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'Score'
|
||||
db.create_table('foldit_score', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='foldit_scores', to=orm['auth.User'])),
|
||||
('unique_user_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('puzzle_id', self.gf('django.db.models.fields.IntegerField')()),
|
||||
('best_score', self.gf('django.db.models.fields.FloatField')(db_index=True)),
|
||||
('current_score', self.gf('django.db.models.fields.FloatField')(db_index=True)),
|
||||
('score_version', self.gf('django.db.models.fields.IntegerField')()),
|
||||
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
))
|
||||
db.send_create_signal('foldit', ['Score'])
|
||||
|
||||
# Adding model 'PuzzleComplete'
|
||||
db.create_table('foldit_puzzlecomplete', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='foldit_puzzles_complete', to=orm['auth.User'])),
|
||||
('unique_user_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('puzzle_id', self.gf('django.db.models.fields.IntegerField')()),
|
||||
('puzzle_set', self.gf('django.db.models.fields.IntegerField')(db_index=True)),
|
||||
('puzzle_subset', self.gf('django.db.models.fields.IntegerField')(db_index=True)),
|
||||
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
))
|
||||
db.send_create_signal('foldit', ['PuzzleComplete'])
|
||||
|
||||
# Adding unique constraint on 'PuzzleComplete', fields ['user', 'puzzle_id', 'puzzle_set', 'puzzle_subset']
|
||||
db.create_unique('foldit_puzzlecomplete', ['user_id', 'puzzle_id', 'puzzle_set', 'puzzle_subset'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'PuzzleComplete', fields ['user', 'puzzle_id', 'puzzle_set', 'puzzle_subset']
|
||||
db.delete_unique('foldit_puzzlecomplete', ['user_id', 'puzzle_id', 'puzzle_set', 'puzzle_subset'])
|
||||
|
||||
# Deleting model 'Score'
|
||||
db.delete_table('foldit_score')
|
||||
|
||||
# Deleting model 'PuzzleComplete'
|
||||
db.delete_table('foldit_puzzlecomplete')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'foldit.puzzlecomplete': {
|
||||
'Meta': {'ordering': "['puzzle_id']", 'unique_together': "(('user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'),)", 'object_name': 'PuzzleComplete'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'puzzle_id': ('django.db.models.fields.IntegerField', [], {}),
|
||||
'puzzle_set': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
|
||||
'puzzle_subset': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
|
||||
'unique_user_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'foldit_puzzles_complete'", 'to': "orm['auth.User']"})
|
||||
},
|
||||
'foldit.score': {
|
||||
'Meta': {'object_name': 'Score'},
|
||||
'best_score': ('django.db.models.fields.FloatField', [], {'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'current_score': ('django.db.models.fields.FloatField', [], {'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'puzzle_id': ('django.db.models.fields.IntegerField', [], {}),
|
||||
'score_version': ('django.db.models.fields.IntegerField', [], {}),
|
||||
'unique_user_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'foldit_scores'", 'to': "orm['auth.User']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['foldit']
|
||||
0
lms/djangoapps/foldit/migrations/__init__.py
Normal file
0
lms/djangoapps/foldit/migrations/__init__.py
Normal file
@@ -25,6 +25,47 @@ class Score(models.Model):
|
||||
score_version = models.IntegerField()
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@staticmethod
|
||||
def display_score(score, sum_of=1):
|
||||
"""
|
||||
Argument:
|
||||
score (float), as stored in the DB (i.e., "rosetta score")
|
||||
sum_of (int): if this score is the sum of scores of individual
|
||||
problems, how many elements are in that sum
|
||||
|
||||
Returns:
|
||||
score (float), as displayed to the user in the game and in the leaderboard
|
||||
"""
|
||||
return (-score) * 10 + 8000 * sum_of
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_tops_n(n, puzzles=['994559']):
|
||||
"""
|
||||
Arguments:
|
||||
puzzles: a list of puzzle ids that we will use. If not specified,
|
||||
defaults to puzzle used in 7012x.
|
||||
n (int): number of top scores to return
|
||||
|
||||
|
||||
Returns:
|
||||
The top n sum of scores for puzzles in <puzzles>. Output is a list
|
||||
of disctionaries, sorted by display_score:
|
||||
[ {username: 'a_user',
|
||||
score: 12000} ...]
|
||||
"""
|
||||
if not(type(puzzles) == list):
|
||||
puzzles = [puzzles]
|
||||
scores = Score.objects \
|
||||
.filter(puzzle_id__in=puzzles) \
|
||||
.annotate(total_score=models.Sum('best_score')) \
|
||||
.order_by('-total_score')[:n]
|
||||
num = len(puzzles)
|
||||
|
||||
return [{'username': s.user.username,
|
||||
'score': Score.display_score(s.total_score, num)}
|
||||
for s in scores]
|
||||
|
||||
|
||||
class PuzzleComplete(models.Model):
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from foldit.views import foldit_ops, verify_code
|
||||
from foldit.models import PuzzleComplete
|
||||
from foldit.models import PuzzleComplete, Score
|
||||
from student.models import UserProfile, unique_id_for_user
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -25,92 +25,162 @@ class FolditTestCase(TestCase):
|
||||
|
||||
pwd = 'abc'
|
||||
self.user = User.objects.create_user('testuser', 'test@test.com', pwd)
|
||||
self.user2 = User.objects.create_user('testuser2', 'test2@test.com', pwd)
|
||||
self.unique_user_id = unique_id_for_user(self.user)
|
||||
self.unique_user_id2 = unique_id_for_user(self.user2)
|
||||
now = datetime.now()
|
||||
self.tomorrow = now + timedelta(days=1)
|
||||
self.yesterday = now - timedelta(days=1)
|
||||
|
||||
UserProfile.objects.create(user=self.user)
|
||||
UserProfile.objects.create(user=self.user2)
|
||||
|
||||
def make_request(self, post_data):
|
||||
def make_request(self, post_data, user=None):
|
||||
request = self.factory.post(self.url, post_data)
|
||||
request.user = self.user
|
||||
request.user = self.user if not user else user
|
||||
return request
|
||||
|
||||
def make_puzzle_score_request(self, puzzle_ids, best_scores, user=None):
|
||||
"""
|
||||
Given lists of puzzle_ids and best_scores (must have same length), make a
|
||||
SetPlayerPuzzleScores request and return the response.
|
||||
"""
|
||||
if not(type(best_scores) == list):
|
||||
best_scores = [best_scores]
|
||||
if not(type(puzzle_ids) == list):
|
||||
puzzle_ids = [puzzle_ids]
|
||||
user = self.user if not user else user
|
||||
|
||||
def score_dict(puzzle_id, best_score):
|
||||
return {"PuzzleID": puzzle_id,
|
||||
"ScoreType": "score",
|
||||
"BestScore": best_score,
|
||||
# current scores don't actually matter
|
||||
"CurrentScore": best_score + 0.01,
|
||||
"ScoreVersion": 23}
|
||||
scores = [score_dict(pid, bs) for pid, bs in zip(puzzle_ids, best_scores)]
|
||||
scores_str = json.dumps(scores)
|
||||
|
||||
verify = {"Verify": verify_code(user.email, scores_str),
|
||||
"VerifyMethod": "FoldItVerify"}
|
||||
data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify),
|
||||
'SetPlayerPuzzleScores': scores_str}
|
||||
|
||||
request = self.make_request(data, user)
|
||||
|
||||
response = foldit_ops(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
def test_SetPlayerPuzzleScores(self):
|
||||
|
||||
scores = [ {"PuzzleID": 994391,
|
||||
"ScoreType": "score",
|
||||
"BestScore": 0.078034,
|
||||
"CurrentScore":0.080035,
|
||||
"ScoreVersion":23}]
|
||||
scores_str = json.dumps(scores)
|
||||
|
||||
verify = {"Verify": verify_code(self.user.email, scores_str),
|
||||
"VerifyMethod":"FoldItVerify"}
|
||||
data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify),
|
||||
'SetPlayerPuzzleScores': scores_str}
|
||||
|
||||
request = self.make_request(data)
|
||||
|
||||
response = foldit_ops(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
puzzle_id = 994391
|
||||
best_score = 0.078034
|
||||
response = self.make_puzzle_score_request(puzzle_id, [best_score])
|
||||
|
||||
self.assertEqual(response.content, json.dumps(
|
||||
[{"OperationID": "SetPlayerPuzzleScores",
|
||||
"Value": [{
|
||||
"PuzzleID": 994391,
|
||||
"PuzzleID": puzzle_id,
|
||||
"Status": "Success"}]}]))
|
||||
|
||||
# There should now be a score in the db.
|
||||
top_10 = Score.get_tops_n(10, puzzle_id)
|
||||
self.assertEqual(len(top_10), 1)
|
||||
self.assertEqual(top_10[0]['score'], Score.display_score(best_score))
|
||||
|
||||
def test_SetPlayerPuzzleScores_many(self):
|
||||
|
||||
scores = [ {"PuzzleID": 994391,
|
||||
"ScoreType": "score",
|
||||
"BestScore": 0.078034,
|
||||
"CurrentScore":0.080035,
|
||||
"ScoreVersion":23},
|
||||
|
||||
{"PuzzleID": 994392,
|
||||
"ScoreType": "score",
|
||||
"BestScore": 0.078000,
|
||||
"CurrentScore":0.080011,
|
||||
"ScoreVersion":23}]
|
||||
|
||||
scores_str = json.dumps(scores)
|
||||
|
||||
verify = {"Verify": verify_code(self.user.email, scores_str),
|
||||
"VerifyMethod":"FoldItVerify"}
|
||||
data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify),
|
||||
'SetPlayerPuzzleScores': scores_str}
|
||||
|
||||
request = self.make_request(data)
|
||||
|
||||
response = foldit_ops(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.make_puzzle_score_request([1, 2], [0.078034, 0.080000])
|
||||
|
||||
self.assertEqual(response.content, json.dumps(
|
||||
[{"OperationID": "SetPlayerPuzzleScores",
|
||||
"Value": [{
|
||||
"PuzzleID": 994391,
|
||||
"PuzzleID": 1,
|
||||
"Status": "Success"},
|
||||
|
||||
{"PuzzleID": 994392,
|
||||
{"PuzzleID": 2,
|
||||
"Status": "Success"}]}]))
|
||||
|
||||
|
||||
def test_SetPlayerPuzzleScores_multiple(self):
|
||||
"""
|
||||
Check that multiple posts with the same id are handled properly
|
||||
(keep latest for each user, have multiple users work properly)
|
||||
"""
|
||||
orig_score = 0.07
|
||||
puzzle_id = '1'
|
||||
response = self.make_puzzle_score_request([puzzle_id], [orig_score])
|
||||
|
||||
# There should now be a score in the db.
|
||||
top_10 = Score.get_tops_n(10, puzzle_id)
|
||||
self.assertEqual(len(top_10), 1)
|
||||
self.assertEqual(top_10[0]['score'], Score.display_score(orig_score))
|
||||
|
||||
# Reporting a better score should overwrite
|
||||
better_score = 0.06
|
||||
response = self.make_puzzle_score_request([1], [better_score])
|
||||
|
||||
top_10 = Score.get_tops_n(10, puzzle_id)
|
||||
self.assertEqual(len(top_10), 1)
|
||||
|
||||
# Floats always get in the way, so do almostequal
|
||||
self.assertAlmostEqual(top_10[0]['score'],
|
||||
Score.display_score(better_score),
|
||||
delta=0.5)
|
||||
|
||||
# reporting a worse score shouldn't
|
||||
worse_score = 0.065
|
||||
response = self.make_puzzle_score_request([1], [worse_score])
|
||||
|
||||
top_10 = Score.get_tops_n(10, puzzle_id)
|
||||
self.assertEqual(len(top_10), 1)
|
||||
# should still be the better score
|
||||
self.assertAlmostEqual(top_10[0]['score'],
|
||||
Score.display_score(better_score),
|
||||
delta=0.5)
|
||||
|
||||
def test_SetPlayerPuzzleScores_manyplayers(self):
|
||||
"""
|
||||
Check that when we send scores from multiple users, the correct order
|
||||
of scores is displayed.
|
||||
"""
|
||||
puzzle_id = ['1']
|
||||
player1_score = 0.07
|
||||
player2_score = 0.08
|
||||
response1 = self.make_puzzle_score_request(puzzle_id, player1_score,
|
||||
self.user)
|
||||
|
||||
# There should now be a score in the db.
|
||||
top_10 = Score.get_tops_n(10, puzzle_id)
|
||||
self.assertEqual(len(top_10), 1)
|
||||
self.assertEqual(top_10[0]['score'], Score.display_score(player1_score))
|
||||
|
||||
response2 = self.make_puzzle_score_request(puzzle_id, player2_score,
|
||||
self.user2)
|
||||
|
||||
# There should now be two scores in the db
|
||||
top_10 = Score.get_tops_n(10, puzzle_id)
|
||||
self.assertEqual(len(top_10), 2)
|
||||
|
||||
# Top score should be player2_score. Second should be player1_score
|
||||
self.assertEqual(top_10[0]['score'], Score.display_score(player2_score))
|
||||
self.assertEqual(top_10[1]['score'], Score.display_score(player1_score))
|
||||
|
||||
# Top score user should be self.user2.username
|
||||
self.assertEqual(top_10[0]['username'], self.user2.username)
|
||||
|
||||
def test_SetPlayerPuzzleScores_error(self):
|
||||
|
||||
scores = [ {"PuzzleID": 994391,
|
||||
scores = [{"PuzzleID": 994391,
|
||||
"ScoreType": "score",
|
||||
"BestScore": 0.078034,
|
||||
"CurrentScore":0.080035,
|
||||
"ScoreVersion":23}]
|
||||
"CurrentScore": 0.080035,
|
||||
"ScoreVersion": 23}]
|
||||
validation_str = json.dumps(scores)
|
||||
|
||||
verify = {"Verify": verify_code(self.user.email, validation_str),
|
||||
"VerifyMethod":"FoldItVerify"}
|
||||
"VerifyMethod": "FoldItVerify"}
|
||||
|
||||
# change the real string -- should get an error
|
||||
scores[0]['ScoreVersion'] = 22
|
||||
|
||||
@@ -10,6 +10,8 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from foldit.models import Score, PuzzleComplete
|
||||
from student.models import unique_id_for_user
|
||||
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -38,6 +40,13 @@ def foldit_ops(request):
|
||||
"user %s, scores json %r, verify %r",
|
||||
request.user, puzzle_scores_json, pz_verify_json)
|
||||
else:
|
||||
# This is needed because we are not getting valid json - the
|
||||
# value of ScoreType is an unquoted string. Right now regexes are
|
||||
# quoting the string, but ideally the json itself would be fixed.
|
||||
# To allow for fixes without breaking this, the regex should only
|
||||
# match unquoted strings,
|
||||
a = re.compile(r':([a-zA-Z]*),')
|
||||
puzzle_scores_json = re.sub(a, ':"\g<1>",', puzzle_scores_json)
|
||||
puzzle_scores = json.loads(puzzle_scores_json)
|
||||
responses.append(save_scores(request.user, puzzle_scores))
|
||||
|
||||
@@ -98,10 +107,31 @@ def save_scores(user, puzzle_scores):
|
||||
# BestScore (energy), CurrentScore (Energy), ScoreVersion (int)
|
||||
|
||||
puzzle_id = score['PuzzleID']
|
||||
|
||||
# TODO: save the score
|
||||
best_score = score['BestScore']
|
||||
current_score = score['CurrentScore']
|
||||
score_version = score['ScoreVersion']
|
||||
|
||||
# SetPlayerPuzzleScoreResponse object
|
||||
# Score entries are unique on user/unique_user_id/puzzle_id/score_version
|
||||
try:
|
||||
obj = Score.objects.get(
|
||||
user=user,
|
||||
unique_user_id=unique_id_for_user(user),
|
||||
puzzle_id=puzzle_id,
|
||||
score_version=score_version)
|
||||
obj.current_score = current_score
|
||||
obj.best_score = best_score
|
||||
|
||||
except Score.DoesNotExist:
|
||||
obj = Score(
|
||||
user=user,
|
||||
unique_user_id=unique_id_for_user(user),
|
||||
puzzle_id=puzzle_id,
|
||||
current_score=current_score,
|
||||
best_score=best_score,
|
||||
score_version=score_version)
|
||||
obj.save()
|
||||
|
||||
score_responses.append({'PuzzleID': puzzle_id,
|
||||
'Status': 'Success'})
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def split_by_comma_and_whitespace(s):
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def instructor_dashboard(request, course_id):
|
||||
"""Display the instructor dashboard for a course."""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
course = get_course_with_access(request.user, course_id, 'staff', depth=None)
|
||||
|
||||
instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists
|
||||
|
||||
@@ -893,7 +893,7 @@ def gradebook(request, course_id):
|
||||
- only displayed to course staff
|
||||
- shows students who are enrolled.
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
course = get_course_with_access(request.user, course_id, 'staff', depth=None)
|
||||
|
||||
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile")
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ MITX_FEATURES = {
|
||||
|
||||
# Flip to True when the YouTube iframe API breaks (again)
|
||||
'USE_YOUTUBE_OBJECT_API': False,
|
||||
|
||||
# Give a UI to show a student's submission history in a problem by the
|
||||
# Staff Debug tool.
|
||||
'ENABLE_STUDENT_HISTORY_VIEW': True
|
||||
}
|
||||
|
||||
# Used for A/B testing
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
border: 1px solid rgba(0, 0, 0, 0.9);
|
||||
@include box-shadow(inset 0 1px 0 0 rgba(255, 255, 255, 0.7));
|
||||
overflow: hidden;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-bottom: 30px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
13
lms/templates/courseware/submission_history.html
Normal file
13
lms/templates/courseware/submission_history.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<% import json %>
|
||||
<h3>${username} > ${course_id} > ${location}</h3>
|
||||
|
||||
% for i, entry in enumerate(history_entries):
|
||||
<hr/>
|
||||
<div>
|
||||
<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}
|
||||
</pre>
|
||||
</div>
|
||||
% endfor
|
||||
@@ -5,6 +5,27 @@ function setup_debug(element_id, edit_link, staff_context){
|
||||
$('#' + element_id + '_trig').leanModal();
|
||||
$('#' + element_id + '_xqa_log').leanModal();
|
||||
$('#' + element_id + '_xqa_form').submit(function () {sendlog(element_id, edit_link, staff_context);});
|
||||
|
||||
$("#" + element_id + "_history_trig").leanModal();
|
||||
|
||||
$('#' + element_id + '_history_form').submit(
|
||||
function () {
|
||||
var username = $("#" + element_id + "_history_student_username").val();
|
||||
var location = $("#" + element_id + "_history_location").val();
|
||||
|
||||
// This is a ridiculous way to get the course_id, but I'm not sure
|
||||
// how to do it sensibly from within the staff debug code.
|
||||
// staff_problem_info.html is rendered through a wrapper to get_html
|
||||
// that's injected by the code that adds the histogram -- it's all
|
||||
// kinda bizarre, and it remains awkward to simply ask "what course
|
||||
// is this problem being shown in the context of."
|
||||
var path_parts = window.location.pathname.split('/');
|
||||
var course_id = path_parts[2] + "/" + path_parts[3] + "/" + path_parts[4];
|
||||
$("#" + element_id + "_history_text").load('/courses/' + course_id +
|
||||
"/submission_history/" + username + "/" + location);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function sendlog(element_id, edit_link, staff_context){
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
<section class="foldit">
|
||||
<p><strong>Due:</strong> ${due}
|
||||
|
||||
% if show_basic:
|
||||
${folditbasic}
|
||||
% endif
|
||||
|
||||
<p>
|
||||
<strong>Status:</strong>
|
||||
% if success:
|
||||
You have successfully gotten to level ${goal_level}.
|
||||
% else:
|
||||
You have not yet gotten to level ${goal_level}.
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<h3>Completed puzzles</h3>
|
||||
% if show_leader:
|
||||
${folditchallenge}
|
||||
% endif
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Level</th>
|
||||
<th>Submitted</th>
|
||||
</tr>
|
||||
% for puzzle in completed:
|
||||
<tr>
|
||||
<td>${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}</td>
|
||||
<td>${puzzle['created'].strftime('%Y-%m-%d %H:%M')}</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</table>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
29
lms/templates/folditbasic.html
Normal file
29
lms/templates/folditbasic.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="folditbasic">
|
||||
<p><strong>Due:</strong> ${due}
|
||||
|
||||
<p>
|
||||
<strong>Status:</strong>
|
||||
% if success:
|
||||
You have successfully gotten to level ${goal_level}.
|
||||
% else:
|
||||
You have not yet gotten to level ${goal_level}.
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<h3>Completed puzzles</h3>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Level</th>
|
||||
<th>Submitted</th>
|
||||
</tr>
|
||||
% for puzzle in completed:
|
||||
<tr>
|
||||
<td>${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}</td>
|
||||
<td>${puzzle['created'].strftime('%Y-%m-%d %H:%M')}</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</table>
|
||||
|
||||
</br>
|
||||
</div>
|
||||
16
lms/templates/folditchallenge.html
Normal file
16
lms/templates/folditchallenge.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="folditchallenge">
|
||||
<h3>Puzzle Leaderboard</h3>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
% for pair in top_scores:
|
||||
<tr>
|
||||
<td>${pair[0]}</td>
|
||||
<td>${pair[1]}</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
## The JS for this is defined in xqa_interface.html
|
||||
${module_content}
|
||||
%if location.category in ['problem','video','html']:
|
||||
% if edit_link:
|
||||
@@ -13,6 +14,11 @@ ${module_content}
|
||||
% endif
|
||||
<div><a href="#${element_id}_debug" id="${element_id}_trig">Staff Debug Info</a></div>
|
||||
|
||||
% if settings.MITX_FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW') and \
|
||||
location.category == 'problem':
|
||||
<div><a href="#${element_id}_history" id="${element_id}_history_trig">Submission history</a></div>
|
||||
% endif
|
||||
|
||||
<section id="${element_id}_xqa-modal" class="modal xqa-modal" style="width:80%; left:20%; height:80%; overflow:auto" >
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
@@ -57,8 +63,26 @@ category = ${category | h}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="${element_id}_setup"></div>
|
||||
<section class="modal history-modal" id="${element_id}_history" style="width:80%; left:20%; height:80%; overflow:auto;" >
|
||||
<div class="inner-wrapper" style="color:black">
|
||||
<header>
|
||||
<h2>Submission History Viewer</h2>
|
||||
</header>
|
||||
<form id="${element_id}_history_form">
|
||||
<label for="${element_id}_history_student_username">User:</label>
|
||||
<input id="${element_id}_history_student_username" type="text" placeholder=""/>
|
||||
<input type="hidden" id="${element_id}_history_location" value="${location}"/>
|
||||
<div class="submit">
|
||||
<button name="submit" type="submit">View History</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="${element_id}_history_text" class="staff_info" style="display:block">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="${element_id}_setup"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// assumes courseware.html's loaded this method.
|
||||
|
||||
@@ -360,7 +360,6 @@ if settings.COURSEWARE_ENABLED:
|
||||
|
||||
# discussion forums live within courseware, so courseware must be enabled first
|
||||
if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
|
||||
|
||||
urlpatterns += (
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/news$',
|
||||
'courseware.views.news', name="news"),
|
||||
@@ -373,6 +372,14 @@ if settings.COURSEWARE_ENABLED:
|
||||
'courseware.views.static_tab', name="static_tab"),
|
||||
)
|
||||
|
||||
if settings.MITX_FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'):
|
||||
urlpatterns += (
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/submission_history/(?P<student_username>[^/]*)/(?P<location>.*?)$',
|
||||
'courseware.views.submission_history',
|
||||
name='submission_history'),
|
||||
)
|
||||
|
||||
|
||||
if settings.ENABLE_JASMINE:
|
||||
urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),)
|
||||
|
||||
|
||||
7
rakefile
7
rakefile
@@ -440,6 +440,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