diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 4cffc48bea..5ccb98f090 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -39,7 +39,7 @@ import responsetypes # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag,x) for x in responsetypes.__all__]) -entry_types = ['textline', 'schematic', 'choicegroup', 'textbox', 'imageinput', 'optioninput'] +entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup'] solution_types = ['solution'] # extra things displayed after "show answers" is pressed response_properties = ["responseparam", "answer"] # these get captured as student responses @@ -118,6 +118,9 @@ class LoncapaProblem(object): # the dict has keys = xml subtree of Response, values = Response instance self._preprocess_problem(self.tree) + if not self.student_answers: # True when student_answers is an empty dict + self.set_initial_display() + def do_reset(self): ''' Reset internal state to unfinished, with no answers @@ -126,6 +129,14 @@ class LoncapaProblem(object): self.correct_map = CorrectMap() self.done = False + def set_initial_display(self): + initial_answers = dict() + for responder in self.responders.values(): + if hasattr(responder,'get_initial_display'): + initial_answers.update(responder.get_initial_display()) + + self.student_answers = initial_answers + def __unicode__(self): return u"LoncapaProblem ({0})".format(self.problem_id) @@ -180,14 +191,31 @@ class LoncapaProblem(object): return {'score': correct, 'total': self.get_max_score()} - def update_score(self, score_msg): - newcmap = CorrectMap() + def update_score(self, score_msg, queuekey): + ''' + Deliver grading response (e.g. from async code checking) to + the specific ResponseType that requested grading + + Returns an updated CorrectMap + ''' + cmap = CorrectMap() + cmap.update(self.correct_map) for responder in self.responders.values(): - if hasattr(responder,'update_score'): # Is this the best way to implement 'update_score' for CodeResponse? - results = responder.update_score(score_msg) - newcmap.update(results) - self.correct_map = newcmap - return newcmap + if hasattr(responder,'update_score'): + # Each LoncapaResponse will update the specific entries of 'cmap' that it's responsible for + cmap = responder.update_score(score_msg, cmap, queuekey) + self.correct_map.set_dict(cmap.get_dict()) + return cmap + + def is_queued(self): + ''' + Returns True if any part of the problem has been submitted to an external queue + ''' + queued = False + for answer_id in self.correct_map: + if self.correct_map.is_queued(answer_id): + queued = True + return queued def grade_answers(self, answers): ''' @@ -457,7 +485,7 @@ class LoncapaProblem(object): self.responder_answers = {} for response in self.responders.keys(): try: - self.responder_answers[response] = responder.get_answers() + self.responder_answers[response] = self.responders[response].get_answers() except: log.debug('responder %s failed to properly return get_answers()' % self.responders[response]) # FIXME raise diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 786b2f5e2d..11c5bb75f1 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -14,6 +14,7 @@ class CorrectMap(object): - msg : string (may have HTML) giving extra message response (displayed below textline or textbox) - hint : string (may have HTML) giving optional hint (displayed below textline or textbox, above msg) - hintmode : one of (None,'on_request','always') criteria for displaying hint + - queuekey : a random integer for xqueue_callback verification Behaves as a dict. ''' @@ -29,13 +30,14 @@ class CorrectMap(object): def __iter__(self): return self.cmap.__iter__() - def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None): + def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None, queuekey=None): if answer_id is not None: self.cmap[answer_id] = {'correctness': correctness, 'npoints': npoints, 'msg': msg, 'hint' : hint, 'hintmode' : hintmode, + 'queuekey' : queuekey, } def __repr__(self): @@ -63,6 +65,12 @@ class CorrectMap(object): if answer_id in self.cmap: return self.cmap[answer_id]['correctness'] == 'correct' return None + def is_queued(self,answer_id): + return answer_id in self.cmap and self.cmap[answer_id]['queuekey'] is not None + + def is_right_queuekey(self, answer_id, test_key): + return answer_id in self.cmap and self.cmap[answer_id]['queuekey'] == test_key + def get_npoints(self,answer_id): if self.is_correct(answer_id): npoints = self.cmap[answer_id].get('npoints',1) # default to 1 point if correct diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 66ac57c52d..d809f98ed2 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -8,7 +8,9 @@ Module containing the problem elements which render into input objects - textline - textbox (change this to textarea?) - schemmatic -- choicegroup (for multiplechoice: checkbox, radio, or select option) +- choicegroup +- radiogroup +- checkboxgroup - imageinput (for clickable image) - optioninput (for option list) @@ -132,7 +134,8 @@ def optioninput(element, value, status, render_template, msg=''): oset = [x[1:-1] for x in list(oset)] # osetdict = dict([('option_%s_%s' % (eid,x),oset[x]) for x in range(len(oset)) ]) # make dict with IDs - osetdict = dict([(oset[x],oset[x]) for x in range(len(oset)) ]) # make dict with key,value same + osetdict = [(oset[x],oset[x]) for x in range(len(oset)) ] # make ordered list with (key,value) same + # TODO: allow ordering to be randomized context={'id':eid, 'value':value, @@ -145,6 +148,9 @@ def optioninput(element, value, status, render_template, msg=''): return etree.XML(html) #----------------------------------------------------------------------------- + +# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of +# desired semantics. @register_render_function def choicegroup(element, value, status, render_template, msg=''): ''' @@ -160,7 +166,7 @@ def choicegroup(element, value, status, render_template, msg=''): type="checkbox" else: type="radio" - choices={} + choices=[] for choice in element: if not choice.tag=='choice': raise Exception("[courseware.capa.inputtypes.choicegroup] Error only tags should be immediate children of a , found %s instead" % choice.tag) @@ -168,8 +174,66 @@ def choicegroup(element, value, status, render_template, msg=''): ctext += ''.join([etree.tostring(x) for x in choice]) # TODO: what if choice[0] has math tags in it? if choice.text is not None: ctext += choice.text # TODO: fix order? - choices[choice.get("name")] = ctext - context={'id':eid, 'value':value, 'state':status, 'type':type, 'choices':choices} + choices.append((choice.get("name"),ctext)) + context={'id':eid, 'value':value, 'state':status, 'input_type':type, 'choices':choices, 'inline':True, 'name_array_suffix':''} + html = render_template("choicegroup.html", context) + return etree.XML(html) + + +#----------------------------------------------------------------------------- +def extract_choices(element): + ''' + Extracts choices for a few input types, such as radiogroup and + checkboxgroup. + + TODO: allow order of choices to be randomized, following lon-capa spec. Use "location" attribute, + ie random, top, bottom. + ''' + + choices = [] + + for choice in element: + if not choice.tag=='choice': + raise Exception("[courseware.capa.inputtypes.extract_choices] \ + Expected a tag; got %s instead" + % choice.tag) + choice_text = ''.join([etree.tostring(x) for x in choice]) + + choices.append((choice.get("name"), choice_text)) + + return choices + +# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of +# desired semantics. +@register_render_function +def radiogroup(element, value, status, render_template, msg=''): + ''' + Radio button inputs: (multiple choice) + ''' + + eid=element.get('id') + + choices = extract_choices(element) + + context = { 'id':eid, 'value':value, 'state':status, 'input_type': 'radio', 'choices':choices, 'inline': False, 'name_array_suffix': '[]' } + + html = render_template("choicegroup.html", context) + return etree.XML(html) + +# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of +# desired semantics. +@register_render_function +def checkboxgroup(element, value, status, render_template, msg=''): + ''' + Checkbox inputs: (select one or more choices) + ''' + + eid=element.get('id') + + choices = extract_choices(element) + + context = { 'id':eid, 'value':value, 'state':status, 'input_type': 'checkbox', 'choices':choices, 'inline': False, 'name_array_suffix': '[]' } + html = render_template("choicegroup.html", context) return etree.XML(html) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index b645a2faa7..21a3083c13 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -8,6 +8,7 @@ Used by capa_problem.py ''' # standard library imports +import hashlib import inspect import json import logging @@ -16,9 +17,9 @@ import numpy import random import re import requests +import time import traceback import abc -import time # specific library imports from calc import evaluator, UndefinedVariable @@ -265,6 +266,94 @@ class LoncapaResponse(object): def __unicode__(self): return u'LoncapaProblem Response %s' % self.xml.tag +#----------------------------------------------------------------------------- +class ChoiceResponse(LoncapaResponse): + ''' + This Response type is used when the student chooses from a discrete set of + choices. Currently, to be marked correct, all "correct" choices must be + supplied by the student, and no extraneous choices may be included. + + This response type allows for two inputtypes: radiogroups and checkbox + groups. radiogroups are used when the student should select a single answer, + and checkbox groups are used when the student may supply 0+ answers. + Note: it is suggested to include a "None of the above" choice when no + answer is correct for a checkboxgroup inputtype; this ensures that a student + must actively mark something to get credit. + + If two choices are marked as correct with a radiogroup, the student will + have no way to get the answer right. + + TODO: Allow for marking choices as 'optional' and 'required', which would + not penalize a student for including optional answers and would also allow + for questions in which the student can supply one out of a set of correct + answers.This would also allow for survey-style questions in which all + answers are correct. + + Example: + + + + + This is a wrong answer. + + + This is the right answer. + + + This is another wrong answer. + + + + + In the above example, radiogroup can be replaced with checkboxgroup to allow + the student to select more than one choice. + + ''' + + response_tag = 'choiceresponse' + max_inputfields = 1 + allowed_inputfields = ['checkboxgroup', 'radiogroup'] + + def setup_response(self): + + self.assign_choice_names() + + correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]', + id=self.xml.get('id')) + + self.correct_choices = set([choice.get('name') for choice in correct_xml]) + + def assign_choice_names(self): + ''' + Initialize name attributes in tags for this response. + ''' + + for index, choice in enumerate(self.xml.xpath('//*[@id=$id]//choice', + id=self.xml.get('id'))): + choice.set("name", "choice_"+str(index)) + + def get_score(self, student_answers): + + student_answer = student_answers.get(self.answer_id, []) + + if not isinstance(student_answer, list): + student_answer = [student_answer] + + student_answer = set(student_answer) + + required_selected = len(self.correct_choices - student_answer) == 0 + no_extra_selected = len(student_answer - self.correct_choices) == 0 + + correct = required_selected & no_extra_selected + + if correct: + return CorrectMap(self.answer_id,'correct') + else: + return CorrectMap(self.answer_id,'incorrect') + + def get_answers(self): + return { self.answer_id : self.correct_choices } + #----------------------------------------------------------------------------- class MultipleChoiceResponse(LoncapaResponse): @@ -469,13 +558,13 @@ class CustomResponse(LoncapaResponse): or in a ''' snippets = [{'snippet': ''' - +
Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\) In the space provided below write an algebraic expression for \(I(t)\).
- +
correct=['correct'] try: @@ -696,15 +785,19 @@ class SymbolicResponse(CustomResponse): class CodeResponse(LoncapaResponse): ''' - Grade student code using an external server + Grade student code using an external server, called 'xqueue' + In contrast to ExternalResponse, CodeResponse has following behavior: + 1) Goes through a queueing system + 2) Does not do external request for 'get_answers' ''' response_tag = 'coderesponse' allowed_inputfields = ['textline', 'textbox'] + max_inputfields = 1 def setup_response(self): xml = self.xml - self.url = xml.get('url') or "http://ec2-50-16-59-149.compute-1.amazonaws.com/xqueue/submit/" # FIXME -- hardcoded url + self.url = xml.get('url', "http://ec2-50-16-59-149.compute-1.amazonaws.com/xqueue/submit/") # FIXME -- hardcoded url answer = xml.find('answer') if answer is not None: @@ -713,63 +806,19 @@ class CodeResponse(LoncapaResponse): self.code = self.system.filesystem.open('src/'+answer_src).read() else: self.code = answer.text - else: # no stanza; get code from