From b64fe5c5374952966197e38414148cf4daa8e58a Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 20 Jun 2013 17:09:00 -0400 Subject: [PATCH] Finished prototype of hint moderation view. Began re-writing tests of the crowdsource hinter module. (Old tests no longer cover all the code, now that moderation has been added.) --- .../xmodule/tests/test_crowdsource_hinter.py | 296 ++++++++++++++++++ lms/djangoapps/instructor/hint_manager.py | 129 ++++++-- lms/templates/courseware/hint_manager.html | 35 +++ .../courseware/hint_manager_inner.html | 40 ++- 4 files changed, 462 insertions(+), 38 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py 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..7fe890fa77 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -0,0 +1,296 @@ +from mock import Mock, patch +import unittest + +import xmodule +from xmodule.crowdsource_hinter import CrowdsourceHinterModule +from xmodule.modulestore import Location + +from django.http import QueryDict + +from . import 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(): + CHModuleFactory.num += 1 + return CHModuleFactory.num + + @staticmethod + def create(hints=None, + previous_answers=None, + user_voted=None, + moderate=None, + mod_queue=None): + + location = Location(["i4x", "edX", "capa_test", "problem", + "SampleProblem{0}".format(CHModuleFactory.next_num())]) + model_data = {'data': CHModuleFactory.sample_problem_xml} + + if hints != 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 != 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 != 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 != None: + model_data['user_voted'] = user_voted + + if moderate != None: + model_data['moderate'] = moderate + + descriptor = Mock(weight="1") + system = test_system() + system.render_template = Mock(return_value="
Test Template HTML
") + module = CrowdsourceHinterModule(system, descriptor, model_data) + + return module + +class CrowdsourceHinterTest(unittest.TestCase): + ''' + In the below tests, '24.0' represents a wrong answer, and '42.5' represents + a correct answer. + ''' + + 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'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue(json_out == ' ') + 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'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('Really popular hint' in json_out) + + + 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'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('Best hint' in json_out) + self.assertTrue(json_out.count('hint') == 3) + + + 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'} + json_out = json.loads(m.get_feedback(json_in))['contents'] + self.assertTrue(json_out == ' ') + + 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'} + json_out = json.loads(m.get_feedback(json_in))['contents'] + self.assertTrue('textarea' in json_out) + self.assertTrue('Vote' not in json_out) + + + 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(hints={ + '24.0': {'0': ['a hint', 42], + '1': ['another hint', 35], + '2': ['irrelevent hint', 25.0]} + }, + previous_answers=[ + ['24.0', [0, 1, None]]], + ) + json_in = {'problem_name': '42.5'} + json_out = json.loads(m.get_feedback(json_in))['contents'] + self.assertTrue('a hint' in json_out) + self.assertTrue('another hint' in json_out) + self.assertTrue('irrelevent hint' not in json_out) + self.assertTrue('textarea' in json_out) + + + 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(hints={ + '24.0': {'0': ['a hint', 42], + '1': ['another hint', 35], + '2': ['irrelevent hint', 25.0]} + }, + previous_answers=[ + ['24.0', [0, 1, None]]], + user_voted=True + ) + json_in = {'answer': 0, 'hint': 1} + json_out = json.loads(m.tally_vote(json_in))['contents'] + self.assertTrue(m.hints['24.0']['0'][1] == 42) + self.assertTrue(m.hints['24.0']['1'][1] == 35) + self.assertTrue(m.hints['24.0']['2'][1] == 25.0) + + + def test_vote_withpermission(self): + ''' + A user votes for a hint. + ''' + m = CHModuleFactory.create(hints={ + '24.0': {'0': ['a hint', 42], + '1': ['another hint', 35], + '2': ['irrelevent hint', 25.0]} + }, + previous_answers=[ + ['24.0', [0, 1, None]]], + ) + json_in = {'answer': 0, 'hint': 1} + json_out = json.loads(m.tally_vote(json_in))['contents'] + self.assertTrue(m.hints['24.0']['0'][1] == 42) + self.assertTrue(m.hints['24.0']['1'][1] == 36) + self.assertTrue(m.hints['24.0']['2'][1] == 25.0) + + + def test_submithint_nopermission(self): + ''' + A user tries to submit a hint, but he has already voted. + ''' + m = CHModuleFactory.create(previous_answers=[ + ['24.0', [None, None, None]]], + user_voted=True) + json_in = {'answer': 0, 'hint': 'This is a new hint.'} + m.submit_hint(json_in) + self.assertTrue('24.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(previous_answers=[ + ['24.0', [None, 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': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('This is a new hint.' in json_out) + + + def test_submithint_withpermission_existing(self): + ''' + A user submits a hint to an answer that has other hints + already. + ''' + m = CHModuleFactory.create(previous_answers=[ + ['24.0', [0, None, None]]], + hints={'24.0': {'0': ['Existing hint.', 1]}} + ) + json_in = {'answer': 0, 'hint': 'This is a new hint.'} + m.submit_hint(json_in) + # Make a hint request. + json_in = {'problem name': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('This is a new hint.' in json_out) + + def test_deletehint(self): + ''' + An admin / instructor deletes a hint. + ''' + m = CHModuleFactory.create(hints={ + '24.0': {'0': ['Deleted hint', 5], + '1': ['Safe hint', 4]} + }) + m.delete_hint('24.0', '0') + json_in = {'problem name': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('Deleted hint' not in json_out) + self.assertTrue('Safe hint' in json_out) + + + + + + + + + + + + + diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 431d3f5d7c..5d722b1e79 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -26,6 +26,8 @@ from django.core.urlresolvers import reverse 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 @@ -48,6 +50,10 @@ def hint_manager(request, course_id): 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})) @@ -59,7 +65,6 @@ def get_hints(request, course_id, field): # DON'T TRUST field attributes that come from ajax. Use an if statement # to make sure the field is valid before plugging into functions. - out = '' if field == 'mod_queue': other_field = 'hints' field_label = 'Hints Awaiting Moderation' @@ -71,32 +76,40 @@ def get_hints(request, course_id, field): 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 = {} + name_dict = {} for problem in all_hints: - out += '

Problem: ' + problem.definition_id + '

' - for answer, hint_dict in json.loads(problem.value).items(): - out += '

Answer: ' + answer + '

' - for pk, hint in hint_dict.items(): - out += '

' - out += '' + hint[0] + \ - '
Votes: ' - out += '

' - out += '''

Add a hint to this problem

- Answer (exact formatting): -
Hint:


' + loc = Location(problem.definition_id) + try: + descriptor = modulestore().get_items(loc)[0] + except IndexError: + # Sometimes, the problem is no longer in the course. Just + # don't include said problem. + continue + name_dict[problem.definition_id] = descriptor.get_children()[0].display_name + # Answer list contains (answer, dict_of_hints) tuples. + 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') - out += ' ' - render_dict = {'out': out, - 'field': field, + answer_list = sorted(json.loads(problem.value).items(), key=answer_sorter) + big_out_dict[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': all_hints} + 'all_hints': big_out_dict, + 'id_to_name': name_dict} return render_dict def delete_hints(request, course_id, field): @@ -131,6 +144,80 @@ def change_votes(request, course_id, field): problem_dict[answer][pk][1] = new_votes this_problem.value = json.dumps(problem_dict) this_problem.save() + +def add_hint(request, course_id, field): + ''' + Add a new hint. 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/templates/courseware/hint_manager.html b/lms/templates/courseware/hint_manager.html index 94156d3d68..394792f892 100644 --- a/lms/templates/courseware/hint_manager.html +++ b/lms/templates/courseware/hint_manager.html @@ -28,6 +28,7 @@ data_dict[i] = [$(this).parent().attr("data-problem"), $(this).parent().attr("data-answer"), $(this).parent().attr("data-pk")]; + i += 1 } }); $.ajax(window.location.pathname, { @@ -64,6 +65,40 @@ }); }); + $(".submit-new-hint").click(function(){ + problem_name = $(this).data("problem"); + hint_text = $(".submit-hint-text").filter('*[data-problem="'+problem_name+'"]').val(); + hint_answer = $(".submit-hint-answer").filter('*[data-problem="'+problem_name+'"]').val(); + data_dict = {'op': 'add hint', + 'field': field, + 'problem': problem_name, + 'answer': hint_answer, + 'hint': hint_text}; + $.ajax(window.location.pathname, { + type: "POST", + data: data_dict, + success: update_contents + }); + }); + + $("#approve").click(function(){ + var data_dict = {'op': 'approve', + 'field': field} + var i = 1 + $(".hint-select").each(function(){ + if ($(this).is(":checked")) { + data_dict[i] = [$(this).parent().attr("data-problem"), + $(this).parent().attr("data-answer"), + $(this).parent().attr("data-pk")]; + i += 1 + } + }); + $.ajax(window.location.pathname, { + type: "POST", + data: data_dict, + success: update_contents + }); + }); } $(document).ready(setup); diff --git a/lms/templates/courseware/hint_manager_inner.html b/lms/templates/courseware/hint_manager_inner.html index 41e8d018c5..c69539522f 100644 --- a/lms/templates/courseware/hint_manager_inner.html +++ b/lms/templates/courseware/hint_manager_inner.html @@ -1,39 +1,45 @@ <%block name="main"> - +

${field_label}

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

Problem: ${problem.definition_id}

- <% - import json - loaded_json = json.loads(problem.value).items() - %> - % for answer, hint_dict in loaded_json: -

Answer: ${answer}

+% 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: + Votes: +

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

+ % endif % endfor

Add a hint to this problem

- Answer (exact formatting): - +

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