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:

+ + + +<%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(): +
+
+ % if index in index_to_hints and len(index_to_hints[index]) > 0: +

+ Which hint would be most effective to show a student who also got ${answer}? +

+ % for hint_text, hint_pk in index_to_hints[index]: +

+ + ${hint_text} +

+ % endfor +

+ Don't like any of the hints above? You can also submit your own. +

+ % endif +

+ What hint would you give a student who made the same mistake you did? Please don't give away the answer. +

+ +

+ +
+ % endfor +
+ +

Read about what makes a good hint.

+ + + + +<%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 name="simple_message()"> + ${message} + + +% 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'/> + + + + + + + + + + +
+
+ +
+ ${content.main()} +
+ +
+
diff --git a/lms/templates/courseware/hint_manager_inner.html b/lms/templates/courseware/hint_manager_inner.html new file mode 100644 index 0000000000..c69539522f --- /dev/null +++ b/lms/templates/courseware/hint_manager_inner.html @@ -0,0 +1,45 @@ +<%block name="main"> + + +

${field_label}

+Switch to ${other_field_label} + + +% for definition_id in all_hints: +

Problem: ${id_to_name[definition_id]}

+ % for answer, hint_dict in all_hints[definition_id]: + % if len(hint_dict) > 0: +

Answer: ${answer}

+ % endif + % for pk, hint in hint_dict.items(): +

+ ${hint[0]} +
+ Votes: +

+

+ % endfor + % if len(hint_dict) > 0: +

+ % endif + % endfor + +

Add a hint to this problem

+

Answer:

+ + (Be sure to format your answer in the same way as the other answers you see here.) +
+ Hint:
+ +
+ +
+% endfor + + + +% if field == 'mod_queue': + +% endif + + \ No newline at end of file diff --git a/lms/urls.py b/lms/urls.py index 52a7d99aaf..fe9882b180 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -223,27 +223,27 @@ if settings.COURSEWARE_ENABLED: 'courseware.views.course_info', name="info"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/syllabus$', 'courseware.views.syllabus', name="syllabus"), # TODO arjun remove when custom tabs in place, see courseware/courses.py - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book/(?P[^/]*)/$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book/(?P\d+)/$', 'staticbook.views.index', name="book"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book/(?P[^/]*)/(?P[^/]*)$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book/(?P\d+)/(?P\d+)$', 'staticbook.views.index'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book-shifted/(?P[^/]*)$', 'staticbook.views.index_shifted'), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P[^/]*)/$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P\d+)/$', + 'staticbook.views.pdf_index', name="pdf_book"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P\d+)/(?P\d+)$', 'staticbook.views.pdf_index', name="pdf_book"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P[^/]*)/(?P[^/]*)$', - 'staticbook.views.pdf_index'), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P[^/]*)/chapter/(?P[^/]*)/$', - 'staticbook.views.pdf_index'), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P[^/]*)/chapter/(?P[^/]*)/(?P[^/]*)$', - 'staticbook.views.pdf_index'), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P\d+)/chapter/(?P\d+)/$', + 'staticbook.views.pdf_index', name="pdf_book"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/pdfbook/(?P\d+)/chapter/(?P\d+)/(?P\d+)$', + 'staticbook.views.pdf_index', name="pdf_book"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P[^/]*)/$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P\d+)/$', + 'staticbook.views.html_index', name="html_book"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P\d+)/chapter/(?P\d+)/$', 'staticbook.views.html_index', name="html_book"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/htmlbook/(?P[^/]*)/chapter/(?P[^/]*)/$', - 'staticbook.views.html_index'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/courseware/?$', 'courseware.views.index', name="courseware"), @@ -430,6 +430,13 @@ if settings.MITX_FEATURES.get('ENABLE_DEBUG_RUN_PYTHON'): url(r'^debug/run_python', 'debug.views.run_python'), ) +# Crowdsourced hinting instructor manager. +if settings.MITX_FEATURES.get('ENABLE_HINTER_INSTRUCTOR_VIEW'): + urlpatterns += ( + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/hint_manager$', + 'instructor.hint_manager.hint_manager', name="hint_manager"), + ) + urlpatterns = patterns(*urlpatterns) if settings.DEBUG: