Skip to content

Commit

Permalink
feat: make "Show Answer" option consistent with Problem XBlock (#294)
Browse files Browse the repository at this point in the history
Co-authored-by: Agrendalath <piotr@surowiec.it>
  • Loading branch information
abodacs and Agrendalath authored Oct 18, 2022
1 parent a9e88f6 commit 52cab4c
Show file tree
Hide file tree
Showing 72 changed files with 3,552 additions and 1,910 deletions.
5 changes: 5 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Drag and Drop XBlock changelog
==============================

Version 2.5.0 (2022-10-13)
---------------------------

* Make the "Show Answer" condition customizable (like in the Problem XBlock).

Version 2.4.2 (2022-10-13)
---------------------------

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,15 @@ There are two problem modes available:
* **Standard**: In this mode, the learner gets immediate feedback on each
attempt to place an item, and the number of attempts is not limited.
* **Assessment**: In this mode, the learner places all items on the board and
then clicks a "Submit" button to get feedback. The number of attempts can be
limited. When all attempts are used, the learner can click a "Show Answer"
button to temporarily place items on their correct drop zones.
then clicks a "Submit" button to get feedback.
* The number of attempts can be limited.
* The learner can click a "Show Answer" button to temporarily place items on their correct drop zones.
You can select one of the pre-defined conditions for displaying this button. They work in the same way as in the
Problem XBlock, so you can read about each them in the [Problem Component documentation][capa-show-answer].
By default, the value from the course "Advanced Settings" configuration is used. If you have modified this for
a specific XBlock but want to switch back to using the default value, select the "Default" option.

[capa-show-answer]: https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_components/create_problem.html#show-answer

![Drop zone edit](doc/img/edit-view-zones.png)

Expand Down
114 changes: 103 additions & 11 deletions drag_and_drop_v2/drag_and_drop_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from .default_data import DEFAULT_DATA
from .utils import (
Constants, DummyTranslationService, FeedbackMessage,
Constants, SHOWANSWER, DummyTranslationService, FeedbackMessage,
FeedbackMessages, ItemStats, StateMigration, _clean_data, _
)

Expand All @@ -45,6 +45,7 @@
# pylint: disable=bad-continuation
@XBlock.wants('settings')
@XBlock.wants('replace_urls')
@XBlock.wants('user') # Using `needs` breaks the Course Outline page in Maple.
@XBlock.needs('i18n')
class DragAndDropBlock(
ScorableXBlockMixin,
Expand Down Expand Up @@ -133,6 +134,29 @@ class DragAndDropBlock(
default=True,
enforce_type=True,
)
showanswer = String(
display_name=_("Show answer"),
help=_("Defines when to show the answer to the problem. "
"A default value can be set in Advanced Settings. "
"To revert setting a custom value, choose the 'Default' option."),
scope=Scope.settings,
default=SHOWANSWER.FINISHED,
values=[
{"display_name": _("Default"), "value": SHOWANSWER.DEFAULT},
{"display_name": _("Always"), "value": SHOWANSWER.ALWAYS},
{"display_name": _("Answered"), "value": SHOWANSWER.ANSWERED},
{"display_name": _("Attempted or Past Due"), "value": SHOWANSWER.ATTEMPTED},
{"display_name": _("Closed"), "value": SHOWANSWER.CLOSED},
{"display_name": _("Finished"), "value": SHOWANSWER.FINISHED},
{"display_name": _("Correct or Past Due"), "value": SHOWANSWER.CORRECT_OR_PAST_DUE},
{"display_name": _("Past Due"), "value": SHOWANSWER.PAST_DUE},
{"display_name": _("Never"), "value": SHOWANSWER.NEVER},
{"display_name": _("After All Attempts"), "value": SHOWANSWER.AFTER_ALL_ATTEMPTS},
{"display_name": _("After All Attempts or Correct"), "value": SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT},
{"display_name": _("Attempted"), "value": SHOWANSWER.ATTEMPTED_NO_PAST_DUE},
],
enforce_type=True,
)

weight = Float(
display_name=_("Problem Weight"),
Expand Down Expand Up @@ -391,6 +415,7 @@ def items_without_answers():
"item_background_color": self.item_background_color or None,
"item_text_color": self.item_text_color or None,
"has_deadline_passed": self.has_submission_deadline_passed,
"answer_available": self.is_answer_available,
# final feedback (data.feedback.finish) is not included - it may give away answers.
}

Expand All @@ -410,6 +435,7 @@ def studio_view(self, context):
'js_templates': js_templates,
'id_suffix': id_suffix,
'fields': self.fields,
'showanswer_set': self._field_data.has(self, 'showanswer'), # If false, we're using an inherited value.
'self': self,
'data': six.moves.urllib.parse.quote(json.dumps(self.data)),
}
Expand Down Expand Up @@ -464,6 +490,10 @@ def studio_submit(self, submissions, suffix=''):
self.display_name = submissions['display_name']
self.mode = submissions['mode']
self.max_attempts = submissions['max_attempts']
if (showanswer := submissions['showanswer']) != self.showanswer:
self.showanswer = showanswer
if showanswer == SHOWANSWER.DEFAULT:
del self.showanswer
self.show_title = submissions['show_title']
self.question_text = submissions['problem_text']
self.show_question_header = submissions['show_problem_header']
Expand Down Expand Up @@ -557,7 +587,7 @@ def do_attempt(self, data, suffix=''):
# fields, either as an "input" (i.e. read value) or as output (i.e. set value) or both. As a result,
# incorrect order of invocation causes issues:
self._mark_complete_and_publish_grade() # must happen before _get_feedback - sets grade
correct = self._is_answer_correct() # must happen before manipulating item_state - reads item_state
correct = self.is_correct # must happen before manipulating item_state - reads item_state

overall_feedback_msgs, misplaced_ids = self._get_feedback(include_item_feedback=True)

Expand All @@ -575,7 +605,8 @@ def do_attempt(self, data, suffix=''):
'grade': self._get_weighted_earned_if_set(),
'misplaced_items': list(misplaced_ids),
'feedback': self._present_feedback(feedback_msgs),
'overall_feedback': self._present_feedback(overall_feedback_msgs)
'overall_feedback': self._present_feedback(overall_feedback_msgs),
"answer_available": self.is_answer_available,
}

