diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index 43d970d898..6b106dd94d 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -55,6 +55,7 @@ setup(
"word_cloud = xmodule.word_cloud_module:WordCloudDescriptor",
"hidden = xmodule.hidden_module:HiddenDescriptor",
"raw = xmodule.raw_module:RawDescriptor",
+ "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor",
],
'console_scripts': [
'xmodule_assets = xmodule.static_content:main',
diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py
new file mode 100644
index 0000000000..5b9c0a1899
--- /dev/null
+++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py
@@ -0,0 +1,311 @@
+"""
+Adds crowdsourced hinting functionality to lon-capa numerical response problems.
+
+Currently experimental - not for instructor use, yet.
+"""
+
+import logging
+import json
+import random
+
+from pkg_resources import resource_string
+
+from lxml import etree
+
+from xmodule.x_module import XModule
+from xmodule.xml_module import XmlDescriptor
+from xblock.core import Scope, String, Integer, Boolean, Dict, List
+
+from django.utils.html import escape
+
+log = logging.getLogger(__name__)
+
+
+class CrowdsourceHinterFields(object):
+ """Defines fields for the crowdsource hinter module."""
+ has_children = True
+
+ moderate = String(help='String "True"/"False" - activates moderation', scope=Scope.content,
+ default='False')
+ debug = String(help='String "True"/"False" - allows multiple voting', scope=Scope.content,
+ default='False')
+ # Usage: hints[answer] = {str(pk): [hint_text, #votes]}
+ # hints is a dictionary that takes answer keys.
+ # Each value is itself a dictionary, accepting hint_pk strings as keys,
+ # and returning [hint text, #votes] pairs as values
+ hints = Dict(help='A dictionary containing all the active hints.', scope=Scope.content, default={})
+ 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=[])
+ user_voted = Boolean(help='Specifies if the user has voted on this problem or not.',
+ scope=Scope.user_state, default=False)
+
+
+class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
+ """
+ An Xmodule that makes crowdsourced hints.
+ Currently, only works on capa problems with exactly one numerical response,
+ and no other parts.
+
+ Example usage:
+
+
+
+
+ XML attributes:
+ -moderate="True" will not display hints until staff approve them in the hint manager.
+ -debug="True" will let users vote as often as they want.
+ """
+ icon_class = 'crowdsource_hinter'
+ css = {'scss': [resource_string(__name__, 'css/crowdsource_hinter/display.scss')]}
+ js = {'coffee': [resource_string(__name__, 'js/src/crowdsource_hinter/display.coffee')],
+ 'js': []}
+ js_module_name = "Hinter"
+
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
+
+ def get_html(self):
+ """
+ Puts a wrapper around the problem html. This wrapper includes ajax urls of the
+ hinter and of the problem.
+ - Dependent on lon-capa problem.
+ """
+ if self.debug == 'True':
+ # Reset the user vote, for debugging only!
+ self.user_voted = False
+ if self.hints == {}:
+ # Force self.hints to be written into the database. (When an xmodule is initialized,
+ # fields are not added to the db until explicitly changed at least once.)
+ self.hints = {}
+
+ try:
+ child = self.get_display_items()[0]
+ out = child.get_html()
+ # The event listener uses the ajax url to find the child.
+ child_url = child.system.ajax_url
+ except IndexError:
+ out = 'Error in loading crowdsourced hinter - can\'t find child problem.'
+ child_url = ''
+
+ # Wrap the module in a . This lets us pass data attributes to the javascript.
+ out += ''
+
+ return out
+
+ def capa_answer_to_str(self, answer):
+ """
+ Converts capa 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]))
+
+ def handle_ajax(self, dispatch, get):
+ """
+ This is the landing method for AJAX calls.
+ """
+ if dispatch == 'get_hint':
+ out = self.get_hint(get)
+ elif dispatch == 'get_feedback':
+ out = self.get_feedback(get)
+ elif dispatch == 'vote':
+ out = self.tally_vote(get)
+ elif dispatch == 'submit_hint':
+ out = self.submit_hint(get)
+ else:
+ return json.dumps({'contents': 'Error - invalid operation.'})
+
+ if out is None:
+ out = {'op': 'empty'}
+ else:
+ out.update({'op': dispatch})
+ return json.dumps({'contents': self.system.render_template('hinter_display.html', out)})
+
+ def get_hint(self, get):
+ """
+ The student got the incorrect answer found in get. Give him a hint.
+
+ Called by hinter javascript after a problem is graded as incorrect.
+ Args:
+ `get` -- must be interpretable by capa_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 `get`.
+ - 'answer' is the parsed answer that was submitted.
+ """
+ answer = self.capa_answer_to_str(get)
+ # 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):
+ # 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,
+ 'answer': answer}
+
+ def get_feedback(self, get):
+ """
+ The student got it correct. Ask him to vote on hints, or submit a hint.
+
+ Args:
+ `get` -- 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.
+ """
+ # The student got it right.
+ # Did he submit at least one wrong answer?
+ if len(self.previous_answers) == 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 = {}
+
+ # 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 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:
+ try:
+ index_to_hints[i].append((self.hints[answer][str(hint_id)][0], hint_id))
+ 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}
+
+ def tally_vote(self, get):
+ """
+ Tally a user's vote on his favorite hint.
+
+ Args:
+ `get` -- expected to have the following keys:
+ 'answer': ans_no (index in previous_answers)
+ 'hint': hint_pk
+ Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs.
+ """
+ if self.user_voted:
+ return {}
+ ans_no = int(get['answer'])
+ hint_no = str(get['hint'])
+ answer = self.previous_answers[ans_no][0]
+ # 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
+ 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.
+ hint_and_votes = []
+ for hint_no in self.previous_answers[ans_no][1]:
+ if hint_no is None:
+ continue
+ hint_and_votes.append(temp_dict[answer][str(hint_no)])
+
+ # Reset self.previous_answers.
+ self.previous_answers = []
+ return {'hint_and_votes': hint_and_votes}
+
+ def submit_hint(self, get):
+ """
+ Take a hint submission and add it to the database.
+
+ Args:
+ `get` -- expected to have the following keys:
+ 'answer': answer index in previous_answers
+ '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(get['hint'])
+ answer = self.previous_answers[int(get['answer'])][0]
+ # Only allow a student to vote or submit a hint once.
+ if self.user_voted:
+ return {'message': 'Sorry, but you have already voted!'}
+ # Add the new hint to self.hints or self.mod_queue. (Awkward because a direct write
+ # is necessary.)
+ if self.moderate == 'True':
+ temp_dict = self.mod_queue
+ else:
+ temp_dict = self.hints
+ if answer in temp_dict:
+ temp_dict[answer][self.hint_pk] = [hint, 1] # With one vote (the user himself).
+ else:
+ temp_dict[answer] = {self.hint_pk: [hint, 1]}
+ self.hint_pk += 1
+ if self.moderate == 'True':
+ self.mod_queue = temp_dict
+ else:
+ self.hints = temp_dict
+ # Mark the user has having voted; reset previous_answers
+ self.user_voted = True
+ self.previous_answers = []
+ return {'message': 'Thank you for your hint!'}
+
+
+class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor):
+ module_class = CrowdsourceHinterModule
+ stores_state = True
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ children = []
+ for child in xml_object:
+ try:
+ children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url())
+ except Exception as e:
+ log.exception("Unable to load child when parsing CrowdsourceHinter. Continuing...")
+ if system.error_tracker is not None:
+ system.error_tracker("ERROR: " + str(e))
+ continue
+ return {}, children
+
+ def definition_to_xml(self, resource_fs):
+ xml_object = etree.Element('crowdsource_hinter')
+ for child in self.get_children():
+ xml_object.append(
+ etree.fromstring(child.export_to_xml(resource_fs)))
+ return xml_object
diff --git a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss
new file mode 100644
index 0000000000..fac808cfcb
--- /dev/null
+++ b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss
@@ -0,0 +1,65 @@
+.crowdsource-wrapper {
+ @include box-shadow(inset 0 1px 2px 1px rgba(0,0,0,0.1));
+ @include border-radius(2px);
+ display: none;
+ margin-top: 20px;
+ padding: (15px);
+ 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;
+ font-size: 16px;
+}
+
+.vote {
+ padding-top: 0px !important;
+ padding-bottom: 0px !important;
+}
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index 1f3be9e5e9..4640f7555d 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -223,6 +223,7 @@ class @Problem
@el.removeClass 'showed'
else
@gentle_alert response.success
+ Logger.log 'problem_graded', [@answers, response.contents], @url
reset: =>
Logger.log 'problem_reset', @answers
diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee
new file mode 100644
index 0000000000..72522f5b03
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee
@@ -0,0 +1,76 @@
+class @Hinter
+ # The client side code for the crowdsource_hinter.
+ # Contains code for capturing problem checks and making ajax calls to
+ # the server component. Also contains styling code to clear default
+ # text on a textarea.
+
+ constructor: (element) ->
+ @el = $(element).find('.crowdsource-wrapper')
+ @url = @el.data('url')
+ Logger.listen('problem_graded', @el.data('child-url'), @capture_problem)
+ @render()
+
+ capture_problem: (event_type, data, element) =>
+ # After a problem gets graded, we get the info here.
+ # We want to send this info to the server in another AJAX
+ # request.
+ answers = data[0]
+ response = data[1]
+ if response.search(/class="correct/) == -1
+ # Incorrect. Get hints.
+ $.postWithPrefix "#{@url}/get_hint", answers, (response) =>
+ @render(response.contents)
+ else
+ # Correct. Get feedback from students.
+ $.postWithPrefix "#{@url}/get_feedback", answers, (response) =>
+ @render(response.contents)
+
+ $: (selector) ->
+ $(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_goodhint: =>
+ if @$('.goodhint').css('display') == 'none'
+ @$('.goodhint').css('display', 'block')
+ else
+ @$('.goodhint').css('display', 'none')
+
+ vote: (eventObj) =>
+ target = @$(eventObj.currentTarget)
+ post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')}
+ $.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()}
+ $.postWithPrefix "#{@url}/submit_hint",post_json, (response) =>
+ @render(response.contents)
+
+ clear_default_text: (eventObj) =>
+ target = @$(eventObj.currentTarget)
+ if target.data('cleared') == undefined
+ target.val('')
+ target.data('cleared', true)
+
+ render: (content) ->
+ if content
+ # Trim leading and trailing whitespace
+ content = content.replace /^\s+|\s+$/g, ""
+
+ if content
+ @el.html(content)
+ @el.show()
+ JavascriptLoader.executeModuleScripts @el, () =>
+ @bind()
+ @$('#previous-answer-0').css('display', 'inline')
+ else
+ @el.hide()
diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py
new file mode 100644
index 0000000000..f57e28ef46
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py
@@ -0,0 +1,439 @@
+"""
+Tests the crowdsourced hinter xmodule.
+"""
+
+from mock import Mock
+import unittest
+import copy
+
+from xmodule.crowdsource_hinter import CrowdsourceHinterModule
+from xmodule.vertical_module import VerticalModule, VerticalDescriptor
+
+from . import get_test_system
+
+import json
+
+
+class CHModuleFactory(object):
+ """
+ Helps us make a CrowdsourceHinterModule with the specified internal
+ state.
+ """
+
+ sample_problem_xml = """
+
+
+
+
A numerical input problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.
+
The answer is correct if it is within a specified numerical tolerance of the expected answer.
+
Enter the number of fingers on a human hand:
+
+
+
+
+
+
Explanation
+
If you look at your hand, you can count that you have five fingers.
+
+
+
+
+ """
+
+ num = 0
+
+ @staticmethod
+ def next_num():
+ """
+ Helps make unique names for our mock CrowdsourceHinterModule's
+ """
+ CHModuleFactory.num += 1
+ return CHModuleFactory.num
+
+ @staticmethod
+ def create(hints=None,
+ previous_answers=None,
+ user_voted=None,
+ moderate=None,
+ mod_queue=None):
+ """
+ A factory method for making CHM's
+ """
+ model_data = {'data': CHModuleFactory.sample_problem_xml}
+
+ if hints is not None:
+ model_data['hints'] = hints
+ else:
+ model_data['hints'] = {
+ '24.0': {'0': ['Best hint', 40],
+ '3': ['Another hint', 30],
+ '4': ['A third hint', 20],
+ '6': ['A less popular hint', 3]},
+ '25.0': {'1': ['Really popular hint', 100]}
+ }
+
+ if mod_queue is not None:
+ model_data['mod_queue'] = mod_queue
+ else:
+ model_data['mod_queue'] = {
+ '24.0': {'2': ['A non-approved hint']},
+ '26.0': {'5': ['Another non-approved hint']}
+ }
+
+ if previous_answers is not None:
+ model_data['previous_answers'] = previous_answers
+ else:
+ model_data['previous_answers'] = [
+ ['24.0', [0, 3, 4]],
+ ['29.0', [None, None, None]]
+ ]
+
+ 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")
+ system = get_test_system()
+ module = CrowdsourceHinterModule(system, descriptor, model_data)
+
+ return module
+
+
+class VerticalWithModulesFactory(object):
+ """
+ Makes a vertical with several crowdsourced hinter modules inside.
+ Used to make sure that several crowdsourced hinter modules can co-exist
+ on one vertical.
+ """
+
+ sample_problem_xml = """
+
+
+
+
Test numerical problem.
+
+
+
+
+
+
Explanation
+
If you look at your hand, you can count that you have five fingers.
+
+
+
+
+
+
+
+
Another test numerical problem.
+
+
+
+
+
+
Explanation
+
If you look at your hand, you can count that you have five fingers.
+
+
+
+
+
+ """
+
+ num = 0
+
+ @staticmethod
+ def next_num():
+ CHModuleFactory.num += 1
+ return CHModuleFactory.num
+
+ @staticmethod
+ def create():
+ model_data = {'data': VerticalWithModulesFactory.sample_problem_xml}
+ system = get_test_system()
+ descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system)
+ module = VerticalModule(system, descriptor, model_data)
+
+ return module
+
+
+class FakeChild(object):
+ """
+ A fake Xmodule.
+ """
+ def __init__(self):
+ self.system = Mock()
+ self.system.ajax_url = 'this/is/a/fake/ajax/url'
+
+ def get_html(self):
+ """
+ Return a fake html string.
+ """
+ return 'This is supposed to be test html.'
+
+
+class CrowdsourceHinterTest(unittest.TestCase):
+ """
+ In the below tests, '24.0' represents a wrong answer, and '42.5' represents
+ a correct answer.
+ """
+
+ def test_gethtml(self):
+ """
+ A simple test of get_html - make sure it returns the html of the inner
+ problem.
+ """
+ m = CHModuleFactory.create()
+
+ def fake_get_display_items():
+ """
+ A mock of get_display_items
+ """
+ return [FakeChild()]
+ m.get_display_items = fake_get_display_items
+ out_html = m.get_html()
+ self.assertTrue('This is supposed to be test html.' in out_html)
+ self.assertTrue('this/is/a/fake/ajax/url' in out_html)
+
+ def test_gethtml_nochild(self):
+ """
+ get_html, except the module has no child :( Should return a polite
+ error message.
+ """
+ m = CHModuleFactory.create()
+
+ def fake_get_display_items():
+ """
+ Returns no children.
+ """
+ return []
+ m.get_display_items = fake_get_display_items
+ out_html = m.get_html()
+ self.assertTrue('Error in loading crowdsourced hinter' in out_html)
+
+ @unittest.skip("Needs to be finished.")
+ def test_gethtml_multiple(self):
+ """
+ Makes sure that multiple crowdsourced hinters play nice, when get_html
+ is called.
+ NOT WORKING RIGHT NOW
+ """
+ m = VerticalWithModulesFactory.create()
+ out_html = m.get_html()
+ print out_html
+ self.assertTrue('Test numerical problem.' in out_html)
+ self.assertTrue('Another test numerical problem.' in out_html)
+
+ def test_gethint_0hint(self):
+ """
+ Someone asks for a hint, when there's no hint to give.
+ - Output should be blank.
+ - New entry should be added to previous_answers
+ """
+ m = CHModuleFactory.create()
+ json_in = {'problem_name': '26.0'}
+ out = m.get_hint(json_in)
+ self.assertTrue(out is None)
+ self.assertTrue(['26.0', [None, None, None]] in m.previous_answers)
+
+ def test_gethint_1hint(self):
+ """
+ Someone asks for a hint, with exactly one hint in the database.
+ Output should contain that hint.
+ """
+ m = CHModuleFactory.create()
+ json_in = {'problem_name': '25.0'}
+ out = m.get_hint(json_in)
+ self.assertTrue(out['best_hint'] == 'Really popular hint')
+
+ def test_gethint_manyhints(self):
+ """
+ Someone asks for a hint, with many matching hints in the database.
+ - The top-rated hint should be returned.
+ - Two other random hints should be returned.
+ Currently, the best hint could be returned twice - need to fix this
+ in implementation.
+ """
+ m = CHModuleFactory.create()
+ json_in = {'problem_name': '24.0'}
+ out = m.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)
+
+ def test_getfeedback_0wronganswers(self):
+ """
+ Someone has gotten the problem correct on the first try.
+ Output should be empty.
+ """
+ m = CHModuleFactory.create(previous_answers=[])
+ json_in = {'problem_name': '42.5'}
+ out = m.get_feedback(json_in)
+ self.assertTrue(out is None)
+
+ def test_getfeedback_1wronganswer_nohints(self):
+ """
+ Someone has gotten the problem correct, with one previous wrong
+ answer. However, we don't actually have hints for this problem.
+ There should be a dialog to submit a new hint.
+ """
+ m = CHModuleFactory.create(previous_answers=[['26.0', [None, None, None]]])
+ json_in = {'problem_name': '42.5'}
+ out = m.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')
+
+ def test_getfeedback_1wronganswer_withhints(self):
+ """
+ Same as above, except the user did see hints. There should be
+ a voting dialog, with the correct choices, plus a hint submission
+ dialog.
+ """
+ m = CHModuleFactory.create(previous_answers=[['24.0', [0, 3, None]]])
+ json_in = {'problem_name': '42.5'}
+ out = m.get_feedback(json_in)
+ print out['index_to_hints']
+ self.assertTrue(len(out['index_to_hints'][0]) == 2)
+
+ def test_getfeedback_missingkey(self):
+ """
+ Someone gets a problem correct, but one of the hints that he saw
+ earlier (pk=100) has been deleted. Should just skip that hint.
+ """
+ m = CHModuleFactory.create(
+ previous_answers=[['24.0', [0, 100, None]]])
+ json_in = {'problem_name': '42.5'}
+ out = m.get_feedback(json_in)
+ self.assertTrue(len(out['index_to_hints'][0]) == 1)
+
+ def test_vote_nopermission(self):
+ """
+ A user tries to vote for a hint, but he has already voted!
+ Should not change any vote tallies.
+ """
+ m = CHModuleFactory.create(user_voted=True)
+ json_in = {'answer': 0, 'hint': 1}
+ old_hints = copy.deepcopy(m.hints)
+ m.tally_vote(json_in)
+ self.assertTrue(m.hints == old_hints)
+
+ def test_vote_withpermission(self):
+ """
+ A user votes for a hint.
+ Also tests vote result rendering.
+ """
+ m = CHModuleFactory.create(
+ previous_answers=[['24.0', [0, 3, None]]])
+ json_in = {'answer': 0, 'hint': 3}
+ dict_out = m.tally_vote(json_in)
+ self.assertTrue(m.hints['24.0']['0'][1] == 40)
+ self.assertTrue(m.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_submithint_nopermission(self):
+ """
+ A user tries to submit a hint, but he has already voted.
+ """
+ m = CHModuleFactory.create(user_voted=True)
+ json_in = {'answer': 1, 'hint': 'This is a new hint.'}
+ print m.user_voted
+ m.submit_hint(json_in)
+ print m.hints
+ self.assertTrue('29.0' not in m.hints)
+
+ def test_submithint_withpermission_new(self):
+ """
+ A user submits a hint to an answer for which no hints
+ exist yet.
+ """
+ m = CHModuleFactory.create()
+ json_in = {'answer': 1, 'hint': 'This is a new hint.'}
+ m.submit_hint(json_in)
+ self.assertTrue('29.0' in m.hints)
+
+ def test_submithint_withpermission_existing(self):
+ """
+ A user submits a hint to an answer that has other hints
+ already.
+ """
+ m = CHModuleFactory.create(previous_answers=[['25.0', [1, None, None]]])
+ json_in = {'answer': 0, 'hint': 'This is a new hint.'}
+ m.submit_hint(json_in)
+ # Make a hint request.
+ json_in = {'problem name': '25.0'}
+ out = m.get_hint(json_in)
+ self.assertTrue((out['best_hint'] == 'This is a new hint.')
+ or (out['rand_hint_1'] == 'This is a new hint.'))
+
+ def test_submithint_moderate(self):
+ """
+ A user submits a hint, but moderation is on. The hint should
+ show up in the mod_queue, not the public-facing hints
+ dict.
+ """
+ m = CHModuleFactory.create(moderate='True')
+ json_in = {'answer': 1, 'hint': 'This is a new hint.'}
+ m.submit_hint(json_in)
+ self.assertTrue('29.0' not in m.hints)
+ self.assertTrue('29.0' in m.mod_queue)
+
+ def test_submithint_escape(self):
+ """
+ Make sure that hints are being html-escaped.
+ """
+ m = CHModuleFactory.create()
+ json_in = {'answer': 1, 'hint': ''}
+ m.submit_hint(json_in)
+ print m.hints
+ self.assertTrue(m.hints['29.0'][0][0] == u'<script> alert("Trololo"); </script>')
+
+ def test_template_gethint(self):
+ """
+ Test the templates for get_hint.
+ """
+ m = CHModuleFactory.create()
+
+ def fake_get_hint(get):
+ """
+ Creates a rendering dictionary, with which we can test
+ the templates.
+ """
+ return {'best_hint': 'This is the best hint.',
+ 'rand_hint_1': 'A random hint',
+ 'rand_hint_2': 'Another random hint',
+ 'answer': '42.5'}
+
+ m.get_hint = fake_get_hint
+ json_in = {'problem_name': '42.5'}
+ out = json.loads(m.handle_ajax('get_hint', json_in))['contents']
+ self.assertTrue('This is the best hint.' in out)
+ self.assertTrue('A random hint' in out)
+ self.assertTrue('Another random hint' in out)
+
+ def test_template_feedback(self):
+ """
+ Test the templates for get_feedback.
+ NOT FINISHED
+
+ from lxml import etree
+ m = CHModuleFactory.create()
+
+ def fake_get_feedback(get):
+ index_to_answer = {'0': '42.0', '1': '9000.01'}
+ index_to_hints = {'0': [('A hint for 42', 12),
+ ('Another hint for 42', 14)],
+ '1': [('A hint for 9000.01', 32)]}
+ return {'index_to_hints': index_to_hints, 'index_to_answer': index_to_answer}
+
+ m.get_feedback = fake_get_feedback
+ json_in = {'problem_name': '42.5'}
+ out = json.loads(m.handle_ajax('get_feedback', json_in))['contents']
+ html_tree = etree.XML(out)
+ # To be continued...
+
+ """
+ pass
diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee
index f2dfef5132..6eaa497255 100644
--- a/common/static/coffee/src/logger.coffee
+++ b/common/static/coffee/src/logger.coffee
@@ -1,8 +1,11 @@
class @Logger
+
# events we want sent to Segment.io for tracking
SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"]
- @log: (event_type, data) ->
+ # listeners[event_type][element] -> list of callbacks
+ listeners = {}
+ @log: (event_type, data, element = null) ->
# Segment.io event tracking
if event_type in SEGMENT_IO_WHITELIST
# to avoid changing the format of data sent to our servers, we only massage it here
@@ -11,11 +14,36 @@ class @Logger
else
analytics.track event_type, data
+ # Check to see if we're listening for the event type.
+ if event_type of listeners
+ # Cool. Do the elements also match?
+ # null element in the listener dictionary means any element will do.
+ # null element in the @log call means we don't know the element name.
+ if null of listeners[event_type]
+ # Make the callbacks.
+ for callback in listeners[event_type][null]
+ callback(event_type, data, element)
+ else if element of listeners[event_type]
+ for callback in listeners[event_type][element]
+ callback(event_type, data, element)
+
+ # Regardless of whether any callbacks were made, log this event.
$.getWithPrefix '/event',
event_type: event_type
event: JSON.stringify(data)
page: window.location.href
+ @listen: (event_type, element, callback) ->
+ # Add a listener. If you want any element to trigger this listener,
+ # do element = null
+ if event_type not of listeners
+ listeners[event_type] = {}
+ if element not of listeners[event_type]
+ listeners[event_type][element] = [callback]
+ else
+ listeners[event_type][element].push callback
+
+
@bind: ->
window.onunload = ->
$.ajaxWithPrefix
diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html
new file mode 100644
index 0000000000..6f5d6f37fb
--- /dev/null
+++ b/common/templates/hinter_display.html
@@ -0,0 +1,130 @@
+## The hinter module passes in a field called ${op}, which determines which
+## sub-function to render.
+
+
+<%def name="get_hint()">
+ % if best_hint != '':
+
Hints from students who made similar mistakes:
+
+
${best_hint}
+ % endif
+ % if rand_hint_1 != '':
+
${rand_hint_1}
+ % endif
+ % if rand_hint_2 != '':
+
${rand_hint_2}
+ % endif
+
+%def>
+
+<%def name="get_feedback()">
+
Participation in the hinting system is strictly optional, and will not influence your grade.
+
+ Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below:
+
+
+
+
+ % for index, answer in index_to_answer.items():
+
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.
+
+
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
+ cognitive dissonace
+ is ideal, although in most cases, not possible.
+
+
+ Good hints either:
+
+
Point out the specific misunderstanding your classmate might have
+
Point to concepts or theories where your classmates might have a
+ misunderstanding
+
Show simpler, analogous examples.
+
Provide references to relevant parts of the text
+
+
+
+
Still, remember even a crude hint -- virtually anything short of
+ giving away the answer -- is better than no hint.
+
+%def>
+
+<%def name="show_votes()">
+ % if hint_and_votes is UNDEFINED:
+ Sorry, but you've already voted!
+ % else:
+ Thank you for voting!
+
+ % for hint, votes in hint_and_votes:
+ ${votes} votes.
+ ${hint}
+
+ % endfor
+ % endif
+%def>
+
+<%def name="simple_message()">
+ ${message}
+%def>
+
+% if op == "get_hint":
+ ${get_hint()}
+% endif
+
+% if op == "get_feedback":
+ ${get_feedback()}
+% endif
+
+% if op == "submit_hint":
+ ${simple_message()}
+% endif
+
+% if op == "vote":
+ ${show_votes()}
+% endif
+
diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py
new file mode 100644
index 0000000000..73c4ba220f
--- /dev/null
+++ b/lms/djangoapps/instructor/hint_manager.py
@@ -0,0 +1,238 @@
+"""
+Views for hint management.
+
+Along with the crowdsource_hinter xmodule, this code is still
+experimental, and should not be used in new courses, yet.
+"""
+
+import json
+import re
+
+from django.http import HttpResponse, Http404
+from django_future.csrf import ensure_csrf_cookie
+
+from mitxmako.shortcuts import render_to_response, render_to_string
+
+from courseware.courses import get_course_with_access
+from courseware.models import XModuleContentField
+from xmodule.modulestore import Location
+from xmodule.modulestore.django import modulestore
+
+
+@ensure_csrf_cookie
+def hint_manager(request, course_id):
+ try:
+ get_course_with_access(request.user, course_id, 'staff', depth=None)
+ except Http404:
+ out = 'Sorry, but students are not allowed to access the hint manager!'
+ return HttpResponse(out)
+ if request.method == 'GET':
+ out = get_hints(request, course_id, 'mod_queue')
+ return render_to_response('courseware/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))
+ return HttpResponse(json.dumps({'success': True, 'contents': rendered_html}))
+
+
+def get_hints(request, course_id, field):
+ """
+ Load all of the hints submitted to the course.
+
+ Args:
+ `request` -- Django request object.
+ `course_id` -- The course id, like 'Me/19.002/test_course'
+ `field` -- Either 'hints' or 'mod_queue'; specifies which set of hints to load.
+
+ Keys in returned dict:
+ - 'field': Same as input
+ - 'other_field': 'mod_queue' if `field` == 'hints'; and vice-versa.
+ - 'field_label', 'other_field_label': English name for the above.
+ - 'all_hints': A list of [answer, pk dict] pairs, representing all hints.
+ Sorted by answer.
+ - 'id_to_name': A dictionary mapping problem id to problem name.
+ """
+ if field == 'mod_queue':
+ other_field = 'hints'
+ field_label = 'Hints Awaiting Moderation'
+ other_field_label = 'Approved Hints'
+ elif field == 'hints':
+ other_field = 'mod_queue'
+ field_label = 'Approved Hints'
+ other_field_label = 'Hints Awaiting Moderation'
+ # The course_id is of the form school/number/classname.
+ # We want to use the course_id to find all matching definition_id's.
+ # To do this, just take the school/number part - leave off the classname.
+ chopped_id = '/'.join(course_id.split('/')[:-1])
+ chopped_id = re.escape(chopped_id)
+ all_hints = XModuleContentField.objects.filter(field_name=field, definition_id__regex=chopped_id)
+ # big_out_dict[problem id] = [[answer, {pk: [hint, votes]}], sorted by answer]
+ # big_out_dict maps a problem id to a list of [answer, hints] pairs, sorted in order of answer.
+ big_out_dict = {}
+ # id_to name maps a problem id to the name of the problem.
+ # id_to_name[problem id] = Display name of problem
+ id_to_name = {}
+
+ for hints_by_problem in all_hints:
+ loc = Location(hints_by_problem.definition_id)
+ name = location_to_problem_name(loc)
+ if name is None:
+ continue
+ id_to_name[hints_by_problem.definition_id] = name
+
+ def answer_sorter(thing):
+ """
+ `thing` is a tuple, where `thing[0]` contains an answer, and `thing[1]` contains
+ a dict of hints. This function returns an index based on `thing[0]`, which
+ is used as a key to sort the list of things.
+ """
+ try:
+ return float(thing[0])
+ except ValueError:
+ # Put all non-numerical answers first.
+ return float('-inf')
+
+ # Answer list contains [answer, dict_of_hints] pairs.
+ answer_list = sorted(json.loads(hints_by_problem.value).items(), key=answer_sorter)
+ big_out_dict[hints_by_problem.definition_id] = answer_list
+
+ render_dict = {'field': field,
+ 'other_field': other_field,
+ 'field_label': field_label,
+ 'other_field_label': other_field_label,
+ 'all_hints': big_out_dict,
+ 'id_to_name': id_to_name}
+ return render_dict
+
+
+def location_to_problem_name(loc):
+ """
+ Given the location of a crowdsource_hinter module, try to return the name of the
+ problem it wraps around. Return None if the hinter no longer exists.
+ """
+ try:
+ descriptor = modulestore().get_items(loc)[0]
+ return descriptor.get_children()[0].display_name
+ except IndexError:
+ # Sometimes, the problem is no longer in the course. Just
+ # don't include said problem.
+ return None
+
+
+def delete_hints(request, course_id, field):
+ """
+ Deletes the hints specified.
+
+ `request.POST` contains some fields keyed by integers. Each such field contains a
+ [problem_defn_id, answer, pk] tuple. These tuples specify the hints to be deleted.
+
+ Example `request.POST`:
+ {'op': 'delete_hints',
+ 'field': 'mod_queue',
+ 1: ['problem_whatever', '42.0', '3'],
+ 2: ['problem_whatever', '32.5', '12']}
+ """
+
+ for key in request.POST:
+ if key == 'op' or key == 'field':
+ continue
+ problem_id, answer, pk = request.POST.getlist(key)
+ # Can be optimized - sort the delete list by problem_id, and load each problem
+ # from the database only once.
+ this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
+ problem_dict = json.loads(this_problem.value)
+ del problem_dict[answer][pk]
+ this_problem.value = json.dumps(problem_dict)
+ this_problem.save()
+
+
+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.
+ """
+
+ for key in request.POST:
+ if key == 'op' or key == 'field':
+ continue
+ problem_id, answer, pk, new_votes = request.POST.getlist(key)
+ this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
+ problem_dict = json.loads(this_problem.value)
+ # problem_dict[answer][pk] points to a [hint_text, #votes] pair.
+ problem_dict[answer][pk][1] = int(new_votes)
+ this_problem.value = json.dumps(problem_dict)
+ this_problem.save()
+
+
+def add_hint(request, course_id, field):
+ """
+ Add a new hint. `request.POST`:
+ op
+ field
+ problem - The problem id
+ answer - The answer to which a hint will be added
+ hint - The text of the hint
+ """
+
+ problem_id = request.POST['problem']
+ answer = request.POST['answer']
+ hint_text = request.POST['hint']
+ 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)
+ this_pk = int(hint_pk_entry.value)
+ hint_pk_entry.value = this_pk + 1
+ hint_pk_entry.save()
+
+ problem_dict = json.loads(this_problem.value)
+ if answer not in problem_dict:
+ problem_dict[answer] = {}
+ problem_dict[answer][this_pk] = [hint_text, 1]
+ this_problem.value = json.dumps(problem_dict)
+ this_problem.save()
+
+
+def approve(request, course_id, field):
+ """
+ Approve a list of hints, moving them from the mod_queue to the real
+ hint list. POST:
+ op, field
+ (some number) -> [problem, answer, pk]
+ """
+
+ for key in request.POST:
+ if key == 'op' or key == 'field':
+ continue
+ problem_id, answer, pk = request.POST.getlist(key)
+ # Can be optimized - sort the delete list by problem_id, and load each problem
+ # from the database only once.
+ problem_in_mod = XModuleContentField.objects.get(field_name=field, definition_id=problem_id)
+ problem_dict = json.loads(problem_in_mod.value)
+ hint_to_move = problem_dict[answer][pk]
+ del problem_dict[answer][pk]
+ problem_in_mod.value = json.dumps(problem_dict)
+ problem_in_mod.save()
+
+ problem_in_hints = XModuleContentField.objects.get(field_name='hints', definition_id=problem_id)
+ problem_dict = json.loads(problem_in_hints.value)
+ if answer not in problem_dict:
+ problem_dict[answer] = {}
+ problem_dict[answer][pk] = hint_to_move
+ problem_in_hints.value = json.dumps(problem_dict)
+ problem_in_hints.save()
diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py
new file mode 100644
index 0000000000..8f12572875
--- /dev/null
+++ b/lms/djangoapps/instructor/tests/test_hint_manager.py
@@ -0,0 +1,164 @@
+import json
+
+from django.test.client import Client, RequestFactory
+from django.test.utils import override_settings
+
+from courseware.models import XModuleContentField
+from courseware.tests.factories import ContentFactory
+from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
+import instructor.hint_manager as view
+from student.tests.factories import UserFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+
+
+@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
+class HintManagerTest(ModuleStoreTestCase):
+
+ def setUp(self):
+ """
+ Makes a course, which will be the same for all tests.
+ Set up mako middleware, which is necessary for template rendering to happen.
+ """
+ self.course = CourseFactory.create(org='Me', number='19.002', display_name='test_course')
+ self.url = '/courses/Me/19.002/test_course/hint_manager'
+ self.user = UserFactory.create(username='robot', email='robot@edx.org', password='test', is_staff=True)
+ self.c = Client()
+ self.c.login(username='robot', password='test')
+ self.problem_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001'
+ self.course_id = 'Me/19.002/test_course'
+ ContentFactory.create(field_name='hints',
+ definition_id=self.problem_id,
+ value=json.dumps({'1.0': {'1': ['Hint 1', 2],
+ '3': ['Hint 3', 12]},
+ '2.0': {'4': ['Hint 4', 3]}
+ }))
+ ContentFactory.create(field_name='mod_queue',
+ definition_id=self.problem_id,
+ value=json.dumps({'2.0': {'2': ['Hint 2', 1]}}))
+
+ ContentFactory.create(field_name='hint_pk',
+ definition_id=self.problem_id,
+ value=5)
+ # Mock out location_to_problem_name, which ordinarily accesses the modulestore.
+ # (I can't figure out how to get fake structures into the modulestore.)
+ view.location_to_problem_name = lambda loc: "Test problem"
+
+ def test_student_block(self):
+ """
+ Makes sure that students cannot see the hint management view.
+ """
+ c = Client()
+ UserFactory.create(username='student', email='student@edx.org', password='test')
+ c.login(username='student', password='test')
+ out = c.get(self.url)
+ print out
+ self.assertTrue('Sorry, but students are not allowed to access the hint manager!' in out.content)
+
+ def test_staff_access(self):
+ """
+ Makes sure that staff can access the hint management view.
+ """
+ out = self.c.get('/courses/Me/19.002/test_course/hint_manager')
+ print out
+ self.assertTrue('Hints Awaiting Moderation' in out.content)
+
+ def test_invalid_field_access(self):
+ """
+ Makes sure that field names other than 'mod_queue' and 'hints' are
+ rejected.
+ """
+ out = self.c.post(self.url, {'op': 'delete hints', 'field': 'all your private data'})
+ print out
+ self.assertTrue('an invalid field was accessed' in out.content)
+
+ def test_switchfields(self):
+ """
+ Checks that the op: 'switch fields' POST request works.
+ """
+ out = self.c.post(self.url, {'op': 'switch fields', 'field': 'mod_queue'})
+ print out
+ self.assertTrue('Hint 2' in out.content)
+
+ def test_gethints(self):
+ """
+ Checks that gethints returns the right data.
+ """
+ request = RequestFactory()
+ post = request.post(self.url, {'field': 'mod_queue'})
+ out = view.get_hints(post, self.course_id, 'mod_queue')
+ print out
+ self.assertTrue(out['other_field'] == 'hints')
+ expected = {self.problem_id: [(u'2.0', {u'2': [u'Hint 2', 1]})]}
+ self.assertTrue(out['all_hints'] == expected)
+
+ def test_gethints_other(self):
+ """
+ Same as above, with hints instead of mod_queue
+ """
+ request = RequestFactory()
+ post = request.post(self.url, {'field': 'hints'})
+ out = view.get_hints(post, self.course_id, 'hints')
+ print out
+ self.assertTrue(out['other_field'] == 'mod_queue')
+ expected = {self.problem_id: [('1.0', {'1': ['Hint 1', 2],
+ '3': ['Hint 3', 12]}),
+ ('2.0', {'4': ['Hint 4', 3]})
+ ]}
+ self.assertTrue(out['all_hints'] == expected)
+
+ def test_deletehints(self):
+ """
+ Checks that delete_hints deletes the right stuff.
+ """
+ request = RequestFactory()
+ post = request.post(self.url, {'field': 'hints',
+ 'op': 'delete hints',
+ 1: [self.problem_id, '1.0', '1']})
+ view.delete_hints(post, self.course_id, 'hints')
+ problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value
+ self.assertTrue('1' not in json.loads(problem_hints)['1.0'])
+
+ def test_changevotes(self):
+ """
+ Checks that vote changing works.
+ """
+ request = RequestFactory()
+ post = request.post(self.url, {'field': 'hints',
+ 'op': 'change votes',
+ 1: [self.problem_id, '1.0', '1', 5]})
+ view.change_votes(post, self.course_id, 'hints')
+ problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value
+ # hints[answer][hint_pk (string)] = [hint text, vote count]
+ print json.loads(problem_hints)['1.0']['1']
+ self.assertTrue(json.loads(problem_hints)['1.0']['1'][1] == 5)
+
+ def test_addhint(self):
+ """
+ Check that instructors can add new hints.
+ """
+ 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')
+ 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_approve(self):
+ """
+ Check that instructors can approve hints. (Move them
+ from the mod_queue to the hints.)
+ """
+ request = RequestFactory()
+ post = request.post(self.url, {'field': 'mod_queue',
+ 'op': 'approve',
+ 1: [self.problem_id, '2.0', '2']})
+ view.approve(post, self.course_id, 'mod_queue')
+ problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value
+ self.assertTrue('2.0' not in json.loads(problem_hints) or len(json.loads(problem_hints)['2.0']) == 0)
+ problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value
+ self.assertTrue(json.loads(problem_hints)['2.0']['2'] == ['Hint 2', 1])
+ self.assertTrue(len(json.loads(problem_hints)['2.0']) == 2)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 141bc127be..8b2a1f28cf 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -141,6 +141,9 @@ MITX_FEATURES = {
# Enable instructor dash to submit background tasks
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
+
+ # Allow use of the hint managment instructor view.
+ 'ENABLE_HINTER_INSTRUCTOR_VIEW': False,
}
# Used for A/B testing
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 813f9cf32c..2ceebf39b8 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
+MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
WIKI_ENABLED = True
diff --git a/lms/envs/test.py b/lms/envs/test.py
index e9b683487e..d335fcd600 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -27,6 +27,8 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
+MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
+
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
diff --git a/lms/templates/courseware/hint_manager.html b/lms/templates/courseware/hint_manager.html
new file mode 100644
index 0000000000..ebd7091a09
--- /dev/null
+++ b/lms/templates/courseware/hint_manager.html
@@ -0,0 +1,124 @@
+<%inherit file="/main.html" />
+<%namespace name='static' file='/static_content.html'/>
+<%namespace name="content" file="/courseware/hint_manager_inner.html"/>
+
+
+<%block name="headextra">
+ <%static:css group='course'/>
+
+
+
+
+
+
+
+
+%block>
+
+
+