Merge pull request #314 from edx/hotfix/felix/crowdsource-hinter
Add experimental crowdsource hinter module
This commit is contained in:
@@ -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',
|
||||
|
||||
311
common/lib/xmodule/xmodule/crowdsource_hinter.py
Normal file
311
common/lib/xmodule/xmodule/crowdsource_hinter.py
Normal file
@@ -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:
|
||||
<crowdsource_hinter>
|
||||
<problem blah blah />
|
||||
</crowdsource_hinter>
|
||||
|
||||
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 <section>. This lets us pass data attributes to the javascript.
|
||||
out += '<section class="crowdsource-wrapper" data-url="' + self.system.ajax_url +\
|
||||
'" data-child-url = "' + child_url + '"> </section>'
|
||||
|
||||
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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
439
common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py
Normal file
439
common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py
Normal file
@@ -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 = """
|
||||
<?xml version="1.0"?>
|
||||
<crowdsource_hinter>
|
||||
<problem display_name="Numerical Input" markdown="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: = 5 [explanation] If you look at your hand, you can count that you have five fingers. [explanation] " rerandomize="never" showanswer="finished">
|
||||
<p>A numerical input problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.</p>
|
||||
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
|
||||
<p>Enter the number of fingers on a human hand:</p>
|
||||
<numericalresponse answer="5">
|
||||
<textline/>
|
||||
</numericalresponse>
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>If you look at your hand, you can count that you have five fingers. </p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>
|
||||
</crowdsource_hinter>
|
||||
"""
|
||||
|
||||
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 = """<?xml version="1.0"?>
|
||||
<vertical display_name="Test vertical">
|
||||
<crowdsource_hinter>
|
||||
<problem display_name="Numerical Input" markdown=" " rerandomize="never" showanswer="finished">
|
||||
<p>Test numerical problem.</p>
|
||||
<numericalresponse answer="5">
|
||||
<textline/>
|
||||
</numericalresponse>
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>If you look at your hand, you can count that you have five fingers. </p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>
|
||||
</crowdsource_hinter>
|
||||
|
||||
<crowdsource_hinter>
|
||||
<problem display_name="Numerical Input" markdown=" " rerandomize="never" showanswer="finished">
|
||||
<p>Another test numerical problem.</p>
|
||||
<numericalresponse answer="5">
|
||||
<textline/>
|
||||
</numericalresponse>
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>If you look at your hand, you can count that you have five fingers. </p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>
|
||||
</crowdsource_hinter>
|
||||
</vertical>
|
||||
"""
|
||||
|
||||
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': '<script> alert("Trololo"); </script>'}
|
||||
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
|
||||
@@ -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
|
||||
|
||||
130
common/templates/hinter_display.html
Normal file
130
common/templates/hinter_display.html
Normal file
@@ -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 != '':
|
||||
<h4> Hints from students who made similar mistakes: </h4>
|
||||
<ul>
|
||||
<li> ${best_hint} </li>
|
||||
% endif
|
||||
% if rand_hint_1 != '':
|
||||
<li> ${rand_hint_1} </li>
|
||||
% endif
|
||||
% if rand_hint_2 != '':
|
||||
<li> ${rand_hint_2} </li>
|
||||
% endif
|
||||
</ul>
|
||||
</%def>
|
||||
|
||||
<%def name="get_feedback()">
|
||||
<p><em> Participation in the hinting system is strictly optional, and will not influence your grade. </em></p>
|
||||
<p>
|
||||
Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below:
|
||||
</p>
|
||||
|
||||
<div id="answer-tabs">
|
||||
<ul>
|
||||
% for index, answer in index_to_answer.items():
|
||||
<li><a href="#previous-answer-${index}"> ${answer} </a></li>
|
||||
% endfor
|
||||
</ul>
|
||||
|
||||
% for index, answer in index_to_answer.items():
|
||||
<div class = "previous-answer" id="previous-answer-${index}">
|
||||
<div class = "hint-inner-container">
|
||||
% if index in index_to_hints and len(index_to_hints[index]) > 0:
|
||||
<p>
|
||||
Which hint would be most effective to show a student who also got ${answer}?
|
||||
</p>
|
||||
% for hint_text, hint_pk in index_to_hints[index]:
|
||||
<p>
|
||||
<input class="vote" data-answer="${index}" data-hintno="${hint_pk}" type="button" value="Vote"/>
|
||||
${hint_text}
|
||||
</p>
|
||||
% endfor
|
||||
<p>
|
||||
Don't like any of the hints above? You can also submit your own.
|
||||
</p>
|
||||
% endif
|
||||
<p>
|
||||
What hint would you give a student who made the same mistake you did? Please don't give away the answer.
|
||||
</p>
|
||||
<textarea cols="50" class="custom-hint" id="custom-hint-${index}">
|
||||
What would you say to help someone who got this wrong answer?
|
||||
(Don't give away the answer, please.)
|
||||
</textarea>
|
||||
<br/><br/>
|
||||
<input class="submit-hint" data-answer="${index}" type="button" value="submit">
|
||||
</div></div>
|
||||
% endfor
|
||||
</div>
|
||||
|
||||
<p>Read about <a class="expand-goodhint" href="javascript:void(0);">what makes a good hint</a>.</p>
|
||||
<div class="goodhint" style="display:none">
|
||||
<h4>What makes a good hint?</h4>
|
||||
|
||||
<p>It depends on the type of problem you ran into. For stupid errors --
|
||||
an arithmetic error or similar -- simply letting the student you'll be
|
||||
helping to check their signs is sufficient.</p>
|
||||
|
||||
<p>For deeper errors of understanding, the best hints allow students to
|
||||
discover a contradiction in how they are thinking about the
|
||||
problem. An example that clearly demonstrates inconsistency or
|
||||
<a href="http://en.wikipedia.org/wiki/Cognitive_dissonance" target="_blank"> cognitive dissonace </a>
|
||||
is ideal, although in most cases, not possible.</p>
|
||||
|
||||
<p>
|
||||
Good hints either:
|
||||
<ul>
|
||||
<li> Point out the specific misunderstanding your classmate might have </li>
|
||||
<li> Point to concepts or theories where your classmates might have a
|
||||
misunderstanding </li>
|
||||
<li> Show simpler, analogous examples. </li>
|
||||
<li> Provide references to relevant parts of the text </li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p>Still, remember even a crude hint -- virtually anything short of
|
||||
giving away the answer -- is better than no hint.</p>
|
||||
|
||||
<p>
|
||||
<a href="http://www.apa.org/education/k12/misconceptions.aspx?item=2" target="_blank">Learn even more</a>
|
||||
</p> </div>
|
||||
|
||||
</%def>
|
||||
|
||||
<%def name="show_votes()">
|
||||
% if hint_and_votes is UNDEFINED:
|
||||
Sorry, but you've already voted!
|
||||
% else:
|
||||
Thank you for voting!
|
||||
<br />
|
||||
% for hint, votes in hint_and_votes:
|
||||
<span style="color:green"> ${votes} votes. </span>
|
||||
${hint}
|
||||
<br />
|
||||
% 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
|
||||
|
||||
238
lms/djangoapps/instructor/hint_manager.py
Normal file
238
lms/djangoapps/instructor/hint_manager.py
Normal file
@@ -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()
|
||||
164
lms/djangoapps/instructor/tests/test_hint_manager.py
Normal file
164
lms/djangoapps/instructor/tests/test_hint_manager.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
124
lms/templates/courseware/hint_manager.html
Normal file
124
lms/templates/courseware/hint_manager.html
Normal file
@@ -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'/>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-world-mill-en.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/course_groups/cohorts.js')}"></script>
|
||||
|
||||
<script>
|
||||
function setup() {
|
||||
field = $("#field-label").html()
|
||||
changed_votes = []
|
||||
$(".votes").on('input', function() {
|
||||
changed_votes.push($(this))
|
||||
});
|
||||
|
||||
$("#hint-delete").click(function(){
|
||||
var data_dict = {'op': 'delete hints',
|
||||
'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
|
||||
});
|
||||
});
|
||||
|
||||
$("#update-votes").click(function(){
|
||||
var data_dict = {'op': 'change votes',
|
||||
'field': field}
|
||||
for (var i=0; i<changed_votes.length; i++) {
|
||||
data_dict[i] = [$(changed_votes[i]).parent().attr("data-problem"),
|
||||
$(changed_votes[i]).parent().attr("data-answer"),
|
||||
$(changed_votes[i]).parent().attr("data-pk"),
|
||||
$(changed_votes[i]).val()];
|
||||
}
|
||||
$.ajax(window.location.pathname, {
|
||||
type: "POST",
|
||||
data: data_dict,
|
||||
success: update_contents
|
||||
});
|
||||
});
|
||||
|
||||
$("#switch-fields").click(function(){
|
||||
out_dict = {'op': 'switch fields',
|
||||
'field': $(this).attr("other-field")};
|
||||
$.ajax(window.location.pathname, {
|
||||
type: "POST",
|
||||
data: out_dict,
|
||||
success: update_contents
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
$(".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);
|
||||
|
||||
function update_contents(data, status, jqXHR) {
|
||||
$('.instructor-dashboard-content').html(data.contents);
|
||||
setup();
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</%block>
|
||||
|
||||
<section class="container">
|
||||
<div class="instructor-dashboard-wrapper">
|
||||
|
||||
<section class="instructor-dashboard-content">
|
||||
${content.main()}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
45
lms/templates/courseware/hint_manager_inner.html
Normal file
45
lms/templates/courseware/hint_manager_inner.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<%block name="main">
|
||||
<div id="field-label" style="display:none">${field}</div>
|
||||
|
||||
<h1> ${field_label} </h1>
|
||||
Switch to <a id="switch-fields" other-field="${other_field}">${other_field_label}</a>
|
||||
|
||||
|
||||
% for definition_id in all_hints:
|
||||
<h2> Problem: ${id_to_name[definition_id]} </h2>
|
||||
% for answer, hint_dict in all_hints[definition_id]:
|
||||
% if len(hint_dict) > 0:
|
||||
<h4> Answer: ${answer} </h4><div style="background-color:#EEEEEE">
|
||||
% endif
|
||||
% for pk, hint in hint_dict.items():
|
||||
<p data-problem="${definition_id}" data-pk="${pk}" data-answer="${answer}">
|
||||
<input class="hint-select" type="checkbox"/> ${hint[0]}
|
||||
<br />
|
||||
Votes: <input type="text" class="votes" value="${str(hint[1])}" style="font-size:12px; height:20px; width:50px"></input>
|
||||
<br /><br />
|
||||
</p>
|
||||
% endfor
|
||||
% if len(hint_dict) > 0:
|
||||
</div><br />
|
||||
% endif
|
||||
% endfor
|
||||
|
||||
<h4> Add a hint to this problem </h4>
|
||||
<h4> Answer: </h4>
|
||||
<input type="text" class="submit-hint-answer" data-problem="${definition_id}"/>
|
||||
(Be sure to format your answer in the same way as the other answers you see here.)
|
||||
<br />
|
||||
Hint: <br />
|
||||
<textarea cols="50" style="height:200px" class="submit-hint-text" data-problem="${definition_id}"></textarea>
|
||||
<br />
|
||||
<button class="submit-new-hint" data-problem="${definition_id}"> Submit </button>
|
||||
<br />
|
||||
% endfor
|
||||
|
||||
<button id="hint-delete"> Delete selected </button> <button id="update-votes"> Update votes </button>
|
||||
|
||||
% if field == 'mod_queue':
|
||||
<button id="approve"> Approve selected </button>
|
||||
% endif
|
||||
|
||||
</%block>
|
||||
31
lms/urls.py
31
lms/urls.py
@@ -223,27 +223,27 @@ if settings.COURSEWARE_ENABLED:
|
||||
'courseware.views.course_info', name="info"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/syllabus$',
|
||||
'courseware.views.syllabus', name="syllabus"), # TODO arjun remove when custom tabs in place, see courseware/courses.py
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/book/(?P<book_index>[^/]*)/$',
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/book/(?P<book_index>\d+)/$',
|
||||
'staticbook.views.index', name="book"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/book/(?P<book_index>[^/]*)/(?P<page>[^/]*)$',
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/book/(?P<book_index>\d+)/(?P<page>\d+)$',
|
||||
'staticbook.views.index'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/book-shifted/(?P<page>[^/]*)$',
|
||||
'staticbook.views.index_shifted'),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/$',
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>\d+)/$',
|
||||
'staticbook.views.pdf_index', name="pdf_book"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>\d+)/(?P<page>\d+)$',
|
||||
'staticbook.views.pdf_index', name="pdf_book"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/(?P<page>[^/]*)$',
|
||||
'staticbook.views.pdf_index'),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/$',
|
||||
'staticbook.views.pdf_index'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/(?P<page>[^/]*)$',
|
||||
'staticbook.views.pdf_index'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>\d+)/chapter/(?P<chapter>\d+)/$',
|
||||
'staticbook.views.pdf_index', name="pdf_book"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>\d+)/chapter/(?P<chapter>\d+)/(?P<page>\d+)$',
|
||||
'staticbook.views.pdf_index', name="pdf_book"),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/htmlbook/(?P<book_index>[^/]*)/$',
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/htmlbook/(?P<book_index>\d+)/$',
|
||||
'staticbook.views.html_index', name="html_book"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/htmlbook/(?P<book_index>\d+)/chapter/(?P<chapter>\d+)/$',
|
||||
'staticbook.views.html_index', name="html_book"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/htmlbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/$',
|
||||
'staticbook.views.html_index'),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/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<course_id>[^/]+/[^/]+/[^/]+)/hint_manager$',
|
||||
'instructor.hint_manager.hint_manager', name="hint_manager"),
|
||||
)
|
||||
|
||||
urlpatterns = patterns(*urlpatterns)
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
Reference in New Issue
Block a user