@XBlock.json_handler
Expand Down Expand Up @@ -606,17 +637,17 @@ def show_answer(self, data, suffix=''):
Raises:
* JsonHandlerError with 400 error code in standard mode.
* JsonHandlerError with 409 error code if there are still attempts left
* JsonHandlerError with 409 error code if the answer is unavailable.
"""
if self.mode != Constants.ASSESSMENT_MODE:
raise JsonHandlerError(
400,
self.i18n_service.gettext("show_answer handler should only be called for assessment mode")
)
if self.attempts_remain:
if not self.is_answer_available:
raise JsonHandlerError(
409,
self.i18n_service.gettext("There are attempts remaining")
self.i18n_service.gettext("The answer is unavailable")
)

answer = self._get_correct_state()
Expand Down Expand Up @@ -693,6 +724,65 @@ def has_submission_deadline_passed(self):
else:
return False

@property
def closed(self):
"""
Is the student still allowed to submit answers?
"""
if not self.attempts_remain:
return True
if self.has_submission_deadline_passed:
return True

return False

@property
def is_attempted(self):
"""
Has the problem been attempted?
"""
return self.attempts > 0

@property
def is_finished(self):
"""
Returns True if answer is closed or answer is correct.
"""
return self.closed or self.is_correct

@property
def is_answer_available(self):
"""
Is student allowed to see an answer?
"""
permission_functions = {
SHOWANSWER.NEVER: lambda: False,
SHOWANSWER.ATTEMPTED: lambda: self.is_attempted or self.has_submission_deadline_passed,
SHOWANSWER.ANSWERED: lambda: self.is_correct,
SHOWANSWER.CLOSED: lambda: self.closed,
SHOWANSWER.FINISHED: lambda: self.is_finished,
SHOWANSWER.CORRECT_OR_PAST_DUE: lambda: self.is_correct or self.has_submission_deadline_passed,
SHOWANSWER.PAST_DUE: lambda: self.has_submission_deadline_passed,
SHOWANSWER.ALWAYS: lambda: True,
SHOWANSWER.AFTER_ALL_ATTEMPTS: lambda: not self.attempts_remain,
SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT: lambda: not self.attempts_remain or self.is_correct,
SHOWANSWER.ATTEMPTED_NO_PAST_DUE: lambda: self.is_attempted,
}

if self.mode != Constants.ASSESSMENT_MODE:
return False

user_is_staff = False
if user_service := self.runtime.service(self, 'user'):
user_is_staff = user_service.get_current_user().opt_attrs.get(Constants.ATTR_KEY_USER_IS_STAFF)

if self.showanswer not in [SHOWANSWER.NEVER, ''] and user_is_staff:
# Staff users can see the answer unless the problem explicitly prevents it.
return True

check_permissions_function = permission_functions.get(self.showanswer, lambda: False)
return check_permissions_function()

@XBlock.handler
def student_view_user_state(self, request, suffix=''):
""" GET all user-specific data, and any applicable feedback """
Expand Down Expand Up @@ -819,7 +909,7 @@ def _drop_item_standard(self, item_attempt):
return {
'correct': is_correct,
'grade': self._get_weighted_earned_if_set(),
'finished': self._is_answer_correct(),
'finished': self.is_correct,
'overall_feedback': self._present_feedback(overall_feedback),
'feedback': self._present_feedback([item_feedback])
}
Expand Down Expand Up @@ -869,7 +959,7 @@ def _mark_complete_and_publish_grade(self):
"""
# pylint: disable=fixme
# TODO: (arguable) split this method into "clean" functions (with no side effects and implicit state)
# This method implicitly depends on self.item_state (via _is_answer_correct and _learner_raw_score)
# This method implicitly depends on self.item_state (via is_correct and _learner_raw_score)
# and also updates self.raw_earned if some conditions are met. As a result this method implies some order of
# invocation:
# * it should be called after learner-caused updates to self.item_state is applied
Expand All @@ -880,7 +970,7 @@ def _mark_complete_and_publish_grade(self):
# and help avoid bugs caused by invocation order violation in future.

# There's no going back from "completed" status to "incomplete"
self.completed = self.completed or self._is_answer_correct() or not self.attempts_remain
self.completed = self.completed or self.is_correct or not self.attempts_remain

current_raw_earned = self._learner_raw_score()
# ... and from higher grade to lower
Expand Down Expand Up @@ -987,9 +1077,10 @@ def _get_user_state(self):

overall_feedback_msgs, __ = self._get_feedback()
if self.mode == Constants.STANDARD_MODE:
is_finished = self._is_answer_correct()
is_finished = self.is_correct
else:
is_finished = not self.attempts_remain

return {
'items': item_state,
'finished': is_finished,
Expand Down Expand Up @@ -1188,7 +1279,8 @@ def _answer_correctness(self):
else:
return self.SOLUTION_PARTIAL

def _is_answer_correct(self):
@property
def is_correct(self):
"""
Helper - checks if answer is correct
Expand Down
18 changes: 9 additions & 9 deletions drag_and_drop_v2/public/js/drag_and_drop.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ function DragAndDropTemplates(configuration) {
var showAnswerButton = null;
if (ctx.show_show_answer) {
var options = {
disabled: ctx.showing_answer ? true : ctx.disable_show_answer_button,
disabled: !!ctx.showing_answer,
spinner: ctx.show_answer_spinner
};
showAnswerButton = sidebarButtonTemplate(
Expand Down Expand Up @@ -1883,8 +1883,8 @@ function DragAndDropBlock(runtime, element, configuration) {
state.grade = data.grade;
state.feedback = data.feedback;
state.overall_feedback = data.overall_feedback;
state.answer_available = data.answer_available;
state.last_action_correct = data.correct;

if (attemptsRemain()) {
data.misplaced_items.forEach(function(misplaced_item_id) {
delete state.items[misplaced_item_id]
Expand All @@ -1901,7 +1901,7 @@ function DragAndDropBlock(runtime, element, configuration) {
};

var canSubmitAttempt = function() {
return Object.keys(state.items).length > 0 && isPastDue() && attemptsRemain() && !submittingLocation();
return Object.keys(state.items).length > 0 && !isPastDue() && attemptsRemain() && !submittingLocation();
};

var canReset = function() {
Expand All @@ -1921,16 +1921,17 @@ function DragAndDropBlock(runtime, element, configuration) {
};

var isPastDue = function () {
return !configuration.has_deadline_passed;
return configuration.has_deadline_passed;
};

var canShowAnswer = function() {
return configuration.mode === DragAndDropBlock.ASSESSMENT_MODE && !attemptsRemain();
};

var attemptsRemain = function() {
return !configuration.max_attempts || configuration.max_attempts > state.attempts;
};
var canShowAnswer = function() {
if(state.answer_available === undefined) return configuration.answer_available;
return state.answer_available;
}

var submittingLocation = function() {
var result = false;
Expand Down Expand Up @@ -2009,7 +2010,7 @@ function DragAndDropBlock(runtime, element, configuration) {
problem_html: configuration.problem_text,
show_problem_header: configuration.show_problem_header,
show_submit_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
show_show_answer: configuration.mode == DragAndDropBlock.ASSESSMENT_MODE,
show_show_answer: canShowAnswer(),
target_img_src: configuration.target_img_expanded_url,
target_img_description: configuration.target_img_description,
display_zone_labels: configuration.display_zone_labels,
Expand All @@ -2026,7 +2027,6 @@ function DragAndDropBlock(runtime, element, configuration) {
overall_feedback_messages: state.overall_feedback,
explanation: state.explanation,
disable_reset_button: !canReset(),
disable_show_answer_button: !canShowAnswer(),
disable_submit_button: !canSubmitAttempt(),
submit_spinner: state.submit_spinner,
showing_answer: state.showing_answer,
Expand Down
5 changes: 5 additions & 0 deletions drag_and_drop_v2/public/js/drag_and_drop_edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ function DragAndDropEditBlock(runtime, element, params) {
$fbkTab
.on('change', '.problem-mode', _fn.build.form.problem.toggleAssessmentSettings);

$fbkTab.on('change', '.showanswer', function () {
_fn.build.$el.feedback.form.find('.showanswer-inherited').hide();
});

$zoneTab
.on('change', '.background-image-type input', _fn.build.form.zone.toggleAutozoneSettings)
.on('click', '.add-zone', function(e) {
Expand Down Expand Up @@ -725,6 +729,7 @@ function DragAndDropEditBlock(runtime, element, params) {
'display_name': $element.find('.display-name').val(),
'mode': $element.find(".problem-mode").val(),
'max_attempts': $element.find(".max-attempts").val(),
'showanswer': $element.find(".showanswer").val(),
'show_title': $element.find('.show-title').is(':checked'),
'weight': $element.find('.weight').val(),
'problem_text': $element.find('.problem-text').val(),
Expand Down
1 change: 0 additions & 1 deletion drag_and_drop_v2/public/js/translations/ar/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@
"The background color of draggable items in the problem (example: 'blue' or '#0000ff').": "\u0644\u0648\u0646 \u0627\u0644\u062e\u0644\u0641\u064a\u0629 \u0644\u0644\u0639\u0646\u0627\u0635\u0631 \u0627\u0644\u0642\u0627\u0628\u0644\u0629 \u0644\u0644\u0633\u062d\u0628 \u0641\u064a \u0627\u0644\u0645\u0633\u0623\u0644\u0629 (\u0639\u0644\u0649 \u0633\u0628\u064a\u0644 \u0627\u0644\u0645\u062b\u0627\u0644: \"\u0623\u0632\u0631\u0642\" \u0623\u0648 '#0000ff').",
"The description of the problem or instructions shown to the learner.": "\u0648\u0635\u0641 \u0627\u0644\u0645\u0633\u0623\u0644\u0629 \u0623\u0648 \u0627\u0644\u062a\u0639\u0644\u064a\u0645\u0627\u062a \u0627\u0644\u0645\u0648\u0636\u062d\u0629 \u0644\u0644\u0637\u0627\u0644\u0628.",
"The title of the drag and drop problem. The title is displayed to learners.": "\u0639\u0646\u0648\u0627\u0646 \u0645\u0633\u0623\u0644\u0629 \u0627\u0644\u0633\u062d\u0628 \u0648\u0627\u0644\u0625\u0633\u0642\u0627\u0637. \u064a\u0638\u0647\u0631 \u0627\u0644\u0639\u0646\u0648\u0627\u0646 \u0644\u0644\u0637\u0644\u0627\u0628.",
"There are attempts remaining": "\u0647\u0646\u0627\u0643 \u0645\u062d\u0627\u0648\u0644\u0627\u062a \u0645\u062a\u0628\u0642\u064a\u0629",
"There was an error with your form.": "\u0644\u0642\u062f \u0643\u0627\u0646 \u0647\u0646\u0627\u0643 \u062e\u0637\u0623 \u0641\u064a \u0627\u0633\u062a\u0645\u0627\u0631\u062a\u0643.",
"This is a screen reader-friendly problem.": "\u0647\u0630\u0647 \u0627\u0644\u0645\u0633\u0627\u0644\u0629 \u062a\u0639\u0645\u0644 \u0639\u0644\u0649 \u0642\u0627\u0631\u0626 \u0627\u0644\u0634\u0627\u0634\u0629 \"Screen reader\".",
"This setting limits the number of items that can be dropped into a single zone.": "\u064a\u062d\u062f\u062f \u0647\u0630\u0627 \u0627\u0644\u0627\u0639\u062f\u0627\u062f \u0627\u0644\u062d\u062f \u0627\u0644\u0627\u0639\u0644\u0649 \u0645\u0646 \u0627\u0644\u0639\u0646\u0627\u0635\u0631 \u0627\u0644\u0630\u064a \u064a\u0645\u0643\u0646 \u0627\u0636\u0627\u0641\u062a\u0647\u0627 \u0627\u0644\u0649 \u0627\u0644\u0645\u0646\u0637\u0642\u0629 \u0627\u0644\u0648\u0627\u062d\u062f\u0629.",
Expand Down
1 change: 0 additions & 1 deletion drag_and_drop_v2/public/js/translations/de/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@
"The background color of draggable items in the problem (example: 'blue' or '#0000ff').": "Die Hintergrundfarbe der beweglichen Auswahlm\u00f6glichkeit bei der Fragestellung (Beispiel \"blau\" oder \"#0000ff\" )",
"The description of the problem or instructions shown to the learner.": "Die Beschreibung des Problems oder die Anweisungen werden dem Lernenden angezeigt.",
"The title of the drag and drop problem. The title is displayed to learners.": "Dies ist der Titel der Drag&Drop Aufgabe. Dieser Titel wird den Teilnehmern angezeigt.",
"There are attempts remaining": "Sie haben noch weitere Versuche ",
"There was an error with your form.": "Es gab einen formalen Fehler.",
"This is a screen reader-friendly problem.": "Dies ist eine Screen-Reader kompatible Aufgabe",
"This setting limits the number of items that can be dropped into a single zone.": "Mit dieser Einstellung k\u00f6nnen Sie die Anzahl der Elemente limitieren, welche in den einzelnen Ablagebereichen abgelegt werden k\u00f6nnen.",
Expand Down
Loading

0 comments on commit 52cab4c

Please sign in to comment.