Commit 072ab3ed by Sven Marnach

Add maximum number of attempts and Save button.

parent 29bb31c7
...@@ -78,6 +78,12 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -78,6 +78,12 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
scope=Scope.settings, scope=Scope.settings,
default=1.0, default=1.0,
) )
max_attempts = Integer(
display_name='Maximum attempts',
help='Defines the number of times a student can try to answer this problem. If the value '
'is not set, infinite attempts are allowed.',
scope=Scope.settings,
)
editable_fields = [ editable_fields = [
'display_name', 'display_name',
...@@ -87,17 +93,35 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -87,17 +93,35 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
'row_heights', 'row_heights',
'default_tolerance', 'default_tolerance',
'max_score', 'max_score',
'max_attempts',
] ]
# Dictionary mapping cell ids to the student answers. # Dictionary mapping cell ids to the student answers.
answers = Dict(scope=Scope.user_state) answers = Dict(scope=Scope.user_state)
# Number of correct answers. # Dictionary mapping cell ids to Boolean values indicating whether the cell was answered
num_correct_answers = Integer(scope=Scope.user_state) # correctly at the last check.
answers_correct = Dict(scope=Scope.user_state, default=None)
# The number of points awarded. # The number of points awarded.
score = Float(scope=Scope.user_state) score = Float(scope=Scope.user_state)
# The number of attempts used.
attempts = Integer(scope=Scope.user_state, default=0)
has_score = True has_score = True
@property
def num_correct_answers(self):
"""The number of correct answers during the last check."""
if self.answers_correct is None:
return None
return sum(self.answers_correct.itervalues())
@property
def num_total_answers(self):
"""The total number of answers during the last check."""
if self.answers_correct is None:
return None
return len(self.answers_correct)
def parse_fields(self): def parse_fields(self):
"""Parse the user-provided fields into more processing-friendly structured data.""" """Parse the user-provided fields into more processing-friendly structured data."""
if self.table_definition: if self.table_definition:
...@@ -130,16 +154,23 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -130,16 +154,23 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
cell.id = 'cell_{}_{}'.format(cell.index, row['index']) cell.id = 'cell_{}_{}'.format(cell.index, row['index'])
if not cell.is_static: if not cell.is_static:
self.response_cells[cell.id] = cell self.response_cells[cell.id] = cell
cell.classes = 'active'
cell.value = self.answers.get(cell.id) cell.value = self.answers.get(cell.id)
cell.height = height - 2 cell.height = height - 2
if isinstance(cell, NumericCell) and cell.abs_tolerance is None: if isinstance(cell, NumericCell) and cell.abs_tolerance is None:
cell.set_tolerance(self.default_tolerance) cell.set_tolerance(self.default_tolerance)
if cell.value is None:
cell.classes = 'active unchecked' def get_status(self):
elif cell.check_response(cell.value): """Status dictionary passed to the frontend code."""
cell.classes = 'active right-answer' return dict(
else: answers_correct=self.answers_correct,
cell.classes = 'active wrong-answer' num_correct_answers=self.num_correct_answers,
num_total_answers=self.num_total_answers,
score=self.score,
max_score=self.max_score,
attempts=self.attempts,
max_attempts=self.max_attempts,
)
def student_view(self, context=None): def student_view(self, context=None):
"""Render the table.""" """Render the table."""
...@@ -153,6 +184,7 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -153,6 +184,7 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
head_height=self._row_heights[0] if self._row_heights else None, head_height=self._row_heights[0] if self._row_heights else None,
thead=self.thead, thead=self.thead,
tbody=self.tbody, tbody=self.tbody,
max_attempts=self.max_attempts,
) )
html = loader.render_template('templates/html/activetable.html', context) html = loader.render_template('templates/html/activetable.html', context)
...@@ -166,39 +198,45 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -166,39 +198,45 @@ class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
frag = Fragment(html) frag = Fragment(html)
frag.add_css(css) frag.add_css(css)
frag.add_javascript(loader.load_unicode('static/js/src/activetable.js')) frag.add_javascript(loader.load_unicode('static/js/src/activetable.js'))
frag.initialize_js('ActiveTableXBlock', dict( frag.initialize_js('ActiveTableXBlock', self.get_status())
num_correct_answers=self.num_correct_answers,
num_total_answers=len(self.answers) if self.answers is not None else None,
score=self.score,
max_score=self.max_score,
))
return frag return frag
@XBlock.json_handler def check_and_save_answers(self, data):
def check_answers(self, data, unused_suffix=''): """Common implementation for the check and save handlers."""
"""Check the answers given by the student. if self.max_attempts and self.attempts >= self.max_attempts:
# The "Check" button is hidden when the maximum number of attempts has been reached, so
This handler is called when the "Check" button is clicked. # we can only get here by manually crafted requests. We simply return the current
""" # status without rechecking or storing the answers in that case.
return self.get_status()
self.parse_fields() self.parse_fields()
self.postprocess_table() self.postprocess_table()
correct = { answers_correct = {
cell_id: self.response_cells[cell_id].check_response(value) cell_id: self.response_cells[cell_id].check_response(value)
for cell_id, value in data.iteritems() for cell_id, value in data.iteritems()
} }
# Since the previous statement executed without error, the data is well-formed enough to be # Since the previous statement executed without error, the data is well-formed enough to be
# stored. We now know it's a dictionary and all the keys are valid cell ids. # stored. We now know it's a dictionary and all the keys are valid cell ids.
self.answers = data self.answers = data
self.num_correct_answers = sum(correct.itervalues()) return answers_correct
self.score = self.num_correct_answers * self.max_score / len(correct)
@XBlock.json_handler
def check_answers(self, data, unused_suffix=''):
"""Check the answers given by the student.
This handler is called when the "Check" button is clicked.
"""
self.answers_correct = self.check_and_save_answers(data)
self.attempts += 1
self.score = self.num_correct_answers * self.max_score / len(self.answers_correct)
self.runtime.publish(self, 'grade', dict(value=self.score, max_value=self.max_score)) self.runtime.publish(self, 'grade', dict(value=self.score, max_value=self.max_score))
return dict( return self.get_status()
correct=correct,
num_correct_answers=self.num_correct_answers, @XBlock.json_handler
num_total_answers=len(correct), def save_answers(self, data, unused_suffix=''):
score=self.score, """Save the answers given by the student without checking them."""
max_score=self.max_score, self.check_and_save_answers(data)
) self.answers_correct = None
return self.get_status()
def validate_field_data(self, validation, data): def validate_field_data(self, validation, data):
"""Validate the data entered by the user. """Validate the data entered by the user.
......
...@@ -2,49 +2,84 @@ ...@@ -2,49 +2,84 @@
function ActiveTableXBlock(runtime, element, init_args) { function ActiveTableXBlock(runtime, element, init_args) {
var checkHandlerUrl = runtime.handlerUrl(element, 'check_answers'); var checkHandlerUrl = runtime.handlerUrl(element, 'check_answers');
var saveHandlerUrl = runtime.handlerUrl(element, 'save_answers');
function updateStatus(data) { function markResponseCells(data) {
if (data.answers_correct) {
$.each(data.answers_correct, function(cell_id, correct) {
var $cell = $('#' + cell_id, element);
$cell.removeClass('right-answer wrong-answer unchecked');
if (correct) {
$cell.addClass('right-answer');
$cell.prop('title', 'correct');
} else {
$cell.addClass('wrong-answer');
$cell.prop('title', 'incorrect');
}
});
} else {
$('td.active', element).removeClass('right-answer wrong-answer').addClass('unchecked');
}
}
function updateStatusMessage(data) {
var $status = $('.status', element); var $status = $('.status', element);
var $status_message = $('.status-message', element); var $status_message = $('.status-message', element);
if (data.num_total_answers == data.num_correct_answers) { if (!data.answers_correct) {
$status.removeClass('incorrect correct');
$status.text('unanswered');
$status_message.text('');
}
else if (data.num_total_answers == data.num_correct_answers) {
$status.removeClass('incorrect').addClass('correct'); $status.removeClass('incorrect').addClass('correct');
$status.text('correct'); $status.text('correct');
$status_message.text('Great job! (' + data.score + '/' + data.max_score + ' points)'); $status_message.text('Great job! ');
} else { } else {
$status.removeClass('correct').addClass('incorrect'); $status.removeClass('correct').addClass('incorrect');
$status.text('incorrect'); $status.text('incorrect');
$status_message.text( $status_message.text(
'You have ' + data.num_correct_answers + ' out of ' + data.num_total_answers + 'You have ' + data.num_correct_answers + ' out of ' + data.num_total_answers +
' cells correct. (' + data.score + '/' + data.max_score + ' points)' ' cells correct.'
); );
} }
} }
function markResponseCells(data) { function updateFeedback(data) {
$.each(data.correct, function(cell_id, correct) { var feedback_msg;
var $cell = $('#' + cell_id, element); if (data.score === null) {
$cell.removeClass('right-answer wrong-answer unchecked'); feedback_msg = '(' + data.max_score + ' points possible)';
if (correct) { } else {
$cell.addClass('right-answer'); feedback_msg = '(' + data.score + '/' + data.max_score + ' points)';
$cell.prop('title', 'correct'); }
} else { if (data.max_attempts) {
$cell.addClass('wrong-answer'); feedback_msg = 'You have used ' + data.attempts + ' of ' + data.max_attempts +
$cell.prop('title', 'incorrect'); ' submissions ' + feedback_msg;
if (data.attempts == data.max_attempts - 1) {
$('.action .check .check-label', element).text('Final check');
} }
}); else if (data.attempts >= data.max_attempts) {
updateStatus(data); $('.action .check, .action .save', element).hide();
}
}
$('.submission-feedback', element).text(feedback_msg);
}
function updateStatus(data) {
markResponseCells(data);
updateStatusMessage(data);
updateFeedback(data);
} }
function checkAnswers(e) { function callHandler(url) {
answers = {}; var answers = {};
$('td.active', element).each(function() { $('td.active', element).each(function() {
answers[this.id] = $('input', this).val(); answers[this.id] = $('input', this).val();
}); });
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: checkHandlerUrl, url: url,
data: JSON.stringify(answers), data: JSON.stringify(answers),
success: markResponseCells, success: updateStatus,
}); });
} }
...@@ -55,6 +90,7 @@ function ActiveTableXBlock(runtime, element, init_args) { ...@@ -55,6 +90,7 @@ function ActiveTableXBlock(runtime, element, init_args) {
} }
$('#activetable-help-button', element).click(toggleHelp); $('#activetable-help-button', element).click(toggleHelp);
$('.action .check', element).click(checkAnswers); $('.action .check', element).click(function (e) { callHandler(checkHandlerUrl); });
if (init_args.num_total_answers) updateStatus(init_args); $('.action .save', element).click(function (e) { callHandler(saveHandlerUrl); });
updateStatus(init_args);
} }
...@@ -110,8 +110,14 @@ ...@@ -110,8 +110,14 @@
.activetable_block .status-message { .activetable_block .status-message {
margin: 0.5em 0 1.5em; margin: 0.5em 0 1.5em;
} }
.activetable_block .action .check { .activetable_block .action button {
height: 40px; height: 40px;
margin-right: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
} }
.activetable_block .submission-feedback {
display: inline-block;
font-style: italic;
margin-left: 10px;
}
...@@ -35,9 +35,13 @@ ...@@ -35,9 +35,13 @@
{% else %} {% else %}
<p>This component isn't configured properly and can't be displayed.</p> <p>This component isn't configured properly and can't be displayed.</p>
{% endif %} {% endif %}
<p class="status">unanswered</p> <p class="status"></p>
<div class="status-message"></div> <div class="status-message"></div>
<div class="action"> <div class="action">
<button class="check"><span class="check-label">Check</span><span class="sr"> your answer</span></button> <button class="check"><span class="check-label">Check</span><span class="sr"> your answer</span></button>
{% if max_attempts %}
<button class="save">Save<span class="sr"> your answer</span></button>
<div class="submission-feedback"></div>
{% endif %}
</div> </div>
</div> </div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment