Commit e03b3914 by Eugeny Kolpakov Committed by GitHub

Merge pull request #14737 from open-craft/mit-capa-hide-correct

MIT CAPA improvements: Add show_correctness field
parents 529df7c6 f18a2be8
...@@ -1163,6 +1163,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -1163,6 +1163,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
'explanatory_message': explanatory_message, 'explanatory_message': explanatory_message,
'group_access': xblock.group_access, 'group_access': xblock.group_access,
'user_partitions': user_partitions, 'user_partitions': user_partitions,
'show_correctness': xblock.show_correctness,
}) })
if xblock.category == 'sequential': if xblock.category == 'sequential':
......
...@@ -54,6 +54,7 @@ class CourseMetadata(object): ...@@ -54,6 +54,7 @@ class CourseMetadata(object):
'exam_review_rules', 'exam_review_rules',
'hide_after_due', 'hide_after_due',
'self_paced', 'self_paced',
'show_correctness',
'chrome', 'chrome',
'default_tab', 'default_tab',
] ]
......
...@@ -15,7 +15,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -15,7 +15,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
'use strict'; 'use strict';
var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor, var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor,
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor, StaffLockEditor, ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor, StaffLockEditor,
ContentVisibilityEditor, TimedExaminationPreferenceEditor, AccessEditor; ContentVisibilityEditor, TimedExaminationPreferenceEditor, AccessEditor, ShowCorrectnessEditor;
CourseOutlineXBlockModal = BaseModal.extend({ CourseOutlineXBlockModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, { events: _.extend({}, BaseModal.prototype.events, {
...@@ -714,7 +714,51 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -714,7 +714,51 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
AbstractVisibilityEditor.prototype.getContext.call(this), AbstractVisibilityEditor.prototype.getContext.call(this),
{ {
hide_after_due: this.modelVisibility() === 'hide_after_due', hide_after_due: this.modelVisibility() === 'hide_after_due',
self_paced: this.model.get('self_paced') === true self_paced: course.get('self_paced') === true
}
);
}
});
ShowCorrectnessEditor = AbstractEditor.extend({
templateName: 'show-correctness-editor',
className: 'edit-show-correctness',
afterRender: function() {
AbstractEditor.prototype.afterRender.call(this);
this.setValue(this.model.get('show_correctness') || 'always');
},
setValue: function(value) {
this.$('input[name=show-correctness][value=' + value + ']').prop('checked', true);
},
currentValue: function() {
return this.$('input[name=show-correctness]:checked').val();
},
hasChanges: function() {
return this.model.get('show_correctness') !== this.currentValue();
},
getRequestData: function() {
if (this.hasChanges()) {
return {
publish: 'republish',
metadata: {
show_correctness: this.currentValue()
}
};
} else {
return {};
}
},
getContext: function() {
return $.extend(
{},
AbstractEditor.prototype.getContext.call(this),
{
self_paced: course.get('self_paced') === true
} }
); );
} }
...@@ -732,6 +776,11 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -732,6 +776,11 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
getEditModal: function(xblockInfo, options) { getEditModal: function(xblockInfo, options) {
var tabs = []; var tabs = [];
var editors = []; var editors = [];
var advancedTab = {
name: 'advanced',
displayName: gettext('Advanced'),
editors: []
};
if (xblockInfo.isVertical()) { if (xblockInfo.isVertical()) {
editors = [StaffLockEditor]; editors = [StaffLockEditor];
} else { } else {
...@@ -742,8 +791,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -742,8 +791,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
editors: [] editors: []
}, },
{ {
name: 'advanced', name: 'visibility',
displayName: gettext('Advanced'), displayName: gettext('Visibility'),
editors: [] editors: []
} }
]; ];
...@@ -752,14 +801,19 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -752,14 +801,19 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
tabs[1].editors = [StaffLockEditor]; tabs[1].editors = [StaffLockEditor];
} else if (xblockInfo.isSequential()) { } else if (xblockInfo.isSequential()) {
tabs[0].editors = [ReleaseDateEditor, GradingEditor, DueDateEditor]; tabs[0].editors = [ReleaseDateEditor, GradingEditor, DueDateEditor];
tabs[1].editors = [ContentVisibilityEditor]; tabs[1].editors = [ContentVisibilityEditor, ShowCorrectnessEditor];
if (options.enable_proctored_exams || options.enable_timed_exams) { if (options.enable_proctored_exams || options.enable_timed_exams) {
tabs[1].editors.push(TimedExaminationPreferenceEditor); advancedTab.editors.push(TimedExaminationPreferenceEditor);
} }
if (typeof(xblockInfo.get('is_prereq')) !== 'undefined') { if (typeof(xblockInfo.get('is_prereq')) !== 'undefined') {
tabs[1].editors.push(AccessEditor); advancedTab.editors.push(AccessEditor);
}
// Show the Advanced tab iff it has editors to display
if (advancedTab.editors.length > 0) {
tabs.push(advancedTab);
} }
} }
} }
......
...@@ -585,8 +585,11 @@ ...@@ -585,8 +585,11 @@
} }
.list-fields { .list-fields {
.field-message { .field-message {
@extend %t-copy-sub2;
color: $gray-d1; color: $gray-d1;
font-size: ($baseline/2); }
label {
@extend %t-title7;
} }
.field { .field {
display: inline-block; display: inline-block;
...@@ -595,7 +598,6 @@ ...@@ -595,7 +598,6 @@
margin-bottom: ($baseline/4); margin-bottom: ($baseline/4);
label { label {
@extend %t-copy-sub1;
@extend %t-strong; @extend %t-strong;
@include transition(color $tmg-f3 ease-in-out 0s); @include transition(color $tmg-f3 ease-in-out 0s);
margin: 0 0 ($baseline/4) 0; margin: 0 0 ($baseline/4) 0;
......
...@@ -26,7 +26,7 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -26,7 +26,7 @@ from openedx.core.djangolib.markup import HTML, Text
<%block name="header_extras"> <%block name="header_extras">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" /> <link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs']: % for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor']:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
......
<form>
<h3 class="modal-section-title" id="show_correctness_label"><%- gettext('Assessment Results Visibility') %></h3>
<div class="modal-section-content show-correctness">
<div role="group" class="list-fields" aria-labelledby="show_correctness_label">
<label class="label">
<input class="input input-radio" name="show-correctness" type="radio" value="always" aria-describedby="always_show_correctness_description" />
<%- gettext('Always show assessment results') %>
</label>
<p class='field-message' id='always_show_correctness_description'>
<%- gettext('When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.') %>
</p>
<label class="label">
<input class="input input-radio" name="show-correctness" type="radio" value="never" aria-describedby="never_show_correctness_description" />
<%- gettext('Never show assessment results') %>
</label>
<p class='field-message' id='never_show_correctness_description'>
<%- gettext('Learners never see whether their answers to assessments are correct or incorrect, nor the score received.') %>
</p>
<label class="label">
<input class="input input-radio" name="show-correctness" type="radio" value="past_due" aria-describedby="show_correctness_past_due_description" />
<%- gettext('Show assessment results when subsection is past due') %>
</label>
<p class='field-message' id='show_correctness_past_due_description'>
<% if (self_paced) { %>
<%- gettext('Learners do not see whether their answers to assessments were correct or incorrect, nor the score received, until after the course end date has passed.') %>
<%- gettext('If the course does not have an end date, learners always see their scores when they submit answers to assessments.') %>
<% } else { %>
<%- gettext('Learners do not see whether their answers to assessments were correct or incorrect, nor the score received, until after the due date for the subsection has passed.') %>
<%- gettext('If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.') %>
<% } %>
</p>
</div>
</div>
</form>
...@@ -798,6 +798,11 @@ class LoncapaProblem(object): ...@@ -798,6 +798,11 @@ class LoncapaProblem(object):
if problemid in self.correct_map: if problemid in self.correct_map:
pid = input_id pid = input_id
# If we're withholding correctness, don't show adaptive hints either.
# Note that regular, "demand" hints will be shown, if the course author has added them to the problem.
if not self.capa_module.correctness_available():
status = 'submitted'
else:
# If the the problem has not been saved since the last submit set the status to the # If the the problem has not been saved since the last submit set the status to the
# current correctness value and set the message as expected. Otherwise we do not want to # current correctness value and set the message as expected. Otherwise we do not want to
# display correctness because the answer may have changed since the problem was graded. # display correctness because the answer may have changed since the problem was graded.
......
...@@ -90,6 +90,7 @@ class Status(object): ...@@ -90,6 +90,7 @@ class Status(object):
'incomplete': _('incomplete'), 'incomplete': _('incomplete'),
'unanswered': _('unanswered'), 'unanswered': _('unanswered'),
'unsubmitted': _('unanswered'), 'unsubmitted': _('unanswered'),
'submitted': _('submitted'),
'queued': _('processing'), 'queued': _('processing'),
} }
tooltips = { tooltips = {
...@@ -197,7 +198,7 @@ class InputTypeBase(object): ...@@ -197,7 +198,7 @@ class InputTypeBase(object):
(what the student entered last time) (what the student entered last time)
* 'id' -- the id of this input, typically * 'id' -- the id of this input, typically
"{problem-location}_{response-num}_{input-num}" "{problem-location}_{response-num}_{input-num}"
* 'status' (answered, unanswered, unsubmitted) * 'status' (submitted, unanswered, unsubmitted)
* 'input_state' -- dictionary containing any inputtype-specific state * 'input_state' -- dictionary containing any inputtype-specific state
that has been preserved that has been preserved
* 'feedback' (dictionary containing keys for hints, errors, or other * 'feedback' (dictionary containing keys for hints, errors, or other
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
<div class="script_placeholder" data-src="${jschannel_loader}"/> <div class="script_placeholder" data-src="${jschannel_loader}"/>
<div class="script_placeholder" data-src="${jsinput_loader}"/> <div class="script_placeholder" data-src="${jsinput_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']: % if status in ['unsubmitted', 'submitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}"> <div class="${status.classname}">
% endif % endif
...@@ -49,7 +49,7 @@ ...@@ -49,7 +49,7 @@
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div> <div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']: % if status in ['unsubmitted', 'submitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<div class="script_placeholder" data-src="${preprocessor['script_src']}"/> <div class="script_placeholder" data-src="${preprocessor['script_src']}"/>
% endif % endif
% if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'): % if status in ('unsubmitted', 'submitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
<div class="${status.classname} ${doinline}"> <div class="${status.classname} ${doinline}">
% endif % endif
...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
<textarea style="display:none" id="input_${id}_dynamath" name="input_${id}_dynamath"></textarea> <textarea style="display:none" id="input_${id}_dynamath" name="input_${id}_dynamath"></textarea>
% endif % endif
% if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'): % if status in ('unsubmitted', 'submitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
</div> </div>
% endif % endif
......
...@@ -173,6 +173,7 @@ class TemplateTestCase(unittest.TestCase): ...@@ -173,6 +173,7 @@ class TemplateTestCase(unittest.TestCase):
cases = [ cases = [
('correct', 'correct'), ('correct', 'correct'),
('unsubmitted', 'unanswered'), ('unsubmitted', 'unanswered'),
('submitted', 'submitted'),
('incorrect', 'incorrect'), ('incorrect', 'incorrect'),
('incomplete', 'incorrect') ('incomplete', 'incorrect')
] ]
......
...@@ -24,7 +24,7 @@ from capa.inputtypes import Status ...@@ -24,7 +24,7 @@ from capa.inputtypes import Status
from capa.responsetypes import StudentInputError, ResponseError, LoncapaProblemError from capa.responsetypes import StudentInputError, ResponseError, LoncapaProblemError
from capa.util import convert_files_to_filenames, get_inner_html_from_xpath from capa.util import convert_files_to_filenames, get_inner_html_from_xpath
from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString
from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER, SHOW_CORRECTNESS
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from .fields import Date, Timedelta from .fields import Date, Timedelta
from .progress import Progress from .progress import Progress
...@@ -114,6 +114,18 @@ class CapaFields(object): ...@@ -114,6 +114,18 @@ class CapaFields(object):
help=_("Amount of time after the due date that submissions will be accepted"), help=_("Amount of time after the due date that submissions will be accepted"),
scope=Scope.settings scope=Scope.settings
) )
show_correctness = String(
display_name=_("Show Results"),
help=_("Defines when to show whether a learner's answer to the problem is correct. "
"Configured on the subsection."),
scope=Scope.settings,
default=SHOW_CORRECTNESS.ALWAYS,
values=[
{"display_name": _("Always"), "value": SHOW_CORRECTNESS.ALWAYS},
{"display_name": _("Never"), "value": SHOW_CORRECTNESS.NEVER},
{"display_name": _("Past Due"), "value": SHOW_CORRECTNESS.PAST_DUE},
],
)
showanswer = String( showanswer = String(
display_name=_("Show Answer"), display_name=_("Show Answer"),
help=_("Defines when to show the answer to the problem. " help=_("Defines when to show the answer to the problem. "
...@@ -391,12 +403,25 @@ class CapaMixin(CapaFields): ...@@ -391,12 +403,25 @@ class CapaMixin(CapaFields):
return None return None
return None return None
def get_display_progress(self):
"""
Return (score, total) to be displayed to the learner.
"""
progress = self.get_progress()
score, total = (progress.frac() if progress else (0, 0))
# Withhold the score if hiding correctness
if not self.correctness_available():
score = None
return score, total
def get_html(self): def get_html(self):
""" """
Return some html with data about the module Return some html with data about the module
""" """
progress = self.get_progress() curr_score, total_possible = self.get_display_progress()
curr_score, total_possible = (progress.frac() if progress else (0, 0))
return self.runtime.render_template('problem_ajax.html', { return self.runtime.render_template('problem_ajax.html', {
'element_id': self.location.html_id(), 'element_id': self.location.html_id(),
'id': self.location.to_deprecated_string(), 'id': self.location.to_deprecated_string(),
...@@ -739,7 +764,11 @@ class CapaMixin(CapaFields): ...@@ -739,7 +764,11 @@ class CapaMixin(CapaFields):
if render_notifications: if render_notifications:
progress = self.get_progress() progress = self.get_progress()
id_list = self.lcp.correct_map.keys() id_list = self.lcp.correct_map.keys()
if len(id_list) == 1:
# Show only a generic message if hiding correctness
if not self.correctness_available():
answer_notification_type = 'submitted'
elif len(id_list) == 1:
# Only one answer available # Only one answer available
answer_notification_type = self.lcp.correct_map.get_correctness(id_list[0]) answer_notification_type = self.lcp.correct_map.get_correctness(id_list[0])
elif len(id_list) > 1: elif len(id_list) > 1:
...@@ -782,6 +811,8 @@ class CapaMixin(CapaFields): ...@@ -782,6 +811,8 @@ class CapaMixin(CapaFields):
).format(progress=str(progress)) ).format(progress=str(progress))
else: else:
answer_notification_message = _('Partially Correct') answer_notification_message = _('Partially Correct')
elif answer_notification_type == 'submitted':
answer_notification_message = _("Answer submitted.")
return answer_notification_type, answer_notification_message return answer_notification_type, answer_notification_message
...@@ -855,7 +886,10 @@ class CapaMixin(CapaFields): ...@@ -855,7 +886,10 @@ class CapaMixin(CapaFields):
""" """
Is the user allowed to see an answer? Is the user allowed to see an answer?
""" """
if self.showanswer == '': if not self.correctness_available():
# If correctness is being withheld, then don't show answers either.
return False
elif self.showanswer == '':
return False return False
elif self.showanswer == SHOWANSWER.NEVER: elif self.showanswer == SHOWANSWER.NEVER:
return False return False
...@@ -883,6 +917,24 @@ class CapaMixin(CapaFields): ...@@ -883,6 +917,24 @@ class CapaMixin(CapaFields):
return False return False
def correctness_available(self):
"""
Is the user allowed to see whether she's answered correctly?
Limits access to the correct/incorrect flags, messages, and problem score.
"""
if self.show_correctness == SHOW_CORRECTNESS.NEVER:
return False
elif self.runtime.user_is_staff:
# This is after the 'never' check because admins can see correctness
# unless the problem explicitly prevents it
return True
elif self.show_correctness == SHOW_CORRECTNESS.PAST_DUE:
return self.is_past_due()
# else: self.show_correctness == SHOW_CORRECTNESS.ALWAYS
return True
def update_score(self, data): def update_score(self, data):
""" """
Delivers grading response (e.g. from asynchronous code checking) to Delivers grading response (e.g. from asynchronous code checking) to
...@@ -1233,6 +1285,10 @@ class CapaMixin(CapaFields): ...@@ -1233,6 +1285,10 @@ class CapaMixin(CapaFields):
# render problem into HTML # render problem into HTML
html = self.get_problem_html(encapsulate=False, submit_notification=True) html = self.get_problem_html(encapsulate=False, submit_notification=True)
# Withhold success indicator if hiding correctness
if not self.correctness_available():
success = 'submitted'
return { return {
'success': success, 'success': success,
'contents': html 'contents': html
......
...@@ -4,6 +4,15 @@ Constants for capa_base problems ...@@ -4,6 +4,15 @@ Constants for capa_base problems
""" """
class SHOW_CORRECTNESS(object): # pylint: disable=invalid-name
"""
Constants for when to show correctness
"""
ALWAYS = "always"
PAST_DUE = "past_due"
NEVER = "never"
class SHOWANSWER(object): class SHOWANSWER(object):
""" """
Constants for when to show answer Constants for when to show answer
......
...@@ -120,7 +120,8 @@ class CapaModule(CapaMixin, XModule): ...@@ -120,7 +120,8 @@ class CapaModule(CapaMixin, XModule):
after = self.get_progress() after = self.get_progress()
after_attempts = self.attempts after_attempts = self.attempts
progress_changed = (after != before) or (after_attempts != before_attempts) progress_changed = (after != before) or (after_attempts != before_attempts)
curr_score, total_possible = (after.frac() if after else (0, 0)) curr_score, total_possible = self.get_display_progress()
result.update({ result.update({
'progress_changed': progress_changed, 'progress_changed': progress_changed,
'current_score': curr_score, 'current_score': curr_score,
...@@ -215,6 +216,7 @@ class CapaDescriptor(CapaFields, RawDescriptor): ...@@ -215,6 +216,7 @@ class CapaDescriptor(CapaFields, RawDescriptor):
CapaDescriptor.force_save_button, CapaDescriptor.force_save_button,
CapaDescriptor.markdown, CapaDescriptor.markdown,
CapaDescriptor.use_latex_compiler, CapaDescriptor.use_latex_compiler,
CapaDescriptor.show_correctness,
]) ])
return non_editable_fields return non_editable_fields
......
...@@ -250,6 +250,15 @@ div.problem { ...@@ -250,6 +250,15 @@ div.problem {
border-color: $incorrect; border-color: $incorrect;
} }
} }
&.choicegroup_submitted {
border: 2px solid $submitted;
// keep blue for submitted answers on hover.
&:hover {
border-color: $submitted;
}
}
} }
.indicator-container { .indicator-container {
...@@ -325,6 +334,7 @@ div.problem { ...@@ -325,6 +334,7 @@ div.problem {
@include status-icon($incorrect, $cross-icon); @include status-icon($incorrect, $cross-icon);
} }
&.submitted,
&.unsubmitted, &.unsubmitted,
&.unanswered { &.unanswered {
.status-icon { .status-icon {
...@@ -419,6 +429,12 @@ div.problem { ...@@ -419,6 +429,12 @@ div.problem {
} }
} }
&.submitted, &.ui-icon-check {
input {
border-color: $submitted;
}
}
p.answer { p.answer {
display: inline-block; display: inline-block;
margin-top: ($baseline / 2); margin-top: ($baseline / 2);
...@@ -790,6 +806,18 @@ div.problem { ...@@ -790,6 +806,18 @@ div.problem {
} }
} }
// CASE: submitted, correctness withheld
> .submitted {
input {
border: 2px solid $submitted;
}
.status {
content: '';
}
}
// CASE: unanswered and unsubmitted // CASE: unanswered and unsubmitted
> .unanswered, > .unsubmitted { > .unanswered, > .unsubmitted {
...@@ -824,7 +852,11 @@ div.problem { ...@@ -824,7 +852,11 @@ div.problem {
.indicator-container { .indicator-container {
display: inline-block; display: inline-block;
.status.correct:after, .status.partially-correct:after, .status.incorrect:after, .status.unanswered:after { .status.correct:after,
.status.partially-correct:after,
.status.incorrect:after,
.status.submitted:after,
.status.unanswered:after {
@include margin-left(0); @include margin-left(0);
} }
} }
...@@ -1531,6 +1563,10 @@ div.problem { ...@@ -1531,6 +1563,10 @@ div.problem {
@extend label.choicegroup_incorrect; @extend label.choicegroup_incorrect;
} }
label.choicetextgroup_submitted, section.choicetextgroup_submitted {
@extend label.choicegroup_submitted;
}
label.choicetextgroup_show_correct, section.choicetextgroup_show_correct { label.choicetextgroup_show_correct, section.choicetextgroup_show_correct {
&:after { &:after {
@include margin-left($baseline*.75); @include margin-left($baseline*.75);
...@@ -1569,6 +1605,10 @@ div.problem .imageinput.capa_inputtype { ...@@ -1569,6 +1605,10 @@ div.problem .imageinput.capa_inputtype {
.partially-correct { .partially-correct {
@include status-icon($partially-correct, $asterisk-icon); @include status-icon($partially-correct, $asterisk-icon);
} }
.submitted {
content: '';
}
} }
// +Problem - Annotation Problem Overrides // +Problem - Annotation Problem Overrides
...@@ -1596,4 +1636,8 @@ div.problem .annotation-input { ...@@ -1596,4 +1636,8 @@ div.problem .annotation-input {
.partially-correct { .partially-correct {
@include status-icon($partially-correct, $asterisk-icon); @include status-icon($partially-correct, $asterisk-icon);
} }
.submitted {
content: '';
}
} }
...@@ -138,6 +138,28 @@ describe 'Problem', -> ...@@ -138,6 +138,28 @@ describe 'Problem', ->
it 'shows 0 points possible for the detail', -> it 'shows 0 points possible for the detail', ->
testProgessData(@problem, 0, 0, 1, "False", "0 points possible (ungraded)") testProgessData(@problem, 0, 0, 1, "False", "0 points possible (ungraded)")
describe 'with a score of null (show_correctness == false)', ->
it 'reports the number of points possible and graded, results hidden', ->
testProgessData(@problem, null, 1, 0, "True", "1 point possible (graded, results hidden)")
it 'reports the number of points possible (plural) and graded, results hidden', ->
testProgessData(@problem, null, 2, 0, "True", "2 points possible (graded, results hidden)")
it 'reports the number of points possible and ungraded, results hidden', ->
testProgessData(@problem, null, 1, 0, "False", "1 point possible (ungraded, results hidden)")
it 'displays ungraded if number of points possible is 0, results hidden', ->
testProgessData(@problem, null, 0, 0, "False", "0 points possible (ungraded, results hidden)")
it 'displays ungraded if number of points possible is 0, even if graded value is True, results hidden', ->
testProgessData(@problem, null, 0, 0, "True", "0 points possible (ungraded, results hidden)")
it 'reports the correct score with status none and >0 attempts, results hidden', ->
testProgessData(@problem, null, 1, 1, "True", "1 point possible (graded, results hidden)")
it 'reports the correct score with >1 weight, status none, and >0 attempts, results hidden', ->
testProgessData(@problem, null, 2, 2, "True", "2 points possible (graded, results hidden)")
describe 'render', -> describe 'render', ->
beforeEach -> beforeEach ->
@problem = new Problem($('.xblock-student_view')) @problem = new Problem($('.xblock-student_view'))
......
...@@ -214,11 +214,36 @@ ...@@ -214,11 +214,36 @@
attemptsUsed = this.el.data('attempts-used'); attemptsUsed = this.el.data('attempts-used');
graded = this.el.data('graded'); graded = this.el.data('graded');
// The problem is ungraded if it's explicitly marked as such, or if the total possible score is 0
if (graded === 'True' && totalScore !== 0) {
graded = true;
} else {
graded = false;
}
if (curScore === undefined || totalScore === undefined) { if (curScore === undefined || totalScore === undefined) {
progress = ''; // Render an empty string.
progressTemplate = '';
} else if (curScore === null || curScore === 'None') {
// Render 'x point(s) possible (un/graded, results hidden)' if no current score provided.
if (graded) {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (graded, results hidden)',
'%(num_points)s points possible (graded, results hidden)',
totalScore
);
} else {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (ungraded, results hidden)',
'%(num_points)s points possible (ungraded, results hidden)',
totalScore
);
}
} else if (attemptsUsed === 0 || totalScore === 0) { } else if (attemptsUsed === 0 || totalScore === 0) {
// Render 'x point(s) possible' if student has not yet attempted question // Render 'x point(s) possible' if student has not yet attempted question
if (graded === 'True' && totalScore !== 0) { if (graded) {
progressTemplate = ngettext( progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).; // Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
'%(num_points)s point possible (graded)', '%(num_points)s points possible (graded)', '%(num_points)s point possible (graded)', '%(num_points)s points possible (graded)',
...@@ -231,10 +256,9 @@ ...@@ -231,10 +256,9 @@
totalScore totalScore
); );
} }
progress = interpolate(progressTemplate, {num_points: totalScore}, true);
} else { } else {
// Render 'x/y point(s)' if student has attempted question // Render 'x/y point(s)' if student has attempted question
if (graded === 'True' && totalScore !== 0) { if (graded) {
progressTemplate = ngettext( progressTemplate = ngettext(
// This comment needs to be on one line to be properly scraped for the translators. // This comment needs to be on one line to be properly scraped for the translators.
// Translators: %(earned)s is the number of points earned. %(possible)s is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points); // Translators: %(earned)s is the number of points earned. %(possible)s is the total number of points (examples: 0/1, 1/1, 2/3, 5/10). The total number of points will always be at least 1. We pluralize based on the total number of points (example: 0/1 point; 1/2 points);
...@@ -249,13 +273,14 @@ ...@@ -249,13 +273,14 @@
totalScore totalScore
); );
} }
}
progress = interpolate( progress = interpolate(
progressTemplate, { progressTemplate, {
earned: curScore, earned: curScore,
num_points: totalScore,
possible: totalScore possible: totalScore
}, true }, true
); );
}
return this.$('.problem-progress').text(progress); return this.$('.problem-progress').text(progress);
}; };
...@@ -573,6 +598,7 @@ ...@@ -573,6 +598,7 @@
complete: this.enableSubmitButtonAfterResponse, complete: this.enableSubmitButtonAfterResponse,
success: function(response) { success: function(response) {
switch (response.success) { switch (response.success) {
case 'submitted':
case 'incorrect': case 'incorrect':
case 'correct': case 'correct':
that.render(response.contents); that.render(response.contents);
...@@ -599,6 +625,7 @@ ...@@ -599,6 +625,7 @@
Logger.log('problem_check', this.answers); Logger.log('problem_check', this.answers);
return $.postWithPrefix('' + this.url + '/problem_check', this.answers, function(response) { return $.postWithPrefix('' + this.url + '/problem_check', this.answers, function(response) {
switch (response.success) { switch (response.success) {
case 'submitted':
case 'incorrect': case 'incorrect':
case 'correct': case 'correct':
window.SR.readTexts(that.get_sr_status(response.contents)); window.SR.readTexts(that.get_sr_status(response.contents));
......
...@@ -100,6 +100,19 @@ class InheritanceMixin(XBlockMixin): ...@@ -100,6 +100,19 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.settings, scope=Scope.settings,
default="finished", default="finished",
) )
show_correctness = String(
display_name=_("Show Results"),
help=_(
# Translators: DO NOT translate the words in quotes here, they are
# specific words for the acceptable values.
'Specify when to show answer correctness and score to learners. '
'Valid values are "always", "never", and "past_due".'
),
scope=Scope.settings,
default="always",
)
rerandomize = String( rerandomize = String(
display_name=_("Randomization"), display_name=_("Randomization"),
help=_( help=_(
......
...@@ -265,6 +265,46 @@ class CapaModuleTest(unittest.TestCase): ...@@ -265,6 +265,46 @@ class CapaModuleTest(unittest.TestCase):
problem.attempts = 1 problem.attempts = 1
self.assertTrue(problem.answer_available()) self.assertTrue(problem.answer_available())
@ddt.data(
# If show_correctness=always, Answer is visible after attempted
({
'showanswer': 'attempted',
'max_attempts': '1',
'show_correctness': 'always',
}, True),
# If show_correctness=never, Answer is never visible
({
'showanswer': 'attempted',
'max_attempts': '1',
'show_correctness': 'never',
}, False),
# If show_correctness=past_due, answer is not visible before due date
({
'showanswer': 'attempted',
'show_correctness': 'past_due',
'max_attempts': '1',
'due': 'tomorrow_str',
}, False),
# If show_correctness=past_due, answer is visible after due date
({
'showanswer': 'attempted',
'show_correctness': 'past_due',
'max_attempts': '1',
'due': 'yesterday_str',
}, True),
)
@ddt.unpack
def test_showanswer_hide_correctness(self, problem_data, answer_available):
"""
Ensure that the answer will not be shown when correctness is being hidden.
"""
if 'due' in problem_data:
problem_data['due'] = getattr(self, problem_data['due'])
problem = CapaFactory.create(**problem_data)
self.assertFalse(problem.answer_available())
problem.attempts = 1
self.assertEqual(problem.answer_available(), answer_available)
def test_showanswer_closed(self): def test_showanswer_closed(self):
# can see after attempts used up, even with due date in the future # can see after attempts used up, even with due date in the future
...@@ -414,6 +454,73 @@ class CapaModuleTest(unittest.TestCase): ...@@ -414,6 +454,73 @@ class CapaModuleTest(unittest.TestCase):
graceperiod=self.two_day_delta_str) graceperiod=self.two_day_delta_str)
self.assertTrue(still_in_grace.answer_available()) self.assertTrue(still_in_grace.answer_available())
@ddt.data('', 'other-value')
def test_show_correctness_other(self, show_correctness):
"""
Test that correctness is visible if show_correctness is not set to one of the values
from SHOW_CORRECTNESS constant.
"""
problem = CapaFactory.create(show_correctness=show_correctness)
self.assertTrue(problem.correctness_available())
def test_show_correctness_default(self):
"""
Test that correctness is visible by default.
"""
problem = CapaFactory.create()
self.assertTrue(problem.correctness_available())
def test_show_correctness_never(self):
"""
Test that correctness is hidden when show_correctness turned off.
"""
problem = CapaFactory.create(show_correctness='never')
self.assertFalse(problem.correctness_available())
@ddt.data(
# Correctness not visible if due date in the future, even after using up all attempts
({
'show_correctness': 'past_due',
'max_attempts': '1',
'attempts': '1',
'due': 'tomorrow_str',
}, False),
# Correctness visible if due date in the past
({
'show_correctness': 'past_due',
'max_attempts': '1',
'attempts': '0',
'due': 'yesterday_str',
}, True),
# Correctness not visible if due date in the future
({
'show_correctness': 'past_due',
'max_attempts': '1',
'attempts': '0',
'due': 'tomorrow_str',
}, False),
# Correctness not visible because grace period hasn't expired,
# even after using up all attempts
({
'show_correctness': 'past_due',
'max_attempts': '1',
'attempts': '1',
'due': 'yesterday_str',
'graceperiod': 'two_day_delta_str',
}, False),
)
@ddt.unpack
def test_show_correctness_past_due(self, problem_data, expected_result):
"""
Test that with show_correctness="past_due", correctness will only be visible
after the problem is closed for everyone--e.g. after due date + grace period.
"""
problem_data['due'] = getattr(self, problem_data['due'])
if 'graceperiod' in problem_data:
problem_data['graceperiod'] = getattr(self, problem_data['graceperiod'])
problem = CapaFactory.create(**problem_data)
self.assertEqual(problem.correctness_available(), expected_result)
def test_closed(self): def test_closed(self):
# Attempts < Max attempts --> NOT closed # Attempts < Max attempts --> NOT closed
...@@ -814,6 +921,36 @@ class CapaModuleTest(unittest.TestCase): ...@@ -814,6 +921,36 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the number of attempts is NOT incremented # Expect that the number of attempts is NOT incremented
self.assertEqual(module.attempts, 1) self.assertEqual(module.attempts, 1)
@ddt.data(
("never", True, None, 'submitted'),
("never", False, None, 'submitted'),
("past_due", True, None, 'submitted'),
("past_due", False, None, 'submitted'),
("always", True, 1, 'correct'),
("always", False, 0, 'incorrect'),
)
@ddt.unpack
def test_handle_ajax_show_correctness(self, show_correctness, is_correct, expected_score, expected_success):
module = CapaFactory.create(show_correctness=show_correctness,
due=self.tomorrow_str,
correct=is_correct)
# Simulate marking the input correct/incorrect
with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct:
mock_is_correct.return_value = is_correct
# Check the problem
get_request_dict = {CapaFactory.input_key(): '0'}
json_result = module.handle_ajax('problem_check', get_request_dict)
result = json.loads(json_result)
# Expect that the AJAX result withholds correctness and score
self.assertEqual(result['current_score'], expected_score)
self.assertEqual(result['success'], expected_success)
# Expect that the number of attempts is incremented by 1
self.assertEqual(module.attempts, 1)
def test_reset_problem(self): def test_reset_problem(self):
module = CapaFactory.create(done=True) module = CapaFactory.create(done=True)
module.new_lcp = Mock(wraps=module.new_lcp) module.new_lcp = Mock(wraps=module.new_lcp)
...@@ -1584,6 +1721,27 @@ class CapaModuleTest(unittest.TestCase): ...@@ -1584,6 +1721,27 @@ class CapaModuleTest(unittest.TestCase):
other_module.get_progress() other_module.get_progress()
mock_progress.assert_called_with(1, 1) mock_progress.assert_called_with(1, 1)
@ddt.data(
("never", True, None),
("never", False, None),
("past_due", True, None),
("past_due", False, None),
("always", True, 1),
("always", False, 0),
)
@ddt.unpack
def test_get_display_progress_show_correctness(self, show_correctness, is_correct, expected_score):
"""
Check that score and total are calculated correctly for the progress fraction.
"""
module = CapaFactory.create(correct=is_correct,
show_correctness=show_correctness,
due=self.tomorrow_str)
module.weight = 1
score, total = module.get_display_progress()
self.assertEqual(score, expected_score)
self.assertEqual(total, 1)
def test_get_html(self): def test_get_html(self):
""" """
Check that get_html() calls get_progress() with no arguments. Check that get_html() calls get_progress() with no arguments.
......
...@@ -151,6 +151,7 @@ $general-color-accent: $uxpl-blue-base !default ...@@ -151,6 +151,7 @@ $general-color-accent: $uxpl-blue-base !default
$correct: $success-color !default; $correct: $success-color !default;
$partially-correct: $success-color !default; $partially-correct: $success-color !default;
$incorrect: $error-color !default; $incorrect: $error-color !default;
$submitted: $general-color !default;
// BUTTONS // BUTTONS
......
...@@ -317,6 +317,14 @@ class ProblemPage(PageObject): ...@@ -317,6 +317,14 @@ class ProblemPage(PageObject):
self.wait_for_element_visibility('.fa-asterisk', "Waiting for asterisk notification icon") self.wait_for_element_visibility('.fa-asterisk', "Waiting for asterisk notification icon")
self.wait_for_focus_on_submit_notification() self.wait_for_focus_on_submit_notification()
def wait_submitted_notification(self):
"""
Check for visibility of the "answer received" general notification and icon.
"""
msg = "Wait for submitted notification to be visible"
self.wait_for_element_visibility('.notification.general.notification-submit', msg)
self.wait_for_focus_on_submit_notification()
def click_hint(self): def click_hint(self):
""" """
Click the Hint button. Click the Hint button.
...@@ -418,14 +426,16 @@ class ProblemPage(PageObject): ...@@ -418,14 +426,16 @@ class ProblemPage(PageObject):
solution_selector = '.solution-span div.detailed-solution' solution_selector = '.solution-span div.detailed-solution'
return self.q(css=solution_selector).is_present() return self.q(css=solution_selector).is_present()
def is_correct_choice_highlighted(self, correct_choices): def is_choice_highlighted(self, choice, choices_list):
""" """
Check if correct answer/choice highlighted for choice group. Check if the given answer/choice is highlighted for choice group.
""" """
correct_status_xpath = '//fieldset/div[contains(@class, "field")][{0}]/label[contains(@class, "choicegroup_correct")]/span[contains(@class, "status correct")]' # pylint: disable=line-too-long choice_status_xpath = ('//fieldset/div[contains(@class, "field")][{{0}}]'
'/label[contains(@class, "choicegroup_{choice}")]'
'/span[contains(@class, "status {choice}")]'.format(choice=choice))
any_status_xpath = '//fieldset/div[contains(@class, "field")][{0}]/label/span' any_status_xpath = '//fieldset/div[contains(@class, "field")][{0}]/label/span'
for choice in correct_choices: for choice in choices_list:
if not self.q(xpath=correct_status_xpath.format(choice)).is_present(): if not self.q(xpath=choice_status_xpath.format(choice)).is_present():
return False return False
# Check that there is only a single status span, as there were some bugs with multiple # Check that there is only a single status span, as there were some bugs with multiple
...@@ -435,6 +445,18 @@ class ProblemPage(PageObject): ...@@ -435,6 +445,18 @@ class ProblemPage(PageObject):
return True return True
def is_correct_choice_highlighted(self, correct_choices):
"""
Check if correct answer/choice highlighted for choice group.
"""
return self.is_choice_highlighted('correct', correct_choices)
def is_submitted_choice_highlighted(self, correct_choices):
"""
Check if submitted answer/choice highlighted for choice group.
"""
return self.is_choice_highlighted('submitted', correct_choices)
@property @property
def problem_question(self): def problem_question(self):
""" """
......
...@@ -575,6 +575,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -575,6 +575,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
self.q(css=".action-save").first.click() self.q(css=".action-save").first.click()
self.wait_for_ajax() self.wait_for_ajax()
def select_visibility_tab(self):
"""
Select the advanced settings tab
"""
self.q(css=".settings-tab-button[data-tab='visibility']").first.click()
self.wait_for_element_presence('input[value=hide_after_due]', 'Visibility fields not present.')
def select_advanced_tab(self, desired_item='special_exam'): def select_advanced_tab(self, desired_item='special_exam'):
""" """
Select the advanced settings tab Select the advanced settings tab
...@@ -584,8 +591,6 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -584,8 +591,6 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
self.wait_for_element_presence('input.no_special_exam', 'Special exam settings fields not present.') self.wait_for_element_presence('input.no_special_exam', 'Special exam settings fields not present.')
if desired_item == 'gated_content': if desired_item == 'gated_content':
self.wait_for_element_visibility('#is_prereq', 'Gating settings fields are present.') self.wait_for_element_visibility('#is_prereq', 'Gating settings fields are present.')
if desired_item == 'hide_after_due_date':
self.wait_for_element_presence('input[value=hide_after_due]', 'Visibility fields not present.')
def make_exam_proctored(self): def make_exam_proctored(self):
""" """
...@@ -601,6 +606,7 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -601,6 +606,7 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
self.q(css="input.timed_exam").first.click() self.q(css="input.timed_exam").first.click()
if hide_after_due: if hide_after_due:
self.select_visibility_tab()
self.q(css='input[name=content-visibility][value=hide_after_due]').first.click() self.q(css='input[name=content-visibility][value=hide_after_due]').first.click()
self.q(css=".action-save").first.click() self.q(css=".action-save").first.click()
self.wait_for_ajax() self.wait_for_ajax()
...@@ -1057,7 +1063,7 @@ class CourseOutlineModal(object): ...@@ -1057,7 +1063,7 @@ class CourseOutlineModal(object):
if needed. if needed.
""" """
if not self.is_staff_lock_visible: if not self.is_staff_lock_visible:
self.find_css(".settings-tab-button[data-tab=advanced]").click() self.find_css(".settings-tab-button[data-tab=visibility]").click()
EmptyPromise( EmptyPromise(
lambda: self.is_staff_lock_visible, lambda: self.is_staff_lock_visible,
"Staff lock option is visible", "Staff lock option is visible",
......
...@@ -900,7 +900,7 @@ class SubsectionHiddenAfterDueDateTest(UniqueCourseTest): ...@@ -900,7 +900,7 @@ class SubsectionHiddenAfterDueDateTest(UniqueCourseTest):
self.studio_course_outline.visit() self.studio_course_outline.visit()
self.studio_course_outline.open_subsection_settings_dialog() self.studio_course_outline.open_subsection_settings_dialog()
self.studio_course_outline.select_advanced_tab('hide_after_due_date') self.studio_course_outline.select_visibility_tab()
self.studio_course_outline.make_subsection_hidden_after_due_date() self.studio_course_outline.make_subsection_hidden_after_due_date()
self.logout_page.visit() self.logout_page.visit()
......
...@@ -98,6 +98,15 @@ from openedx.core.djangolib.markup import HTML ...@@ -98,6 +98,15 @@ from openedx.core.djangolib.markup import HTML
notification_message=answer_notification_message" notification_message=answer_notification_message"
/> />
% endif % endif
% if 'submitted' == answer_notification_type:
<%include file="problem_notifications.html" args="
notification_type='general',
notification_icon='fa-info-circle',
notification_name='submit',
is_hidden=False,
notification_message=answer_notification_message"
/>
% endif
% endif % endif
<%include file="problem_notifications.html" args=" <%include file="problem_notifications.html" args="
notification_type='warning', notification_type='warning',
......
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