Merge pull request #442 from edx/felix/formula-hints
Crowdsourced Hints - "0.2 release"
This commit is contained in:
@@ -915,7 +915,26 @@ class NumericalResponse(LoncapaResponse):
|
||||
else:
|
||||
return CorrectMap(self.answer_id, 'incorrect')
|
||||
|
||||
# TODO: add check_hint_condition(self, hxml_set, student_answers)
|
||||
def compare_answer(self, ans1, ans2):
|
||||
"""
|
||||
Outside-facing function that lets us compare two numerical answers,
|
||||
with this problem's tolerance.
|
||||
"""
|
||||
return compare_with_tolerance(
|
||||
evaluator({}, {}, ans1),
|
||||
evaluator({}, {}, ans2),
|
||||
self.tolerance
|
||||
)
|
||||
|
||||
def validate_answer(self, answer):
|
||||
"""
|
||||
Returns whether this answer is in a valid form.
|
||||
"""
|
||||
try:
|
||||
evaluator(dict(), dict(), answer)
|
||||
return True
|
||||
except (StudentInputError, UndefinedVariable):
|
||||
return False
|
||||
|
||||
def get_answers(self):
|
||||
return {self.answer_id: self.correct_answer}
|
||||
@@ -1778,46 +1797,24 @@ class FormulaResponse(LoncapaResponse):
|
||||
self.correct_answer, given, self.samples)
|
||||
return CorrectMap(self.answer_id, correctness)
|
||||
|
||||
def check_formula(self, expected, given, samples):
|
||||
variables = samples.split('@')[0].split(',')
|
||||
numsamples = int(samples.split('@')[1].split('#')[1])
|
||||
sranges = zip(*map(lambda x: map(float, x.split(",")),
|
||||
samples.split('@')[1].split('#')[0].split(':')))
|
||||
|
||||
ranges = dict(zip(variables, sranges))
|
||||
for _ in range(numsamples):
|
||||
instructor_variables = self.strip_dict(dict(self.context))
|
||||
student_variables = {}
|
||||
# ranges give numerical ranges for testing
|
||||
for var in ranges:
|
||||
# TODO: allow specified ranges (i.e. integers and complex numbers) for random variables
|
||||
value = random.uniform(*ranges[var])
|
||||
instructor_variables[str(var)] = value
|
||||
student_variables[str(var)] = value
|
||||
# log.debug('formula: instructor_vars=%s, expected=%s' %
|
||||
# (instructor_variables,expected))
|
||||
|
||||
# Call `evaluator` on the instructor's answer and get a number
|
||||
instructor_result = evaluator(
|
||||
instructor_variables, {},
|
||||
expected, case_sensitive=self.case_sensitive
|
||||
)
|
||||
def tupleize_answers(self, answer, var_dict_list):
|
||||
"""
|
||||
Takes in an answer and a list of dictionaries mapping variables to values.
|
||||
Each dictionary represents a test case for the answer.
|
||||
Returns a tuple of formula evaluation results.
|
||||
"""
|
||||
out = []
|
||||
for var_dict in var_dict_list:
|
||||
try:
|
||||
# log.debug('formula: student_vars=%s, given=%s' %
|
||||
# (student_variables,given))
|
||||
|
||||
# Call `evaluator` on the student's answer; look for exceptions
|
||||
student_result = evaluator(
|
||||
student_variables,
|
||||
{},
|
||||
given,
|
||||
case_sensitive=self.case_sensitive
|
||||
)
|
||||
out.append(evaluator(
|
||||
var_dict,
|
||||
dict(),
|
||||
answer,
|
||||
case_sensitive=self.case_sensitive,
|
||||
))
|
||||
except UndefinedVariable as uv:
|
||||
log.debug(
|
||||
'formularesponse: undefined variable in given=%s',
|
||||
given
|
||||
)
|
||||
'formularesponse: undefined variable in formula=%s' % answer)
|
||||
raise StudentInputError(
|
||||
"Invalid input: " + uv.message + " not permitted in answer"
|
||||
)
|
||||
@@ -1840,17 +1837,70 @@ class FormulaResponse(LoncapaResponse):
|
||||
# If non-factorial related ValueError thrown, handle it the same as any other Exception
|
||||
log.debug('formularesponse: error {0} in formula'.format(ve))
|
||||
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
|
||||
cgi.escape(given))
|
||||
cgi.escape(answer))
|
||||
except Exception as err:
|
||||
# traceback.print_exc()
|
||||
log.debug('formularesponse: error %s in formula', err)
|
||||
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
|
||||
cgi.escape(given))
|
||||
cgi.escape(answer))
|
||||
return out
|
||||
|
||||
# No errors in student's response--actually test for correctness
|
||||
if not compare_with_tolerance(student_result, instructor_result, self.tolerance):
|
||||
return "incorrect"
|
||||
return "correct"
|
||||
def randomize_variables(self, samples):
|
||||
"""
|
||||
Returns a list of dictionaries mapping variables to random values in range,
|
||||
as expected by tupleize_answers.
|
||||
"""
|
||||
variables = samples.split('@')[0].split(',')
|
||||
numsamples = int(samples.split('@')[1].split('#')[1])
|
||||
sranges = zip(*map(lambda x: map(float, x.split(",")),
|
||||
samples.split('@')[1].split('#')[0].split(':')))
|
||||
ranges = dict(zip(variables, sranges))
|
||||
|
||||
out = []
|
||||
for i in range(numsamples):
|
||||
var_dict = {}
|
||||
# ranges give numerical ranges for testing
|
||||
for var in ranges:
|
||||
# TODO: allow specified ranges (i.e. integers and complex numbers) for random variables
|
||||
value = random.uniform(*ranges[var])
|
||||
var_dict[str(var)] = value
|
||||
out.append(var_dict)
|
||||
return out
|
||||
|
||||
def check_formula(self, expected, given, samples):
|
||||
"""
|
||||
Given an expected answer string, a given (student-produced) answer
|
||||
string, and a samples string, return whether the given answer is
|
||||
"correct" or "incorrect".
|
||||
"""
|
||||
var_dict_list = self.randomize_variables(samples)
|
||||
student_result = self.tupleize_answers(given, var_dict_list)
|
||||
instructor_result = self.tupleize_answers(expected, var_dict_list)
|
||||
|
||||
correct = all(compare_with_tolerance(student, instructor, self.tolerance)
|
||||
for student, instructor in zip(student_result, instructor_result))
|
||||
if correct:
|
||||
return "correct"
|
||||
else:
|
||||
return "incorrect"
|
||||
|
||||
def compare_answer(self, ans1, ans2):
|
||||
"""
|
||||
An external interface for comparing whether a and b are equal.
|
||||
"""
|
||||
internal_result = self.check_formula(ans1, ans2, self.samples)
|
||||
return internal_result == "correct"
|
||||
|
||||
def validate_answer(self, answer):
|
||||
"""
|
||||
Returns whether this answer is in a valid form.
|
||||
"""
|
||||
var_dict_list = self.randomize_variables(self.samples)
|
||||
try:
|
||||
self.tupleize_answers(answer, var_dict_list)
|
||||
return True
|
||||
except StudentInputError:
|
||||
return False
|
||||
|
||||
def strip_dict(self, d):
|
||||
''' Takes a dict. Returns an identical dict, with all non-word
|
||||
|
||||
@@ -496,6 +496,20 @@ class FormulaResponseTest(ResponseTest):
|
||||
input_dict = {'1_2_1': '1/0'}
|
||||
self.assertRaises(StudentInputError, problem.grade_answers, input_dict)
|
||||
|
||||
def test_validate_answer(self):
|
||||
"""
|
||||
Makes sure that validate_answer works.
|
||||
"""
|
||||
sample_dict = {'x': (1, 2)}
|
||||
problem = self.build_problem(
|
||||
sample_dict=sample_dict,
|
||||
num_samples=10,
|
||||
tolerance="1%",
|
||||
answer="x"
|
||||
)
|
||||
self.assertTrue(problem.responders.values()[0].validate_answer('14*x'))
|
||||
self.assertFalse(problem.responders.values()[0].validate_answer('3*y+2*x'))
|
||||
|
||||
|
||||
class StringResponseTest(ResponseTest):
|
||||
from capa.tests.response_xml_factory import StringResponseXMLFactory
|
||||
@@ -915,6 +929,20 @@ class NumericalResponseTest(ResponseTest):
|
||||
with self.assertRaisesRegexp(StudentInputError, msg_regex):
|
||||
problem.grade_answers({'1_2_1': 'foobar'})
|
||||
|
||||
def test_compare_answer(self):
|
||||
"""Tests the answer compare function."""
|
||||
problem = self.build_problem(answer="42")
|
||||
responder = problem.responders.values()[0]
|
||||
self.assertTrue(responder.compare_answer('48', '8*6'))
|
||||
self.assertFalse(responder.compare_answer('48', '9*5'))
|
||||
|
||||
def test_validate_answer(self):
|
||||
"""Tests the answer validation function."""
|
||||
problem = self.build_problem(answer="42")
|
||||
responder = problem.responders.values()[0]
|
||||
self.assertTrue(responder.validate_answer('23.5'))
|
||||
self.assertFalse(responder.validate_answer('fish'))
|
||||
|
||||
|
||||
class CustomResponseTest(ResponseTest):
|
||||
from capa.tests.response_xml_factory import CustomResponseXMLFactory
|
||||
|
||||
@@ -7,15 +7,18 @@ Currently experimental - not for instructor use, yet.
|
||||
import logging
|
||||
import json
|
||||
import random
|
||||
import copy
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String, Integer, Boolean, Dict, List
|
||||
|
||||
from capa.responsetypes import FormulaResponse
|
||||
|
||||
from django.utils.html import escape
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -37,10 +40,15 @@ class CrowdsourceHinterFields(object):
|
||||
mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content,
|
||||
default={})
|
||||
hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0)
|
||||
# A list of previous answers this student made to this problem.
|
||||
# Of the form [answer, [hint_pk_1, hint_pk_2, hint_pk_3]] for each problem. hint_pk's are
|
||||
# None if the hint was not given.
|
||||
previous_answers = List(help='A list of previous submissions.', scope=Scope.user_state, default=[])
|
||||
|
||||
# A list of previous hints that a student viewed.
|
||||
# Of the form [answer, [hint_pk_1, ...]] for each problem.
|
||||
# Sorry about the variable name - I know it's confusing.
|
||||
previous_answers = List(help='A list of hints viewed.', scope=Scope.user_state, default=[])
|
||||
|
||||
# user_submissions actually contains a list of previous answers submitted.
|
||||
# (Originally, preivous_answers did this job, hence the name confusion.)
|
||||
user_submissions = List(help='A list of previous submissions', scope=Scope.user_state, default=[])
|
||||
user_voted = Boolean(help='Specifies if the user has voted on this problem or not.',
|
||||
scope=Scope.user_state, default=False)
|
||||
|
||||
@@ -68,6 +76,26 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
# We need to know whether we are working with a FormulaResponse problem.
|
||||
try:
|
||||
responder = self.get_display_items()[0].lcp.responders.values()[0]
|
||||
except (IndexError, AttributeError):
|
||||
log.exception('Unable to find a capa problem child.')
|
||||
return
|
||||
|
||||
self.is_formula = isinstance(self, FormulaResponse)
|
||||
if self.is_formula:
|
||||
self.answer_to_str = self.formula_answer_to_str
|
||||
else:
|
||||
self.answer_to_str = self.numerical_answer_to_str
|
||||
# compare_answer is expected to return whether its two inputs are close enough
|
||||
# to be equal, or raise a StudentInputError if one of the inputs is malformatted.
|
||||
if hasattr(responder, 'compare_answer') and hasattr(responder, 'validate_answer'):
|
||||
self.compare_answer = responder.compare_answer
|
||||
self.validate_answer = responder.validate_answer
|
||||
else:
|
||||
# This response type is not supported!
|
||||
log.exception('Response type not supported for hinting: ' + str(responder))
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
@@ -98,14 +126,29 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
return out
|
||||
|
||||
def capa_answer_to_str(self, answer):
|
||||
def numerical_answer_to_str(self, answer):
|
||||
"""
|
||||
Converts capa answer format to a string representation
|
||||
Converts capa numerical answer format to a string representation
|
||||
of the answer.
|
||||
-Lon-capa dependent.
|
||||
-Assumes that the problem only has one part.
|
||||
"""
|
||||
return str(float(answer.values()[0]))
|
||||
return str(answer.values()[0])
|
||||
|
||||
def formula_answer_to_str(self, answer):
|
||||
"""
|
||||
Converts capa formula answer into a string.
|
||||
-Lon-capa dependent.
|
||||
-Assumes that the problem only has one part.
|
||||
"""
|
||||
return str(answer.values()[0])
|
||||
|
||||
def get_matching_answers(self, answer):
|
||||
"""
|
||||
Look in self.hints, and find all answer keys that are "equal with tolerance"
|
||||
to the input answer.
|
||||
"""
|
||||
return [key for key in self.hints if self.compare_answer(key, answer)]
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""
|
||||
@@ -124,6 +167,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
if out is None:
|
||||
out = {'op': 'empty'}
|
||||
elif 'error' in out:
|
||||
# Error in processing.
|
||||
out.update({'op': 'error'})
|
||||
else:
|
||||
out.update({'op': dispatch})
|
||||
return json.dumps({'contents': self.system.render_template('hinter_display.html', out)})
|
||||
@@ -134,51 +180,67 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
Called by hinter javascript after a problem is graded as incorrect.
|
||||
Args:
|
||||
`data` -- must be interpretable by capa_answer_to_str.
|
||||
`data` -- must be interpretable by answer_to_str.
|
||||
Output keys:
|
||||
- 'best_hint' is the hint text with the most votes.
|
||||
- 'rand_hint_1' and 'rand_hint_2' are two random hints to the answer in `data`.
|
||||
- 'hints' is a list of hint strings to show to the user.
|
||||
- 'answer' is the parsed answer that was submitted.
|
||||
Will record the user's wrong answer in user_submissions, and the hints shown
|
||||
in previous_answers.
|
||||
"""
|
||||
# First, validate our inputs.
|
||||
try:
|
||||
answer = self.capa_answer_to_str(data)
|
||||
except ValueError:
|
||||
answer = self.answer_to_str(data)
|
||||
except (ValueError, AttributeError):
|
||||
# Sometimes, we get an answer that's just not parsable. Do nothing.
|
||||
log.exception('Answer not parsable: ' + str(data))
|
||||
return
|
||||
# Look for a hint to give.
|
||||
# Make a local copy of self.hints - this means we only need to do one json unpacking.
|
||||
# (This is because xblocks storage makes the following command a deep copy.)
|
||||
local_hints = self.hints
|
||||
if (answer not in local_hints) or (len(local_hints[answer]) == 0):
|
||||
if not self.validate_answer(answer):
|
||||
# Answer is not in the right form.
|
||||
log.exception('Answer not valid: ' + str(answer))
|
||||
return
|
||||
if answer not in self.user_submissions:
|
||||
self.user_submissions += [answer]
|
||||
|
||||
# For all answers similar enough to our own, accumulate all hints together.
|
||||
# Also track the original answer of each hint.
|
||||
matching_answers = self.get_matching_answers(answer)
|
||||
matching_hints = {}
|
||||
for matching_answer in matching_answers:
|
||||
temp_dict = copy.deepcopy(self.hints[matching_answer])
|
||||
for key, value in temp_dict.items():
|
||||
# Each value now has hint, votes, matching_answer.
|
||||
temp_dict[key] = value + [matching_answer]
|
||||
matching_hints.update(temp_dict)
|
||||
# matching_hints now maps pk's to lists of [hint, votes, matching_answer]
|
||||
|
||||
# Finally, randomly choose a subset of matching_hints to actually show.
|
||||
if not matching_hints:
|
||||
# No hints to give. Return.
|
||||
self.previous_answers += [[answer, [None, None, None]]]
|
||||
return
|
||||
# Get the top hint, plus two random hints.
|
||||
n_hints = len(local_hints[answer])
|
||||
best_hint_index = max(local_hints[answer], key=lambda key: local_hints[answer][key][1])
|
||||
best_hint = local_hints[answer][best_hint_index][0]
|
||||
if len(local_hints[answer]) == 1:
|
||||
rand_hint_1 = ''
|
||||
rand_hint_2 = ''
|
||||
self.previous_answers += [[answer, [best_hint_index, None, None]]]
|
||||
elif n_hints == 2:
|
||||
best_hint = local_hints[answer].values()[0][0]
|
||||
best_hint_index = local_hints[answer].keys()[0]
|
||||
rand_hint_1 = local_hints[answer].values()[1][0]
|
||||
hint_index_1 = local_hints[answer].keys()[1]
|
||||
rand_hint_2 = ''
|
||||
self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]]
|
||||
else:
|
||||
(hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\
|
||||
random.sample(local_hints[answer].items(), 2)
|
||||
rand_hint_1 = rand_hint_1[0]
|
||||
rand_hint_2 = rand_hint_2[0]
|
||||
self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]]
|
||||
|
||||
return {'best_hint': best_hint,
|
||||
'rand_hint_1': rand_hint_1,
|
||||
'rand_hint_2': rand_hint_2,
|
||||
n_hints = len(matching_hints)
|
||||
hints = []
|
||||
# max(dict) returns the maximum key in dict.
|
||||
# The key function takes each pk, and returns the number of votes for the
|
||||
# hint with that pk.
|
||||
best_hint_index = max(matching_hints, key=lambda pk: matching_hints[pk][1])
|
||||
hints.append(matching_hints[best_hint_index][0])
|
||||
best_hint_answer = matching_hints[best_hint_index][2]
|
||||
# The brackets surrounding the index are for backwards compatability purposes.
|
||||
# (It used to be that each answer was paired with multiple hints in a list.)
|
||||
self.previous_answers += [[best_hint_answer, [best_hint_index]]]
|
||||
for _ in xrange(min(2, n_hints - 1)):
|
||||
# Keep making random hints until we hit a target, or run out.
|
||||
while True:
|
||||
# random.choice randomly chooses an element from its input list.
|
||||
# (We then unpack the item, in this case data for a hint.)
|
||||
(hint_index, (rand_hint, _, hint_answer)) =\
|
||||
random.choice(matching_hints.items())
|
||||
if rand_hint not in hints:
|
||||
break
|
||||
hints.append(rand_hint)
|
||||
self.previous_answers += [[hint_answer, [hint_index]]]
|
||||
return {'hints': hints,
|
||||
'answer': answer}
|
||||
|
||||
def get_feedback(self, data):
|
||||
@@ -188,38 +250,37 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
Args:
|
||||
`data` -- not actually used. (It is assumed that the answer is correct.)
|
||||
Output keys:
|
||||
- 'index_to_hints' maps previous answer indices to hints that the user saw earlier.
|
||||
- 'index_to_answer' maps previous answer indices to the actual answer submitted.
|
||||
- 'answer_to_hints': a nested dictionary.
|
||||
answer_to_hints[answer][hint_pk] returns the text of the hint.
|
||||
- 'user_submissions': the same thing as self.user_submissions. A list of
|
||||
the answers that the user previously submitted.
|
||||
"""
|
||||
# The student got it right.
|
||||
# Did he submit at least one wrong answer?
|
||||
if len(self.previous_answers) == 0:
|
||||
if len(self.user_submissions) == 0:
|
||||
# No. Nothing to do here.
|
||||
return
|
||||
# Make a hint-voting interface for each wrong answer. The student will only
|
||||
# be allowed to make one vote / submission, but he can choose which wrong answer
|
||||
# he wants to look at.
|
||||
# index_to_hints[previous answer #] = [(hint text, hint pk), + ]
|
||||
index_to_hints = {}
|
||||
# index_to_answer[previous answer #] = answer text
|
||||
index_to_answer = {}
|
||||
answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text
|
||||
|
||||
# Go through each previous answer, and populate index_to_hints and index_to_answer.
|
||||
for i in xrange(len(self.previous_answers)):
|
||||
answer, hints_offered = self.previous_answers[i]
|
||||
index_to_hints[i] = []
|
||||
index_to_answer[i] = answer
|
||||
if answer not in answer_to_hints:
|
||||
answer_to_hints[answer] = {}
|
||||
if answer in self.hints:
|
||||
# Go through each hint, and add to index_to_hints
|
||||
for hint_id in hints_offered:
|
||||
if hint_id is not None:
|
||||
if (hint_id is not None) and (hint_id not in answer_to_hints[answer]):
|
||||
try:
|
||||
index_to_hints[i].append((self.hints[answer][str(hint_id)][0], hint_id))
|
||||
answer_to_hints[answer][hint_id] = self.hints[answer][str(hint_id)][0]
|
||||
except KeyError:
|
||||
# Sometimes, the hint that a user saw will have been deleted by the instructor.
|
||||
continue
|
||||
|
||||
return {'index_to_hints': index_to_hints, 'index_to_answer': index_to_answer}
|
||||
return {'answer_to_hints': answer_to_hints,
|
||||
'user_submissions': self.user_submissions}
|
||||
|
||||
def tally_vote(self, data):
|
||||
"""
|
||||
@@ -227,31 +288,51 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
Args:
|
||||
`data` -- expected to have the following keys:
|
||||
'answer': ans_no (index in previous_answers)
|
||||
'answer': text of answer we're voting on
|
||||
'hint': hint_pk
|
||||
'pk_list': A list of [answer, pk] pairs, each of which representing a hint.
|
||||
We will return a list of how many votes each hint in the list has so far.
|
||||
It's up to the browser to specify which hints to return vote counts for.
|
||||
|
||||
Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs.
|
||||
"""
|
||||
if self.user_voted:
|
||||
return {}
|
||||
ans_no = int(data['answer'])
|
||||
hint_no = str(data['hint'])
|
||||
answer = self.previous_answers[ans_no][0]
|
||||
return {'error': 'Sorry, but you have already voted!'}
|
||||
ans = data['answer']
|
||||
if not self.validate_answer(ans):
|
||||
# Uh oh. Invalid answer.
|
||||
log.exception('Failure in hinter tally_vote: Unable to parse answer: {ans}'.format(ans=ans))
|
||||
return {'error': 'Failure in voting!'}
|
||||
hint_pk = str(data['hint'])
|
||||
# We use temp_dict because we need to do a direct write for the database to update.
|
||||
temp_dict = self.hints
|
||||
temp_dict[answer][hint_no][1] += 1
|
||||
try:
|
||||
temp_dict[ans][hint_pk][1] += 1
|
||||
except KeyError:
|
||||
log.exception('''Failure in hinter tally_vote: User voted for non-existant hint:
|
||||
Answer={ans} pk={hint_pk}'''.format(ans=ans, hint_pk=hint_pk))
|
||||
return {'error': 'Failure in voting!'}
|
||||
self.hints = temp_dict
|
||||
# Don't let the user vote again!
|
||||
self.user_voted = True
|
||||
|
||||
# Return a list of how many votes each hint got.
|
||||
pk_list = json.loads(data['pk_list'])
|
||||
hint_and_votes = []
|
||||
for hint_no in self.previous_answers[ans_no][1]:
|
||||
if hint_no is None:
|
||||
for answer, vote_pk in pk_list:
|
||||
if not self.validate_answer(answer):
|
||||
log.exception('In hinter tally_vote, couldn\'t parse {ans}'.format(ans=answer))
|
||||
continue
|
||||
hint_and_votes.append(temp_dict[answer][str(hint_no)])
|
||||
try:
|
||||
hint_and_votes.append(temp_dict[answer][str(vote_pk)])
|
||||
except KeyError:
|
||||
log.exception('In hinter tally_vote, couldn\'t find: {ans}, {vote_pk}'.format(
|
||||
ans=answer, vote_pk=str(vote_pk)))
|
||||
|
||||
# Reset self.previous_answers.
|
||||
hint_and_votes.sort(key=lambda pair: pair[1], reverse=True)
|
||||
# Reset self.previous_answers and user_submissions.
|
||||
self.previous_answers = []
|
||||
self.user_submissions = []
|
||||
return {'hint_and_votes': hint_and_votes}
|
||||
|
||||
def submit_hint(self, data):
|
||||
@@ -260,13 +341,17 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
Args:
|
||||
`data` -- expected to have the following keys:
|
||||
'answer': answer index in previous_answers
|
||||
'answer': text of answer
|
||||
'hint': text of the new hint that the user is adding
|
||||
Returns a thank-you message.
|
||||
"""
|
||||
# Do html escaping. Perhaps in the future do profanity filtering, etc. as well.
|
||||
hint = escape(data['hint'])
|
||||
answer = self.previous_answers[int(data['answer'])][0]
|
||||
answer = data['answer']
|
||||
if not self.validate_answer(answer):
|
||||
log.exception('Failure in hinter submit_hint: Unable to parse answer: {ans}'.format(
|
||||
ans=answer))
|
||||
return {'error': 'Could not submit answer'}
|
||||
# Only allow a student to vote or submit a hint once.
|
||||
if self.user_voted:
|
||||
return {'message': 'Sorry, but you have already voted!'}
|
||||
@@ -277,9 +362,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
else:
|
||||
temp_dict = self.hints
|
||||
if answer in temp_dict:
|
||||
temp_dict[answer][self.hint_pk] = [hint, 1] # With one vote (the user himself).
|
||||
temp_dict[answer][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself).
|
||||
else:
|
||||
temp_dict[answer] = {self.hint_pk: [hint, 1]}
|
||||
temp_dict[answer] = {str(self.hint_pk): [hint, 1]}
|
||||
self.hint_pk += 1
|
||||
if self.moderate == 'True':
|
||||
self.mod_queue = temp_dict
|
||||
@@ -288,10 +373,11 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
# Mark the user has having voted; reset previous_answers
|
||||
self.user_voted = True
|
||||
self.previous_answers = []
|
||||
self.user_submissions = []
|
||||
return {'message': 'Thank you for your hint!'}
|
||||
|
||||
|
||||
class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor):
|
||||
class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor):
|
||||
module_class = CrowdsourceHinterModule
|
||||
stores_state = True
|
||||
|
||||
|
||||
@@ -7,52 +7,6 @@
|
||||
background: rgb(253, 248, 235);
|
||||
}
|
||||
|
||||
#answer-tabs {
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-widget-header {
|
||||
border-bottom: 1px solid #DCDCDC;
|
||||
background: #FDF8EB;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-tabs-nav .ui-state-default {
|
||||
border: 1px solid #DCDCDC;
|
||||
background: #E6E6E3;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-tabs-nav .ui-state-default:hover {
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-tabs-nav .ui-state-active:hover {
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-tabs-nav .ui-state-active {
|
||||
border: 1px solid #DCDCDC;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-tabs-nav .ui-state-active a {
|
||||
color: #222222;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
#answer-tabs .ui-tabs-nav .ui-state-default a:hover {
|
||||
color: #222222;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
#answer-tabs .custom-hint {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint-inner-container {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
@@ -63,3 +17,24 @@
|
||||
padding-top: 0px !important;
|
||||
padding-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.wizard-view {
|
||||
float: left;
|
||||
width: 790px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.wizard-container {
|
||||
width: 3000px;
|
||||
|
||||
-webkit-transition:all 1.0s ease-in-out;
|
||||
-moz-transition:all 1.0s ease-in-out;
|
||||
-o-transition:all 1.0s ease-in-out;
|
||||
transition:all 1.0s ease-in-out;
|
||||
}
|
||||
|
||||
.wizard-viewbox {
|
||||
width: 800px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<li id="vert-0" data-id="i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_def7a1142dd0">
|
||||
|
||||
|
||||
<section class="xmodule_display xmodule_CrowdsourceHinterModule" data-type="Hinter" id="hinter-root">
|
||||
|
||||
|
||||
<section class="xmodule_display xmodule_CapaModule" data-type="Problem" id="problem">
|
||||
<section id="problem_i4x-Me-19_002-problem-Numerical_Input" class="problems-wrapper" data-problem-id="i4x://Me/19.002/problem/Numerical_Input" data-url="/courses/Me/19.002/Test/modx/i4x://Me/19.002/problem/Numerical_Input" data-progress_status="done" data-progress_detail="1/1">
|
||||
|
||||
|
||||
<h2 class="problem-header">
|
||||
Numerical Input
|
||||
</h2>
|
||||
|
||||
<section class="problem-progress">(1/1 points)</section>
|
||||
|
||||
<section class="problem">
|
||||
<div><p>The answer is 2*x^2*y + 5
|
||||
</p><span><br><span> Answer =</span>
|
||||
<section id="inputtype_i4x-Me-19_002-problem-Numerical_Input_2_1" class="text-input-dynamath capa_inputtype ">
|
||||
<div class="correct " id="status_i4x-Me-19_002-problem-Numerical_Input_2_1">
|
||||
<input type="text" name="input_i4x-Me-19_002-problem-Numerical_Input_2_1" id="input_i4x-Me-19_002-problem-Numerical_Input_2_1" aria-describedby="answer_i4x-Me-19_002-problem-Numerical_Input_2_1" value="2*x^2*y +5" class="math" size="40">
|
||||
|
||||
</div></section></span>
|
||||
|
||||
<input type="file" />
|
||||
|
||||
<section class="solution-span"><span id="solution_i4x-Me-19_002-problem-Numerical_Input_solution_1"></span></section></div>
|
||||
|
||||
<section class="action">
|
||||
<input type="hidden" name="problem_id" value="Numerical Input">
|
||||
|
||||
<input class="check Check" type="button" value="Check">
|
||||
<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<div id="i4x_Me_19_002_problem_Numerical_Input_setup"></div>
|
||||
|
||||
|
||||
|
||||
<section class="crowdsource-wrapper" data-url="/courses/Me/19.002/Test/modx/i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_def7a1142dd0" data-child-url="/courses/Me/19.002/Test/modx/i4x://Me/19.002/problem/Numerical_Input" style="display: none;"> </section>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
</li>
|
||||
@@ -125,9 +125,10 @@ describe 'Problem', ->
|
||||
expect(@problem.bind).toHaveBeenCalled()
|
||||
|
||||
describe 'check_fd', ->
|
||||
xit 'should have specs written for this functionality', ->
|
||||
xit 'should have more specs written for this functionality', ->
|
||||
expect(false)
|
||||
|
||||
|
||||
describe 'check', ->
|
||||
beforeEach ->
|
||||
@problem = new Problem($('.xmodule_display'))
|
||||
@@ -137,6 +138,15 @@ describe 'Problem', ->
|
||||
@problem.check()
|
||||
expect(Logger.log).toHaveBeenCalledWith 'problem_check', 'foo=1&bar=2'
|
||||
|
||||
it 'log the problem_graded event, after the problem is done grading.', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
response =
|
||||
success: 'correct'
|
||||
contents: 'mock grader response'
|
||||
callback(response)
|
||||
@problem.check()
|
||||
expect(Logger.log).toHaveBeenCalledWith 'problem_graded', ['foo=1&bar=2', 'mock grader response'], @problem.url
|
||||
|
||||
it 'submit the answer for check', ->
|
||||
spyOn $, 'postWithPrefix'
|
||||
@problem.check()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
describe 'Crowdsourced hinter', ->
|
||||
beforeEach ->
|
||||
window.update_schematics = ->
|
||||
jasmine.stubRequests()
|
||||
# note that the fixturesPath is set in spec/helper.coffee
|
||||
loadFixtures 'crowdsource_hinter.html'
|
||||
@hinter = new Hinter($('#hinter-root'))
|
||||
|
||||
describe 'high-level integration tests', ->
|
||||
# High-level, happy-path tests for integration with capa problems.
|
||||
beforeEach ->
|
||||
# Make a more thorough $.postWithPrefix mock.
|
||||
spyOn($, 'postWithPrefix').andCallFake( ->
|
||||
last_argument = arguments[arguments.length - 1]
|
||||
if typeof last_argument == 'function'
|
||||
response =
|
||||
success: 'incorrect'
|
||||
contents: 'mock grader response'
|
||||
last_argument(response)
|
||||
)
|
||||
@problem = new Problem($('#problem'))
|
||||
@problem.bind()
|
||||
|
||||
it 'knows when a capa problem is graded, using check.', ->
|
||||
@problem.answers = 'test answer'
|
||||
@problem.check()
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_hint", 'test answer', jasmine.any(Function))
|
||||
|
||||
it 'knows when a capa problem is graded usig check_fd.', ->
|
||||
spyOn($, 'ajaxWithPrefix').andCallFake((url, settings) ->
|
||||
response =
|
||||
success: 'incorrect'
|
||||
contents: 'mock grader response'
|
||||
settings.success(response)
|
||||
)
|
||||
@problem.answers = 'test answer'
|
||||
@problem.check_fd()
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_hint", 'test answer', jasmine.any(Function))
|
||||
|
||||
describe 'capture_problem', ->
|
||||
beforeEach ->
|
||||
spyOn($, 'postWithPrefix').andReturn(null)
|
||||
|
||||
it 'gets hints for an incorrect answer', ->
|
||||
data = ['some answers', '<thing class="incorrect">']
|
||||
@hinter.capture_problem('problem_graded', data, 'fake element')
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_hint", 'some answers', jasmine.any(Function))
|
||||
|
||||
it 'gets feedback for a correct answer', ->
|
||||
data = ['some answers', '<thing class="correct">']
|
||||
@hinter.capture_problem('problem_graded', data, 'fake element')
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_feedback", 'some answers', jasmine.any(Function))
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class @Problem
|
||||
|
||||
problem_prefix = @element_id.replace(/problem_/,'')
|
||||
@inputs = @$("[id^=input_#{problem_prefix}_]")
|
||||
|
||||
@$('section.action input:button').click @refreshAnswers
|
||||
@$('section.action input.check').click @check_fd
|
||||
@$('section.action input.reset').click @reset
|
||||
@@ -247,6 +246,7 @@ class @Problem
|
||||
@updateProgress response
|
||||
else
|
||||
@gentle_alert response.success
|
||||
Logger.log 'problem_graded', [@answers, response.contents], @url
|
||||
|
||||
if not abort_submission
|
||||
$.ajaxWithPrefix("#{@url}/problem_check", settings)
|
||||
|
||||
@@ -29,42 +29,73 @@ class @Hinter
|
||||
$(selector, @el)
|
||||
|
||||
bind: =>
|
||||
window.update_schematics()
|
||||
@$('input.vote').click @vote
|
||||
@$('input.submit-hint').click @submit_hint
|
||||
@$('.custom-hint').click @clear_default_text
|
||||
@$('#answer-tabs').tabs({active: 0})
|
||||
@$('.expand-goodhint').click @expand_goodhint
|
||||
@$('.expand').click @expand
|
||||
@$('.wizard-link').click @wizard_link_handle
|
||||
@$('.answer-choice').click @answer_choice_handle
|
||||
|
||||
expand_goodhint: =>
|
||||
if @$('.goodhint').css('display') == 'none'
|
||||
@$('.goodhint').css('display', 'block')
|
||||
expand: (eventObj) =>
|
||||
# Expand a hidden div.
|
||||
target = @$('#' + @$(eventObj.currentTarget).data('target'))
|
||||
if @$(target).css('display') == 'none'
|
||||
@$(target).css('display', 'block')
|
||||
else
|
||||
@$('.goodhint').css('display', 'none')
|
||||
@$(target).css('display', 'none')
|
||||
# Fix positioning errors with the bottom class.
|
||||
@set_bottom_links()
|
||||
|
||||
vote: (eventObj) =>
|
||||
# Make an ajax request with the user's vote.
|
||||
target = @$(eventObj.currentTarget)
|
||||
post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')}
|
||||
all_pks = @$('#pk-list').attr('data-pk-list')
|
||||
post_json = {'answer': target.attr('data-answer'), 'hint': target.data('hintno'), 'pk_list': all_pks}
|
||||
$.postWithPrefix "#{@url}/vote", post_json, (response) =>
|
||||
@render(response.contents)
|
||||
|
||||
submit_hint: (eventObj) =>
|
||||
target = @$(eventObj.currentTarget)
|
||||
textarea_id = '#custom-hint-' + target.data('answer')
|
||||
post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()}
|
||||
# Make an ajax request with the user's new hint.
|
||||
textarea = $('.custom-hint')
|
||||
if @answer == ''
|
||||
# The user didn't choose an answer, somehow. Do nothing.
|
||||
return
|
||||
post_json = {'answer': @answer, 'hint': textarea.val()}
|
||||
$.postWithPrefix "#{@url}/submit_hint",post_json, (response) =>
|
||||
@render(response.contents)
|
||||
|
||||
clear_default_text: (eventObj) =>
|
||||
# Remove placeholder text in the hint submission textbox.
|
||||
target = @$(eventObj.currentTarget)
|
||||
if target.data('cleared') == undefined
|
||||
target.val('')
|
||||
target.data('cleared', true)
|
||||
|
||||
wizard_link_handle: (eventObj) =>
|
||||
# Move to another wizard view, based on the link that the user clicked.
|
||||
target = @$(eventObj.currentTarget)
|
||||
@go_to(target.attr('dest'))
|
||||
|
||||
answer_choice_handle: (eventObj) =>
|
||||
# A special case of wizard_link_handle - we need to track a state variable,
|
||||
# the answer that the user chose.
|
||||
@answer = @$(eventObj.target).attr('value')
|
||||
@$('#blank-answer').html(@answer)
|
||||
@go_to('p3')
|
||||
|
||||
set_bottom_links: =>
|
||||
# Makes each .bottom class stick to the bottom of .wizard-viewbox
|
||||
@$('.bottom').css('margin-top', '0px')
|
||||
viewbox_height = parseInt(@$('.wizard-viewbox').css('height'), 10)
|
||||
@$('.bottom').each((index, obj) ->
|
||||
view_height = parseInt($(obj).parent().css('height'), 10)
|
||||
$(obj).css('margin-top', (viewbox_height - view_height) + 'px')
|
||||
)
|
||||
|
||||
render: (content) ->
|
||||
if content
|
||||
# Trim leading and trailing whitespace
|
||||
content = content.replace /^\s+|\s+$/g, ""
|
||||
content = content.trim()
|
||||
|
||||
if content
|
||||
@el.html(content)
|
||||
@@ -74,3 +105,37 @@ class @Hinter
|
||||
@$('#previous-answer-0').css('display', 'inline')
|
||||
else
|
||||
@el.hide()
|
||||
# Initialize the answer choice - remembers which answer the user picked on
|
||||
# p2 when he submits a hint on p3.
|
||||
@answer = ''
|
||||
# Determine whether the browser supports CSS3 transforms.
|
||||
styles = document.body.style
|
||||
if styles.WebkitTransform == '' or styles.transform == ''
|
||||
@go_to = @transform_go_to
|
||||
else
|
||||
@go_to = @legacy_go_to
|
||||
|
||||
# Make the correct wizard view show up.
|
||||
hints_exist = @$('#hints-exist').html() == 'True'
|
||||
if hints_exist
|
||||
@go_to('p1')
|
||||
else
|
||||
@go_to('p2')
|
||||
|
||||
transform_go_to: (view_id) ->
|
||||
# Switch wizard views using sliding transitions.
|
||||
id_to_index = {
|
||||
'p1': 0,
|
||||
'p2': 1,
|
||||
'p3': 2,
|
||||
}
|
||||
translate_string = 'translateX(' +id_to_index[view_id] * -1 * parseInt($('#' + view_id).css('width'), 10) + 'px)'
|
||||
@$('.wizard-container').css('transform', translate_string)
|
||||
@$('.wizard-container').css('-webkit-transform', translate_string)
|
||||
@set_bottom_links()
|
||||
|
||||
legacy_go_to: (view_id) ->
|
||||
# For older browsers - switch wizard views by changing the screen.
|
||||
@$('.wizard-view').css('display', 'none')
|
||||
@$('#' + view_id).css('display', 'block')
|
||||
@set_bottom_links()
|
||||
@@ -2,7 +2,7 @@
|
||||
Tests the crowdsourced hinter xmodule.
|
||||
"""
|
||||
|
||||
from mock import Mock
|
||||
from mock import Mock, MagicMock
|
||||
import unittest
|
||||
import copy
|
||||
|
||||
@@ -53,6 +53,7 @@ class CHModuleFactory(object):
|
||||
@staticmethod
|
||||
def create(hints=None,
|
||||
previous_answers=None,
|
||||
user_submissions=None,
|
||||
user_voted=None,
|
||||
moderate=None,
|
||||
mod_queue=None):
|
||||
@@ -85,17 +86,59 @@ class CHModuleFactory(object):
|
||||
else:
|
||||
model_data['previous_answers'] = [
|
||||
['24.0', [0, 3, 4]],
|
||||
['29.0', [None, None, None]]
|
||||
['29.0', []]
|
||||
]
|
||||
|
||||
if user_submissions is not None:
|
||||
model_data['user_submissions'] = user_submissions
|
||||
else:
|
||||
model_data['user_submissions'] = ['24.0', '29.0']
|
||||
|
||||
if user_voted is not None:
|
||||
model_data['user_voted'] = user_voted
|
||||
|
||||
if moderate is not None:
|
||||
model_data['moderate'] = moderate
|
||||
|
||||
descriptor = Mock(weight="1")
|
||||
descriptor = Mock(weight='1')
|
||||
# Make the descriptor have a capa problem child.
|
||||
capa_descriptor = MagicMock()
|
||||
capa_descriptor.name = 'capa'
|
||||
descriptor.get_children = lambda: [capa_descriptor]
|
||||
|
||||
# Make a fake capa module.
|
||||
capa_module = MagicMock()
|
||||
capa_module.lcp = MagicMock()
|
||||
responder = MagicMock()
|
||||
|
||||
def validate_answer(answer):
|
||||
""" A mock answer validator - simulates a numerical response"""
|
||||
try:
|
||||
float(answer)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
responder.validate_answer = validate_answer
|
||||
|
||||
def compare_answer(ans1, ans2):
|
||||
""" A fake answer comparer """
|
||||
return ans1 == ans2
|
||||
responder.compare_answer = compare_answer
|
||||
|
||||
capa_module.lcp.responders = {'responder0': responder}
|
||||
capa_module.displayable_items = lambda: [capa_module]
|
||||
|
||||
system = get_test_system()
|
||||
# Make the system have a marginally-functional get_module
|
||||
|
||||
def fake_get_module(descriptor):
|
||||
"""
|
||||
A fake module-maker.
|
||||
"""
|
||||
if descriptor.name == 'capa':
|
||||
return capa_module
|
||||
system.get_module = fake_get_module
|
||||
|
||||
module = CrowdsourceHinterModule(system, descriptor, model_data)
|
||||
|
||||
return module
|
||||
@@ -146,11 +189,13 @@ class VerticalWithModulesFactory(object):
|
||||
|
||||
@staticmethod
|
||||
def next_num():
|
||||
"""Increments a global counter for naming."""
|
||||
CHModuleFactory.num += 1
|
||||
return CHModuleFactory.num
|
||||
|
||||
@staticmethod
|
||||
def create():
|
||||
"""Make a vertical."""
|
||||
model_data = {'data': VerticalWithModulesFactory.sample_problem_xml}
|
||||
system = get_test_system()
|
||||
descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system)
|
||||
@@ -226,6 +271,24 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
self.assertTrue('Test numerical problem.' in out_html)
|
||||
self.assertTrue('Another test numerical problem.' in out_html)
|
||||
|
||||
def test_numerical_answer_to_str(self):
|
||||
"""
|
||||
Tests the get request to string converter for numerical responses.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
get = {'response1': '4'}
|
||||
parsed = mock_module.numerical_answer_to_str(get)
|
||||
self.assertTrue(parsed == '4')
|
||||
|
||||
def test_formula_answer_to_str(self):
|
||||
"""
|
||||
Tests the get request to string converter for formula responses.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
get = {'response1': 'x*y^2'}
|
||||
parsed = mock_module.formula_answer_to_str(get)
|
||||
self.assertTrue(parsed == 'x*y^2')
|
||||
|
||||
def test_gethint_0hint(self):
|
||||
"""
|
||||
Someone asks for a hint, when there's no hint to give.
|
||||
@@ -235,21 +298,36 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'problem_name': '26.0'}
|
||||
out = mock_module.get_hint(json_in)
|
||||
print mock_module.previous_answers
|
||||
self.assertTrue(out is None)
|
||||
self.assertTrue(['26.0', [None, None, None]] in mock_module.previous_answers)
|
||||
self.assertTrue('26.0' in mock_module.user_submissions)
|
||||
|
||||
def test_gethint_unparsable(self):
|
||||
"""
|
||||
Someone submits a hint that cannot be parsed into a float.
|
||||
Someone submits an answer that is in the wrong format.
|
||||
- The answer should not be added to previous_answers.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
old_answers = copy.deepcopy(mock_module.previous_answers)
|
||||
json_in = {'problem_name': 'fish'}
|
||||
json_in = 'blah'
|
||||
out = mock_module.get_hint(json_in)
|
||||
self.assertTrue(out is None)
|
||||
self.assertTrue(mock_module.previous_answers == old_answers)
|
||||
|
||||
def test_gethint_signature_error(self):
|
||||
"""
|
||||
Someone submits an answer that cannot be calculated as a float.
|
||||
Nothing should change.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
old_answers = copy.deepcopy(mock_module.previous_answers)
|
||||
old_user_submissions = copy.deepcopy(mock_module.user_submissions)
|
||||
json_in = {'problem1': 'fish'}
|
||||
out = mock_module.get_hint(json_in)
|
||||
self.assertTrue(out is None)
|
||||
self.assertTrue(mock_module.previous_answers == old_answers)
|
||||
self.assertTrue(mock_module.user_submissions == old_user_submissions)
|
||||
|
||||
def test_gethint_1hint(self):
|
||||
"""
|
||||
Someone asks for a hint, with exactly one hint in the database.
|
||||
@@ -258,7 +336,11 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'problem_name': '25.0'}
|
||||
out = mock_module.get_hint(json_in)
|
||||
self.assertTrue(out['best_hint'] == 'Really popular hint')
|
||||
self.assertTrue('Really popular hint' in out['hints'])
|
||||
# Also make sure that the input gets added to user_submissions,
|
||||
# and that the hint is logged in previous_answers.
|
||||
self.assertTrue('25.0' in mock_module.user_submissions)
|
||||
self.assertTrue(['25.0', ['1']] in mock_module.previous_answers)
|
||||
|
||||
def test_gethint_manyhints(self):
|
||||
"""
|
||||
@@ -271,18 +353,18 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'problem_name': '24.0'}
|
||||
out = mock_module.get_hint(json_in)
|
||||
self.assertTrue(out['best_hint'] == 'Best hint')
|
||||
self.assertTrue('rand_hint_1' in out)
|
||||
self.assertTrue('rand_hint_2' in out)
|
||||
self.assertTrue('Best hint' in out['hints'])
|
||||
self.assertTrue(len(out['hints']) == 3)
|
||||
|
||||
def test_getfeedback_0wronganswers(self):
|
||||
"""
|
||||
Someone has gotten the problem correct on the first try.
|
||||
Output should be empty.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create(previous_answers=[])
|
||||
mock_module = CHModuleFactory.create(previous_answers=[], user_submissions=[])
|
||||
json_in = {'problem_name': '42.5'}
|
||||
out = mock_module.get_feedback(json_in)
|
||||
print out
|
||||
self.assertTrue(out is None)
|
||||
|
||||
def test_getfeedback_1wronganswer_nohints(self):
|
||||
@@ -294,9 +376,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
mock_module = CHModuleFactory.create(previous_answers=[['26.0', [None, None, None]]])
|
||||
json_in = {'problem_name': '42.5'}
|
||||
out = mock_module.get_feedback(json_in)
|
||||
print out['index_to_answer']
|
||||
self.assertTrue(out['index_to_hints'][0] == [])
|
||||
self.assertTrue(out['index_to_answer'][0] == '26.0')
|
||||
self.assertTrue(out['answer_to_hints'] == {'26.0': {}})
|
||||
|
||||
def test_getfeedback_1wronganswer_withhints(self):
|
||||
"""
|
||||
@@ -307,8 +387,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
mock_module = CHModuleFactory.create(previous_answers=[['24.0', [0, 3, None]]])
|
||||
json_in = {'problem_name': '42.5'}
|
||||
out = mock_module.get_feedback(json_in)
|
||||
print out['index_to_hints']
|
||||
self.assertTrue(len(out['index_to_hints'][0]) == 2)
|
||||
self.assertTrue(len(out['answer_to_hints']['24.0']) == 2)
|
||||
|
||||
def test_getfeedback_missingkey(self):
|
||||
"""
|
||||
@@ -319,7 +398,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
previous_answers=[['24.0', [0, 100, None]]])
|
||||
json_in = {'problem_name': '42.5'}
|
||||
out = mock_module.get_feedback(json_in)
|
||||
self.assertTrue(len(out['index_to_hints'][0]) == 1)
|
||||
self.assertTrue(len(out['answer_to_hints']['24.0']) == 1)
|
||||
|
||||
def test_vote_nopermission(self):
|
||||
"""
|
||||
@@ -327,7 +406,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
Should not change any vote tallies.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create(user_voted=True)
|
||||
json_in = {'answer': 0, 'hint': 1}
|
||||
json_in = {'answer': '24.0', 'hint': 1, 'pk_list': json.dumps([['24.0', 1], ['24.0', 3]])}
|
||||
old_hints = copy.deepcopy(mock_module.hints)
|
||||
mock_module.tally_vote(json_in)
|
||||
self.assertTrue(mock_module.hints == old_hints)
|
||||
@@ -339,19 +418,56 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
"""
|
||||
mock_module = CHModuleFactory.create(
|
||||
previous_answers=[['24.0', [0, 3, None]]])
|
||||
json_in = {'answer': 0, 'hint': 3}
|
||||
json_in = {'answer': '24.0', 'hint': 3, 'pk_list': json.dumps([['24.0', 0], ['24.0', 3]])}
|
||||
dict_out = mock_module.tally_vote(json_in)
|
||||
self.assertTrue(mock_module.hints['24.0']['0'][1] == 40)
|
||||
self.assertTrue(mock_module.hints['24.0']['3'][1] == 31)
|
||||
self.assertTrue(['Best hint', 40] in dict_out['hint_and_votes'])
|
||||
self.assertTrue(['Another hint', 31] in dict_out['hint_and_votes'])
|
||||
|
||||
def test_vote_unparsable(self):
|
||||
"""
|
||||
A user somehow votes for an unparsable answer.
|
||||
Should return a friendly error.
|
||||
(This is an unusual exception path - I don't know how it occurs,
|
||||
except if you manually make a post request. But, it seems to happen
|
||||
occasionally.)
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
# None means that the answer couldn't be parsed.
|
||||
mock_module.answer_signature = lambda text: None
|
||||
json_in = {'answer': 'fish', 'hint': 3, 'pk_list': '[]'}
|
||||
dict_out = mock_module.tally_vote(json_in)
|
||||
print dict_out
|
||||
self.assertTrue(dict_out == {'error': 'Failure in voting!'})
|
||||
|
||||
def test_vote_nohint(self):
|
||||
"""
|
||||
A user somehow votes for a hint that doesn't exist.
|
||||
Should return a friendly error.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'answer': '24.0', 'hint': '25', 'pk_list': '[]'}
|
||||
dict_out = mock_module.tally_vote(json_in)
|
||||
self.assertTrue(dict_out == {'error': 'Failure in voting!'})
|
||||
|
||||
def test_vote_badpklist(self):
|
||||
"""
|
||||
Some of the pk's specified in pk_list are invalid.
|
||||
Should just skip those.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'answer': '24.0', 'hint': '0', 'pk_list': json.dumps([['24.0', 0], ['24.0', 12]])}
|
||||
hint_and_votes = mock_module.tally_vote(json_in)['hint_and_votes']
|
||||
self.assertTrue(['Best hint', 41] in hint_and_votes)
|
||||
self.assertTrue(len(hint_and_votes) == 1)
|
||||
|
||||
def test_submithint_nopermission(self):
|
||||
"""
|
||||
A user tries to submit a hint, but he has already voted.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create(user_voted=True)
|
||||
json_in = {'answer': 1, 'hint': 'This is a new hint.'}
|
||||
json_in = {'answer': '29.0', 'hint': 'This is a new hint.'}
|
||||
print mock_module.user_voted
|
||||
mock_module.submit_hint(json_in)
|
||||
print mock_module.hints
|
||||
@@ -363,7 +479,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
exist yet.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'answer': 1, 'hint': 'This is a new hint.'}
|
||||
json_in = {'answer': '29.0', 'hint': 'This is a new hint.'}
|
||||
mock_module.submit_hint(json_in)
|
||||
self.assertTrue('29.0' in mock_module.hints)
|
||||
|
||||
@@ -373,13 +489,12 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
already.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create(previous_answers=[['25.0', [1, None, None]]])
|
||||
json_in = {'answer': 0, 'hint': 'This is a new hint.'}
|
||||
json_in = {'answer': '25.0', 'hint': 'This is a new hint.'}
|
||||
mock_module.submit_hint(json_in)
|
||||
# Make a hint request.
|
||||
json_in = {'problem name': '25.0'}
|
||||
out = mock_module.get_hint(json_in)
|
||||
self.assertTrue((out['best_hint'] == 'This is a new hint.')
|
||||
or (out['rand_hint_1'] == 'This is a new hint.'))
|
||||
self.assertTrue('This is a new hint.' in out['hints'])
|
||||
|
||||
def test_submithint_moderate(self):
|
||||
"""
|
||||
@@ -388,7 +503,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
dict.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create(moderate='True')
|
||||
json_in = {'answer': 1, 'hint': 'This is a new hint.'}
|
||||
json_in = {'answer': '29.0', 'hint': 'This is a new hint.'}
|
||||
mock_module.submit_hint(json_in)
|
||||
self.assertTrue('29.0' not in mock_module.hints)
|
||||
self.assertTrue('29.0' in mock_module.mod_queue)
|
||||
@@ -398,10 +513,20 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
Make sure that hints are being html-escaped.
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
json_in = {'answer': 1, 'hint': '<script> alert("Trololo"); </script>'}
|
||||
json_in = {'answer': '29.0', 'hint': '<script> alert("Trololo"); </script>'}
|
||||
mock_module.submit_hint(json_in)
|
||||
self.assertTrue(mock_module.hints['29.0']['0'][0] == u'<script> alert("Trololo"); </script>')
|
||||
|
||||
def test_submithint_unparsable(self):
|
||||
mock_module = CHModuleFactory.create()
|
||||
mock_module.answer_signature = lambda text: None
|
||||
json_in = {'answer': 'fish', 'hint': 'A hint'}
|
||||
dict_out = mock_module.submit_hint(json_in)
|
||||
print dict_out
|
||||
print mock_module.hints
|
||||
self.assertTrue(mock_module.hints['29.0'][0][0] == u'<script> alert("Trololo"); </script>')
|
||||
self.assertTrue('error' in dict_out)
|
||||
self.assertTrue(None not in mock_module.hints)
|
||||
self.assertTrue('fish' not in mock_module.hints)
|
||||
|
||||
def test_template_gethint(self):
|
||||
"""
|
||||
@@ -409,7 +534,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
"""
|
||||
mock_module = CHModuleFactory.create()
|
||||
|
||||
def fake_get_hint(get):
|
||||
def fake_get_hint(_):
|
||||
"""
|
||||
Creates a rendering dictionary, with which we can test
|
||||
the templates.
|
||||
|
||||
@@ -3,94 +3,137 @@
|
||||
|
||||
|
||||
<%def name="get_hint()">
|
||||
% if best_hint != '':
|
||||
% if len(hints) > 0:
|
||||
<h4> Hints from students who made similar mistakes: </h4>
|
||||
<ul>
|
||||
<li> ${best_hint} </li>
|
||||
% for hint in hints:
|
||||
<li> ${hint} </li>
|
||||
% endfor
|
||||
</ul>
|
||||
% endif
|
||||
% if rand_hint_1 != '':
|
||||
<li> ${rand_hint_1} </li>
|
||||
% endif
|
||||
% if rand_hint_2 != '':
|
||||
<li> ${rand_hint_2} </li>
|
||||
% endif
|
||||
</ul>
|
||||
</%def>
|
||||
|
||||
<%def name="get_feedback()">
|
||||
<p><em> Participation in the hinting system is strictly optional, and will not influence your grade. </em></p>
|
||||
<p>
|
||||
Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below:
|
||||
</p>
|
||||
<%
|
||||
def unspace(in_str):
|
||||
"""
|
||||
HTML id's can't have spaces in them. This little function
|
||||
removes spaces.
|
||||
"""
|
||||
return ''.join(in_str.split())
|
||||
|
||||
<div id="answer-tabs">
|
||||
<ul>
|
||||
% for index, answer in index_to_answer.items():
|
||||
<li><a href="#previous-answer-${index}"> ${answer} </a></li>
|
||||
% endfor
|
||||
</ul>
|
||||
|
||||
% for index, answer in index_to_answer.items():
|
||||
<div class = "previous-answer" id="previous-answer-${index}">
|
||||
<div class = "hint-inner-container">
|
||||
% if index in index_to_hints and len(index_to_hints[index]) > 0:
|
||||
<p>
|
||||
Which hint would be most effective to show a student who also got ${answer}?
|
||||
</p>
|
||||
% for hint_text, hint_pk in index_to_hints[index]:
|
||||
<p>
|
||||
<input class="vote" data-answer="${index}" data-hintno="${hint_pk}" type="button" value="Vote"/>
|
||||
${hint_text}
|
||||
</p>
|
||||
% endfor
|
||||
<p>
|
||||
Don't like any of the hints above? You can also submit your own.
|
||||
</p>
|
||||
% endif
|
||||
<p>
|
||||
What hint would you give a student who made the same mistake you did? Please don't give away the answer.
|
||||
</p>
|
||||
<textarea cols="50" class="custom-hint" id="custom-hint-${index}">
|
||||
What would you say to help someone who got this wrong answer?
|
||||
(Don't give away the answer, please.)
|
||||
</textarea>
|
||||
<br/><br/>
|
||||
<input class="submit-hint" data-answer="${index}" type="button" value="submit">
|
||||
</div></div>
|
||||
% endfor
|
||||
</div>
|
||||
|
||||
<p>Read about <a class="expand-goodhint" href="javascript:void(0);">what makes a good hint</a>.</p>
|
||||
<div class="goodhint" style="display:none">
|
||||
<h4>What makes a good hint?</h4>
|
||||
|
||||
<p>It depends on the type of problem you ran into. For stupid errors --
|
||||
an arithmetic error or similar -- simply letting the student you'll be
|
||||
helping to check their signs is sufficient.</p>
|
||||
|
||||
<p>For deeper errors of understanding, the best hints allow students to
|
||||
discover a contradiction in how they are thinking about the
|
||||
problem. An example that clearly demonstrates inconsistency or
|
||||
<a href="http://en.wikipedia.org/wiki/Cognitive_dissonance" target="_blank"> cognitive dissonace </a>
|
||||
is ideal, although in most cases, not possible.</p>
|
||||
# Make a list of all hints shown. (This is fed back to the site as pk_list.)
|
||||
# At the same time, determine whether any hints were shown at all.
|
||||
# If the user never saw hints, don't ask him to vote.
|
||||
import json
|
||||
hints_exist = False
|
||||
pk_list = []
|
||||
for answer, pk_dict in answer_to_hints.items():
|
||||
if len(pk_dict) > 0:
|
||||
hints_exist = True
|
||||
for pk, hint_text in pk_dict.items():
|
||||
pk_list.append([answer, pk])
|
||||
json_pk_list = json.dumps(pk_list)
|
||||
%>
|
||||
|
||||
<!-- Tells coffeescript whether there are hints to show. -->
|
||||
<span id="hints-exist" style="display:none">${hints_exist}</span>
|
||||
<div class="wizard-viewbox"><div class="wizard-container">
|
||||
<div class="wizard-view" id="p1">
|
||||
<p>
|
||||
Good hints either:
|
||||
<ul>
|
||||
<li> Point out the specific misunderstanding your classmate might have </li>
|
||||
<li> Point to concepts or theories where your classmates might have a
|
||||
misunderstanding </li>
|
||||
<li> Show simpler, analogous examples. </li>
|
||||
<li> Provide references to relevant parts of the text </li>
|
||||
</ul>
|
||||
<em> Optional. </em> Help us improve our hints! Which hint was most helpful to you?
|
||||
</p>
|
||||
|
||||
<p>Still, remember even a crude hint -- virtually anything short of
|
||||
giving away the answer -- is better than no hint.</p>
|
||||
<div id="pk-list" data-pk-list='${json_pk_list}' style="display:none"> </div>
|
||||
|
||||
% for answer, pk_dict in answer_to_hints.items():
|
||||
% for hint_pk, hint_text in pk_dict.items():
|
||||
<p>
|
||||
<input class="vote" data-answer="${answer}" data-hintno="${hint_pk}" type="button" value="Vote">
|
||||
${hint_text}
|
||||
</p>
|
||||
% endfor
|
||||
% endfor
|
||||
|
||||
<p>
|
||||
<a href="http://www.apa.org/education/k12/misconceptions.aspx?item=2" target="_blank">Learn even more</a>
|
||||
</p> </div>
|
||||
Don't like any of the hints above?
|
||||
<a class="wizard-link" dest="p2" href="javascript: void(0);">
|
||||
Write your own!
|
||||
</a></p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-view" id="p2">
|
||||
% if hints_exist:
|
||||
<p>
|
||||
Choose the incorrect answer for which you want to write a hint:
|
||||
</p>
|
||||
% else:
|
||||
<p>
|
||||
<em>Optional.</em> Help other students by submitting a hint! Pick one of your previous
|
||||
answers for which you would like to write a hint:
|
||||
</p>
|
||||
% endif
|
||||
% for answer in user_submissions:
|
||||
<a class="answer-choice" href="javascript: void(0)" value="${answer}">${answer}</a><br />
|
||||
% endfor
|
||||
% if hints_exist:
|
||||
<p class="bottom">
|
||||
<a href="javascript: void(0);" class="wizard-link" dest="p1"> Back </a>
|
||||
</p>
|
||||
% endif
|
||||
|
||||
</div>
|
||||
|
||||
<div class="wizard-view" id="p3">
|
||||
|
||||
<p>
|
||||
Write a hint for other students who get the wrong answer of <span id="blank-answer"></span>.
|
||||
</p>
|
||||
<p>Read about <a class="expand" data-target="goodhint" href="javascript:void(0);">what makes a good hint</a>.</p>
|
||||
|
||||
<textarea cols="50" class="custom-hint" data-answer="${answer}" style="height: 200px">
|
||||
Write your hint here. Please don't give away the correct answer.
|
||||
</textarea>
|
||||
<br /><br />
|
||||
<input class="submit-hint" data-answer="${answer}" type="button" value="Submit">
|
||||
<div id="goodhint" style="display:none">
|
||||
<h4>What makes a good hint?</h4>
|
||||
|
||||
<p>It depends on the type of problem you ran into. For stupid errors --
|
||||
an arithmetic error or similar -- simply letting the student you'll be
|
||||
helping to check their signs is sufficient.</p>
|
||||
|
||||
<p>For deeper errors of understanding, the best hints allow students to
|
||||
discover a contradiction in how they are thinking about the
|
||||
problem. An example that clearly demonstrates inconsistency or
|
||||
<a href="http://en.wikipedia.org/wiki/Cognitive_dissonance" target="_blank"> cognitive dissonace </a>
|
||||
is ideal, although in most cases, not possible.</p>
|
||||
|
||||
<p>
|
||||
Good hints either:
|
||||
<ul>
|
||||
<li> Point out the specific misunderstanding your classmate might have </li>
|
||||
<li> Point to concepts or theories where your classmates might have a
|
||||
misunderstanding </li>
|
||||
<li> Show simpler, analogous examples. </li>
|
||||
<li> Provide references to relevant parts of the text </li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p>Still, remember even a crude hint -- virtually anything short of
|
||||
giving away the answer -- is better than no hint.</p>
|
||||
|
||||
<p>
|
||||
<a href="http://www.apa.org/education/k12/misconceptions.aspx?item=2" target="_blank">Learn even more</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="bottom">
|
||||
<a href="javascript: void(0);" class="wizard-link" dest="p2"> Back </a>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Close wizard contaner and wizard viewbox. -->
|
||||
</div></div>
|
||||
|
||||
|
||||
</%def>
|
||||
|
||||
@@ -124,6 +167,10 @@ What would you say to help someone who got this wrong answer?
|
||||
${simple_message()}
|
||||
% endif
|
||||
|
||||
% if op == "error":
|
||||
${error}
|
||||
% endif
|
||||
|
||||
% if op == "vote":
|
||||
${show_votes()}
|
||||
% endif
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Views for hint management.
|
||||
|
||||
Along with the crowdsource_hinter xmodule, this code is still
|
||||
experimental, and should not be used in new courses, yet.
|
||||
Get to these views through courseurl/hint_manager.
|
||||
For example: https://courses.edx.org/courses/MITx/2.01x/2013_Spring/hint_manager
|
||||
|
||||
These views will only be visible if MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -15,12 +17,17 @@ from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
|
||||
from courseware.courses import get_course_with_access
|
||||
from courseware.models import XModuleContentField
|
||||
import courseware.module_render as module_render
|
||||
import courseware.model_data as model_data
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def hint_manager(request, course_id):
|
||||
"""
|
||||
The URL landing function for all calls to the hint manager, both POST and GET.
|
||||
"""
|
||||
try:
|
||||
get_course_with_access(request.user, course_id, 'staff', depth=None)
|
||||
except Http404:
|
||||
@@ -28,24 +35,29 @@ def hint_manager(request, course_id):
|
||||
return HttpResponse(out)
|
||||
if request.method == 'GET':
|
||||
out = get_hints(request, course_id, 'mod_queue')
|
||||
return render_to_response('courseware/hint_manager.html', out)
|
||||
out.update({'error': ''})
|
||||
return render_to_response('instructor/hint_manager.html', out)
|
||||
field = request.POST['field']
|
||||
if not (field == 'mod_queue' or field == 'hints'):
|
||||
# Invalid field. (Don't let users continue - they may overwrite other db's)
|
||||
out = 'Error in hint manager - an invalid field was accessed.'
|
||||
return HttpResponse(out)
|
||||
|
||||
if request.POST['op'] == 'delete hints':
|
||||
delete_hints(request, course_id, field)
|
||||
if request.POST['op'] == 'switch fields':
|
||||
pass
|
||||
if request.POST['op'] == 'change votes':
|
||||
change_votes(request, course_id, field)
|
||||
if request.POST['op'] == 'add hint':
|
||||
add_hint(request, course_id, field)
|
||||
if request.POST['op'] == 'approve':
|
||||
approve(request, course_id, field)
|
||||
rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(request, course_id, field))
|
||||
switch_dict = {
|
||||
'delete hints': delete_hints,
|
||||
'switch fields': lambda *args: None, # Takes any number of arguments, returns None.
|
||||
'change votes': change_votes,
|
||||
'add hint': add_hint,
|
||||
'approve': approve,
|
||||
}
|
||||
|
||||
# Do the operation requested, and collect any error messages.
|
||||
error_text = switch_dict[request.POST['op']](request, course_id, field)
|
||||
if error_text is None:
|
||||
error_text = ''
|
||||
render_dict = get_hints(request, course_id, field)
|
||||
render_dict.update({'error': error_text})
|
||||
rendered_html = render_to_string('instructor/hint_manager_inner.html', render_dict)
|
||||
return HttpResponse(json.dumps({'success': True, 'contents': rendered_html}))
|
||||
|
||||
|
||||
@@ -165,7 +177,13 @@ def change_votes(request, course_id, field):
|
||||
Updates the number of votes.
|
||||
|
||||
The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples.
|
||||
- Very similar to `delete_hints`. Is there a way to merge them? Nah, too complicated.
|
||||
See `delete_hints`.
|
||||
|
||||
Example `request.POST`:
|
||||
{'op': 'delete_hints',
|
||||
'field': 'mod_queue',
|
||||
1: ['problem_whatever', '42.0', '3', 42],
|
||||
2: ['problem_whatever', '32.5', '12', 9001]}
|
||||
"""
|
||||
|
||||
for key in request.POST:
|
||||
@@ -193,6 +211,18 @@ def add_hint(request, course_id, field):
|
||||
problem_id = request.POST['problem']
|
||||
answer = request.POST['answer']
|
||||
hint_text = request.POST['hint']
|
||||
|
||||
# Validate the answer. This requires initializing the xmodules, which
|
||||
# is annoying.
|
||||
loc = Location(problem_id)
|
||||
descriptors = modulestore().get_items(loc, course_id=course_id)
|
||||
m_d_c = model_data.ModelDataCache(descriptors, course_id, request.user)
|
||||
hinter_module = module_render.get_module(request.user, request, loc, m_d_c, course_id)
|
||||
if not hinter_module.validate_answer(answer):
|
||||
# Invalid answer. Don't add it to the database, or else the
|
||||
# hinter will crash when we encounter it.
|
||||
return 'Error - the answer you specified is not properly formatted: ' + str(answer)
|
||||
|
||||
this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
|
||||
|
||||
hint_pk_entry = XModuleContentField.objects.get(field_name='hint_pk', definition_id=problem_id)
|
||||
@@ -214,6 +244,8 @@ def approve(request, course_id, field):
|
||||
hint list. POST:
|
||||
op, field
|
||||
(some number) -> [problem, answer, pk]
|
||||
|
||||
The numbered fields are analogous to those in `delete_hints` and `change_votes`.
|
||||
"""
|
||||
|
||||
for key in request.POST:
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
from django.test.client import Client, RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from mock import patch, MagicMock
|
||||
|
||||
from courseware.models import XModuleContentField
|
||||
from courseware.tests.factories import ContentFactory
|
||||
@@ -137,16 +138,45 @@ class HintManagerTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Check that instructors can add new hints.
|
||||
"""
|
||||
# Because add_hint accesses the xmodule, this test requires a bunch
|
||||
# of monkey patching.
|
||||
hinter = MagicMock()
|
||||
hinter.validate_answer = lambda string: True
|
||||
|
||||
request = RequestFactory()
|
||||
post = request.post(self.url, {'field': 'mod_queue',
|
||||
'op': 'add hint',
|
||||
'problem': self.problem_id,
|
||||
'answer': '3.14',
|
||||
'hint': 'This is a new hint.'})
|
||||
view.add_hint(post, self.course_id, 'mod_queue')
|
||||
post.user = 'fake user'
|
||||
with patch('courseware.module_render.get_module', MagicMock(return_value=hinter)):
|
||||
with patch('courseware.model_data.ModelDataCache', MagicMock(return_value=None)):
|
||||
view.add_hint(post, self.course_id, 'mod_queue')
|
||||
problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value
|
||||
self.assertTrue('3.14' in json.loads(problem_hints))
|
||||
|
||||
def test_addbadhint(self):
|
||||
"""
|
||||
Check that instructors cannot add hints with unparsable answers.
|
||||
"""
|
||||
# Patching.
|
||||
hinter = MagicMock()
|
||||
hinter.validate_answer = lambda string: False
|
||||
|
||||
request = RequestFactory()
|
||||
post = request.post(self.url, {'field': 'mod_queue',
|
||||
'op': 'add hint',
|
||||
'problem': self.problem_id,
|
||||
'answer': 'fish',
|
||||
'hint': 'This is a new hint.'})
|
||||
post.user = 'fake user'
|
||||
with patch('courseware.module_render.get_module', MagicMock(return_value=hinter)):
|
||||
with patch('courseware.model_data.ModelDataCache', MagicMock(return_value=None)):
|
||||
view.add_hint(post, self.course_id, 'mod_queue')
|
||||
problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value
|
||||
self.assertTrue('fish' not in json.loads(problem_hints))
|
||||
|
||||
def test_approve(self):
|
||||
"""
|
||||
Check that instructors can approve hints. (Move them
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%namespace name="content" file="/courseware/hint_manager_inner.html"/>
|
||||
<%namespace name="content" file="/instructor/hint_manager_inner.html"/>
|
||||
|
||||
|
||||
<%block name="headextra">
|
||||
@@ -4,6 +4,7 @@
|
||||
<h1> ${field_label} </h1>
|
||||
Switch to <a id="switch-fields" other-field="${other_field}">${other_field_label}</a>
|
||||
|
||||
<p style="color:red"> ${error} </p>
|
||||
|
||||
% for definition_id in all_hints:
|
||||
<h2> Problem: ${id_to_name[definition_id]} </h2>
|
||||
@@ -36,6 +37,7 @@ Switch to <a id="switch-fields" other-field="${other_field}">${other_field_label
|
||||
<br />
|
||||
% endfor
|
||||
|
||||
<p style="color:red"> ${error} </p>
|
||||
<button id="hint-delete"> Delete selected </button> <button id="update-votes"> Update votes </button>
|
||||
|
||||
% if field == 'mod_queue':
|
||||
Reference in New Issue
Block a user