Commit b5e1d57e by Felix Sun

Merge pull request #442 from edx/felix/formula-hints

Crowdsourced Hints - "0.2 release"
parents 02cb0b48 444f51d6
...@@ -915,7 +915,26 @@ class NumericalResponse(LoncapaResponse): ...@@ -915,7 +915,26 @@ class NumericalResponse(LoncapaResponse):
else: else:
return CorrectMap(self.answer_id, 'incorrect') 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): def get_answers(self):
return {self.answer_id: self.correct_answer} return {self.answer_id: self.correct_answer}
...@@ -1778,46 +1797,24 @@ class FormulaResponse(LoncapaResponse): ...@@ -1778,46 +1797,24 @@ class FormulaResponse(LoncapaResponse):
self.correct_answer, given, self.samples) self.correct_answer, given, self.samples)
return CorrectMap(self.answer_id, correctness) return CorrectMap(self.answer_id, correctness)
def check_formula(self, expected, given, samples): def tupleize_answers(self, answer, var_dict_list):
variables = samples.split('@')[0].split(',') """
numsamples = int(samples.split('@')[1].split('#')[1]) Takes in an answer and a list of dictionaries mapping variables to values.
sranges = zip(*map(lambda x: map(float, x.split(",")), Each dictionary represents a test case for the answer.
samples.split('@')[1].split('#')[0].split(':'))) Returns a tuple of formula evaluation results.
"""
ranges = dict(zip(variables, sranges)) out = []
for _ in range(numsamples): for var_dict in var_dict_list:
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
)
try: try:
# log.debug('formula: student_vars=%s, given=%s' % out.append(evaluator(
# (student_variables,given)) var_dict,
dict(),
# Call `evaluator` on the student's answer; look for exceptions answer,
student_result = evaluator( case_sensitive=self.case_sensitive,
student_variables, ))
{},
given,
case_sensitive=self.case_sensitive
)
except UndefinedVariable as uv: except UndefinedVariable as uv:
log.debug( log.debug(
'formularesponse: undefined variable in given=%s', 'formularesponse: undefined variable in formula=%s' % answer)
given
)
raise StudentInputError( raise StudentInputError(
"Invalid input: " + uv.message + " not permitted in answer" "Invalid input: " + uv.message + " not permitted in answer"
) )
...@@ -1840,17 +1837,70 @@ class FormulaResponse(LoncapaResponse): ...@@ -1840,17 +1837,70 @@ class FormulaResponse(LoncapaResponse):
# If non-factorial related ValueError thrown, handle it the same as any other Exception # If non-factorial related ValueError thrown, handle it the same as any other Exception
log.debug('formularesponse: error {0} in formula'.format(ve)) log.debug('formularesponse: error {0} in formula'.format(ve))
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
cgi.escape(given)) cgi.escape(answer))
except Exception as err: except Exception as err:
# traceback.print_exc() # traceback.print_exc()
log.debug('formularesponse: error %s in formula', err) log.debug('formularesponse: error %s in formula', err)
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % 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 def randomize_variables(self, samples):
if not compare_with_tolerance(student_result, instructor_result, self.tolerance): """
return "incorrect" Returns a list of dictionaries mapping variables to random values in range,
return "correct" 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): def strip_dict(self, d):
''' Takes a dict. Returns an identical dict, with all non-word ''' Takes a dict. Returns an identical dict, with all non-word
......
...@@ -496,6 +496,20 @@ class FormulaResponseTest(ResponseTest): ...@@ -496,6 +496,20 @@ class FormulaResponseTest(ResponseTest):
input_dict = {'1_2_1': '1/0'} input_dict = {'1_2_1': '1/0'}
self.assertRaises(StudentInputError, problem.grade_answers, input_dict) 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): class StringResponseTest(ResponseTest):
from capa.tests.response_xml_factory import StringResponseXMLFactory from capa.tests.response_xml_factory import StringResponseXMLFactory
...@@ -915,6 +929,20 @@ class NumericalResponseTest(ResponseTest): ...@@ -915,6 +929,20 @@ class NumericalResponseTest(ResponseTest):
with self.assertRaisesRegexp(StudentInputError, msg_regex): with self.assertRaisesRegexp(StudentInputError, msg_regex):
problem.grade_answers({'1_2_1': 'foobar'}) 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): class CustomResponseTest(ResponseTest):
from capa.tests.response_xml_factory import CustomResponseXMLFactory from capa.tests.response_xml_factory import CustomResponseXMLFactory
......
...@@ -7,15 +7,18 @@ Currently experimental - not for instructor use, yet. ...@@ -7,15 +7,18 @@ Currently experimental - not for instructor use, yet.
import logging import logging
import json import json
import random import random
import copy
from pkg_resources import resource_string from pkg_resources import resource_string
from lxml import etree from lxml import etree
from xmodule.x_module import XModule 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 xblock.core import Scope, String, Integer, Boolean, Dict, List
from capa.responsetypes import FormulaResponse
from django.utils.html import escape from django.utils.html import escape
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -37,10 +40,15 @@ class CrowdsourceHinterFields(object): ...@@ -37,10 +40,15 @@ class CrowdsourceHinterFields(object):
mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content,
default={}) default={})
hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) 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 # A list of previous hints that a student viewed.
# None if the hint was not given. # Of the form [answer, [hint_pk_1, ...]] for each problem.
previous_answers = List(help='A list of previous submissions.', scope=Scope.user_state, default=[]) # 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.', user_voted = Boolean(help='Specifies if the user has voted on this problem or not.',
scope=Scope.user_state, default=False) scope=Scope.user_state, default=False)
...@@ -68,6 +76,26 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -68,6 +76,26 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
XModule.__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): def get_html(self):
""" """
...@@ -98,14 +126,29 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -98,14 +126,29 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
return out 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. of the answer.
-Lon-capa dependent. -Lon-capa dependent.
-Assumes that the problem only has one part. -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): def handle_ajax(self, dispatch, data):
""" """
...@@ -124,6 +167,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -124,6 +167,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
if out is None: if out is None:
out = {'op': 'empty'} out = {'op': 'empty'}
elif 'error' in out:
# Error in processing.
out.update({'op': 'error'})
else: else:
out.update({'op': dispatch}) out.update({'op': dispatch})
return json.dumps({'contents': self.system.render_template('hinter_display.html', out)}) return json.dumps({'contents': self.system.render_template('hinter_display.html', out)})
...@@ -134,51 +180,67 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -134,51 +180,67 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
Called by hinter javascript after a problem is graded as incorrect. Called by hinter javascript after a problem is graded as incorrect.
Args: Args:
`data` -- must be interpretable by capa_answer_to_str. `data` -- must be interpretable by answer_to_str.
Output keys: Output keys:
- 'best_hint' is the hint text with the most votes. - 'hints' is a list of hint strings to show to the user.
- 'rand_hint_1' and 'rand_hint_2' are two random hints to the answer in `data`.
- 'answer' is the parsed answer that was submitted. - '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: try:
answer = self.capa_answer_to_str(data) answer = self.answer_to_str(data)
except ValueError: except (ValueError, AttributeError):
# Sometimes, we get an answer that's just not parsable. Do nothing. # Sometimes, we get an answer that's just not parsable. Do nothing.
log.exception('Answer not parsable: ' + str(data)) log.exception('Answer not parsable: ' + str(data))
return return
# Look for a hint to give. if not self.validate_answer(answer):
# Make a local copy of self.hints - this means we only need to do one json unpacking. # Answer is not in the right form.
# (This is because xblocks storage makes the following command a deep copy.) log.exception('Answer not valid: ' + str(answer))
local_hints = self.hints return
if (answer not in local_hints) or (len(local_hints[answer]) == 0): 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. # No hints to give. Return.
self.previous_answers += [[answer, [None, None, None]]]
return return
# Get the top hint, plus two random hints. # Get the top hint, plus two random hints.
n_hints = len(local_hints[answer]) n_hints = len(matching_hints)
best_hint_index = max(local_hints[answer], key=lambda key: local_hints[answer][key][1]) hints = []
best_hint = local_hints[answer][best_hint_index][0] # max(dict) returns the maximum key in dict.
if len(local_hints[answer]) == 1: # The key function takes each pk, and returns the number of votes for the
rand_hint_1 = '' # hint with that pk.
rand_hint_2 = '' best_hint_index = max(matching_hints, key=lambda pk: matching_hints[pk][1])
self.previous_answers += [[answer, [best_hint_index, None, None]]] hints.append(matching_hints[best_hint_index][0])
elif n_hints == 2: best_hint_answer = matching_hints[best_hint_index][2]
best_hint = local_hints[answer].values()[0][0] # The brackets surrounding the index are for backwards compatability purposes.
best_hint_index = local_hints[answer].keys()[0] # (It used to be that each answer was paired with multiple hints in a list.)
rand_hint_1 = local_hints[answer].values()[1][0] self.previous_answers += [[best_hint_answer, [best_hint_index]]]
hint_index_1 = local_hints[answer].keys()[1] for _ in xrange(min(2, n_hints - 1)):
rand_hint_2 = '' # Keep making random hints until we hit a target, or run out.
self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] while True:
else: # random.choice randomly chooses an element from its input list.
(hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ # (We then unpack the item, in this case data for a hint.)
random.sample(local_hints[answer].items(), 2) (hint_index, (rand_hint, _, hint_answer)) =\
rand_hint_1 = rand_hint_1[0] random.choice(matching_hints.items())
rand_hint_2 = rand_hint_2[0] if rand_hint not in hints:
self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]] break
hints.append(rand_hint)
return {'best_hint': best_hint, self.previous_answers += [[hint_answer, [hint_index]]]
'rand_hint_1': rand_hint_1, return {'hints': hints,
'rand_hint_2': rand_hint_2,
'answer': answer} 'answer': answer}
def get_feedback(self, data): def get_feedback(self, data):
...@@ -188,38 +250,37 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -188,38 +250,37 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
Args: Args:
`data` -- not actually used. (It is assumed that the answer is correct.) `data` -- not actually used. (It is assumed that the answer is correct.)
Output keys: Output keys:
- 'index_to_hints' maps previous answer indices to hints that the user saw earlier. - 'answer_to_hints': a nested dictionary.
- 'index_to_answer' maps previous answer indices to the actual answer submitted. 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. # The student got it right.
# Did he submit at least one wrong answer? # 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. # No. Nothing to do here.
return return
# Make a hint-voting interface for each wrong answer. The student will only # 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 # be allowed to make one vote / submission, but he can choose which wrong answer
# he wants to look at. # he wants to look at.
# index_to_hints[previous answer #] = [(hint text, hint pk), + ] answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text
index_to_hints = {}
# index_to_answer[previous answer #] = answer text
index_to_answer = {}
# Go through each previous answer, and populate index_to_hints and index_to_answer. # Go through each previous answer, and populate index_to_hints and index_to_answer.
for i in xrange(len(self.previous_answers)): for i in xrange(len(self.previous_answers)):
answer, hints_offered = self.previous_answers[i] answer, hints_offered = self.previous_answers[i]
index_to_hints[i] = [] if answer not in answer_to_hints:
index_to_answer[i] = answer answer_to_hints[answer] = {}
if answer in self.hints: if answer in self.hints:
# Go through each hint, and add to index_to_hints # Go through each hint, and add to index_to_hints
for hint_id in hints_offered: 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: 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: except KeyError:
# Sometimes, the hint that a user saw will have been deleted by the instructor. # Sometimes, the hint that a user saw will have been deleted by the instructor.
continue continue
return {'answer_to_hints': answer_to_hints,
return {'index_to_hints': index_to_hints, 'index_to_answer': index_to_answer} 'user_submissions': self.user_submissions}
def tally_vote(self, data): def tally_vote(self, data):
""" """
...@@ -227,31 +288,51 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -227,31 +288,51 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
Args: Args:
`data` -- expected to have the following keys: `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 '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. Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs.
""" """
if self.user_voted: if self.user_voted:
return {} return {'error': 'Sorry, but you have already voted!'}
ans_no = int(data['answer']) ans = data['answer']
hint_no = str(data['hint']) if not self.validate_answer(ans):
answer = self.previous_answers[ans_no][0] # 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. # We use temp_dict because we need to do a direct write for the database to update.
temp_dict = self.hints 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 self.hints = temp_dict
# Don't let the user vote again! # Don't let the user vote again!
self.user_voted = True self.user_voted = True
# Return a list of how many votes each hint got. # Return a list of how many votes each hint got.
pk_list = json.loads(data['pk_list'])
hint_and_votes = [] hint_and_votes = []
for hint_no in self.previous_answers[ans_no][1]: for answer, vote_pk in pk_list:
if hint_no is None: if not self.validate_answer(answer):
log.exception('In hinter tally_vote, couldn\'t parse {ans}'.format(ans=answer))
continue 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.previous_answers = []
self.user_submissions = []
return {'hint_and_votes': hint_and_votes} return {'hint_and_votes': hint_and_votes}
def submit_hint(self, data): def submit_hint(self, data):
...@@ -260,13 +341,17 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -260,13 +341,17 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
Args: Args:
`data` -- expected to have the following keys: `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 'hint': text of the new hint that the user is adding
Returns a thank-you message. Returns a thank-you message.
""" """
# Do html escaping. Perhaps in the future do profanity filtering, etc. as well. # Do html escaping. Perhaps in the future do profanity filtering, etc. as well.
hint = escape(data['hint']) 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. # Only allow a student to vote or submit a hint once.
if self.user_voted: if self.user_voted:
return {'message': 'Sorry, but you have already voted!'} return {'message': 'Sorry, but you have already voted!'}
...@@ -277,9 +362,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -277,9 +362,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
else: else:
temp_dict = self.hints temp_dict = self.hints
if answer in temp_dict: 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: else:
temp_dict[answer] = {self.hint_pk: [hint, 1]} temp_dict[answer] = {str(self.hint_pk): [hint, 1]}
self.hint_pk += 1 self.hint_pk += 1
if self.moderate == 'True': if self.moderate == 'True':
self.mod_queue = temp_dict self.mod_queue = temp_dict
...@@ -288,10 +373,11 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ...@@ -288,10 +373,11 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
# Mark the user has having voted; reset previous_answers # Mark the user has having voted; reset previous_answers
self.user_voted = True self.user_voted = True
self.previous_answers = [] self.previous_answers = []
self.user_submissions = []
return {'message': 'Thank you for your hint!'} return {'message': 'Thank you for your hint!'}
class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor):
module_class = CrowdsourceHinterModule module_class = CrowdsourceHinterModule
stores_state = True stores_state = True
......
...@@ -7,52 +7,6 @@ ...@@ -7,52 +7,6 @@
background: rgb(253, 248, 235); 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 { .hint-inner-container {
padding-left: 15px; padding-left: 15px;
padding-right: 15px; padding-right: 15px;
...@@ -63,3 +17,24 @@ ...@@ -63,3 +17,24 @@
padding-top: 0px !important; padding-top: 0px !important;
padding-bottom: 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;
}
<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>
\ No newline at end of file
...@@ -125,9 +125,10 @@ describe 'Problem', -> ...@@ -125,9 +125,10 @@ describe 'Problem', ->
expect(@problem.bind).toHaveBeenCalled() expect(@problem.bind).toHaveBeenCalled()
describe 'check_fd', -> describe 'check_fd', ->
xit 'should have specs written for this functionality', -> xit 'should have more specs written for this functionality', ->
expect(false) expect(false)
describe 'check', -> describe 'check', ->
beforeEach -> beforeEach ->
@problem = new Problem($('.xmodule_display')) @problem = new Problem($('.xmodule_display'))
...@@ -137,6 +138,15 @@ describe 'Problem', -> ...@@ -137,6 +138,15 @@ describe 'Problem', ->
@problem.check() @problem.check()
expect(Logger.log).toHaveBeenCalledWith 'problem_check', 'foo=1&bar=2' 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', -> it 'submit the answer for check', ->
spyOn $, 'postWithPrefix' spyOn $, 'postWithPrefix'
@problem.check() @problem.check()
......
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 ...@@ -19,7 +19,6 @@ class @Problem
problem_prefix = @element_id.replace(/problem_/,'') problem_prefix = @element_id.replace(/problem_/,'')
@inputs = @$("[id^=input_#{problem_prefix}_]") @inputs = @$("[id^=input_#{problem_prefix}_]")
@$('section.action input:button').click @refreshAnswers @$('section.action input:button').click @refreshAnswers
@$('section.action input.check').click @check_fd @$('section.action input.check').click @check_fd
@$('section.action input.reset').click @reset @$('section.action input.reset').click @reset
...@@ -247,6 +246,7 @@ class @Problem ...@@ -247,6 +246,7 @@ class @Problem
@updateProgress response @updateProgress response
else else
@gentle_alert response.success @gentle_alert response.success
Logger.log 'problem_graded', [@answers, response.contents], @url
if not abort_submission if not abort_submission
$.ajaxWithPrefix("#{@url}/problem_check", settings) $.ajaxWithPrefix("#{@url}/problem_check", settings)
......
...@@ -29,42 +29,73 @@ class @Hinter ...@@ -29,42 +29,73 @@ class @Hinter
$(selector, @el) $(selector, @el)
bind: => bind: =>
window.update_schematics()
@$('input.vote').click @vote @$('input.vote').click @vote
@$('input.submit-hint').click @submit_hint @$('input.submit-hint').click @submit_hint
@$('.custom-hint').click @clear_default_text @$('.custom-hint').click @clear_default_text
@$('#answer-tabs').tabs({active: 0}) @$('.expand').click @expand
@$('.expand-goodhint').click @expand_goodhint @$('.wizard-link').click @wizard_link_handle
@$('.answer-choice').click @answer_choice_handle
expand_goodhint: => expand: (eventObj) =>
if @$('.goodhint').css('display') == 'none' # Expand a hidden div.
@$('.goodhint').css('display', 'block') target = @$('#' + @$(eventObj.currentTarget).data('target'))
if @$(target).css('display') == 'none'
@$(target).css('display', 'block')
else else
@$('.goodhint').css('display', 'none') @$(target).css('display', 'none')
# Fix positioning errors with the bottom class.
@set_bottom_links()
vote: (eventObj) => vote: (eventObj) =>
# Make an ajax request with the user's vote.
target = @$(eventObj.currentTarget) 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) => $.postWithPrefix "#{@url}/vote", post_json, (response) =>
@render(response.contents) @render(response.contents)
submit_hint: (eventObj) => submit_hint: (eventObj) =>
target = @$(eventObj.currentTarget) # Make an ajax request with the user's new hint.
textarea_id = '#custom-hint-' + target.data('answer') textarea = $('.custom-hint')
post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} 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) => $.postWithPrefix "#{@url}/submit_hint",post_json, (response) =>
@render(response.contents) @render(response.contents)
clear_default_text: (eventObj) => clear_default_text: (eventObj) =>
# Remove placeholder text in the hint submission textbox.
target = @$(eventObj.currentTarget) target = @$(eventObj.currentTarget)
if target.data('cleared') == undefined if target.data('cleared') == undefined
target.val('') target.val('')
target.data('cleared', true) 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) -> render: (content) ->
if content if content
# Trim leading and trailing whitespace # Trim leading and trailing whitespace
content = content.replace /^\s+|\s+$/g, "" content = content.trim()
if content if content
@el.html(content) @el.html(content)
...@@ -74,3 +105,37 @@ class @Hinter ...@@ -74,3 +105,37 @@ class @Hinter
@$('#previous-answer-0').css('display', 'inline') @$('#previous-answer-0').css('display', 'inline')
else else
@el.hide() @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()
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Tests the crowdsourced hinter xmodule. Tests the crowdsourced hinter xmodule.
""" """
from mock import Mock from mock import Mock, MagicMock
import unittest import unittest
import copy import copy
...@@ -53,6 +53,7 @@ class CHModuleFactory(object): ...@@ -53,6 +53,7 @@ class CHModuleFactory(object):
@staticmethod @staticmethod
def create(hints=None, def create(hints=None,
previous_answers=None, previous_answers=None,
user_submissions=None,
user_voted=None, user_voted=None,
moderate=None, moderate=None,
mod_queue=None): mod_queue=None):
...@@ -85,17 +86,59 @@ class CHModuleFactory(object): ...@@ -85,17 +86,59 @@ class CHModuleFactory(object):
else: else:
model_data['previous_answers'] = [ model_data['previous_answers'] = [
['24.0', [0, 3, 4]], ['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: if user_voted is not None:
model_data['user_voted'] = user_voted model_data['user_voted'] = user_voted
if moderate is not None: if moderate is not None:
model_data['moderate'] = moderate 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() 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) module = CrowdsourceHinterModule(system, descriptor, model_data)
return module return module
...@@ -146,11 +189,13 @@ class VerticalWithModulesFactory(object): ...@@ -146,11 +189,13 @@ class VerticalWithModulesFactory(object):
@staticmethod @staticmethod
def next_num(): def next_num():
"""Increments a global counter for naming."""
CHModuleFactory.num += 1 CHModuleFactory.num += 1
return CHModuleFactory.num return CHModuleFactory.num
@staticmethod @staticmethod
def create(): def create():
"""Make a vertical."""
model_data = {'data': VerticalWithModulesFactory.sample_problem_xml} model_data = {'data': VerticalWithModulesFactory.sample_problem_xml}
system = get_test_system() system = get_test_system()
descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system) descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system)
...@@ -226,6 +271,24 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -226,6 +271,24 @@ class CrowdsourceHinterTest(unittest.TestCase):
self.assertTrue('Test numerical problem.' in out_html) self.assertTrue('Test numerical problem.' in out_html)
self.assertTrue('Another 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): def test_gethint_0hint(self):
""" """
Someone asks for a hint, when there's no hint to give. Someone asks for a hint, when there's no hint to give.
...@@ -235,21 +298,36 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -235,21 +298,36 @@ class CrowdsourceHinterTest(unittest.TestCase):
mock_module = CHModuleFactory.create() mock_module = CHModuleFactory.create()
json_in = {'problem_name': '26.0'} json_in = {'problem_name': '26.0'}
out = mock_module.get_hint(json_in) out = mock_module.get_hint(json_in)
print mock_module.previous_answers
self.assertTrue(out is None) 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): 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. - The answer should not be added to previous_answers.
""" """
mock_module = CHModuleFactory.create() mock_module = CHModuleFactory.create()
old_answers = copy.deepcopy(mock_module.previous_answers) old_answers = copy.deepcopy(mock_module.previous_answers)
json_in = {'problem_name': 'fish'} json_in = 'blah'
out = mock_module.get_hint(json_in) out = mock_module.get_hint(json_in)
self.assertTrue(out is None) self.assertTrue(out is None)
self.assertTrue(mock_module.previous_answers == old_answers) 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): def test_gethint_1hint(self):
""" """
Someone asks for a hint, with exactly one hint in the database. Someone asks for a hint, with exactly one hint in the database.
...@@ -258,7 +336,11 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -258,7 +336,11 @@ class CrowdsourceHinterTest(unittest.TestCase):
mock_module = CHModuleFactory.create() mock_module = CHModuleFactory.create()
json_in = {'problem_name': '25.0'} json_in = {'problem_name': '25.0'}
out = mock_module.get_hint(json_in) 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): def test_gethint_manyhints(self):
""" """
...@@ -271,18 +353,18 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -271,18 +353,18 @@ class CrowdsourceHinterTest(unittest.TestCase):
mock_module = CHModuleFactory.create() mock_module = CHModuleFactory.create()
json_in = {'problem_name': '24.0'} json_in = {'problem_name': '24.0'}
out = mock_module.get_hint(json_in) out = mock_module.get_hint(json_in)
self.assertTrue(out['best_hint'] == 'Best hint') self.assertTrue('Best hint' in out['hints'])
self.assertTrue('rand_hint_1' in out) self.assertTrue(len(out['hints']) == 3)
self.assertTrue('rand_hint_2' in out)
def test_getfeedback_0wronganswers(self): def test_getfeedback_0wronganswers(self):
""" """
Someone has gotten the problem correct on the first try. Someone has gotten the problem correct on the first try.
Output should be empty. Output should be empty.
""" """
mock_module = CHModuleFactory.create(previous_answers=[]) mock_module = CHModuleFactory.create(previous_answers=[], user_submissions=[])
json_in = {'problem_name': '42.5'} json_in = {'problem_name': '42.5'}
out = mock_module.get_feedback(json_in) out = mock_module.get_feedback(json_in)
print out
self.assertTrue(out is None) self.assertTrue(out is None)
def test_getfeedback_1wronganswer_nohints(self): def test_getfeedback_1wronganswer_nohints(self):
...@@ -294,9 +376,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -294,9 +376,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
mock_module = CHModuleFactory.create(previous_answers=[['26.0', [None, None, None]]]) mock_module = CHModuleFactory.create(previous_answers=[['26.0', [None, None, None]]])
json_in = {'problem_name': '42.5'} json_in = {'problem_name': '42.5'}
out = mock_module.get_feedback(json_in) out = mock_module.get_feedback(json_in)
print out['index_to_answer'] self.assertTrue(out['answer_to_hints'] == {'26.0': {}})
self.assertTrue(out['index_to_hints'][0] == [])
self.assertTrue(out['index_to_answer'][0] == '26.0')
def test_getfeedback_1wronganswer_withhints(self): def test_getfeedback_1wronganswer_withhints(self):
""" """
...@@ -307,8 +387,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -307,8 +387,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
mock_module = CHModuleFactory.create(previous_answers=[['24.0', [0, 3, None]]]) mock_module = CHModuleFactory.create(previous_answers=[['24.0', [0, 3, None]]])
json_in = {'problem_name': '42.5'} json_in = {'problem_name': '42.5'}
out = mock_module.get_feedback(json_in) out = mock_module.get_feedback(json_in)
print out['index_to_hints'] self.assertTrue(len(out['answer_to_hints']['24.0']) == 2)
self.assertTrue(len(out['index_to_hints'][0]) == 2)
def test_getfeedback_missingkey(self): def test_getfeedback_missingkey(self):
""" """
...@@ -319,7 +398,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -319,7 +398,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
previous_answers=[['24.0', [0, 100, None]]]) previous_answers=[['24.0', [0, 100, None]]])
json_in = {'problem_name': '42.5'} json_in = {'problem_name': '42.5'}
out = mock_module.get_feedback(json_in) 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): def test_vote_nopermission(self):
""" """
...@@ -327,7 +406,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -327,7 +406,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
Should not change any vote tallies. Should not change any vote tallies.
""" """
mock_module = CHModuleFactory.create(user_voted=True) 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) old_hints = copy.deepcopy(mock_module.hints)
mock_module.tally_vote(json_in) mock_module.tally_vote(json_in)
self.assertTrue(mock_module.hints == old_hints) self.assertTrue(mock_module.hints == old_hints)
...@@ -339,19 +418,56 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -339,19 +418,56 @@ class CrowdsourceHinterTest(unittest.TestCase):
""" """
mock_module = CHModuleFactory.create( mock_module = CHModuleFactory.create(
previous_answers=[['24.0', [0, 3, None]]]) 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) 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']['0'][1] == 40)
self.assertTrue(mock_module.hints['24.0']['3'][1] == 31) self.assertTrue(mock_module.hints['24.0']['3'][1] == 31)
self.assertTrue(['Best hint', 40] in dict_out['hint_and_votes']) self.assertTrue(['Best hint', 40] in dict_out['hint_and_votes'])
self.assertTrue(['Another hint', 31] 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): def test_submithint_nopermission(self):
""" """
A user tries to submit a hint, but he has already voted. A user tries to submit a hint, but he has already voted.
""" """
mock_module = CHModuleFactory.create(user_voted=True) 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 print mock_module.user_voted
mock_module.submit_hint(json_in) mock_module.submit_hint(json_in)
print mock_module.hints print mock_module.hints
...@@ -363,7 +479,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -363,7 +479,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
exist yet. exist yet.
""" """
mock_module = CHModuleFactory.create() 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) mock_module.submit_hint(json_in)
self.assertTrue('29.0' in mock_module.hints) self.assertTrue('29.0' in mock_module.hints)
...@@ -373,13 +489,12 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -373,13 +489,12 @@ class CrowdsourceHinterTest(unittest.TestCase):
already. already.
""" """
mock_module = CHModuleFactory.create(previous_answers=[['25.0', [1, None, None]]]) 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) mock_module.submit_hint(json_in)
# Make a hint request. # Make a hint request.
json_in = {'problem name': '25.0'} json_in = {'problem name': '25.0'}
out = mock_module.get_hint(json_in) out = mock_module.get_hint(json_in)
self.assertTrue((out['best_hint'] == 'This is a new hint.') self.assertTrue('This is a new hint.' in out['hints'])
or (out['rand_hint_1'] == 'This is a new hint.'))
def test_submithint_moderate(self): def test_submithint_moderate(self):
""" """
...@@ -388,7 +503,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -388,7 +503,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
dict. dict.
""" """
mock_module = CHModuleFactory.create(moderate='True') 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) mock_module.submit_hint(json_in)
self.assertTrue('29.0' not in mock_module.hints) self.assertTrue('29.0' not in mock_module.hints)
self.assertTrue('29.0' in mock_module.mod_queue) self.assertTrue('29.0' in mock_module.mod_queue)
...@@ -398,10 +513,20 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -398,10 +513,20 @@ class CrowdsourceHinterTest(unittest.TestCase):
Make sure that hints are being html-escaped. Make sure that hints are being html-escaped.
""" """
mock_module = CHModuleFactory.create() 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) mock_module.submit_hint(json_in)
self.assertTrue(mock_module.hints['29.0']['0'][0] == u'&lt;script&gt; alert(&quot;Trololo&quot;); &lt;/script&gt;')
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 print mock_module.hints
self.assertTrue(mock_module.hints['29.0'][0][0] == u'&lt;script&gt; alert(&quot;Trololo&quot;); &lt;/script&gt;') 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): def test_template_gethint(self):
""" """
...@@ -409,7 +534,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ...@@ -409,7 +534,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
""" """
mock_module = CHModuleFactory.create() mock_module = CHModuleFactory.create()
def fake_get_hint(get): def fake_get_hint(_):
""" """
Creates a rendering dictionary, with which we can test Creates a rendering dictionary, with which we can test
the templates. the templates.
......
...@@ -3,94 +3,137 @@ ...@@ -3,94 +3,137 @@
<%def name="get_hint()"> <%def name="get_hint()">
% if best_hint != '': % if len(hints) > 0:
<h4> Hints from students who made similar mistakes: </h4> <h4> Hints from students who made similar mistakes: </h4>
<ul> <ul>
<li> ${best_hint} </li> % for hint in hints:
% endif <li> ${hint} </li>
% if rand_hint_1 != '': % endfor
<li> ${rand_hint_1} </li> </ul>
% endif
% if rand_hint_2 != '':
<li> ${rand_hint_2} </li>
% endif % endif
</ul>
</%def> </%def>
<%def name="get_feedback()"> <%def name="get_feedback()">
<p><em> Participation in the hinting system is strictly optional, and will not influence your grade. </em></p> <%
<p> def unspace(in_str):
Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below: """
</p> HTML id's can't have spaces in them. This little function
removes spaces.
"""
return ''.join(in_str.split())
# 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>
<em> Optional. </em> Help us improve our hints! Which hint was most helpful to you?
</p>
<div id="answer-tabs"> <div id="pk-list" data-pk-list='${json_pk_list}' style="display:none"> </div>
<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(): % for answer, pk_dict in answer_to_hints.items():
<div class = "previous-answer" id="previous-answer-${index}"> % for hint_pk, hint_text in pk_dict.items():
<div class = "hint-inner-container"> <p>
% if index in index_to_hints and len(index_to_hints[index]) > 0: <input class="vote" data-answer="${answer}" data-hintno="${hint_pk}" type="button" value="Vote">
<p> ${hint_text}
Which hint would be most effective to show a student who also got ${answer}? </p>
</p> % endfor
% for hint_text, hint_pk in index_to_hints[index]: % endfor
<p>
<input class="vote" data-answer="${index}" data-hintno="${hint_pk}" type="button" value="Vote"/> <p>
${hint_text} Don't like any of the hints above?
</p> <a class="wizard-link" dest="p2" href="javascript: void(0);">
% endfor Write your own!
<p> </a></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> </div>
<p>Read about <a class="expand-goodhint" href="javascript:void(0);">what makes a good hint</a>.</p> <div class="wizard-view" id="p2">
<div class="goodhint" style="display:none"> % if hints_exist:
<h4>What makes a good hint?</h4> <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
<p>It depends on the type of problem you ran into. For stupid errors -- </div>
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 <div class="wizard-view" id="p3">
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> <p>
Good hints either: Write a hint for other students who get the wrong answer of <span id="blank-answer"></span>.
<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>
<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>
<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>
</%def> </%def>
...@@ -124,6 +167,10 @@ What would you say to help someone who got this wrong answer? ...@@ -124,6 +167,10 @@ What would you say to help someone who got this wrong answer?
${simple_message()} ${simple_message()}
% endif % endif
% if op == "error":
${error}
% endif
% if op == "vote": % if op == "vote":
${show_votes()} ${show_votes()}
% endif % endif
......
""" """
Views for hint management. Views for hint management.
Along with the crowdsource_hinter xmodule, this code is still Get to these views through courseurl/hint_manager.
experimental, and should not be used in new courses, yet. 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 import json
...@@ -15,12 +17,17 @@ from mitxmako.shortcuts import render_to_response, render_to_string ...@@ -15,12 +17,17 @@ from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from courseware.models import XModuleContentField 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 import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
@ensure_csrf_cookie @ensure_csrf_cookie
def hint_manager(request, course_id): def hint_manager(request, course_id):
"""
The URL landing function for all calls to the hint manager, both POST and GET.
"""
try: try:
get_course_with_access(request.user, course_id, 'staff', depth=None) get_course_with_access(request.user, course_id, 'staff', depth=None)
except Http404: except Http404:
...@@ -28,24 +35,29 @@ def hint_manager(request, course_id): ...@@ -28,24 +35,29 @@ def hint_manager(request, course_id):
return HttpResponse(out) return HttpResponse(out)
if request.method == 'GET': if request.method == 'GET':
out = get_hints(request, course_id, 'mod_queue') 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'] field = request.POST['field']
if not (field == 'mod_queue' or field == 'hints'): if not (field == 'mod_queue' or field == 'hints'):
# Invalid field. (Don't let users continue - they may overwrite other db's) # Invalid field. (Don't let users continue - they may overwrite other db's)
out = 'Error in hint manager - an invalid field was accessed.' out = 'Error in hint manager - an invalid field was accessed.'
return HttpResponse(out) return HttpResponse(out)
if request.POST['op'] == 'delete hints': switch_dict = {
delete_hints(request, course_id, field) 'delete hints': delete_hints,
if request.POST['op'] == 'switch fields': 'switch fields': lambda *args: None, # Takes any number of arguments, returns None.
pass 'change votes': change_votes,
if request.POST['op'] == 'change votes': 'add hint': add_hint,
change_votes(request, course_id, field) 'approve': approve,
if request.POST['op'] == 'add hint': }
add_hint(request, course_id, field)
if request.POST['op'] == 'approve': # Do the operation requested, and collect any error messages.
approve(request, course_id, field) error_text = switch_dict[request.POST['op']](request, course_id, field)
rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(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})) return HttpResponse(json.dumps({'success': True, 'contents': rendered_html}))
...@@ -165,7 +177,13 @@ def change_votes(request, course_id, field): ...@@ -165,7 +177,13 @@ def change_votes(request, course_id, field):
Updates the number of votes. Updates the number of votes.
The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples. 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: for key in request.POST:
...@@ -193,6 +211,18 @@ def add_hint(request, course_id, field): ...@@ -193,6 +211,18 @@ def add_hint(request, course_id, field):
problem_id = request.POST['problem'] problem_id = request.POST['problem']
answer = request.POST['answer'] answer = request.POST['answer']
hint_text = request.POST['hint'] 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) 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) hint_pk_entry = XModuleContentField.objects.get(field_name='hint_pk', definition_id=problem_id)
...@@ -214,6 +244,8 @@ def approve(request, course_id, field): ...@@ -214,6 +244,8 @@ def approve(request, course_id, field):
hint list. POST: hint list. POST:
op, field op, field
(some number) -> [problem, answer, pk] (some number) -> [problem, answer, pk]
The numbered fields are analogous to those in `delete_hints` and `change_votes`.
""" """
for key in request.POST: for key in request.POST:
......
...@@ -2,6 +2,7 @@ import json ...@@ -2,6 +2,7 @@ import json
from django.test.client import Client, RequestFactory from django.test.client import Client, RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from mock import patch, MagicMock
from courseware.models import XModuleContentField from courseware.models import XModuleContentField
from courseware.tests.factories import ContentFactory from courseware.tests.factories import ContentFactory
...@@ -137,16 +138,45 @@ class HintManagerTest(ModuleStoreTestCase): ...@@ -137,16 +138,45 @@ class HintManagerTest(ModuleStoreTestCase):
""" """
Check that instructors can add new hints. 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() request = RequestFactory()
post = request.post(self.url, {'field': 'mod_queue', post = request.post(self.url, {'field': 'mod_queue',
'op': 'add hint', 'op': 'add hint',
'problem': self.problem_id, 'problem': self.problem_id,
'answer': '3.14', 'answer': '3.14',
'hint': 'This is a new hint.'}) '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 problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value
self.assertTrue('3.14' in json.loads(problem_hints)) 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): def test_approve(self):
""" """
Check that instructors can approve hints. (Move them Check that instructors can approve hints. (Move them
......
<%inherit file="/main.html" /> <%inherit file="/main.html" />
<%namespace name='static' file='/static_content.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"> <%block name="headextra">
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
<h1> ${field_label} </h1> <h1> ${field_label} </h1>
Switch to <a id="switch-fields" other-field="${other_field}">${other_field_label}</a> 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: % for definition_id in all_hints:
<h2> Problem: ${id_to_name[definition_id]} </h2> <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 ...@@ -36,6 +37,7 @@ Switch to <a id="switch-fields" other-field="${other_field}">${other_field_label
<br /> <br />
% endfor % endfor
<p style="color:red"> ${error} </p>
<button id="hint-delete"> Delete selected </button> <button id="update-votes"> Update votes </button> <button id="hint-delete"> Delete selected </button> <button id="update-votes"> Update votes </button>
% if field == 'mod_queue': % if field == 'mod_queue':
......
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