Commit 156a702e by Brian Talbot

Merge branch 'master' into feature/btalbot/studio-alerts

parents cbdf9ea2 59250225
from xmodule.templates import update_templates
update_templates()
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()
\ No newline at end of file
...@@ -1112,6 +1112,7 @@ def module_info(request, module_location): ...@@ -1112,6 +1112,7 @@ def module_info(request, module_location):
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def get_course_settings(request, org, course, name): def get_course_settings(request, org, course, name):
...@@ -1127,12 +1128,15 @@ def get_course_settings(request, org, course, name): ...@@ -1127,12 +1128,15 @@ def get_course_settings(request, org, course, name):
raise PermissionDenied() raise PermissionDenied()
course_module = modulestore().get_item(location) course_module = modulestore().get_item(location)
course_details = CourseDetails.fetch(location)
return render_to_response('settings.html', { return render_to_response('settings.html', {
'context_course': course_module, 'context_course': course_module,
'course_location' : location, 'course_location': location,
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) 'details_url': reverse(course_settings_updates,
kwargs={"org": org,
"course": course,
"name": name,
"section": "details"})
}) })
@login_required @login_required
......
...@@ -59,11 +59,6 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -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 // 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, _videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
save_videosource: function(newsource) { save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string // newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
......
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;
}
}
}
})
\ No newline at end of file
...@@ -44,6 +44,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -44,6 +44,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.render(); self.render();
} }
); );
// when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render);
}, },
render: function () { render: function () {
...@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
$(updateEle).empty(); $(updateEle).empty();
var self = this; var self = this;
this.collection.each(function (update) { this.collection.each(function (update) {
try {
var newEle = self.template({ updateModel : update }); var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle); $(updateEle).append(newEle);
} catch (e) {
// ignore
}
}); });
this.$el.find(".new-update-form").hide(); this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' }); this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
...@@ -150,7 +156,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -150,7 +156,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
}, },
closeEditor: function(self, removePost) { closeEditor: function(self, removePost) {
var targetModel = self.collection.getByCid(self.$currentPost.attr('name')); var targetModel = self.collection.get(self.$currentPost.attr('name'));
if(removePost) { if(removePost) {
self.$currentPost.remove(); self.$currentPost.remove();
...@@ -160,8 +166,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -160,8 +166,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.$currentPost.removeClass('editing'); self.$currentPost.removeClass('editing');
self.$currentPost.find('.date-display').html(targetModel.get('date')); self.$currentPost.find('.date-display').html(targetModel.get('date'));
self.$currentPost.find('.date').val(targetModel.get('date')); self.$currentPost.find('.date').val(targetModel.get('date'));
try {
// just in case the content causes an error (embedded js errors)
self.$currentPost.find('.update-contents').html(targetModel.get('content')); self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(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(); self.$currentPost.find('form').hide();
window.$modalCover.unbind('click'); window.$modalCover.unbind('click');
window.$modalCover.hide(); window.$modalCover.hide();
...@@ -172,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -172,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// Dereferencing from events to screen elements // Dereferencing from events to screen elements
eventModel: function(event) { eventModel: function(event) {
// not sure if it should be currentTarget or delegateTarget // 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) { modelDom: function(event) {
......
...@@ -20,8 +20,8 @@ ...@@ -20,8 +20,8 @@
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
$(document).ready(function(){ $(document).ready(function(){
var course_updates = new CMS.Models.CourseUpdateCollection(); var course_updates = new CMS.Models.CourseUpdateCollection();
course_updates.reset(${course_updates|n});
course_updates.urlbase = '${url_base}'; course_updates.urlbase = '${url_base}';
course_updates.fetch();
var course_handouts = new CMS.Models.ModuleInfo({ var course_handouts = new CMS.Models.ModuleInfo({
id: '${handouts_location}' id: '${handouts_location}'
......
...@@ -30,13 +30,18 @@ from contentstore import utils ...@@ -30,13 +30,18 @@ from contentstore import utils
}).blur(function() { }).blur(function() {
$("label").removeClass("is-focused"); $("label").removeClass("is-focused");
}); });
var model = new CMS.Models.Settings.CourseDetails();
model.urlRoot = '${details_url}';
model.fetch({success :
function(model) {
var editor = new CMS.Views.Settings.Details({ var editor = new CMS.Views.Settings.Details({
el: $('.settings-details'), el: $('.settings-details'),
model: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true}) model: model
}); });
editor.render(); editor.render();
}
});
}); });
</script> </script>
......
...@@ -554,7 +554,7 @@ def create_account(request, post_override=None): ...@@ -554,7 +554,7 @@ def create_account(request, post_override=None):
try: try:
validate_slug(post_vars['username']) validate_slug(post_vars['username'])
except ValidationError: 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' js['field'] = 'username'
return HttpResponse(json.dumps(js)) return HttpResponse(json.dumps(js))
......
...@@ -510,7 +510,9 @@ class LoncapaProblem(object): ...@@ -510,7 +510,9 @@ class LoncapaProblem(object):
# let each Response render itself # let each Response render itself
if problemtree in self.responders: 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: # let each custom renderer render itself:
if problemtree.tag in customrender.registry.registered_tags(): if problemtree.tag in customrender.registry.registered_tags():
......
...@@ -27,6 +27,7 @@ class CorrectMap(object): ...@@ -27,6 +27,7 @@ class CorrectMap(object):
self.cmap = dict() self.cmap = dict()
self.items = self.cmap.items self.items = self.cmap.items
self.keys = self.cmap.keys self.keys = self.cmap.keys
self.overall_message = ""
self.set(*args, **kwargs) self.set(*args, **kwargs)
def __getitem__(self, *args, **kwargs): def __getitem__(self, *args, **kwargs):
...@@ -104,15 +105,20 @@ class CorrectMap(object): ...@@ -104,15 +105,20 @@ class CorrectMap(object):
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
def get_queuetime_str(self, answer_id): def get_queuetime_str(self, answer_id):
if self.cmap[answer_id]['queuestate']:
return self.cmap[answer_id]['queuestate']['time'] return self.cmap[answer_id]['queuestate']['time']
else:
return None
def get_npoints(self, answer_id): def get_npoints(self, answer_id):
""" 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') npoints = self.get_property(answer_id, 'npoints')
if npoints is not None: return npoints if npoints is not None else 1
return npoints else:
elif self.is_correct(answer_id):
return 1
# if not correct and no points have been assigned, return 0
return 0 return 0
def set_property(self, answer_id, property, value): def set_property(self, answer_id, property, value):
...@@ -153,3 +159,15 @@ class CorrectMap(object): ...@@ -153,3 +159,15 @@ class CorrectMap(object):
if not isinstance(other_cmap, CorrectMap): if not isinstance(other_cmap, CorrectMap):
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap) raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
self.cmap.update(other_cmap.get_dict()) 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): ...@@ -174,13 +174,14 @@ class LoncapaResponse(object):
''' '''
return sum(self.maxpoints.values()) 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. Return XHTML Element tree representation of this Response.
Arguments: Arguments:
- renderer : procedure which produces HTML given an ElementTree - 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 # render ourself as a <span> + our content
tree = etree.Element('span') tree = etree.Element('span')
...@@ -195,6 +196,11 @@ class LoncapaResponse(object): ...@@ -195,6 +196,11 @@ class LoncapaResponse(object):
if item_xhtml is not None: if item_xhtml is not None:
tree.append(item_xhtml) tree.append(item_xhtml)
tree.tail = self.xml.tail 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 return tree
def evaluate_answers(self, student_answers, old_cmap): def evaluate_answers(self, student_answers, old_cmap):
...@@ -319,6 +325,29 @@ class LoncapaResponse(object): ...@@ -319,6 +325,29 @@ class LoncapaResponse(object):
def __unicode__(self): def __unicode__(self):
return u'LoncapaProblem Response %s' % self.xml.tag 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(): ...@@ -965,6 +994,7 @@ def sympy_check2():
# not expecting 'unknown's # not expecting 'unknown's
correct = ['unknown'] * len(idset) correct = ['unknown'] * len(idset)
messages = [''] * len(idset) messages = [''] * len(idset)
overall_message = ""
# put these in the context of the check function evaluator # put these in the context of the check function evaluator
# note that this doesn't help the "cfn" version - only the exec version # note that this doesn't help the "cfn" version - only the exec version
...@@ -996,6 +1026,10 @@ def sympy_check2(): ...@@ -996,6 +1026,10 @@ def sympy_check2():
# the list of messages to be filled in by the check function # the list of messages to be filled in by the check function
'messages': messages, '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 # any options to be passed to the cfn
'options': self.xml.get('options'), 'options': self.xml.get('options'),
'testdat': 'hello world', 'testdat': 'hello world',
...@@ -1010,6 +1044,7 @@ def sympy_check2(): ...@@ -1010,6 +1044,7 @@ def sympy_check2():
exec self.code in self.context['global_context'], self.context exec self.code in self.context['global_context'], self.context
correct = self.context['correct'] correct = self.context['correct']
messages = self.context['messages'] messages = self.context['messages']
overall_message = self.context['overall_message']
except Exception as err: except Exception as err:
print "oops in customresponse (code) error %s" % err print "oops in customresponse (code) error %s" % err
print "context = ", self.context print "context = ", self.context
...@@ -1044,34 +1079,100 @@ def sympy_check2(): ...@@ -1044,34 +1079,100 @@ def sympy_check2():
log.error(traceback.format_exc()) log.error(traceback.format_exc())
raise Exception("oops in customresponse (cfn) error %s" % err) raise Exception("oops in customresponse (cfn) error %s" % err)
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret) log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
if type(ret) == dict: if type(ret) == dict:
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
msg = ret['msg']
if 1: # One kind of dictionary the check function can return has the
# try to clean up message html # form {'ok': BOOLEAN, 'msg': STRING}
msg = '<html>' + msg + '</html>' # If there are multiple inputs, they all get marked
msg = msg.replace('&#60;', '&lt;') # to the same correct/incorrect value
#msg = msg.replace('&lt;','<') if 'ok' in ret:
msg = etree.tostring(fromstring_bs(msg, convertEntities=None), correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
pretty_print=True) msg = ret.get('msg', None)
#msg = etree.tostring(fromstring_bs(msg),pretty_print=True) msg = self.clean_message_html(msg)
msg = msg.replace('&#13;', '')
#msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', 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 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: else:
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset) correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset)
# build map giving "correct"ness of the answer(s) # build map giving "correct"ness of the answer(s)
correct_map = CorrectMap() correct_map = CorrectMap()
overall_message = self.clean_message_html(overall_message)
correct_map.set_overall_message(overall_message)
for k in range(len(idset)): for k in range(len(idset)):
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0 npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
correct_map.set(idset[k], correct[k], msg=messages[k], correct_map.set(idset[k], correct[k], msg=messages[k],
npoints=npoints) npoints=npoints)
return correct_map 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('&#60;', '&lt;')
# Use etree to prettify the HTML
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
pretty_print=True)
msg = msg.replace('&#13;', '')
# 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): def get_answers(self):
''' '''
Give correct answer expected for this response. Give correct answer expected for this response.
......
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)
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 ...@@ -8,6 +8,7 @@ import json
from nose.plugins.skip import SkipTest from nose.plugins.skip import SkipTest
import os import os
import unittest import unittest
import textwrap
from . import test_system from . import test_system
...@@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest): ...@@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest):
# Inline code can update the global messages list # Inline code can update the global messages list
# to pass messages to the CorrectMap for a particular input # 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) problem = self.build_problem(answer=inline_script)
input_dict = {'1_2_1': '0'} input_dict = {'1_2_1': '0'}
msg = problem.grade_answers(input_dict).get_msg('1_2_1') correctmap = problem.grade_answers(input_dict)
self.assertEqual(msg, "Test Message")
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> # 'expect' is the expect attribute of the <customresponse>
# #
# 'answer_given' is the answer the student gave (if there is just one input) # '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) # 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 # The function should return a dict of the form
# { 'ok': BOOL, 'msg': STRING } # { 'ok': BOOL, 'msg': STRING }
# #
script = """def check_func(expect, answer_given, student_answers): script = textwrap.dedent("""
return {'ok': answer_given == expect, 'msg': 'Message text'}""" 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") problem = self.build_problem(script=script, cfn="check_func", expect="42")
...@@ -698,7 +712,7 @@ class CustomResponseTest(ResponseTest): ...@@ -698,7 +712,7 @@ class CustomResponseTest(ResponseTest):
msg = correct_map.get_msg('1_2_1') msg = correct_map.get_msg('1_2_1')
self.assertEqual(correctness, 'correct') self.assertEqual(correctness, 'correct')
self.assertEqual(msg, "Message text\n") self.assertEqual(msg, "Message text")
# Incorrect answer # Incorrect answer
input_dict = {'1_2_1': '0'} input_dict = {'1_2_1': '0'}
...@@ -708,19 +722,108 @@ class CustomResponseTest(ResponseTest): ...@@ -708,19 +722,108 @@ class CustomResponseTest(ResponseTest):
msg = correct_map.get_msg('1_2_1') msg = correct_map.get_msg('1_2_1')
self.assertEqual(correctness, 'incorrect') self.assertEqual(correctness, 'incorrect')
self.assertEqual(msg, "Message text\n") self.assertEqual(msg, "Message text")
def test_function_code_multiple_input_no_msg(self):
# Check functions also have the option of returning
# a single boolean value
# If true, mark all the inputs correct
# If false, mark all the inputs incorrect
script = textwrap.dedent("""
def check_func(expect, answer_given):
return (answer_given[0] == expect and
answer_given[1] == expect)
""")
problem = self.build_problem(script=script, cfn="check_func",
expect="42", num_inputs=2)
# Correct answer -- expect both inputs marked correct
input_dict = {'1_2_1': '42', '1_2_2': '42'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
self.assertEqual(correctness, 'correct')
correctness = correct_map.get_correctness('1_2_2')
self.assertEqual(correctness, 'correct')
# One answer incorrect -- expect both inputs marked incorrect
input_dict = {'1_2_1': '0', '1_2_2': '42'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
self.assertEqual(correctness, 'incorrect')
correctness = correct_map.get_correctness('1_2_2')
self.assertEqual(correctness, 'incorrect')
def test_multiple_inputs(self):
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 # When given multiple inputs, the 'answer_given' argument
# to the check_func() is a list of inputs # to the check_func() is a list of inputs
#
# The sample script below marks the problem as correct # The sample script below marks the problem as correct
# if and only if it receives answer_given=[1,2,3] # if and only if it receives answer_given=[1,2,3]
# (or string values ['1','2','3']) # (or string values ['1','2','3'])
script = """def check_func(expect, answer_given, student_answers): #
# 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) check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2) check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3) check3 = (int(answer_given[2]) == 3)
return {'ok': (check1 and check2 and check3), 'msg': 'Message text'}""" return {'ok': (check1 and check2 and check3),
'msg': 'Message text'}
""")
problem = self.build_problem(script=script, problem = self.build_problem(script=script,
cfn="check_func", num_inputs=3) cfn="check_func", num_inputs=3)
...@@ -743,6 +846,37 @@ class CustomResponseTest(ResponseTest): ...@@ -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_2'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_3'), '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): class SchematicResponseTest(ResponseTest):
from response_xml_factory import SchematicResponseXMLFactory from response_xml_factory import SchematicResponseXMLFactory
......
...@@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor):
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically # 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 # disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.definition['data'].get('grading_policy', None)) self.set_grading_policy(self.definition['data'].get('grading_policy', None))
self.test_center_exams = [] self.test_center_exams = []
...@@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor):
grading_policy.update(course_policy) grading_policy.update(course_policy)
# Here is where we should parse any configurations, so that we can fail early # 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 # Use setters so that side effecting to .definitions works
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER']) self.raw_grader = grading_policy['GRADER'] # used for cms access
self._grading_policy = grading_policy self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
@classmethod @classmethod
def read_grading_policy(cls, paths, system): def read_grading_policy(cls, paths, system):
...@@ -319,7 +318,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -319,7 +318,7 @@ class CourseDescriptor(SequenceDescriptor):
@property @property
def grader(self): def grader(self):
return self._grading_policy['GRADER'] return grader_from_conf(self.raw_grader)
@property @property
def raw_grader(self): def raw_grader(self):
......
$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 ...@@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class FolditModule(XModule): class FolditModule(XModule):
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
def __init__(self, system, location, definition, descriptor, def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs): instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor, XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs) 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 Example:
self.required_sublevel = 5 <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(): def parse_due_date():
""" """
...@@ -66,6 +79,14 @@ class FolditModule(XModule): ...@@ -66,6 +79,14 @@ class FolditModule(XModule):
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id), PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
key=lambda d: (d['set'], d['subset'])) 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): def get_html(self):
""" """
...@@ -75,15 +96,47 @@ class FolditModule(XModule): ...@@ -75,15 +96,47 @@ class FolditModule(XModule):
self.required_level, self.required_level,
self.required_sublevel) self.required_sublevel)
showbasic = (self.metadata.get("show_basic_score").lower() == "true")
showleader = (self.metadata.get("show_leaderboard").lower() == "true")
context = { context = {
'due': self.due_str, 'due': self.due_str,
'success': self.is_complete(), 'success': self.is_complete(),
'goal_level': goal_level, 'goal_level': goal_level,
'completed': self.completed_puzzles(), '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) 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)
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): def get_score(self):
""" """
...@@ -97,9 +150,10 @@ class FolditModule(XModule): ...@@ -97,9 +150,10 @@ class FolditModule(XModule):
return 1 return 1
class FolditDescriptor(XmlDescriptor, EditingDescriptor): 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" mako_template = "widgets/html-edit.html"
module_class = FolditModule module_class = FolditModule
...@@ -119,6 +173,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor): ...@@ -119,6 +173,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
@classmethod @classmethod
def definition_from_xml(cls, xml_object, system): 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): ...@@ -64,7 +64,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
location = Location(location) location = Location(location)
json_data = self.module_data.get(location) json_data = self.module_data.get(location)
if json_data is None: 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: else:
# load the module and apply the inherited metadata # load the module and apply the inherited metadata
try: try:
......
####################################
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 ...@@ -24,6 +24,7 @@ Specific Problem Types
course_data_formats/drag_and_drop/drag_and_drop_input.rst course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
course_data_formats/custom_response.rst
Internal Data Formats Internal Data Formats
......
...@@ -270,7 +270,7 @@ def progress_summary(student, request, course, student_module_cache): ...@@ -270,7 +270,7 @@ def progress_summary(student, request, course, student_module_cache):
# would be simpler # would be simpler
course_module = get_module(student, request, course_module = get_module(student, request,
course.location, student_module_cache, course.location, student_module_cache,
course.id) course.id, depth=None)
if not course_module: if not course_module:
# This student must not have access to the course. # This student must not have access to the course.
return None return None
......
# -*- 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']
\ No newline at end of file
# -*- 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']
\ No newline at end of file
...@@ -12,8 +12,10 @@ file and check it in at the same time as your model changes. To do that, ...@@ -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 ASSUMPTIONS: modules have unique IDs, even across different module_types
""" """
from django.db import models
from django.contrib.auth.models import User 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): class StudentModule(models.Model):
""" """
...@@ -60,6 +62,37 @@ class StudentModule(models.Model): ...@@ -60,6 +62,37 @@ class StudentModule(models.Model):
self.student.username, self.module_state_key, str(self.state)[:20]]) 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 # TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors
......
...@@ -5,10 +5,11 @@ from functools import partial ...@@ -5,10 +5,11 @@ from functools import partial
from django.conf import settings from django.conf import settings
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required 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 django.shortcuts import redirect
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
#from django.views.decorators.csrf import ensure_csrf_cookie #from django.views.decorators.csrf import ensure_csrf_cookie
...@@ -20,7 +21,7 @@ from courseware.access import has_access ...@@ -20,7 +21,7 @@ from courseware.access import has_access
from courseware.courses import (get_courses, get_course_with_access, from courseware.courses import (get_courses, get_course_with_access,
get_courses_by_university, sort_by_announcement) get_courses_by_university, sort_by_announcement)
import courseware.tabs as tabs 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 module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor
from django_comment_client.utils import get_discussion_title from django_comment_client.utils import get_discussion_title
...@@ -306,6 +307,10 @@ def index(request, course_id, chapter=None, section=None, ...@@ -306,6 +307,10 @@ def index(request, course_id, chapter=None, section=None,
# Specifically asked-for section doesn't exist # Specifically asked-for section doesn't exist
raise Http404 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 # Load all descendants of the section, because we're going to display its
# html, which in general will need all of its children # html, which in general will need all of its children
section_module_cache = StudentModuleCache.cache_for_descriptor_descendents( section_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
...@@ -570,7 +575,7 @@ def progress(request, course_id, student_id=None): ...@@ -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 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') staff_access = has_access(request.user, course, 'staff')
if student_id is None or student_id == request.user.id: if student_id is None or student_id == request.user.id:
...@@ -590,7 +595,7 @@ def progress(request, course_id, student_id=None): ...@@ -590,7 +595,7 @@ def progress(request, course_id, student_id=None):
student = User.objects.prefetch_related("groups").get(id=student.id) student = User.objects.prefetch_related("groups").get(id=student.id)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( 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, courseware_summary = grades.progress_summary(student, request, course,
student_module_cache) student_module_cache)
...@@ -608,3 +613,48 @@ def progress(request, course_id, student_id=None): ...@@ -608,3 +613,48 @@ def progress(request, course_id, student_id=None):
context.update() context.update()
return render_to_response('courseware/progress.html', context) 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 ...@@ -3,6 +3,7 @@ import json
from datetime import datetime from datetime import datetime
from django.http import Http404 from django.http import Http404
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from django.db import connection
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -12,16 +13,18 @@ def dictfetchall(cursor): ...@@ -12,16 +13,18 @@ def dictfetchall(cursor):
'''Returns a list of all rows from a cursor as a column: result dict. '''Returns a list of all rows from a cursor as a column: result dict.
Borrowed from Django documentation''' Borrowed from Django documentation'''
desc = cursor.description desc = cursor.description
table=[] table = []
table.append([col[0] for col in desc]) 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 return table
def SQL_query_to_list(cursor, query_string): def SQL_query_to_list(cursor, query_string):
cursor.execute(query_string) cursor.execute(query_string)
raw_result=dictfetchall(cursor) raw_result=dictfetchall(cursor)
print raw_result
return raw_result return raw_result
def dashboard(request): def dashboard(request):
...@@ -50,7 +53,6 @@ def dashboard(request): ...@@ -50,7 +53,6 @@ def dashboard(request):
results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count() results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count()
# establish a direct connection to the database (for executing raw SQL) # establish a direct connection to the database (for executing raw SQL)
from django.db import connection
cursor = connection.cursor() cursor = connection.cursor()
# define the queries that will generate our user-facing tables # define the queries that will generate our user-facing tables
......
# -*- 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']
\ No newline at end of file
...@@ -25,6 +25,47 @@ class Score(models.Model): ...@@ -25,6 +25,47 @@ class Score(models.Model):
score_version = models.IntegerField() score_version = models.IntegerField()
created = models.DateTimeField(auto_now_add=True) 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): class PuzzleComplete(models.Model):
""" """
......
...@@ -9,7 +9,7 @@ from django.conf import settings ...@@ -9,7 +9,7 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from foldit.views import foldit_ops, verify_code 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 student.models import UserProfile, unique_id_for_user
from datetime import datetime, timedelta from datetime import datetime, timedelta
...@@ -25,92 +25,162 @@ class FolditTestCase(TestCase): ...@@ -25,92 +25,162 @@ class FolditTestCase(TestCase):
pwd = 'abc' pwd = 'abc'
self.user = User.objects.create_user('testuser', 'test@test.com', pwd) 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_id = unique_id_for_user(self.user)
self.unique_user_id2 = unique_id_for_user(self.user2)
now = datetime.now() now = datetime.now()
self.tomorrow = now + timedelta(days=1) self.tomorrow = now + timedelta(days=1)
self.yesterday = now - timedelta(days=1) self.yesterday = now - timedelta(days=1)
UserProfile.objects.create(user=self.user) 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 = self.factory.post(self.url, post_data)
request.user = self.user request.user = self.user if not user else user
return request return request
def test_SetPlayerPuzzleScores(self): def make_puzzle_score_request(self, puzzle_ids, best_scores, user=None):
"""
scores = [ {"PuzzleID": 994391, 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", "ScoreType": "score",
"BestScore": 0.078034, "BestScore": best_score,
"CurrentScore":0.080035, # current scores don't actually matter
"ScoreVersion":23}] "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) scores_str = json.dumps(scores)
verify = {"Verify": verify_code(self.user.email, scores_str), verify = {"Verify": verify_code(user.email, scores_str),
"VerifyMethod":"FoldItVerify"} "VerifyMethod": "FoldItVerify"}
data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify), data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify),
'SetPlayerPuzzleScores': scores_str} 'SetPlayerPuzzleScores': scores_str}
request = self.make_request(data) request = self.make_request(data, user)
response = foldit_ops(request) response = foldit_ops(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
return response
def test_SetPlayerPuzzleScores(self):
puzzle_id = 994391
best_score = 0.078034
response = self.make_puzzle_score_request(puzzle_id, [best_score])
self.assertEqual(response.content, json.dumps( self.assertEqual(response.content, json.dumps(
[{"OperationID": "SetPlayerPuzzleScores", [{"OperationID": "SetPlayerPuzzleScores",
"Value": [{ "Value": [{
"PuzzleID": 994391, "PuzzleID": puzzle_id,
"Status": "Success"}]}])) "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): def test_SetPlayerPuzzleScores_many(self):
scores = [ {"PuzzleID": 994391, response = self.make_puzzle_score_request([1, 2], [0.078034, 0.080000])
"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)
self.assertEqual(response.content, json.dumps( self.assertEqual(response.content, json.dumps(
[{"OperationID": "SetPlayerPuzzleScores", [{"OperationID": "SetPlayerPuzzleScores",
"Value": [{ "Value": [{
"PuzzleID": 994391, "PuzzleID": 1,
"Status": "Success"}, "Status": "Success"},
{"PuzzleID": 994392, {"PuzzleID": 2,
"Status": "Success"}]}])) "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): def test_SetPlayerPuzzleScores_error(self):
scores = [ {"PuzzleID": 994391, scores = [{"PuzzleID": 994391,
"ScoreType": "score", "ScoreType": "score",
"BestScore": 0.078034, "BestScore": 0.078034,
"CurrentScore":0.080035, "CurrentScore": 0.080035,
"ScoreVersion":23}] "ScoreVersion": 23}]
validation_str = json.dumps(scores) validation_str = json.dumps(scores)
verify = {"Verify": verify_code(self.user.email, validation_str), verify = {"Verify": verify_code(self.user.email, validation_str),
"VerifyMethod":"FoldItVerify"} "VerifyMethod": "FoldItVerify"}
# change the real string -- should get an error # change the real string -- should get an error
scores[0]['ScoreVersion'] = 22 scores[0]['ScoreVersion'] = 22
......
...@@ -10,6 +10,8 @@ from django.views.decorators.csrf import csrf_exempt ...@@ -10,6 +10,8 @@ from django.views.decorators.csrf import csrf_exempt
from foldit.models import Score, PuzzleComplete from foldit.models import Score, PuzzleComplete
from student.models import unique_id_for_user from student.models import unique_id_for_user
import re
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -38,6 +40,13 @@ def foldit_ops(request): ...@@ -38,6 +40,13 @@ def foldit_ops(request):
"user %s, scores json %r, verify %r", "user %s, scores json %r, verify %r",
request.user, puzzle_scores_json, pz_verify_json) request.user, puzzle_scores_json, pz_verify_json)
else: 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) puzzle_scores = json.loads(puzzle_scores_json)
responses.append(save_scores(request.user, puzzle_scores)) responses.append(save_scores(request.user, puzzle_scores))
...@@ -98,10 +107,31 @@ def save_scores(user, puzzle_scores): ...@@ -98,10 +107,31 @@ def save_scores(user, puzzle_scores):
# BestScore (energy), CurrentScore (Energy), ScoreVersion (int) # BestScore (energy), CurrentScore (Energy), ScoreVersion (int)
puzzle_id = score['PuzzleID'] puzzle_id = score['PuzzleID']
best_score = score['BestScore']
# TODO: save the score current_score = score['CurrentScore']
score_version = score['ScoreVersion']
# SetPlayerPuzzleScoreResponse object # 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, score_responses.append({'PuzzleID': puzzle_id,
'Status': 'Success'}) 'Status': 'Success'})
......
...@@ -59,7 +59,7 @@ def split_by_comma_and_whitespace(s): ...@@ -59,7 +59,7 @@ def split_by_comma_and_whitespace(s):
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard(request, course_id): def instructor_dashboard(request, course_id):
"""Display the instructor dashboard for a course.""" """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 instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists
...@@ -893,7 +893,7 @@ def gradebook(request, course_id): ...@@ -893,7 +893,7 @@ def gradebook(request, course_id):
- only displayed to course staff - only displayed to course staff
- shows students who are enrolled. - 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") enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile")
......
...@@ -83,6 +83,10 @@ MITX_FEATURES = { ...@@ -83,6 +83,10 @@ MITX_FEATURES = {
# Flip to True when the YouTube iframe API breaks (again) # Flip to True when the YouTube iframe API breaks (again)
'USE_YOUTUBE_OBJECT_API': False, '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 # Used for A/B testing
......
...@@ -57,6 +57,8 @@ ...@@ -57,6 +57,8 @@
border: 1px solid rgba(0, 0, 0, 0.9); border: 1px solid rgba(0, 0, 0, 0.9);
@include box-shadow(inset 0 1px 0 0 rgba(255, 255, 255, 0.7)); @include box-shadow(inset 0 1px 0 0 rgba(255, 255, 255, 0.7));
overflow: hidden; overflow: hidden;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 30px; padding-bottom: 30px;
position: relative; position: relative;
z-index: 2; z-index: 2;
......
<% 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){ ...@@ -5,6 +5,27 @@ function setup_debug(element_id, edit_link, staff_context){
$('#' + element_id + '_trig').leanModal(); $('#' + element_id + '_trig').leanModal();
$('#' + element_id + '_xqa_log').leanModal(); $('#' + element_id + '_xqa_log').leanModal();
$('#' + element_id + '_xqa_form').submit(function () {sendlog(element_id, edit_link, staff_context);}); $('#' + 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){ function sendlog(element_id, edit_link, staff_context){
......
<section class="foldit"> <section class="foldit">
<p><strong>Due:</strong> ${due}
<p> % if show_basic:
<strong>Status:</strong> ${folditbasic}
% if success: % endif
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> % if show_leader:
<tr> ${folditchallenge}
<th>Level</th> % endif
<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>
<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>
<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>
## The JS for this is defined in xqa_interface.html
${module_content} ${module_content}
%if location.category in ['problem','video','html']: %if location.category in ['problem','video','html']:
% if edit_link: % if edit_link:
...@@ -13,6 +14,11 @@ ${module_content} ...@@ -13,6 +14,11 @@ ${module_content}
% endif % endif
<div><a href="#${element_id}_debug" id="${element_id}_trig">Staff Debug Info</a></div> <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" > <section id="${element_id}_xqa-modal" class="modal xqa-modal" style="width:80%; left:20%; height:80%; overflow:auto" >
<div class="inner-wrapper"> <div class="inner-wrapper">
<header> <header>
...@@ -57,8 +63,26 @@ category = ${category | h} ...@@ -57,8 +63,26 @@ category = ${category | h}
</div> </div>
</section> </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"> <script type="text/javascript">
// assumes courseware.html's loaded this method. // assumes courseware.html's loaded this method.
......
...@@ -360,7 +360,6 @@ if settings.COURSEWARE_ENABLED: ...@@ -360,7 +360,6 @@ if settings.COURSEWARE_ENABLED:
# discussion forums live within courseware, so courseware must be enabled first # discussion forums live within courseware, so courseware must be enabled first
if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
urlpatterns += ( urlpatterns += (
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/news$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/news$',
'courseware.views.news', name="news"), 'courseware.views.news', name="news"),
...@@ -373,6 +372,14 @@ if settings.COURSEWARE_ENABLED: ...@@ -373,6 +372,14 @@ if settings.COURSEWARE_ENABLED:
'courseware.views.static_tab', name="static_tab"), '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: if settings.ENABLE_JASMINE:
urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),) urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),)
......
...@@ -441,6 +441,13 @@ namespace :cms do ...@@ -441,6 +441,13 @@ namespace :cms do
end end
namespace :cms do 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" desc "Import course data within the given DATA_DIR variable"
task :xlint do task :xlint do
if ENV['DATA_DIR'] and ENV['COURSE_DIR'] if ENV['DATA_DIR'] and ENV['COURSE_DIR']
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment