From defaff350a3c974f0cf7807836fbe3a7b90ef254 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Tue, 22 Sep 2015 15:17:26 +0200
Subject: [PATCH 05/43] Show review step when clicking on "Review grade"
button.
---
problem_builder/public/js/mentoring_with_steps.js | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index aa5940ed..04945994 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -109,9 +109,15 @@ function MentoringWithStepsBlock(runtime, element) {
}
}
+ function showGrade() {
+ cleanAll();
+ reviewStep.show();
+ }
+
function handleTryAgain(result) {
activeStep = result.active_step;
updateDisplay();
+ reviewStep.hide();
tryAgainDOM.hide();
submitDOM.show();
if (! isLastStep()) {
@@ -143,6 +149,7 @@ function MentoringWithStepsBlock(runtime, element) {
nextDOM.show();
reviewDOM = $(element).find('.submit .input-review');
+ reviewDOM.on('click', showGrade);
tryAgainDOM = $(element).find('.submit .input-try-again');
tryAgainDOM.on('click', tryAgain);
From 433ce153da1e2262d5eb0337421fb9d6abf9c191 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Tue, 22 Sep 2015 15:57:28 +0200
Subject: [PATCH 06/43] Allow only one review step per mentoring block.
---
.../public/js/mentoring_with_steps_edit.js | 36 +++++++++++++------
1 file changed, 26 insertions(+), 10 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps_edit.js b/problem_builder/public/js/mentoring_with_steps_edit.js
index 6bc6197f..31e48e69 100644
--- a/problem_builder/public/js/mentoring_with_steps_edit.js
+++ b/problem_builder/public/js/mentoring_with_steps_edit.js
@@ -1,22 +1,38 @@
function MentoringWithStepsEdit(runtime, element) {
"use strict";
- // Disable "add" buttons when a message of that type already exists:
- var $buttons = $('.add-xblock-component-button[data-category=pb-message]', element);
- var updateButtons = function() {
- $buttons.each(function() {
- var msg_type = $(this).data('boilerplate');
- $(this).toggleClass('disabled', $('.xblock .submission-message.'+msg_type).length > 0);
- });
+
+ var blockIsPresent = function(klass) {
+ return $('.xblock ' + klass).length > 0;
};
- updateButtons();
- $buttons.click(function(ev) {
+
+ var updateButton = function(button, condition) {
+ button.toggleClass('disabled', condition);
+ };
+
+ var disableButton = function(ev) {
if ($(this).is('.disabled')) {
ev.preventDefault();
ev.stopPropagation();
} else {
$(this).addClass('disabled');
}
- });
+ };
+
+ var initButtons = function(dataCategory) {
+ var $buttons = $('.add-xblock-component-button[data-category='+dataCategory+']', element);
+ $buttons.each(function() {
+ if (dataCategory === 'pb-message') {
+ var msg_type = $(this).data('boilerplate');
+ updateButton($(this), blockIsPresent('.submission-message.'+msg_type));
+ } else {
+ updateButton($(this), blockIsPresent('.xblock-header-sb-review-step'));
+ }
+ });
+ $buttons.on('click', disableButton);
+ };
+
+ initButtons('pb-message');
+ initButtons('sb-review-step');
ProblemBuilderUtil.transformClarifications(element);
StudioEditableXBlockMixin(runtime, element);
From f07502fe50f09aac5bbba7c2b63623b5ce7b9929 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Tue, 22 Sep 2015 16:47:18 +0200
Subject: [PATCH 07/43] Redirect to review step when reloading page after
submitting last step.
---
problem_builder/mentoring.py | 8 ++++++
.../public/js/mentoring_with_steps.js | 26 +++++++++++++++----
2 files changed, 29 insertions(+), 5 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index dab24bdf..5ad0d97e 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -865,6 +865,11 @@ def steps(self):
child_isinstance(self, child_id, MentoringStepBlock)
]
+ @property
+ def has_review_step(self):
+ from .step import ReviewStepBlock
+ return any(child_isinstance(self, child_id, ReviewStepBlock) for child_id in self.children)
+
def student_view(self, context):
fragment = Fragment()
children_contents = []
@@ -919,6 +924,9 @@ def allowed_nested_blocks(self):
def update_active_step(self, new_value, suffix=''):
if new_value < len(self.steps):
self.active_step = new_value
+ elif new_value == len(self.steps):
+ if self.has_review_step:
+ self.active_step = -1
return {
'active_step': self.active_step
}
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 04945994..89abfc3c 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -10,6 +10,10 @@ function MentoringWithStepsBlock(runtime, element) {
return (activeStep === steps.length-1);
}
+ function atReviewStep() {
+ return (activeStep === -1);
+ }
+
function updateActiveStep(newValue) {
var handlerUrl = runtime.handlerUrl(element, 'update_active_step');
$.post(handlerUrl, JSON.stringify(newValue))
@@ -64,14 +68,26 @@ function MentoringWithStepsBlock(runtime, element) {
function updateDisplay() {
cleanAll();
- showActiveStep();
- validateXBlock();
- nextDOM.attr('disabled', 'disabled');
- if (isLastStep()) {
- reviewDOM.show();
+ if (atReviewStep()) {
+ showReviewStep();
+ } else {
+ showActiveStep();
+ validateXBlock();
+ nextDOM.attr('disabled', 'disabled');
+ if (isLastStep()) {
+ reviewDOM.show();
+ }
}
}
+ function showReviewStep() {
+ reviewStep.show();
+ submitDOM.hide();
+ nextDOM.hide();
+ tryAgainDOM.removeAttr('disabled');
+ tryAgainDOM.show();
+ }
+
function showActiveStep() {
var step = steps[activeStep];
$(step.element).show();
From 1806c75400d7ebd82160f941fec5648e2212cb4c Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Tue, 22 Sep 2015 16:51:55 +0200
Subject: [PATCH 08/43] Hide "Submit", "Next Step", "Review grade" buttons when
displaying review step.
---
problem_builder/public/js/mentoring_with_steps.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 89abfc3c..f343aaa1 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -84,6 +84,7 @@ function MentoringWithStepsBlock(runtime, element) {
reviewStep.show();
submitDOM.hide();
nextDOM.hide();
+ reviewDOM.hide();
tryAgainDOM.removeAttr('disabled');
tryAgainDOM.show();
}
@@ -127,7 +128,7 @@ function MentoringWithStepsBlock(runtime, element) {
function showGrade() {
cleanAll();
- reviewStep.show();
+ showReviewStep();
}
function handleTryAgain(result) {
From d91007aea2cf9336e7582b8cf31c43d7310ce184 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Tue, 22 Sep 2015 16:55:25 +0200
Subject: [PATCH 09/43] Don't show "Try again" button at last step.
---
problem_builder/public/js/mentoring_with_steps.js | 2 --
1 file changed, 2 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index f343aaa1..4f6a7ed4 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -42,8 +42,6 @@ function MentoringWithStepsBlock(runtime, element) {
if (isLastStep()) {
reviewDOM.removeAttr('disabled');
- tryAgainDOM.removeAttr('disabled');
- tryAgainDOM.show();
}
}
From 431bc4808b8851ed89805535ecca5cd413233e66 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Tue, 22 Sep 2015 17:11:47 +0200
Subject: [PATCH 10/43] Handle cases where review step is not present (because
it has not been added to current mentoring block).
---
problem_builder/public/js/mentoring_with_steps.js | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 4f6a7ed4..7009d7c9 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -14,6 +14,10 @@ function MentoringWithStepsBlock(runtime, element) {
return (activeStep === -1);
}
+ function reviewStepPresent() {
+ return reviewStep.length > 0;
+ }
+
function updateActiveStep(newValue) {
var handlerUrl = runtime.handlerUrl(element, 'update_active_step');
$.post(handlerUrl, JSON.stringify(newValue))
@@ -41,7 +45,12 @@ function MentoringWithStepsBlock(runtime, element) {
if (nextDOM.is(':visible')) { nextDOM.focus(); }
if (isLastStep()) {
- reviewDOM.removeAttr('disabled');
+ if (reviewStepPresent()) {
+ reviewDOM.removeAttr('disabled');
+ } else {
+ tryAgainDOM.removeAttr('disabled');
+ tryAgainDOM.show();
+ }
}
}
@@ -72,7 +81,7 @@ function MentoringWithStepsBlock(runtime, element) {
showActiveStep();
validateXBlock();
nextDOM.attr('disabled', 'disabled');
- if (isLastStep()) {
+ if (isLastStep() && reviewStepPresent()) {
reviewDOM.show();
}
}
From a06370c01cbc06a1f3b1adba1336f19cead207ac Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 11:21:10 +0200
Subject: [PATCH 11/43] Show appropriate assessment message when reaching
review step.
---
problem_builder/mentoring.py | 29 +++++++++++++++++++
.../public/js/mentoring_with_steps.js | 12 +++++++-
.../templates/html/mentoring_with_steps.html | 6 ++++
3 files changed, 46 insertions(+), 1 deletion(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 5ad0d97e..a0e7b4e3 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -870,6 +870,35 @@ def has_review_step(self):
from .step import ReviewStepBlock
return any(child_isinstance(self, child_id, ReviewStepBlock) for child_id in self.children)
+ @property
+ def max_attempts_reached(self):
+ return False
+
+ @property
+ def assessment_message(self):
+ """
+ Get the message to display to a student following a submission in assessment mode.
+ """
+ if not self.max_attempts_reached:
+ return self.get_message_content('on-assessment-review', or_default=True)
+ else:
+ assessment_message = _("Note: you have used all attempts. Continue to the next unit")
+ return '
{}
'.format(assessment_message)
+
+ def get_message_content(self, message_type, or_default=False):
+ for child_id in self.children:
+ if child_isinstance(self, child_id, MentoringMessageBlock):
+ child = self.runtime.get_block(child_id)
+ if child.type == message_type:
+ content = child.content
+ if hasattr(self.runtime, 'replace_jump_to_id_urls'):
+ content = self.runtime.replace_jump_to_id_urls(content)
+ return content
+ if or_default:
+ # Return the default value since no custom message is set.
+ # Note the WYSIWYG editor usually wraps the .content HTML in a
tag so we do the same here.
+ return '
{}
'.format(MentoringMessageBlock.MESSAGE_TYPES[message_type]['default'])
+
def student_view(self, context):
fragment = Fragment()
children_contents = []
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 7009d7c9..a9e4189f 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -4,7 +4,7 @@ function MentoringWithStepsBlock(runtime, element) {
function(c) { return c.element.className.indexOf('sb-step') > -1; }
);
var activeStep = $('.mentoring', element).data('active-step');
- var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM, submitXHR;
+ var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM, assessmentMessageDOM, submitXHR;
function isLastStep() {
return (activeStep === steps.length-1);
@@ -71,11 +71,13 @@ function MentoringWithStepsBlock(runtime, element) {
checkmark.removeClass('checkmark-partially-correct icon-ok fa-check');
checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation');
hideAllSteps();
+ assessmentMessageDOM.html('');
}
function updateDisplay() {
cleanAll();
if (atReviewStep()) {
+ showAssessmentMessage();
showReviewStep();
} else {
showActiveStep();
@@ -87,6 +89,11 @@ function MentoringWithStepsBlock(runtime, element) {
}
}
+ function showAssessmentMessage() {
+ var data = $('.grade', element).data();
+ assessmentMessageDOM.html(data.assessment_message);
+ }
+
function showReviewStep() {
reviewStep.show();
submitDOM.hide();
@@ -135,6 +142,7 @@ function MentoringWithStepsBlock(runtime, element) {
function showGrade() {
cleanAll();
+ showAssessmentMessage();
showReviewStep();
}
@@ -178,6 +186,8 @@ function MentoringWithStepsBlock(runtime, element) {
tryAgainDOM = $(element).find('.submit .input-try-again');
tryAgainDOM.on('click', tryAgain);
+ assessmentMessageDOM = $('.assessment-message', element);
+
var options = {
onChange: onChange
};
diff --git a/problem_builder/templates/html/mentoring_with_steps.html b/problem_builder/templates/html/mentoring_with_steps.html
index 795a99d6..b1f9891d 100644
--- a/problem_builder/templates/html/mentoring_with_steps.html
+++ b/problem_builder/templates/html/mentoring_with_steps.html
@@ -9,10 +9,16 @@
{{ title }}
+
+
{% for child_content in children_contents %}
{{ child_content|safe }}
{% endfor %}
+
+
From 839840a3627016337c202976a8bbdeed0cf4ce59 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 11:29:23 +0200
Subject: [PATCH 12/43] Add max_attempts and num_attempts fields to new
mentoring block and make max_attempts editable in Studio.
---
problem_builder/mentoring.py | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index a0e7b4e3..580e33d7 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -831,6 +831,15 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
"""
An XBlock providing mentoring capabilities with explicit steps
"""
+ # Content
+ max_attempts = Integer(
+ display_name=_("Max. Attempts Allowed"),
+ help=_("Maximum number of attempts allowed for this mentoring block"),
+ default=0,
+ scope=Scope.content,
+ enforce_type=True
+ )
+
# Settings
display_name = String(
display_name=_("Title (Display name)"),
@@ -840,6 +849,12 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
)
# User state
+ num_attempts = Integer(
+ # Number of attempts user has attempted this mentoring block
+ default=0,
+ scope=Scope.user_state,
+ enforce_type=True
+ )
active_step = Integer(
# Keep track of the student progress.
default=0,
@@ -847,7 +862,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
enforce_type=True
)
- editable_fields = ('display_name',)
+ editable_fields = ('display_name', 'max_attempts')
@lazy
def questions(self):
From 677ffef8e06a09b067ff606a5c1d411f877495d4 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 13:56:29 +0200
Subject: [PATCH 13/43] Update num_attempts field of new mentoring block after
submitting last step.
---
problem_builder/mentoring.py | 5 +++++
problem_builder/public/js/mentoring_with_steps.js | 8 ++++++++
problem_builder/step.py | 6 ++++++
3 files changed, 19 insertions(+)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 580e33d7..ee1868bf 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -975,6 +975,11 @@ def update_active_step(self, new_value, suffix=''):
'active_step': self.active_step
}
+ @XBlock.json_handler
+ def update_num_attempts(self, data, suffix=''):
+ self.num_attempts += 1
+ return {}
+
@XBlock.json_handler
def try_again(self, data, suffix=''):
self.active_step = 0
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index a9e4189f..b7e66e03 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -26,9 +26,17 @@ function MentoringWithStepsBlock(runtime, element) {
});
}
+ function updateNumAttempts() {
+ var handlerUrl = runtime.handlerUrl(element, 'update_num_attempts');
+ $.post(handlerUrl, JSON.stringify({}));
+ }
+
function handleResults(response) {
// Update active step so next step is shown on page reload (even if user does not click "Next Step")
updateActiveStep(activeStep+1);
+ if (response.update_attempts) {
+ updateNumAttempts();
+ }
// Update UI
if (response.completed === 'correct') {
diff --git a/problem_builder/step.py b/problem_builder/step.py
index ba8715be..db3cb49f 100644
--- a/problem_builder/step.py
+++ b/problem_builder/step.py
@@ -102,6 +102,11 @@ class MentoringStepBlock(
def siblings(self):
return self.get_parent().steps
+ @property
+ def is_last_step(self):
+ parent = self.get_parent()
+ return self.step_number == len(parent.steps)
+
@property
def allowed_nested_blocks(self):
"""
@@ -149,6 +154,7 @@ def submit(self, submissions, suffix=''):
'message': 'Success!',
'completed': completed,
'results': submit_results,
+ 'update_attempts': self.is_last_step
}
def reset(self):
From 177e01e29088a7ff6591ebb89357d59e74587efa Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 13:57:47 +0200
Subject: [PATCH 14/43] Add implementation for max_attempts_reached property
(was just returning False until now).
---
problem_builder/mentoring.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index ee1868bf..85fc9966 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -887,7 +887,7 @@ def has_review_step(self):
@property
def max_attempts_reached(self):
- return False
+ return self.max_attempts > 0 and self.num_attempts >= self.max_attempts
@property
def assessment_message(self):
From c251a61445286b4cac6a60956c3821a2edab232f Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 14:46:28 +0200
Subject: [PATCH 15/43] Show info about number of attempts used if limited
number of attempts available.
---
problem_builder/mentoring.py | 2 ++
problem_builder/public/js/mentoring_with_steps.js | 14 +++++++++++++-
.../templates/html/mentoring_with_steps.html | 5 +++++
3 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 85fc9966..7a5345f5 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -937,6 +937,8 @@ def student_view(self, context):
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_with_steps.js'))
+ fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html")
+
self.include_theme_files(fragment)
fragment.initialize_js('MentoringWithStepsBlock')
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index b7e66e03..1fe1a81a 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -4,7 +4,8 @@ function MentoringWithStepsBlock(runtime, element) {
function(c) { return c.element.className.indexOf('sb-step') > -1; }
);
var activeStep = $('.mentoring', element).data('active-step');
- var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM, assessmentMessageDOM, submitXHR;
+ var attemptsTemplate = _.template($('#xblock-attempts-template').html());
+ var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM, assessmentMessageDOM, attemptsDOM, submitXHR;
function isLastStep() {
return (activeStep === steps.length-1);
@@ -80,6 +81,7 @@ function MentoringWithStepsBlock(runtime, element) {
checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation');
hideAllSteps();
assessmentMessageDOM.html('');
+ attemptsDOM.html('');
}
function updateDisplay() {
@@ -87,6 +89,7 @@ function MentoringWithStepsBlock(runtime, element) {
if (atReviewStep()) {
showAssessmentMessage();
showReviewStep();
+ showAttempts();
} else {
showActiveStep();
validateXBlock();
@@ -111,6 +114,13 @@ function MentoringWithStepsBlock(runtime, element) {
tryAgainDOM.show();
}
+ function showAttempts() {
+ var data = attemptsDOM.data();
+ if (data.max_attempts > 0) {
+ attemptsDOM.html(attemptsTemplate(data));
+ } // Don't show attempts if unlimited attempts available (max_attempts === 0)
+ }
+
function showActiveStep() {
var step = steps[activeStep];
$(step.element).show();
@@ -152,6 +162,7 @@ function MentoringWithStepsBlock(runtime, element) {
cleanAll();
showAssessmentMessage();
showReviewStep();
+ showAttempts();
}
function handleTryAgain(result) {
@@ -195,6 +206,7 @@ function MentoringWithStepsBlock(runtime, element) {
tryAgainDOM.on('click', tryAgain);
assessmentMessageDOM = $('.assessment-message', element);
+ attemptsDOM = $('.attempts', element);
var options = {
onChange: onChange
diff --git a/problem_builder/templates/html/mentoring_with_steps.html b/problem_builder/templates/html/mentoring_with_steps.html
index b1f9891d..23656ed1 100644
--- a/problem_builder/templates/html/mentoring_with_steps.html
+++ b/problem_builder/templates/html/mentoring_with_steps.html
@@ -25,6 +25,11 @@
{{ title }}
+
+
+
+
From 6a86b6785c32a92531ad1612e6e65529850dcc7d Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 14:58:50 +0200
Subject: [PATCH 16/43] Disable "Try again" button if no attempts left.
---
problem_builder/mentoring.py | 4 +++-
problem_builder/public/js/mentoring_with_steps.js | 10 +++++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 7a5345f5..2d615643 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -980,7 +980,9 @@ def update_active_step(self, new_value, suffix=''):
@XBlock.json_handler
def update_num_attempts(self, data, suffix=''):
self.num_attempts += 1
- return {}
+ return {
+ 'num_attempts': self.num_attempts
+ }
@XBlock.json_handler
def try_again(self, data, suffix=''):
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 1fe1a81a..c139dd6e 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -29,7 +29,10 @@ function MentoringWithStepsBlock(runtime, element) {
function updateNumAttempts() {
var handlerUrl = runtime.handlerUrl(element, 'update_num_attempts');
- $.post(handlerUrl, JSON.stringify({}));
+ $.post(handlerUrl, JSON.stringify({}))
+ .success(function(response) {
+ attemptsDOM.data('num_attempts', response.num_attempts);
+ });
}
function handleResults(response) {
@@ -163,6 +166,11 @@ function MentoringWithStepsBlock(runtime, element) {
showAssessmentMessage();
showReviewStep();
showAttempts();
+ // Disable "Try again" button if no attempts left
+ var data = attemptsDOM.data();
+ if (data.max_attempts > 0 && data.num_attempts >= data.max_attempts) {
+ tryAgainDOM.attr("disabled", "disabled");
+ }
}
function handleTryAgain(result) {
From 8f86a2e286df4f432af5d576090c56b927266e4a Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 15:01:21 +0200
Subject: [PATCH 17/43] Make sure "Review grade" button is disabled at last
step.
---
problem_builder/public/js/mentoring_with_steps.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index c139dd6e..11e9df76 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -98,6 +98,7 @@ function MentoringWithStepsBlock(runtime, element) {
validateXBlock();
nextDOM.attr('disabled', 'disabled');
if (isLastStep() && reviewStepPresent()) {
+ reviewDOM.attr('disabled', 'disabled');
reviewDOM.show();
}
}
From 95c94869dc73d241bf52e9570cbd3d5f81e98d97 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 15:35:45 +0200
Subject: [PATCH 18/43] Don't show "Try again" button if no review step
present; show message about number of submissions used instead.
---
.../public/js/mentoring_with_steps.js | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 11e9df76..4f73777c 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -19,6 +19,14 @@ function MentoringWithStepsBlock(runtime, element) {
return reviewStep.length > 0;
}
+ function someAttemptsLeft() {
+ var data = attemptsDOM.data();
+ if (data.max_attempts === 0) { // Unlimited number of attempts available
+ return true;
+ }
+ return (data.num_attempts < data.max_attempts);
+ }
+
function updateActiveStep(newValue) {
var handlerUrl = runtime.handlerUrl(element, 'update_active_step');
$.post(handlerUrl, JSON.stringify(newValue))
@@ -60,8 +68,12 @@ function MentoringWithStepsBlock(runtime, element) {
if (reviewStepPresent()) {
reviewDOM.removeAttr('disabled');
} else {
- tryAgainDOM.removeAttr('disabled');
- tryAgainDOM.show();
+ if (someAttemptsLeft()) {
+ tryAgainDOM.removeAttr('disabled');
+ tryAgainDOM.show();
+ } else {
+ showAttempts();
+ }
}
}
}
@@ -168,8 +180,7 @@ function MentoringWithStepsBlock(runtime, element) {
showReviewStep();
showAttempts();
// Disable "Try again" button if no attempts left
- var data = attemptsDOM.data();
- if (data.max_attempts > 0 && data.num_attempts >= data.max_attempts) {
+ if (!someAttemptsLeft()) {
tryAgainDOM.attr("disabled", "disabled");
}
}
From ca1e9f95df7999c19fe98f65b0c774b1410c7abc Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 15:37:28 +0200
Subject: [PATCH 19/43] Don't allow num_attempts to be larger than
max_attempts.
---
problem_builder/mentoring.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 2d615643..08096a23 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -979,7 +979,8 @@ def update_active_step(self, new_value, suffix=''):
@XBlock.json_handler
def update_num_attempts(self, data, suffix=''):
- self.num_attempts += 1
+ if self.num_attempts < self.max_attempts:
+ self.num_attempts += 1
return {
'num_attempts': self.num_attempts
}
From bc87907751cfa2d85284a309ddfa7e7e78df1d94 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 19:03:30 +0200
Subject: [PATCH 20/43] Display score (percentage) and count of
correct/partial/incorrect answers.
---
problem_builder/mentoring.py | 52 +++++++++++++++++++
.../public/js/mentoring_with_steps.js | 26 ++++++++--
problem_builder/step.py | 2 +-
.../templates/html/mentoring_with_steps.html | 4 ++
4 files changed, 80 insertions(+), 4 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 08096a23..fbbafa49 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -869,6 +869,12 @@ def questions(self):
""" Get the usage_ids of all of this XBlock's children that are "Questions" """
return list(chain.from_iterable(self.runtime.get_block(step_id).steps for step_id in self.steps))
+ def get_questions(self):
+ """ Get all questions associated with this block, cached if possible. """
+ if getattr(self, "_questions_cache", None) is None:
+ self._questions_cache = [self.runtime.get_block(question_id) for question_id in self.questions]
+ return self._questions_cache
+
@property
def steps(self):
"""
@@ -880,6 +886,21 @@ def steps(self):
child_isinstance(self, child_id, MentoringStepBlock)
]
+ def get_steps(self):
+ """ Get the step children of this block, cached if possible. """
+ if getattr(self, "_steps_cache", None) is None:
+ self._steps_cache = [self.runtime.get_block(child_id) for child_id in self.steps]
+ return self._steps_cache
+
+ def answer_mapper(self, answer_status):
+ steps = self.get_steps()
+ answer_map = []
+ for step in steps:
+ for answer in step.student_results:
+ if answer[1]['status'] == answer_status:
+ answer_map.append({'id': answer[0], 'details': answer[1]})
+ return answer_map
+
@property
def has_review_step(self):
from .step import ReviewStepBlock
@@ -900,6 +921,27 @@ def assessment_message(self):
assessment_message = _("Note: you have used all attempts. Continue to the next unit")
return '
{}
'.format(assessment_message)
+ @property
+ def score(self):
+ questions = self.get_questions()
+ total_child_weight = sum(float(question.weight) for question in questions)
+ if total_child_weight == 0:
+ return Score(0, 0, [], [], [])
+ steps = self.get_steps()
+ questions_map = {question.name: question for question in questions}
+ points_earned = 0
+ for step in steps:
+ for question_name, question_results in step.student_results:
+ question = questions_map.get(question_name)
+ if question: # Under what conditions would this evaluate to False?
+ points_earned += question_results['score'] * question.weight
+ score = points_earned / total_child_weight
+ correct = self.answer_mapper(CORRECT)
+ incorrect = self.answer_mapper(INCORRECT)
+ partially_correct = self.answer_mapper(PARTIAL)
+
+ return Score(score, int(round(score * 100)), correct, incorrect, partially_correct)
+
def get_message_content(self, message_type, or_default=False):
for child_id in self.children:
if child_isinstance(self, child_id, MentoringMessageBlock):
@@ -938,6 +980,7 @@ def student_view(self, context):
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_with_steps.js'))
fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html")
+ fragment.add_resource(loader.load_unicode('templates/html/mentoring_assessment_templates.html'), "text/html")
self.include_theme_files(fragment)
@@ -985,6 +1028,15 @@ def update_num_attempts(self, data, suffix=''):
'num_attempts': self.num_attempts
}
+ @XBlock.json_handler
+ def get_score(self, data, suffix):
+ return {
+ 'score': self.score.percentage,
+ 'correct_answers': len(self.score.correct),
+ 'incorrect_answers': len(self.score.incorrect),
+ 'partially_correct_answers': len(self.score.partially_correct),
+ }
+
@XBlock.json_handler
def try_again(self, data, suffix=''):
self.active_step = 0
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 4f73777c..0a8052b8 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -4,8 +4,10 @@ function MentoringWithStepsBlock(runtime, element) {
function(c) { return c.element.className.indexOf('sb-step') > -1; }
);
var activeStep = $('.mentoring', element).data('active-step');
+ var gradeTemplate = _.template($('#xblock-grade-template').html());
var attemptsTemplate = _.template($('#xblock-attempts-template').html());
- var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM, assessmentMessageDOM, attemptsDOM, submitXHR;
+ var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM,
+ assessmentMessageDOM, gradeDOM, attemptsDOM, submitXHR;
function isLastStep() {
return (activeStep === steps.length-1);
@@ -43,11 +45,25 @@ function MentoringWithStepsBlock(runtime, element) {
});
}
+ function updateGrade() {
+ var handlerUrl = runtime.handlerUrl(element, 'get_score');
+ $.post(handlerUrl, JSON.stringify({}))
+ .success(function(response) {
+ gradeDOM.data('score', response.score);
+ gradeDOM.data('correct_answer', response.correct_answers);
+ gradeDOM.data('incorrect_answer', response.incorrect_answers);
+ gradeDOM.data('partially_correct_answer', response.partially_correct_answers);
+ });
+ }
+
function handleResults(response) {
// Update active step so next step is shown on page reload (even if user does not click "Next Step")
updateActiveStep(activeStep+1);
- if (response.update_attempts) {
+
+ // If step submitted was last step of this mentoring block, update grade and number of attempts used
+ if (response.attempt_complete) {
updateNumAttempts();
+ updateGrade();
}
// Update UI
@@ -96,6 +112,7 @@ function MentoringWithStepsBlock(runtime, element) {
checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation');
hideAllSteps();
assessmentMessageDOM.html('');
+ gradeDOM.html('');
attemptsDOM.html('');
}
@@ -117,12 +134,14 @@ function MentoringWithStepsBlock(runtime, element) {
}
function showAssessmentMessage() {
- var data = $('.grade', element).data();
+ var data = gradeDOM.data();
assessmentMessageDOM.html(data.assessment_message);
}
function showReviewStep() {
reviewStep.show();
+ var data = gradeDOM.data();
+ gradeDOM.html(gradeTemplate(data));
submitDOM.hide();
nextDOM.hide();
reviewDOM.hide();
@@ -226,6 +245,7 @@ function MentoringWithStepsBlock(runtime, element) {
tryAgainDOM.on('click', tryAgain);
assessmentMessageDOM = $('.assessment-message', element);
+ gradeDOM = $('.grade', element);
attemptsDOM = $('.attempts', element);
var options = {
diff --git a/problem_builder/step.py b/problem_builder/step.py
index db3cb49f..42e66368 100644
--- a/problem_builder/step.py
+++ b/problem_builder/step.py
@@ -154,7 +154,7 @@ def submit(self, submissions, suffix=''):
'message': 'Success!',
'completed': completed,
'results': submit_results,
- 'update_attempts': self.is_last_step
+ 'attempt_complete': self.is_last_step
}
def reset(self):
diff --git a/problem_builder/templates/html/mentoring_with_steps.html b/problem_builder/templates/html/mentoring_with_steps.html
index 23656ed1..d3aae3ae 100644
--- a/problem_builder/templates/html/mentoring_with_steps.html
+++ b/problem_builder/templates/html/mentoring_with_steps.html
@@ -17,6 +17,10 @@
{{ title }}
From fce6efc17c3658ea63903254ee8734d2dc575a88 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 20:57:44 +0200
Subject: [PATCH 21/43] Introduce extended_feedback field and make it editable
in Studio.
---
problem_builder/mentoring.py | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index fbbafa49..1d97d385 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -833,16 +833,22 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
"""
# Content
max_attempts = Integer(
- display_name=_("Max. Attempts Allowed"),
- help=_("Maximum number of attempts allowed for this mentoring block"),
+ display_name=_("Max. attempts allowed"),
+ help=_("Maximum number of times students are allowed to attempt this mentoring block"),
default=0,
scope=Scope.content,
enforce_type=True
)
+ extended_feedback = Boolean(
+ display_name=_("Extended feedback"),
+ help=_("Show extended feedback when all attempts are used up?"),
+ default=False,
+ Scope=Scope.content
+ )
# Settings
display_name = String(
- display_name=_("Title (Display name)"),
+ display_name=_("Title (display name)"),
help=_("Title to display"),
default=_("Mentoring Questions (with explicit steps)"),
scope=Scope.settings
@@ -862,7 +868,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
enforce_type=True
)
- editable_fields = ('display_name', 'max_attempts')
+ editable_fields = ('display_name', 'max_attempts', 'extended_feedback')
@lazy
def questions(self):
From a69d48616493c97a32fb8dcdd9f74d3e8661e699 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Wed, 23 Sep 2015 22:18:20 +0200
Subject: [PATCH 22/43] Change default display names of existing mentoring
block ("Problem Builder") and new mentoring block ("Step Builder").
---
problem_builder/mentoring.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 1d97d385..761b4c54 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -225,7 +225,7 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentM
display_name = String(
display_name=_("Title (Display name)"),
help=_("Title to display"),
- default=_("Mentoring Questions"),
+ default=_("Problem Builder"),
scope=Scope.settings
)
feedback_label = String(
@@ -850,7 +850,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
display_name = String(
display_name=_("Title (display name)"),
help=_("Title to display"),
- default=_("Mentoring Questions (with explicit steps)"),
+ default=_("Step Builder"),
scope=Scope.settings
)
From ef0812a600d74b9dbb5d8e71fb494631768f53ac Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Thu, 24 Sep 2015 06:43:45 +0200
Subject: [PATCH 23/43] Show review tips if there are some attempts left.
---
problem_builder/mentoring.py | 29 +++++++++++++++++++
.../public/js/mentoring_with_steps.js | 25 +++++++++++++++-
.../templates/html/mentoring_with_steps.html | 6 +++-
3 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 761b4c54..da86844a 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -948,6 +948,35 @@ def score(self):
return Score(score, int(round(score * 100)), correct, incorrect, partially_correct)
+ @property
+ def review_tips(self):
+ """ Get review tips, shown for wrong answers. """
+ review_tips = []
+ status_cache = dict()
+ steps = self.get_steps()
+ for step in steps:
+ status_cache.update(dict(step.student_results))
+ for question in self.get_questions():
+ result = status_cache.get(question.name)
+ if result and result.get('status') != 'correct':
+ # The student got this wrong. Check if there is a review tip to show.
+ tip_html = question.get_review_tip()
+ if tip_html:
+ if hasattr(self.runtime, 'replace_jump_to_id_urls'):
+ tip_html = self.runtime.replace_jump_to_id_urls(tip_html)
+ review_tips.append(tip_html)
+ return review_tips
+
+ @property
+ def review_tips_json(self):
+ return json.dumps(self.review_tips)
+
+ @XBlock.json_handler
+ def get_review_tips(self, data, suffix):
+ return {
+ 'review_tips': self.review_tips
+ }
+
def get_message_content(self, message_type, or_default=False):
for child_id in self.children:
if child_isinstance(self, child_id, MentoringMessageBlock):
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 0a8052b8..855b9a95 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -5,9 +5,10 @@ function MentoringWithStepsBlock(runtime, element) {
);
var activeStep = $('.mentoring', element).data('active-step');
var gradeTemplate = _.template($('#xblock-grade-template').html());
+ var reviewTipsTemplate = _.template($('#xblock-review-tips-template').html()); // Tips about specific questions the user got wrong
var attemptsTemplate = _.template($('#xblock-attempts-template').html());
var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM,
- assessmentMessageDOM, gradeDOM, attemptsDOM, submitXHR;
+ assessmentMessageDOM, gradeDOM, attemptsDOM, reviewTipsDOM, submitXHR;
function isLastStep() {
return (activeStep === steps.length-1);
@@ -56,6 +57,14 @@ function MentoringWithStepsBlock(runtime, element) {
});
}
+ function updateReviewTips() {
+ var handlerUrl = runtime.handlerUrl(element, 'get_review_tips');
+ $.post(handlerUrl, JSON.stringify({}))
+ .success(function(response) {
+ gradeDOM.data('assessment_review_tips', response.review_tips);
+ });
+ }
+
function handleResults(response) {
// Update active step so next step is shown on page reload (even if user does not click "Next Step")
updateActiveStep(activeStep+1);
@@ -64,6 +73,7 @@ function MentoringWithStepsBlock(runtime, element) {
if (response.attempt_complete) {
updateNumAttempts();
updateGrade();
+ updateReviewTips();
}
// Update UI
@@ -114,6 +124,7 @@ function MentoringWithStepsBlock(runtime, element) {
assessmentMessageDOM.html('');
gradeDOM.html('');
attemptsDOM.html('');
+ reviewTipsDOM.empty().hide();
}
function updateDisplay() {
@@ -142,6 +153,17 @@ function MentoringWithStepsBlock(runtime, element) {
reviewStep.show();
var data = gradeDOM.data();
gradeDOM.html(gradeTemplate(data));
+ // Review tips
+ if (someAttemptsLeft()) {
+ if (data.assessment_review_tips.length > 0) {
+ // on-assessment-review-question messages specific to questions the student got wrong:
+ reviewTipsDOM.html(reviewTipsTemplate({
+ tips: data.assessment_review_tips
+ }));
+ reviewTipsDOM.show();
+ }
+ }
+
submitDOM.hide();
nextDOM.hide();
reviewDOM.hide();
@@ -247,6 +269,7 @@ function MentoringWithStepsBlock(runtime, element) {
assessmentMessageDOM = $('.assessment-message', element);
gradeDOM = $('.grade', element);
attemptsDOM = $('.attempts', element);
+ reviewTipsDOM = $('.assessment-review-tips', element);
var options = {
onChange: onChange
diff --git a/problem_builder/templates/html/mentoring_with_steps.html b/problem_builder/templates/html/mentoring_with_steps.html
index d3aae3ae..6845e54b 100644
--- a/problem_builder/templates/html/mentoring_with_steps.html
+++ b/problem_builder/templates/html/mentoring_with_steps.html
@@ -20,7 +20,8 @@
+
From 0d62734639cc500fd20ee36ff44cc6d7a984069a Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 16:14:24 +0200
Subject: [PATCH 26/43] Display step- and answer-level feedback when reviewing
step.
---
.../public/js/mentoring_with_steps.js | 38 +++++++++++++++++++
problem_builder/public/js/step.js | 32 +++++++++++++++-
problem_builder/step.py | 23 +++++++++++
3 files changed, 92 insertions(+), 1 deletion(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 9cc844c4..f895e9d8 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -111,6 +111,29 @@ function MentoringWithStepsBlock(runtime, element) {
step.submit(handleResults);
}
+ function getResults() {
+ var step = steps[activeStep];
+ step.getResults(handleReviewResults);
+ }
+
+ function handleReviewResults(response) {
+ // Show step-level feedback
+ if (response.completed === 'correct') {
+ checkmark.addClass('checkmark-correct icon-ok fa-check');
+ } else if (response.completed === 'partial') {
+ checkmark.addClass('checkmark-partially-correct icon-ok fa-check');
+ } else {
+ checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
+ }
+ // Forward to active step to show answer level feedback
+ var step = steps[activeStep];
+ var results = response.results;
+ var options = {
+ checkmark: checkmark
+ };
+ step.handleReview(results, options);
+ }
+
function hideAllSteps() {
for (var i=0; i < steps.length; i++) {
$(steps[i].element).hide();
@@ -216,6 +239,8 @@ function MentoringWithStepsBlock(runtime, element) {
submitDOM.attr('disabled', 'disabled');
reviewLinkDOM.show();
// ...
+
+ getResults();
}
function showAttempts() {
@@ -258,10 +283,23 @@ function MentoringWithStepsBlock(runtime, element) {
function initSteps(options) {
for (var i=0; i < steps.length; i++) {
var step = steps[i];
+ var mentoring = {
+ setContent: setContent
+ };
+ options.mentoring = mentoring;
step.initChildren(options);
}
}
+ function setContent(dom, content) {
+ dom.html('');
+ dom.append(content);
+ var template = $('#light-child-template', dom).html();
+ if (template) {
+ dom.append(template);
+ }
+ }
+
function showGrade() {
cleanAll();
showAssessmentMessage();
diff --git a/problem_builder/public/js/step.js b/problem_builder/public/js/step.js
index 618fdda6..f89566bd 100644
--- a/problem_builder/public/js/step.js
+++ b/problem_builder/public/js/step.js
@@ -1,7 +1,7 @@
function MentoringStepBlock(runtime, element) {
var children = runtime.children(element);
- var submitXHR;
+ var submitXHR, resultsXHR;
function callIfExists(obj, fn) {
if (typeof obj !== 'undefined' && typeof obj[fn] == 'function') {
@@ -51,6 +51,36 @@ function MentoringStepBlock(runtime, element) {
.success(function(response) {
result_handler(response);
});
+ },
+
+ getResults: function(result_handler) {
+ var handler_name = 'get_results';
+ var data = [];
+ for (var i = 0; i < children.length; i++) {
+ var child = children[i];
+ if (child && child.name !== undefined) { // Check if we are dealing with a question
+ data[i] = child.name;
+ }
+ }
+ var handlerUrl = runtime.handlerUrl(element, handler_name);
+ if (resultsXHR) {
+ resultsXHR.abort();
+ }
+ resultsXHR = $.post(handlerUrl, JSON.stringify(data))
+ .success(function(response) {
+ result_handler(response);
+ });
+ },
+
+ handleReview: function(results, options) {
+ for (var i = 0; i < children.length; i++) {
+ var child = children[i];
+ if (child && child.name !== undefined) { // Check if we are dealing with a question
+ var result = results[child.name];
+ callIfExists(child, 'handleSubmit', result, options);
+ callIfExists(child, 'handleReview', result);
+ }
+ }
}
};
diff --git a/problem_builder/step.py b/problem_builder/step.py
index 42e66368..9214c96c 100644
--- a/problem_builder/step.py
+++ b/problem_builder/step.py
@@ -157,6 +157,29 @@ def submit(self, submissions, suffix=''):
'attempt_complete': self.is_last_step
}
+ @XBlock.json_handler
+ def get_results(self, queries, suffix=''):
+ results = {}
+ answers = dict(self.student_results)
+ for question in self.get_steps():
+ previous_results = answers[question.name]
+ result = question.get_results(previous_results)
+ results[question.name] = result
+
+ # Compute "answer status" for this step
+ if all(result[1]['status'] == 'correct' for result in self.student_results):
+ completed = Correctness.CORRECT
+ elif all(result[1]['status'] == 'incorrect' for result in self.student_results):
+ completed = Correctness.INCORRECT
+ else:
+ completed = Correctness.PARTIAL
+
+ # Add 'message' to results? Looks like it's not used on the client ...
+ return {
+ 'results': results,
+ 'completed': completed,
+ }
+
def reset(self):
while self.student_results:
self.student_results.pop()
From a0dfa541ea41b3c46f4886bc5db0239c81d590f0 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 17:38:56 +0200
Subject: [PATCH 27/43] Conditionally enable "Try again" button when showing
review step.
---
problem_builder/public/js/mentoring_with_steps.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index f895e9d8..e363a966 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -193,8 +193,11 @@ function MentoringWithStepsBlock(runtime, element) {
gradeDOM.html(gradeTemplate(data));
$('a.step-link', element).on('click', getStepToReview);
- // Review tips
if (someAttemptsLeft()) {
+
+ tryAgainDOM.removeAttr('disabled');
+
+ // Review tips
if (data.assessment_review_tips.length > 0) {
// on-assessment-review-question messages specific to questions the student got wrong:
reviewTipsDOM.html(reviewTipsTemplate({
@@ -207,7 +210,6 @@ function MentoringWithStepsBlock(runtime, element) {
submitDOM.hide();
nextDOM.hide();
reviewDOM.hide();
- tryAgainDOM.removeAttr('disabled');
tryAgainDOM.show();
}
From c3a7d6de5b79bcbc80361614040e7d54b98c9a7d Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 18:46:42 +0200
Subject: [PATCH 28/43] Make sure relevant info is updated and retrieved
sequentially after submitting step.
---
.../public/js/mentoring_with_steps.js | 46 +++++++++----------
1 file changed, 22 insertions(+), 24 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index e363a966..179f43ce 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -31,11 +31,25 @@ function MentoringWithStepsBlock(runtime, element) {
return (data.num_attempts < data.max_attempts);
}
- function updateActiveStep(newValue) {
+ function updateActiveStep(response) {
+ // Update UI
+ if (response.completed === 'correct') {
+ checkmark.addClass('checkmark-correct icon-ok fa-check');
+ } else if (response.completed === 'partial') {
+ checkmark.addClass('checkmark-partially-correct icon-ok fa-check');
+ } else {
+ checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
+ }
+
var handlerUrl = runtime.handlerUrl(element, 'update_active_step');
- $.post(handlerUrl, JSON.stringify(newValue))
+ $.post(handlerUrl, JSON.stringify(activeStep+1))
.success(function(response) {
activeStep = response.active_step;
+ if (activeStep === -1) {
+ updateNumAttempts();
+ } else {
+ handleResults();
+ }
});
}
@@ -44,6 +58,7 @@ function MentoringWithStepsBlock(runtime, element) {
$.post(handlerUrl, JSON.stringify({}))
.success(function(response) {
attemptsDOM.data('num_attempts', response.num_attempts);
+ updateGrade();
});
}
@@ -55,6 +70,7 @@ function MentoringWithStepsBlock(runtime, element) {
gradeDOM.data('correct_answer', response.correct_answers);
gradeDOM.data('incorrect_answer', response.incorrect_answers);
gradeDOM.data('partially_correct_answer', response.partially_correct_answers);
+ updateReviewTips();
});
}
@@ -63,35 +79,17 @@ function MentoringWithStepsBlock(runtime, element) {
$.post(handlerUrl, JSON.stringify({}))
.success(function(response) {
gradeDOM.data('assessment_review_tips', response.review_tips);
+ handleResults();
});
}
- function handleResults(response) {
- // Update active step so next step is shown on page reload (even if user does not click "Next Step")
- updateActiveStep(activeStep+1);
-
- // If step submitted was last step of this mentoring block, update grade and number of attempts used
- if (response.attempt_complete) {
- updateNumAttempts();
- updateGrade();
- updateReviewTips();
- }
-
- // Update UI
- if (response.completed === 'correct') {
- checkmark.addClass('checkmark-correct icon-ok fa-check');
- } else if (response.completed === 'partial') {
- checkmark.addClass('checkmark-partially-correct icon-ok fa-check');
- } else {
- checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
- }
-
+ function handleResults() {
submitDOM.attr('disabled', 'disabled');
nextDOM.removeAttr("disabled");
if (nextDOM.is(':visible')) { nextDOM.focus(); }
- if (isLastStep()) {
+ if (atReviewStep()) {
if (reviewStepPresent()) {
reviewDOM.removeAttr('disabled');
} else {
@@ -108,7 +106,7 @@ function MentoringWithStepsBlock(runtime, element) {
function submit() {
// We do not handle submissions at this level, so just forward to "submit" method of active step
var step = steps[activeStep];
- step.submit(handleResults);
+ step.submit(updateActiveStep);
}
function getResults() {
From 9c847f23c37ed09707185f2d4b3386b44ef0cd03 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 19:01:31 +0200
Subject: [PATCH 29/43] Fix: Make sure links for reviewing questions are not
displayed multiple times at review step.
---
problem_builder/mentoring.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 0fcb8409..d148c70e 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -987,7 +987,7 @@ def get_review_tips(self, data, suffix):
}
def show_extended_feedback(self):
- return self.extended_feedback and self.max_attempts_reached
+ return self.extended_feedback
def feedback_dispatch(self, target_data):
if self.show_extended_feedback():
From 026446e421bb8dae4990bc227d1285d0c6f295e9 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 20:20:04 +0200
Subject: [PATCH 30/43] Clean up.
- Improve naming of functions that handle submission results
- Add comments
- Remove empty leftover comments
- DRY out code that handles showing step-level feedback
- Don't name things using snake_case in JS code
- Use "callIfExists" in more places
---
.../public/js/mentoring_with_steps.js | 31 ++++++++++---------
problem_builder/public/js/step.js | 12 +++----
2 files changed, 22 insertions(+), 21 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 179f43ce..598cf8a5 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -31,8 +31,7 @@ function MentoringWithStepsBlock(runtime, element) {
return (data.num_attempts < data.max_attempts);
}
- function updateActiveStep(response) {
- // Update UI
+ function showFeedback(response) {
if (response.completed === 'correct') {
checkmark.addClass('checkmark-correct icon-ok fa-check');
} else if (response.completed === 'partial') {
@@ -40,7 +39,14 @@ function MentoringWithStepsBlock(runtime, element) {
} else {
checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
}
+ }
+
+ function handleResults(response) {
+ showFeedback(response);
+ // Update active step:
+ // If we end up at the review step, proceed with updating the number of attempts used.
+ // Otherwise, get UI ready for showing next step.
var handlerUrl = runtime.handlerUrl(element, 'update_active_step');
$.post(handlerUrl, JSON.stringify(activeStep+1))
.success(function(response) {
@@ -48,7 +54,7 @@ function MentoringWithStepsBlock(runtime, element) {
if (activeStep === -1) {
updateNumAttempts();
} else {
- handleResults();
+ updateControls();
}
});
}
@@ -58,6 +64,7 @@ function MentoringWithStepsBlock(runtime, element) {
$.post(handlerUrl, JSON.stringify({}))
.success(function(response) {
attemptsDOM.data('num_attempts', response.num_attempts);
+ // Now that relevant info is up-to-date, get the latest grade
updateGrade();
});
}
@@ -79,11 +86,11 @@ function MentoringWithStepsBlock(runtime, element) {
$.post(handlerUrl, JSON.stringify({}))
.success(function(response) {
gradeDOM.data('assessment_review_tips', response.review_tips);
- handleResults();
+ updateControls();
});
}
- function handleResults() {
+ function updateControls() {
submitDOM.attr('disabled', 'disabled');
nextDOM.removeAttr("disabled");
@@ -106,7 +113,7 @@ function MentoringWithStepsBlock(runtime, element) {
function submit() {
// We do not handle submissions at this level, so just forward to "submit" method of active step
var step = steps[activeStep];
- step.submit(updateActiveStep);
+ step.submit(handleResults);
}
function getResults() {
@@ -116,13 +123,7 @@ function MentoringWithStepsBlock(runtime, element) {
function handleReviewResults(response) {
// Show step-level feedback
- if (response.completed === 'correct') {
- checkmark.addClass('checkmark-correct icon-ok fa-check');
- } else if (response.completed === 'partial') {
- checkmark.addClass('checkmark-partially-correct icon-ok fa-check');
- } else {
- checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
- }
+ showFeedback(response);
// Forward to active step to show answer level feedback
var step = steps[activeStep];
var results = response.results;
@@ -233,12 +234,10 @@ function MentoringWithStepsBlock(runtime, element) {
nextDOM.removeAttr('disabled');
}
- // ...
tryAgainDOM.hide();
submitDOM.show();
submitDOM.attr('disabled', 'disabled');
reviewLinkDOM.show();
- // ...
getResults();
}
@@ -305,10 +304,12 @@ function MentoringWithStepsBlock(runtime, element) {
showAssessmentMessage();
showReviewStep();
showAttempts();
+
// Disable "Try again" button if no attempts left
if (!someAttemptsLeft()) {
tryAgainDOM.attr("disabled", "disabled");
}
+
nextDOM.off();
nextDOM.on('click', reviewNextStep);
reviewLinkDOM.hide();
diff --git a/problem_builder/public/js/step.js b/problem_builder/public/js/step.js
index f89566bd..0c9eb9b6 100644
--- a/problem_builder/public/js/step.js
+++ b/problem_builder/public/js/step.js
@@ -34,13 +34,13 @@ function MentoringStepBlock(runtime, element) {
return is_valid;
},
- submit: function(result_handler) {
+ submit: function(resultHandler) {
var handler_name = 'submit';
var data = {};
for (var i = 0; i < children.length; i++) {
var child = children[i];
- if (child && child.name !== undefined && typeof(child[handler_name]) !== "undefined") {
- data[child.name.toString()] = child[handler_name]();
+ if (child && child.name !== undefined) {
+ data[child.name.toString()] = callIfExists(child, handler_name);
}
}
var handlerUrl = runtime.handlerUrl(element, handler_name);
@@ -49,11 +49,11 @@ function MentoringStepBlock(runtime, element) {
}
submitXHR = $.post(handlerUrl, JSON.stringify(data))
.success(function(response) {
- result_handler(response);
+ resultHandler(response);
});
},
- getResults: function(result_handler) {
+ getResults: function(resultHandler) {
var handler_name = 'get_results';
var data = [];
for (var i = 0; i < children.length; i++) {
@@ -68,7 +68,7 @@ function MentoringStepBlock(runtime, element) {
}
resultsXHR = $.post(handlerUrl, JSON.stringify(data))
.success(function(response) {
- result_handler(response);
+ resultHandler(response);
});
},
From 8133e6331f0d1e65be2c4690462ed5dc06d6686a Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 22:56:42 +0200
Subject: [PATCH 31/43] Move logic for displaying grade and review links to
review step.
---
.../public/js/mentoring_with_steps.js | 47 +++++---------
problem_builder/public/js/review_step.js | 28 ++++++++
problem_builder/step.py | 2 +
.../html/mentoring_review_templates.html | 63 ------------------
.../templates/html/review_step.html | 65 ++++++++++++++++++-
5 files changed, 112 insertions(+), 93 deletions(-)
create mode 100644 problem_builder/public/js/review_step.js
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 598cf8a5..1cd8ca88 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -1,14 +1,22 @@
function MentoringWithStepsBlock(runtime, element) {
- var steps = runtime.children(element).filter(
+ var children = runtime.children(element);
+ var steps = children.filter(
function(c) { return c.element.className.indexOf('sb-step') > -1; }
);
+ var reviewStep;
+ for (var i = 0; i < children.length; i++) {
+ var child = children[i];
+ if (child.type === 'sb-review-step') {
+ reviewStep = child;
+ break;
+ }
+ }
+
var activeStep = $('.mentoring', element).data('active-step');
- var gradeTemplate = _.template($('#xblock-review-template').html());
- var reviewStepsTemplate = _.template($('#xblock-review-steps-template').html());
var reviewTipsTemplate = _.template($('#xblock-review-tips-template').html()); // Tips about specific questions the user got wrong
var attemptsTemplate = _.template($('#xblock-attempts-template').html());
- var reviewStep, checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM,
+ var checkmark, submitDOM, nextDOM, reviewDOM, tryAgainDOM,
assessmentMessageDOM, gradeDOM, attemptsDOM, reviewTipsDOM, reviewLinkDOM, submitXHR;
function isLastStep() {
@@ -19,10 +27,6 @@ function MentoringWithStepsBlock(runtime, element) {
return (activeStep === -1);
}
- function reviewStepPresent() {
- return reviewStep.length > 0;
- }
-
function someAttemptsLeft() {
var data = attemptsDOM.data();
if (data.max_attempts === 0) { // Unlimited number of attempts available
@@ -97,7 +101,7 @@ function MentoringWithStepsBlock(runtime, element) {
if (nextDOM.is(':visible')) { nextDOM.focus(); }
if (atReviewStep()) {
- if (reviewStepPresent()) {
+ if (reviewStep) {
reviewDOM.removeAttr('disabled');
} else {
if (someAttemptsLeft()) {
@@ -160,7 +164,7 @@ function MentoringWithStepsBlock(runtime, element) {
showActiveStep();
validateXBlock();
nextDOM.attr('disabled', 'disabled');
- if (isLastStep() && reviewStepPresent()) {
+ if (isLastStep() && reviewStep) {
reviewDOM.attr('disabled', 'disabled');
reviewDOM.show();
}
@@ -173,23 +177,13 @@ function MentoringWithStepsBlock(runtime, element) {
}
function showReviewStep() {
- reviewStep.show();
-
var data = gradeDOM.data();
- // Links for reviewing individual questions (WIP)
- var enableExtendedFeedback = (!someAttemptsLeft() && data.extended_feedback);
+ // Forward to review step to render grade data
+ var showExtendedFeedback = (!someAttemptsLeft() && data.extended_feedback);
+ reviewStep.renderGrade(gradeDOM, showExtendedFeedback);
- _.extend(data, {
- 'runDetails': function(correctness) {
- if (!enableExtendedFeedback) {
- return '';
- }
- var self = this;
- return reviewStepsTemplate({'questions': self[correctness], 'correctness': correctness});
- }
- });
- gradeDOM.html(gradeTemplate(data));
+ // Add click handler that takes care of showing associated step to step links
$('a.step-link', element).on('click', getStepToReview);
if (someAttemptsLeft()) {
@@ -221,7 +215,6 @@ function MentoringWithStepsBlock(runtime, element) {
function jumpToReview(stepIndex) {
activeStep = stepIndex;
cleanAll();
- reviewStep.hide();
showActiveStep();
if (isLastStep()) {
@@ -322,7 +315,6 @@ function MentoringWithStepsBlock(runtime, element) {
function handleTryAgain(result) {
activeStep = result.active_step;
updateDisplay();
- reviewStep.hide();
tryAgainDOM.hide();
submitDOM.show();
if (! isLastStep()) {
@@ -342,9 +334,6 @@ function MentoringWithStepsBlock(runtime, element) {
}
function initXBlockView() {
- reviewStep = $('.sb-review-step', element);
- reviewStep.hide();
-
checkmark = $('.assessment-checkmark', element);
submitDOM = $(element).find('.submit .input-main');
diff --git a/problem_builder/public/js/review_step.js b/problem_builder/public/js/review_step.js
new file mode 100644
index 00000000..19636ee5
--- /dev/null
+++ b/problem_builder/public/js/review_step.js
@@ -0,0 +1,28 @@
+function ReviewStepBlock(runtime, element) {
+
+ var gradeTemplate = _.template($('#xblock-feedback-template').html());
+ var reviewStepsTemplate = _.template($('#xblock-step-links-template').html());
+
+ return {
+
+ 'renderGrade': function(gradeDOM, showExtendedFeedback) {
+
+ var data = gradeDOM.data();
+
+ _.extend(data, {
+ 'runDetails': function(correctness) {
+ if (!showExtendedFeedback) {
+ return '';
+ }
+ var self = this;
+ return reviewStepsTemplate({'questions': self[correctness], 'correctness': correctness});
+ }
+ });
+
+ gradeDOM.html(gradeTemplate(data));
+
+ }
+
+ };
+
+}
diff --git a/problem_builder/step.py b/problem_builder/step.py
index 9214c96c..af24ad5b 100644
--- a/problem_builder/step.py
+++ b/problem_builder/step.py
@@ -260,4 +260,6 @@ def _render_view(self, context):
fragment.add_content(loader.render_template('templates/html/review_step.html', {
'self': self,
}))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/review_step.js'))
+ fragment.initialize_js('ReviewStepBlock')
return fragment
diff --git a/problem_builder/templates/html/mentoring_review_templates.html b/problem_builder/templates/html/mentoring_review_templates.html
index 0085e412..84dee723 100644
--- a/problem_builder/templates/html/mentoring_review_templates.html
+++ b/problem_builder/templates/html/mentoring_review_templates.html
@@ -1,66 +1,3 @@
-
-
-
-
-
+
+
+
+
+
From e5a755761f53d4157404239e0125b6a4a2393dab Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 22:58:49 +0200
Subject: [PATCH 32/43] Make sure data relevant for displaying review links is
up-to-date when displaying review step.
---
problem_builder/mentoring.py | 22 ++++++++++++-------
.../public/js/mentoring_with_steps.js | 3 +++
2 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index d148c70e..e9292b83 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -989,18 +989,21 @@ def get_review_tips(self, data, suffix):
def show_extended_feedback(self):
return self.extended_feedback
- def feedback_dispatch(self, target_data):
+ def feedback_dispatch(self, target_data, stringify):
if self.show_extended_feedback():
- return json.dumps(target_data)
+ if stringify:
+ return json.dumps(target_data)
+ else:
+ return target_data
- def correct_json(self):
- return self.feedback_dispatch(self.score.correct)
+ def correct_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.correct, stringify)
- def incorrect_json(self):
- return self.feedback_dispatch(self.score.incorrect)
+ def incorrect_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.incorrect, stringify)
- def partial_json(self):
- return self.feedback_dispatch(self.score.partially_correct)
+ def partial_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.partially_correct, stringify)
def get_message_content(self, message_type, or_default=False):
for child_id in self.children:
@@ -1095,6 +1098,9 @@ def get_score(self, data, suffix):
'correct_answers': len(self.score.correct),
'incorrect_answers': len(self.score.incorrect),
'partially_correct_answers': len(self.score.partially_correct),
+ 'correct': self.correct_json(stringify=False),
+ 'incorrect': self.incorrect_json(False),
+ 'partial': self.partial_json(False),
}
@XBlock.json_handler
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 1cd8ca88..489b4684 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -81,6 +81,9 @@ function MentoringWithStepsBlock(runtime, element) {
gradeDOM.data('correct_answer', response.correct_answers);
gradeDOM.data('incorrect_answer', response.incorrect_answers);
gradeDOM.data('partially_correct_answer', response.partially_correct_answers);
+ gradeDOM.data('correct', response.correct);
+ gradeDOM.data('incorrect', response.incorrect);
+ gradeDOM.data('partially', response.partial);
updateReviewTips();
});
}
From 1645784523ecfe9b253bc9cf4efd1ceb93b923f4 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Fri, 25 Sep 2015 23:25:40 +0200
Subject: [PATCH 33/43] Clean up: Compute score only once in get_score.
---
problem_builder/mentoring.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index e9292b83..ced21726 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -1093,11 +1093,12 @@ def update_num_attempts(self, data, suffix=''):
@XBlock.json_handler
def get_score(self, data, suffix):
+ score = self.score
return {
- 'score': self.score.percentage,
- 'correct_answers': len(self.score.correct),
- 'incorrect_answers': len(self.score.incorrect),
- 'partially_correct_answers': len(self.score.partially_correct),
+ 'score': score.percentage,
+ 'correct_answers': len(score.correct),
+ 'incorrect_answers': len(score.incorrect),
+ 'partially_correct_answers': len(score.partially_correct),
'correct': self.correct_json(stringify=False),
'incorrect': self.incorrect_json(False),
'partial': self.partial_json(False),
From 99b72b12c26acf8774445323cbf7c1258d6465ee Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Sat, 26 Sep 2015 10:22:22 +0200
Subject: [PATCH 34/43] Fix test failures.
---
problem_builder/tests/integration/test_titles.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/problem_builder/tests/integration/test_titles.py b/problem_builder/tests/integration/test_titles.py
index e54983df..0d3fe297 100644
--- a/problem_builder/tests/integration/test_titles.py
+++ b/problem_builder/tests/integration/test_titles.py
@@ -38,8 +38,8 @@ class TitleTest(SeleniumXBlockTest):
@ddt.data(
('', None),
- ('', "Mentoring Questions"),
- ('', "Mentoring Questions"),
+ ('', "Problem Builder"),
+ ('', "Problem Builder"),
('', "A Question"),
('', None),
)
From 4f6d3f86200d9720bd2f1280de7ec29e25ab871d Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Sat, 26 Sep 2015 10:49:09 +0200
Subject: [PATCH 35/43] Move code shared by existing mentoring block (problem
builder) and new mentoring block (step builder) to common superclass.
---
problem_builder/mentoring.py | 155 ++++++++++++-----------------------
1 file changed, 53 insertions(+), 102 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index ced21726..334ec17f 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -92,6 +92,21 @@ class BaseMentoringBlock(
default=True,
scope=Scope.content
)
+ max_attempts = Integer(
+ display_name=_("Max. attempts allowed"),
+ help=_("Maximum number of times students are allowed to attempt the questions belonging to this block"),
+ default=0,
+ scope=Scope.content,
+ enforce_type=True
+ )
+
+ # User state
+ num_attempts = Integer(
+ # Number of attempts a user has answered for this questions
+ default=0,
+ scope=Scope.user_state,
+ enforce_type=True
+ )
has_children = True
@@ -110,6 +125,28 @@ def url_name(self):
except AttributeError:
return unicode(self.scope_ids.usage_id)
+ @property
+ def review_tips_json(self):
+ return json.dumps(self.review_tips)
+
+ @property
+ def max_attempts_reached(self):
+ return self.max_attempts > 0 and self.num_attempts >= self.max_attempts
+
+ def get_message_content(self, message_type, or_default=False):
+ for child_id in self.children:
+ if child_isinstance(self, child_id, MentoringMessageBlock):
+ child = self.runtime.get_block(child_id)
+ if child.type == message_type:
+ content = child.content
+ if hasattr(self.runtime, 'replace_jump_to_id_urls'):
+ content = self.runtime.replace_jump_to_id_urls(content)
+ return content
+ if or_default:
+ # Return the default value since no custom message is set.
+ # Note the WYSIWYG editor usually wraps the .content HTML in a
tag so we do the same here.
+ return '
{}
'.format(MentoringMessageBlock.MESSAGE_TYPES[message_type]['default'])
+
def get_theme(self):
"""
Gets theme settings from settings service. Falls back to default (LMS) theme
@@ -129,6 +166,22 @@ def include_theme_files(self, fragment):
for theme_file in theme_files:
fragment.add_css(ResourceLoader(theme_package).load_unicode(theme_file))
+ def feedback_dispatch(self, target_data, stringify):
+ if self.show_extended_feedback():
+ if stringify:
+ return json.dumps(target_data)
+ else:
+ return target_data
+
+ def correct_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.correct, stringify)
+
+ def incorrect_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.incorrect, stringify)
+
+ def partial_json(self, stringify=True):
+ return self.feedback_dispatch(self.score.partially_correct, stringify)
+
@XBlock.json_handler
def view(self, data, suffix=''):
"""
@@ -185,13 +238,6 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentM
default=None,
scope=Scope.content
)
- max_attempts = Integer(
- display_name=_("Max. Attempts Allowed"),
- help=_("Number of max attempts allowed for this questions"),
- default=0,
- scope=Scope.content,
- enforce_type=True
- )
enforce_dependency = Boolean(
display_name=_("Enforce Dependency"),
help=_("Should the next step be the current block to complete?"),
@@ -247,12 +293,6 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentM
default=False,
scope=Scope.user_state
)
- num_attempts = Integer(
- # Number of attempts a user has answered for this questions
- default=0,
- scope=Scope.user_state,
- enforce_type=True
- )
step = Integer(
# Keep track of the student assessment progress.
default=0,
@@ -475,29 +515,9 @@ def review_tips(self):
review_tips.append(tip_html)
return review_tips
- @property
- def review_tips_json(self):
- return json.dumps(self.review_tips)
-
def show_extended_feedback(self):
return self.extended_feedback and self.max_attempts_reached
- def feedback_dispatch(self, target_data, stringify):
- if self.show_extended_feedback():
- if stringify:
- return json.dumps(target_data)
- else:
- return target_data
-
- def correct_json(self, stringify=True):
- return self.feedback_dispatch(self.score.correct, stringify)
-
- def incorrect_json(self, stringify=True):
- return self.feedback_dispatch(self.score.incorrect, stringify)
-
- def partial_json(self, stringify=True):
- return self.feedback_dispatch(self.score.partially_correct, stringify)
-
@XBlock.json_handler
def get_results(self, queries, suffix=''):
"""
@@ -739,24 +759,6 @@ def try_again(self, data, suffix=''):
'result': 'success'
}
- @property
- def max_attempts_reached(self):
- return self.max_attempts > 0 and self.num_attempts >= self.max_attempts
-
- def get_message_content(self, message_type, or_default=False):
- for child_id in self.children:
- if child_isinstance(self, child_id, MentoringMessageBlock):
- child = self.runtime.get_block(child_id)
- if child.type == message_type:
- content = child.content
- if hasattr(self.runtime, 'replace_jump_to_id_urls'):
- content = self.runtime.replace_jump_to_id_urls(content)
- return content
- if or_default:
- # Return the default value since no custom message is set.
- # Note the WYSIWYG editor usually wraps the .content HTML in a
tag so we do the same here.
- return '
{}
'.format(MentoringMessageBlock.MESSAGE_TYPES[message_type]['default'])
-
def validate(self):
"""
Validates the state of this XBlock except for individual field values.
@@ -832,13 +834,6 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
An XBlock providing mentoring capabilities with explicit steps
"""
# Content
- max_attempts = Integer(
- display_name=_("Max. attempts allowed"),
- help=_("Maximum number of times students are allowed to attempt this mentoring block"),
- default=0,
- scope=Scope.content,
- enforce_type=True
- )
extended_feedback = Boolean(
display_name=_("Extended feedback"),
help=_("Show extended feedback when all attempts are used up?"),
@@ -855,12 +850,6 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
)
# User state
- num_attempts = Integer(
- # Number of attempts user has attempted this mentoring block
- default=0,
- scope=Scope.user_state,
- enforce_type=True
- )
active_step = Integer(
# Keep track of the student progress.
default=0,
@@ -921,10 +910,6 @@ def has_review_step(self):
from .step import ReviewStepBlock
return any(child_isinstance(self, child_id, ReviewStepBlock) for child_id in self.children)
- @property
- def max_attempts_reached(self):
- return self.max_attempts > 0 and self.num_attempts >= self.max_attempts
-
@property
def assessment_message(self):
"""
@@ -976,10 +961,6 @@ def review_tips(self):
review_tips.append(tip_html)
return review_tips
- @property
- def review_tips_json(self):
- return json.dumps(self.review_tips)
-
@XBlock.json_handler
def get_review_tips(self, data, suffix):
return {
@@ -989,36 +970,6 @@ def get_review_tips(self, data, suffix):
def show_extended_feedback(self):
return self.extended_feedback
- def feedback_dispatch(self, target_data, stringify):
- if self.show_extended_feedback():
- if stringify:
- return json.dumps(target_data)
- else:
- return target_data
-
- def correct_json(self, stringify=True):
- return self.feedback_dispatch(self.score.correct, stringify)
-
- def incorrect_json(self, stringify=True):
- return self.feedback_dispatch(self.score.incorrect, stringify)
-
- def partial_json(self, stringify=True):
- return self.feedback_dispatch(self.score.partially_correct, stringify)
-
- def get_message_content(self, message_type, or_default=False):
- for child_id in self.children:
- if child_isinstance(self, child_id, MentoringMessageBlock):
- child = self.runtime.get_block(child_id)
- if child.type == message_type:
- content = child.content
- if hasattr(self.runtime, 'replace_jump_to_id_urls'):
- content = self.runtime.replace_jump_to_id_urls(content)
- return content
- if or_default:
- # Return the default value since no custom message is set.
- # Note the WYSIWYG editor usually wraps the .content HTML in a
tag so we do the same here.
- return '
{}
'.format(MentoringMessageBlock.MESSAGE_TYPES[message_type]['default'])
-
def student_view(self, context):
fragment = Fragment()
children_contents = []
From 1a3602d87f16e957659ba1a3a37cfd2bc0a9c415 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Sat, 26 Sep 2015 11:16:23 +0200
Subject: [PATCH 36/43] Fix: Make sure assessment message is up-to-date when
submitting last possible attempt.
Also, reduce number of trips to the server by merging updateReviewTips into updateGrade.
---
problem_builder/mentoring.py | 10 +++-------
problem_builder/public/js/mentoring_with_steps.js | 15 ++++-----------
2 files changed, 7 insertions(+), 18 deletions(-)
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index 334ec17f..e9ac2ad0 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -961,12 +961,6 @@ def review_tips(self):
review_tips.append(tip_html)
return review_tips
- @XBlock.json_handler
- def get_review_tips(self, data, suffix):
- return {
- 'review_tips': self.review_tips
- }
-
def show_extended_feedback(self):
return self.extended_feedback
@@ -1043,7 +1037,7 @@ def update_num_attempts(self, data, suffix=''):
}
@XBlock.json_handler
- def get_score(self, data, suffix):
+ def get_grade(self, data, suffix):
score = self.score
return {
'score': score.percentage,
@@ -1053,6 +1047,8 @@ def get_score(self, data, suffix):
'correct': self.correct_json(stringify=False),
'incorrect': self.incorrect_json(False),
'partial': self.partial_json(False),
+ 'assessment_message': self.assessment_message,
+ 'assessment_review_tips': self.review_tips,
}
@XBlock.json_handler
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index 489b4684..c4c6e636 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -74,7 +74,7 @@ function MentoringWithStepsBlock(runtime, element) {
}
function updateGrade() {
- var handlerUrl = runtime.handlerUrl(element, 'get_score');
+ var handlerUrl = runtime.handlerUrl(element, 'get_grade');
$.post(handlerUrl, JSON.stringify({}))
.success(function(response) {
gradeDOM.data('score', response.score);
@@ -83,16 +83,9 @@ function MentoringWithStepsBlock(runtime, element) {
gradeDOM.data('partially_correct_answer', response.partially_correct_answers);
gradeDOM.data('correct', response.correct);
gradeDOM.data('incorrect', response.incorrect);
- gradeDOM.data('partially', response.partial);
- updateReviewTips();
- });
- }
-
- function updateReviewTips() {
- var handlerUrl = runtime.handlerUrl(element, 'get_review_tips');
- $.post(handlerUrl, JSON.stringify({}))
- .success(function(response) {
- gradeDOM.data('assessment_review_tips', response.review_tips);
+ gradeDOM.data('partial', response.partial);
+ gradeDOM.data('assessment_message', response.assessment_message);
+ gradeDOM.data('assessment_review_tips', response.assessment_review_tips);
updateControls();
});
}
From bef01e811d983e629e05f97a111e79abd4723647 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Sat, 26 Sep 2015 11:34:49 +0200
Subject: [PATCH 37/43] DRY out code that computes current status
(correct/partial/incorrect) of step.
---
.../public/js/mentoring_with_steps.js | 4 +--
problem_builder/step.py | 31 +++++++------------
2 files changed, 14 insertions(+), 21 deletions(-)
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index c4c6e636..a967acfd 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -36,9 +36,9 @@ function MentoringWithStepsBlock(runtime, element) {
}
function showFeedback(response) {
- if (response.completed === 'correct') {
+ if (response.step_status === 'correct') {
checkmark.addClass('checkmark-correct icon-ok fa-check');
- } else if (response.completed === 'partial') {
+ } else if (response.step_status === 'partial') {
checkmark.addClass('checkmark-partially-correct icon-ok fa-check');
} else {
checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation');
diff --git a/problem_builder/step.py b/problem_builder/step.py
index af24ad5b..bfef0ff5 100644
--- a/problem_builder/step.py
+++ b/problem_builder/step.py
@@ -142,19 +142,10 @@ def submit(self, submissions, suffix=''):
for result in submit_results:
self.student_results.append(result)
- # Compute "answer status" for this step
- if all(result[1]['status'] == 'correct' for result in submit_results):
- completed = Correctness.CORRECT
- elif all(result[1]['status'] == 'incorrect' for result in submit_results):
- completed = Correctness.INCORRECT
- else:
- completed = Correctness.PARTIAL
-
return {
'message': 'Success!',
- 'completed': completed,
+ 'step_status': self.answer_status,
'results': submit_results,
- 'attempt_complete': self.is_last_step
}
@XBlock.json_handler
@@ -166,24 +157,26 @@ def get_results(self, queries, suffix=''):
result = question.get_results(previous_results)
results[question.name] = result
- # Compute "answer status" for this step
- if all(result[1]['status'] == 'correct' for result in self.student_results):
- completed = Correctness.CORRECT
- elif all(result[1]['status'] == 'incorrect' for result in self.student_results):
- completed = Correctness.INCORRECT
- else:
- completed = Correctness.PARTIAL
-
# Add 'message' to results? Looks like it's not used on the client ...
return {
'results': results,
- 'completed': completed,
+ 'step_status': self.answer_status,
}
def reset(self):
while self.student_results:
self.student_results.pop()
+ @property
+ def answer_status(self):
+ if all(result[1]['status'] == 'correct' for result in self.student_results):
+ answer_status = Correctness.CORRECT
+ elif all(result[1]['status'] == 'incorrect' for result in self.student_results):
+ answer_status = Correctness.INCORRECT
+ else:
+ answer_status = Correctness.PARTIAL
+ return answer_status
+
def author_edit_view(self, context):
"""
Add some HTML to the author view that allows authors to add child blocks.
From 9e94e81ff144a907c5e5f037f024bed12f665772 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Sun, 27 Sep 2015 15:42:58 +0200
Subject: [PATCH 38/43] Implement integration tests.
---
problem_builder/mentoring.py | 3 +-
.../public/js/mentoring_with_steps.js | 57 ++-
.../tests/integration/base_test.py | 92 +++++
.../tests/integration/test_assessment.py | 99 +----
.../tests/integration/test_step_builder.py | 346 ++++++++++++++++++
.../xml_templates/step_builder.xml | 63 ++++
6 files changed, 554 insertions(+), 106 deletions(-)
create mode 100644 problem_builder/tests/integration/test_step_builder.py
create mode 100644 problem_builder/tests/integration/xml_templates/step_builder.xml
diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py
index e9ac2ad0..168022e7 100644
--- a/problem_builder/mentoring.py
+++ b/problem_builder/mentoring.py
@@ -918,7 +918,7 @@ def assessment_message(self):
if not self.max_attempts_reached:
return self.get_message_content('on-assessment-review', or_default=True)
else:
- assessment_message = _("Note: you have used all attempts. Continue to the next unit")
+ assessment_message = _("Note: you have used all attempts. Continue to the next unit.")
return '
{}
'.format(assessment_message)
@property
@@ -985,6 +985,7 @@ def student_view(self, context):
'children_contents': children_contents,
}))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder.css'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_with_steps.js'))
fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html")
diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js
index a967acfd..3235b960 100644
--- a/problem_builder/public/js/mentoring_with_steps.js
+++ b/problem_builder/public/js/mentoring_with_steps.js
@@ -1,15 +1,21 @@
function MentoringWithStepsBlock(runtime, element) {
+ // Set up gettext in case it isn't available in the client runtime:
+ if (typeof gettext == "undefined") {
+ window.gettext = function gettext_stub(string) { return string; };
+ window.ngettext = function ngettext_stub(strA, strB, n) { return n == 1 ? strA : strB; };
+ }
+
var children = runtime.children(element);
- var steps = children.filter(
- function(c) { return c.element.className.indexOf('sb-step') > -1; }
- );
+ var steps = [];
var reviewStep;
for (var i = 0; i < children.length; i++) {
var child = children[i];
- if (child.type === 'sb-review-step') {
+ var blockType = $(child.element).data('block-type');
+ if (blockType === 'sb-step') {
+ steps.push(child);
+ } else if (blockType === 'sb-review-step') {
reviewStep = child;
- break;
}
}
@@ -35,6 +41,11 @@ function MentoringWithStepsBlock(runtime, element) {
return (data.num_attempts < data.max_attempts);
}
+ function extendedFeedbackEnabled() {
+ var data = gradeDOM.data();
+ return data.extended_feedback === "True";
+ }
+
function showFeedback(response) {
if (response.step_status === 'correct') {
checkmark.addClass('checkmark-correct icon-ok fa-check');
@@ -139,6 +150,10 @@ function MentoringWithStepsBlock(runtime, element) {
}
}
+ function clearSelections() {
+ $('input[type=radio], input[type=checkbox]', element).prop('checked', false);
+ }
+
function cleanAll() {
checkmark.removeClass('checkmark-correct icon-ok fa-check');
checkmark.removeClass('checkmark-partially-correct icon-ok fa-check');
@@ -176,7 +191,7 @@ function MentoringWithStepsBlock(runtime, element) {
var data = gradeDOM.data();
// Forward to review step to render grade data
- var showExtendedFeedback = (!someAttemptsLeft() && data.extended_feedback);
+ var showExtendedFeedback = (!someAttemptsLeft() && extendedFeedbackEnabled());
reviewStep.renderGrade(gradeDOM, showExtendedFeedback);
// Add click handler that takes care of showing associated step to step links
@@ -272,7 +287,8 @@ function MentoringWithStepsBlock(runtime, element) {
for (var i=0; i < steps.length; i++) {
var step = steps[i];
var mentoring = {
- setContent: setContent
+ setContent: setContent,
+ publish_event: publishEvent
};
options.mentoring = mentoring;
step.initChildren(options);
@@ -288,6 +304,14 @@ function MentoringWithStepsBlock(runtime, element) {
}
}
+ function publishEvent(data) {
+ $.ajax({
+ type: "POST",
+ url: runtime.handlerUrl(element, 'publish_event'),
+ data: JSON.stringify(data)
+ });
+ }
+
function showGrade() {
cleanAll();
showAssessmentMessage();
@@ -310,6 +334,7 @@ function MentoringWithStepsBlock(runtime, element) {
function handleTryAgain(result) {
activeStep = result.active_step;
+ clearSelections();
updateDisplay();
tryAgainDOM.hide();
submitDOM.show();
@@ -329,6 +354,23 @@ function MentoringWithStepsBlock(runtime, element) {
submitXHR = $.post(handlerUrl, JSON.stringify({})).success(handleTryAgain);
}
+ function initClickHandlers() {
+ $(document).on("click", function(event, ui) {
+ var target = $(event.target);
+ var itemFeedbackParentSelector = '.choice';
+ var itemFeedbackSelector = ".choice .choice-tips";
+
+ function clickedInside(selector, parent_selector){
+ return target.is(selector) || target.parents(parent_selector).length>0;
+ }
+
+ if (!clickedInside(itemFeedbackSelector, itemFeedbackParentSelector)) {
+ $(itemFeedbackSelector).not(':hidden').hide();
+ $('.choice-tips-container').removeClass('with-tips');
+ }
+ });
+ }
+
function initXBlockView() {
checkmark = $('.assessment-checkmark', element);
@@ -366,6 +408,7 @@ function MentoringWithStepsBlock(runtime, element) {
updateDisplay();
}
+ initClickHandlers();
initXBlockView();
}
diff --git a/problem_builder/tests/integration/base_test.py b/problem_builder/tests/integration/base_test.py
index d7441f07..3113644f 100644
--- a/problem_builder/tests/integration/base_test.py
+++ b/problem_builder/tests/integration/base_test.py
@@ -30,6 +30,8 @@
loader = ResourceLoader(__name__)
+CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct"
+
class PopupCheckMixin(object):
"""
@@ -133,6 +135,88 @@ class Namespace(object):
return mentoring, controls
+ def assert_hidden(self, elem):
+ self.assertFalse(elem.is_displayed())
+
+ def assert_disabled(self, elem):
+ self.assertTrue(elem.is_displayed())
+ self.assertFalse(elem.is_enabled())
+
+ def assert_clickable(self, elem):
+ self.assertTrue(elem.is_displayed())
+ self.assertTrue(elem.is_enabled())
+
+ def ending_controls(self, controls, last):
+ if last:
+ self.assert_hidden(controls.next_question)
+ self.assert_disabled(controls.review)
+ else:
+ self.assert_disabled(controls.next_question)
+ self.assert_hidden(controls.review)
+
+ def selected_controls(self, controls, last):
+ self.assert_clickable(controls.submit)
+ self.ending_controls(controls, last)
+
+ def assert_message_text(self, mentoring, text):
+ message_wrapper = mentoring.find_element_by_css_selector('.assessment-message')
+ self.assertEqual(message_wrapper.text, text)
+ self.assertTrue(message_wrapper.is_displayed())
+
+ def assert_no_message_text(self, mentoring):
+ message_wrapper = mentoring.find_element_by_css_selector('.assessment-message')
+ self.assertEqual(message_wrapper.text, '')
+
+ def check_question_feedback(self, step_builder, question):
+ question_checkmark = step_builder.find_element_by_css_selector('.assessment-checkmark')
+ question_feedback = question.find_element_by_css_selector(".feedback")
+ self.assertTrue(question_feedback.is_displayed())
+ self.assertEqual(question_feedback.text, "Question Feedback Message")
+
+ question.click()
+ self.assertFalse(question_feedback.is_displayed())
+
+ question_checkmark.click()
+ self.assertTrue(question_feedback.is_displayed())
+
+ def do_submit_wait(self, controls, last):
+ if last:
+ self.wait_until_clickable(controls.review)
+ else:
+ self.wait_until_clickable(controls.next_question)
+
+ def do_post(self, controls, last):
+ if last:
+ controls.review.click()
+ else:
+ controls.next_question.click()
+
+ def multiple_response_question(self, number, mentoring, controls, choice_names, result, last=False):
+ question = self.peek_at_multiple_response_question(number, mentoring, controls, last=last)
+
+ choices = GetChoices(question)
+ expected_choices = {
+ "Its elegance": False,
+ "Its beauty": False,
+ "Its gracefulness": False,
+ "Its bugs": False,
+ }
+ self.assertEquals(choices.state, expected_choices)
+
+ for name in choice_names:
+ choices.select(name)
+ expected_choices[name] = True
+
+ self.assertEquals(choices.state, expected_choices)
+
+ self.selected_controls(controls, last)
+
+ controls.submit.click()
+
+ self.do_submit_wait(controls, last)
+ self._assert_checkmark(mentoring, result)
+ controls.review.click()
+
def expect_question_visible(self, number, mentoring, question_text=None):
if not question_text:
question_text = self.question_text(number)
@@ -163,6 +247,14 @@ def answer_mcq(self, number, name, value, mentoring, controls, is_last=False):
self.wait_until_clickable(controls.next_question)
controls.next_question.click()
+ def _assert_checkmark(self, mentoring, result):
+ """Assert that only the desired checkmark is present."""
+ states = {CORRECT: 0, INCORRECT: 0, PARTIAL: 0}
+ states[result] += 1
+
+ for name, count in states.items():
+ self.assertEqual(len(mentoring.find_elements_by_css_selector(".checkmark-{}".format(name))), count)
+
class GetChoices(object):
""" Helper class for interacting with MCQ options """
diff --git a/problem_builder/tests/integration/test_assessment.py b/problem_builder/tests/integration/test_assessment.py
index 8161d885..cde4af0f 100644
--- a/problem_builder/tests/integration/test_assessment.py
+++ b/problem_builder/tests/integration/test_assessment.py
@@ -18,9 +18,7 @@
# "AGPLv3". If not, see .
#
from ddt import ddt, unpack, data
-from .base_test import MentoringAssessmentBaseTest, GetChoices
-
-CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct"
+from .base_test import CORRECT, INCORRECT, PARTIAL, MentoringAssessmentBaseTest, GetChoices
@ddt
@@ -47,29 +45,10 @@ def _selenium_bug_workaround_scroll_to(self, mentoring, question):
controls.click()
title.click()
- def assert_hidden(self, elem):
- self.assertFalse(elem.is_displayed())
-
- def assert_disabled(self, elem):
- self.assertTrue(elem.is_displayed())
- self.assertFalse(elem.is_enabled())
-
- def assert_clickable(self, elem):
- self.assertTrue(elem.is_displayed())
- self.assertTrue(elem.is_enabled())
-
def assert_persistent_elements_present(self, mentoring):
self.assertIn("A Simple Assessment", mentoring.text)
self.assertIn("This paragraph is shared between all questions.", mentoring.text)
- def _assert_checkmark(self, mentoring, result):
- """Assert that only the desired checkmark is present."""
- states = {CORRECT: 0, INCORRECT: 0, PARTIAL: 0}
- states[result] += 1
-
- for name, count in states.items():
- self.assertEqual(len(mentoring.find_elements_by_css_selector(".checkmark-{}".format(name))), count)
-
def go_to_workbench_main_page(self):
self.browser.get(self.live_server_url)
@@ -104,35 +83,6 @@ def freeform_answer(self, number, mentoring, controls, text_input, result, saved
self._assert_checkmark(mentoring, result)
self.do_post(controls, last)
- def ending_controls(self, controls, last):
- if last:
- self.assert_hidden(controls.next_question)
- self.assert_disabled(controls.review)
- else:
- self.assert_disabled(controls.next_question)
- self.assert_hidden(controls.review)
-
- def selected_controls(self, controls, last):
- self.assert_clickable(controls.submit)
- if last:
- self.assert_hidden(controls.next_question)
- self.assert_disabled(controls.review)
- else:
- self.assert_disabled(controls.next_question)
- self.assert_hidden(controls.review)
-
- def do_submit_wait(self, controls, last):
- if last:
- self.wait_until_clickable(controls.review)
- else:
- self.wait_until_clickable(controls.next_question)
-
- def do_post(self, controls, last):
- if last:
- controls.review.click()
- else:
- controls.next_question.click()
-
def single_choice_question(self, number, mentoring, controls, choice_name, result, last=False):
question = self.expect_question_visible(number, mentoring)
@@ -213,44 +163,6 @@ def peek_at_multiple_response_question(
return question
- def check_question_feedback(self, mentoring, question):
- question_checkmark = mentoring.find_element_by_css_selector('.assessment-checkmark')
- question_feedback = question.find_element_by_css_selector(".feedback")
- self.assertTrue(question_feedback.is_displayed())
- self.assertEqual(question_feedback.text, "Question Feedback Message")
-
- question.click()
- self.assertFalse(question_feedback.is_displayed())
-
- question_checkmark.click()
- self.assertTrue(question_feedback.is_displayed())
-
- def multiple_response_question(self, number, mentoring, controls, choice_names, result, last=False):
- question = self.peek_at_multiple_response_question(number, mentoring, controls, last=last)
-
- choices = GetChoices(question)
- expected_choices = {
- "Its elegance": False,
- "Its beauty": False,
- "Its gracefulness": False,
- "Its bugs": False,
- }
- self.assertEquals(choices.state, expected_choices)
-
- for name in choice_names:
- choices.select(name)
- expected_choices[name] = True
-
- self.assertEquals(choices.state, expected_choices)
-
- self.selected_controls(controls, last)
-
- controls.submit.click()
-
- self.do_submit_wait(controls, last)
- self._assert_checkmark(mentoring, result)
- controls.review.click()
-
def peek_at_review(self, mentoring, controls, expected, extended_feedback=False):
self.wait_until_text_in("You scored {percentage}% on this assessment.".format(**expected), mentoring)
self.assert_persistent_elements_present(mentoring)
@@ -288,15 +200,6 @@ def peek_at_review(self, mentoring, controls, expected, extended_feedback=False)
self.assert_hidden(controls.review)
self.assert_hidden(controls.review_link)
- def assert_message_text(self, mentoring, text):
- message_wrapper = mentoring.find_element_by_css_selector('.assessment-message')
- self.assertEqual(message_wrapper.text, text)
- self.assertTrue(message_wrapper.is_displayed())
-
- def assert_no_message_text(self, mentoring):
- message_wrapper = mentoring.find_element_by_css_selector('.assessment-message')
- self.assertEqual(message_wrapper.text, '')
-
def extended_feedback_checks(self, mentoring, controls, expected_results):
# Multiple choice is third correctly answered question
self.assert_hidden(controls.review_link)
diff --git a/problem_builder/tests/integration/test_step_builder.py b/problem_builder/tests/integration/test_step_builder.py
new file mode 100644
index 00000000..ffa06835
--- /dev/null
+++ b/problem_builder/tests/integration/test_step_builder.py
@@ -0,0 +1,346 @@
+from .base_test import CORRECT, INCORRECT, PARTIAL, MentoringAssessmentBaseTest, GetChoices
+
+from ddt import ddt, data
+
+
+@ddt
+class StepBuilderTest(MentoringAssessmentBaseTest):
+
+ def freeform_answer(self, number, step_builder, controls, text_input, result, saved_value="", last=False):
+ self.expect_question_visible(number, step_builder)
+
+ answer = step_builder.find_element_by_css_selector("textarea.answer.editable")
+
+ self.assertIn(self.question_text(number), step_builder.text)
+ self.assertIn("What is your goal?", step_builder.text)
+
+ self.assertEquals(saved_value, answer.get_attribute("value"))
+ if not saved_value:
+ self.assert_disabled(controls.submit)
+ self.assert_disabled(controls.next_question)
+
+ answer.clear()
+ answer.send_keys(text_input)
+ self.assertEquals(text_input, answer.get_attribute("value"))
+
+ self.assert_clickable(controls.submit)
+ self.ending_controls(controls, last)
+ self.assert_hidden(controls.review)
+ self.assert_hidden(controls.try_again)
+
+ controls.submit.click()
+
+ self.do_submit_wait(controls, last)
+ self._assert_checkmark(step_builder, result)
+ self.do_post(controls, last)
+
+ def single_choice_question(self, number, step_builder, controls, choice_name, result, last=False):
+ question = self.expect_question_visible(number, step_builder)
+
+ self.assertIn("Do you like this MCQ?", question.text)
+
+ self.assert_disabled(controls.submit)
+ self.ending_controls(controls, last)
+ self.assert_hidden(controls.try_again)
+
+ choices = GetChoices(question)
+ expected_state = {"Yes": False, "Maybe not": False, "I don't understand": False}
+ self.assertEquals(choices.state, expected_state)
+
+ choices.select(choice_name)
+ expected_state[choice_name] = True
+ self.assertEquals(choices.state, expected_state)
+
+ self.selected_controls(controls, last)
+
+ controls.submit.click()
+
+ self.do_submit_wait(controls, last)
+ self._assert_checkmark(step_builder, result)
+
+ self.do_post(controls, last)
+
+ def rating_question(self, number, step_builder, controls, choice_name, result, last=False):
+ self.expect_question_visible(number, step_builder)
+
+ self.assertIn("How much do you rate this MCQ?", step_builder.text)
+
+ self.assert_disabled(controls.submit)
+ self.ending_controls(controls, last)
+ self.assert_hidden(controls.try_again)
+
+ choices = GetChoices(step_builder, ".rating")
+ expected_choices = {
+ "1 - Not good at all": False,
+ "2": False, "3": False, "4": False,
+ "5 - Extremely good": False,
+ "I don't want to rate it": False,
+ }
+ self.assertEquals(choices.state, expected_choices)
+ choices.select(choice_name)
+ expected_choices[choice_name] = True
+ self.assertEquals(choices.state, expected_choices)
+
+ self.ending_controls(controls, last)
+
+ controls.submit.click()
+
+ self.do_submit_wait(controls, last)
+ self._assert_checkmark(step_builder, result)
+ self.do_post(controls, last)
+
+ def peek_at_multiple_response_question(
+ self, number, step_builder, controls, last=False, extended_feedback=False, alternative_review=False
+ ):
+ question = self.expect_question_visible(number, step_builder)
+ self.assertIn("What do you like in this MRQ?", step_builder.text)
+ return question
+
+ if extended_feedback:
+ self.assert_disabled(controls.submit)
+ self.check_question_feedback(step_builder, question)
+ if alternative_review:
+ self.assert_clickable(controls.review_link)
+ self.assert_hidden(controls.try_again)
+
+ def peek_at_review(self, step_builder, controls, expected, extended_feedback=False):
+ self.wait_until_text_in("You scored {percentage}% on this assessment.".format(**expected), step_builder)
+
+ # Check grade breakdown
+ if expected["correct"] == 1:
+ self.assertIn("You answered 1 questions correctly.".format(**expected), step_builder.text)
+ else:
+ self.assertIn("You answered {correct} questions correctly.".format(**expected), step_builder.text)
+
+ if expected["partial"] == 1:
+ self.assertIn("You answered 1 question partially correctly.", step_builder.text)
+ else:
+ self.assertIn("You answered {partial} questions partially correctly.".format(**expected), step_builder.text)
+
+ if expected["incorrect"] == 1:
+ self.assertIn("You answered 1 question incorrectly.", step_builder.text)
+ else:
+ self.assertIn("You answered {incorrect} questions incorrectly.".format(**expected), step_builder.text)
+
+ # Check presence of review links
+ # - If unlimited attempts: no review links
+ # - If limited attempts:
+ # - If not max attempts reached: no review links
+ # - If max attempts reached:
+ # - If extended feedback: review links available
+ # - If not extended feedback: review links
+
+ review_list = step_builder.find_elements_by_css_selector('.review-list')
+
+ if expected["max_attempts"] == 0:
+ self.assertFalse(review_list)
+ else:
+ if expected["num_attempts"] < expected["max_attempts"]:
+ self.assertFalse(review_list)
+ elif expected["num_attempts"] == expected["max_attempts"]:
+ if extended_feedback:
+ for correctness in ['correct', 'incorrect', 'partial']:
+ review_items = step_builder.find_elements_by_css_selector('.%s-list li' % correctness)
+ self.assertEqual(len(review_items), expected[correctness])
+ else:
+ self.assertFalse(review_list)
+
+ # Check if info about number of attempts used is correct
+ if expected["max_attempts"] == 1:
+ self.assertIn("You have used {num_attempts} of 1 submission.".format(**expected), step_builder.text)
+ elif expected["max_attempts"] == 0:
+ self.assertNotIn("You have used", step_builder.text)
+ else:
+ self.assertIn(
+ "You have used {num_attempts} of {max_attempts} submissions.".format(**expected),
+ step_builder.text
+ )
+
+ # Check controls
+ self.assert_hidden(controls.submit)
+ self.assert_hidden(controls.next_question)
+ self.assert_hidden(controls.review)
+ self.assert_hidden(controls.review_link)
+
+ def popup_check(self, step_builder, item_feedbacks, prefix='', do_submit=True):
+ for index, expected_feedback in enumerate(item_feedbacks):
+ choice_wrapper = step_builder.find_elements_by_css_selector(prefix + " .choice")[index]
+ choice_wrapper.click()
+
+ item_feedback_icon = choice_wrapper.find_element_by_css_selector(".choice-result")
+ item_feedback_icon.click()
+
+ item_feedback_popup = choice_wrapper.find_element_by_css_selector(".choice-tips")
+ self.assertTrue(item_feedback_popup.is_displayed())
+ self.assertEqual(item_feedback_popup.text, expected_feedback)
+
+ item_feedback_popup.click()
+ self.assertTrue(item_feedback_popup.is_displayed())
+
+ step_builder.click()
+ self.assertFalse(item_feedback_popup.is_displayed())
+
+ def extended_feedback_checks(self, step_builder, controls, expected_results):
+ # MRQ is third correctly answered question
+ self.assert_hidden(controls.review_link)
+ step_builder.find_elements_by_css_selector('.correct-list li a')[2].click()
+ self.peek_at_multiple_response_question(
+ None, step_builder, controls, extended_feedback=True, alternative_review=True
+ )
+
+ # Step should display 5 checkmarks (4 correct items for MRQ, plus step-level feedback about correctness)
+ correct_marks = step_builder.find_elements_by_css_selector('.checkmark-correct')
+ incorrect_marks = step_builder.find_elements_by_css_selector('.checkmark-incorrect')
+ self.assertEqual(len(correct_marks), 5)
+ self.assertEqual(len(incorrect_marks), 0)
+
+ item_feedbacks = [
+ "This is something everyone has to like about this MRQ",
+ "This is something everyone has to like about this MRQ",
+ "This MRQ is indeed very graceful",
+ "Nah, there aren't any!"
+ ]
+ self.popup_check(step_builder, item_feedbacks, prefix='div[data-name="mrq_1_1"]', do_submit=False)
+ controls.review_link.click()
+ self.peek_at_review(step_builder, controls, expected_results, extended_feedback=True)
+
+ # Review rating question (directly precedes MRQ)
+ step_builder.find_elements_by_css_selector('.incorrect-list li a')[0].click()
+ # It should be possible to visit the MRQ from here
+ self.wait_until_clickable(controls.next_question)
+ controls.next_question.click()
+ self.peek_at_multiple_response_question(
+ None, step_builder, controls, extended_feedback=True, alternative_review=True
+ )
+
+ @data(
+ {"max_attempts": 0, "extended_feedback": False}, # Unlimited attempts, no extended feedback
+ {"max_attempts": 1, "extended_feedback": True}, # Limited attempts, extended feedback
+ {"max_attempts": 1, "extended_feedback": False}, # Limited attempts, no extended feedback
+ {"max_attempts": 2, "extended_feedback": True}, # Limited attempts, extended feedback
+ )
+ def test_step_builder(self, params):
+ max_attempts = params['max_attempts']
+ extended_feedback = params['extended_feedback']
+ step_builder, controls = self.load_assessment_scenario("step_builder.xml", params)
+
+ # Step 1
+ # Submit free-form answer, go to next step
+ self.freeform_answer(None, step_builder, controls, 'This is the answer', CORRECT)
+
+ # Step 2
+ # Submit MCQ, go to next step
+ self.single_choice_question(None, step_builder, controls, 'Maybe not', INCORRECT)
+
+ # Step 3
+ # Submit rating, go to next step
+ self.rating_question(None, step_builder, controls, "5 - Extremely good", CORRECT)
+
+ # Last step
+ # Submit MRQ, go to review
+ self.multiple_response_question(None, step_builder, controls, ("Its beauty",), PARTIAL, last=True)
+
+ # Review step
+ expected_results = {
+ "correct": 2, "partial": 1, "incorrect": 1, "percentage": 63,
+ "num_attempts": 1, "max_attempts": max_attempts
+ }
+ self.peek_at_review(step_builder, controls, expected_results, extended_feedback=extended_feedback)
+
+ if max_attempts == 1:
+ self.assert_message_text(step_builder, "Note: you have used all attempts. Continue to the next unit.")
+ self.assert_disabled(controls.try_again)
+ return
+
+ self.assert_message_text(step_builder, "Assessment additional feedback message text")
+ self.assert_clickable(controls.try_again)
+
+ # Try again
+ controls.try_again.click()
+
+ self.wait_until_hidden(controls.try_again)
+ self.assert_no_message_text(step_builder)
+
+ self.freeform_answer(
+ None, step_builder, controls, 'This is a different answer', CORRECT, saved_value='This is the answer'
+ )
+ self.single_choice_question(None, step_builder, controls, 'Yes', CORRECT)
+ self.rating_question(None, step_builder, controls, "1 - Not good at all", INCORRECT)
+
+ user_selection = ("Its elegance", "Its beauty", "Its gracefulness")
+ self.multiple_response_question(None, step_builder, controls, user_selection, CORRECT, last=True)
+
+ expected_results = {
+ "correct": 3, "partial": 0, "incorrect": 1, "percentage": 75,
+ "num_attempts": 2, "max_attempts": max_attempts
+ }
+ self.peek_at_review(step_builder, controls, expected_results, extended_feedback=extended_feedback)
+
+ if max_attempts == 2:
+ self.assert_disabled(controls.try_again)
+ else:
+ self.assert_clickable(controls.try_again)
+
+ if 1 <= max_attempts <= 2:
+ self.assert_message_text(step_builder, "Note: you have used all attempts. Continue to the next unit.")
+ else:
+ self.assert_message_text(step_builder, "Assessment additional feedback message text")
+
+ if extended_feedback:
+ self.extended_feedback_checks(step_builder, controls, expected_results)
+
+ def test_review_tips(self):
+ params = {
+ "max_attempts": 3,
+ "extended_feedback": False,
+ "include_review_tips": True
+ }
+ step_builder, controls = self.load_assessment_scenario("step_builder.xml", params)
+
+ # Get one question wrong and one partially wrong on attempt 1 of 3: ####################
+ self.freeform_answer(None, step_builder, controls, 'This is the answer', CORRECT)
+ self.single_choice_question(None, step_builder, controls, 'Maybe not', INCORRECT)
+ self.rating_question(None, step_builder, controls, "5 - Extremely good", CORRECT)
+ self.multiple_response_question(None, step_builder, controls, ("Its beauty",), PARTIAL, last=True)
+
+ # The review tips for MCQ 2 and the MRQ should be shown:
+ review_tips = step_builder.find_element_by_css_selector('.assessment-review-tips')
+ self.assertTrue(review_tips.is_displayed())
+ self.assertIn('You might consider reviewing the following items', review_tips.text)
+ self.assertIn('Take another look at', review_tips.text)
+ self.assertIn('Lesson 1', review_tips.text)
+ self.assertNotIn('Lesson 2', review_tips.text) # This MCQ was correct
+ self.assertIn('Lesson 3', review_tips.text)
+ # The on-assessment-review message is also shown if attempts remain:
+ self.assert_message_text(step_builder, "Assessment additional feedback message text")
+
+ # Try again
+ self.assert_clickable(controls.try_again)
+ controls.try_again.click()
+
+ # Get no questions wrong on attempt 2 of 3: ############################################
+ self.freeform_answer(
+ None, step_builder, controls, 'This is the answer', CORRECT, saved_value='This is the answer'
+ )
+ self.single_choice_question(None, step_builder, controls, 'Yes', CORRECT)
+ self.rating_question(None, step_builder, controls, "5 - Extremely good", CORRECT)
+ user_selection = ("Its elegance", "Its beauty", "Its gracefulness")
+ self.multiple_response_question(None, step_builder, controls, user_selection, CORRECT, last=True)
+
+ self.assert_message_text(step_builder, "Assessment additional feedback message text")
+ self.assertFalse(review_tips.is_displayed())
+
+ # Try again
+ self.assert_clickable(controls.try_again)
+ controls.try_again.click()
+
+ # Get some questions wrong again on attempt 3 of 3:
+ self.freeform_answer(
+ None, step_builder, controls, 'This is the answer', CORRECT, saved_value='This is the answer'
+ )
+ self.single_choice_question(None, step_builder, controls, 'Maybe not', INCORRECT)
+ self.rating_question(None, step_builder, controls, "1 - Not good at all", INCORRECT)
+ self.multiple_response_question(None, step_builder, controls, ("Its beauty",), PARTIAL, last=True)
+
+ # The review tips will not be shown because no attempts remain:
+ self.assertFalse(review_tips.is_displayed())
diff --git a/problem_builder/tests/integration/xml_templates/step_builder.xml b/problem_builder/tests/integration/xml_templates/step_builder.xml
new file mode 100644
index 00000000..59299b7e
--- /dev/null
+++ b/problem_builder/tests/integration/xml_templates/step_builder.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+ Yes
+ Maybe not
+ I don't understand
+
+ Great!
+ Ah, damn.
+
Really?
+ {% if include_review_tips %}
+
+ Take another look at Lesson 1
+
+ {% endif %}
+
+
+
+
+
+ I don't want to rate it
+ I love good grades.
+ Will do better next time...
+ Your loss!
+ {% if include_review_tips %}
+
+ Take another look at Lesson 2
+
+ {% endif %}
+
+
+
+
+
+ Its elegance
+ Its beauty
+ Its gracefulness
+ Its bugs
+
+ This MRQ is indeed very graceful
+ This is something everyone has to like about this MRQ
+ Nah, there aren't any!
+ {% if include_review_tips %}
+
+ Take another look at Lesson 3
+
+ {% endif %}
+
+
+
+
+
+
+ Assessment additional feedback message text
+
+
+
From c92b9fecacf139d48324be2da06bac3a669c06a3 Mon Sep 17 00:00:00 2001
From: Tim Krones
Date: Mon, 28 Sep 2015 11:58:29 +0200
Subject: [PATCH 39/43] Update documentation.
---
README.md | 47 +++--
doc/Usage.md | 181 +++++++++++++-----
...tempts-remaining-extended-feedback-off.png | Bin 0 -> 30642 bytes
...ttempts-remaining-extended-feedback-on.png | Bin 0 -> 35444 bytes
.../review-step-some-attempts-remaining.png | Bin 0 -> 44214 bytes
...view-step-unlimited-attempts-available.png | Bin 0 -> 41371 bytes
.../reviewing-performance-for-single-step.png | Bin 0 -> 30398 bytes
...p-with-multiple-questions-after-submit.png | Bin 0 -> 24416 bytes
...-with-multiple-questions-before-submit.png | Bin 0 -> 24001 bytes
9 files changed, 157 insertions(+), 71 deletions(-)
create mode 100644 doc/img/review-step-no-attempts-remaining-extended-feedback-off.png
create mode 100644 doc/img/review-step-no-attempts-remaining-extended-feedback-on.png
create mode 100644 doc/img/review-step-some-attempts-remaining.png
create mode 100644 doc/img/review-step-unlimited-attempts-available.png
create mode 100644 doc/img/reviewing-performance-for-single-step.png
create mode 100644 doc/img/step-with-multiple-questions-after-submit.png
create mode 100644 doc/img/step-with-multiple-questions-before-submit.png
diff --git a/README.md b/README.md
index ec26fc3c..7dd87268 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,24 @@
-Problem Builder XBlock
-----------------------
+Problem Builder and Step Builder
+--------------------------------
[](https://travis-ci.org/open-craft/problem-builder)
-This XBlock allows creation of questions of various types and simulating the
-workflow of real-life mentoring, within an edX course.
+This repository provides two XBlocks: Problem Builder and Step Builder.
-It supports:
+Both blocks allow to create questions of various types. They can be
+used to simulate the workflow of real-life mentoring, within an edX
+course.
+
+Supported features include:
* **Free-form answers** (textarea) which can be shared accross
different XBlock instances (for example, to allow a student to
- review and edit an answer he gave before).
-* **Self-assessment MCQs** (multiple choice), to display predetermined
- feedback to a student based on his choices in the
+ review and edit an answer they gave before).
+* **Self-assessment MCQs** (multiple choice questions), to display
+ predetermined feedback to a student based on his choices in the
self-assessment. Supports rating scales and arbitrary answers.
* **MRQs (Multiple Response Questions)**, a type of multiple choice
- question that allows the student to choose more than one choice.
+ question that allows the student to select more than one choice.
* **Answer recaps** that display a read-only summary of a user's
answer to a free-form question asked earlier in the course.
* **Progression tracking**, to require that the student has
@@ -26,15 +29,15 @@ It supports:
* **Dashboards**, for displaying a summary of the student's answers
to multiple choice questions. [Details](doc/Dashboard.md)
-The screenshot shows an example of a problem builder block containing a
-free-form question, two MCQs and one MRQ.
+The following screenshot shows an example of a Problem Builder block
+containing a free-form question, two MCQs and one MRQ:

Installation
------------
-Install the requirements into the python virtual environment of your
+Install the requirements into the Python virtual environment of your
`edx-platform` installation by running the following command from the
root folder:
@@ -45,14 +48,20 @@ $ pip install -r requirements.txt
Enabling in Studio
------------------
-You can enable the Problem Builder XBlock in studio through the advanced
-settings.
+You can enable the Problem Builder and Step Builder XBlocks in Studio
+by modifying the advanced settings for your course:
+
+1. From the main page of a specific course, navigate to **Settings** ->
+ **Advanced Settings** from the top menu.
+2. Find the **Advanced Module List** setting.
+3. To enable Problem Builder for your course, add `"problem-builder"`
+ to the modules listed there.
+4. To enable Step Builder for your course, add `"step-builder"` to the
+ modules listed there.
+5. Click the **Save changes** button.
-1. From the main page of a specific course, navigate to `Settings ->
- Advanced Settings` from the top menu.
-2. Check for the `advanced_modules` policy key, and add `"problem-builder"`
- to the policy value list.
-3. Click the "Save changes" button.
+Note that it is perfectly fine to enable both Problem Builder and Step
+Builder for your course -- the blocks do not interfere with each other.
Usage
-----
diff --git a/doc/Usage.md b/doc/Usage.md
index 3bf75868..7f96831b 100644
--- a/doc/Usage.md
+++ b/doc/Usage.md
@@ -1,23 +1,30 @@
-Mentoring Block Usage
+Problem Builder Usage
=====================
-When you add the `Problem Builder` component to a course in the studio, the
-built-in editing tools guide you through the process of configuring the
-block and adding individual questions.
+When you add the **Problem Builder** component to a course in the
+studio, the built-in editing tools guide you through the process of
+configuring the block and adding individual questions.
### Problem Builder modes
There are 2 mentoring modes available:
-* *standard*: Traditional mentoring. All questions are displayed on the
+* **standard**: Traditional mentoring. All questions are displayed on the
page and submitted at the same time. The students get some tips and
feedback about their answers. This is the default mode.
-* *assessment*: Questions are displayed and submitted one by one. The
+* **assessment**: Questions are displayed and submitted one by one. The
students don't get tips or feedback, but only know if their answer was
correct. Assessment mode comes with a default `max_attempts` of `2`.
-Below are some LMS screenshots of a problem builder block in assessment mode.
+**Note that assessment mode is deprecated**: In the future, Problem
+Builder will only provide functionality that is currently part of
+standard mode. Assessment mode will remain functional for a while to
+ensure backward compatibility with courses that are currently using
+it. If you want to use assessment functionality for a new course,
+please use the Step Builder XBlock (described below).
+
+Below are some LMS screenshots of a Problem Builder block in assessment mode.
Question before submitting an answer:
@@ -35,9 +42,71 @@ Score review and the "Try Again" button:

-### Free-form Question
-Free-form questions are represented by a "Long Answer" component.
+Step Builder Usage
+==================
+
+The Step Builder XBlock replaces assessment mode functionality of the
+Problem Builder XBlock, while allowing to group questions into explict
+steps:
+
+Instead of adding questions to Step Builder itself, you'll need to add
+one or more **Mentoring Step** blocks to Step Builder. You can then
+add one or more questions to each step. This allows you to group
+questions into logical units (without being limited to showing only a
+single question per step). As students progress through the block,
+Step Builder will display one step at a time. All questions belonging
+to a step need to be completed before the step can be submitted.
+
+In addition to regular steps, Step Builder also provides a **Review
+Step** block which allows students to review their performance, and to
+jump back to individual steps to review their answers (if **Extended
+feedback** setting is on and maximum number of attempts has been
+reached). Note that only one such block is allowed per instance.
+
+**Screenshots: Step**
+
+Step with multiple questions (before submitting it):
+
+
+
+Step with multiple questions (after submitting it):
+
+
+
+As indicated by the orange check mark, this step is *partially*
+correct (i.e., some answers are correct and some are incorrect or
+partially correct).
+
+**Screenshots: Review Step**
+
+Unlimited attempts available:
+
+
+Limited attempts, some attempts remaining:
+
+
+
+Limited attempts, no attempts remaining, extended feedback off:
+
+
+
+Limited attempts, no attempts remaining, extended feedback on:
+
+
+
+**Screenshots: Step-level feedback**
+
+Reviewing performance for a single step:
+
+
+
+Question Types
+==============
+
+### Free-form Questions
+
+Free-form questions are represented by a **Long Answer** component.
Example screenshot before answering the question:
@@ -47,39 +116,41 @@ Screenshot after answering the question:

-You can add "Long Answer Recap" components to problem builder blocks later on
-in the course to provide a read-only view of any answer that the student
-entered earlier.
+You can add **Long Answer Recap** components to problem builder blocks
+later on in the course to provide a read-only view of any answer that
+the student entered earlier.
The read-only answer is rendered as a quote in the LMS:

-### Multiple Choice Questions (MCQ)
+### Multiple Choice Questions (MCQs)
Multiple Choice Questions can be added to a problem builder component and
have the following configurable options:
-* Question - The question to ask the student
-* Message - A feedback message to display to the student after they
+* **Question** - The question to ask the student
+* **Message** - A feedback message to display to the student after they
have made their choice.
-* Weight - The weight is used when computing total grade/score of
+* **Weight** - The weight is used when computing total grade/score of
the problem builder block. The larger the weight, the more influence this
question will have on the grade. Value of zero means this question
has no influence on the grade (float, defaults to `1`).
-* Correct Choice - Specify which choice[s] is considered correct. If
+* **Correct Choice[s]** - Specify which choice[s] are considered correct. If
a student selects a choice that is not indicated as correct here,
the student will get the question wrong.
-Using the Studio editor, you can add "Custom Choice" blocks to the MCQ.
-Each Custom Choice represents one of the options from which students
-will choose their answer.
+Using the Studio editor, you can add **Custom Choice** blocks to an
+MCQ. Each Custom Choice represents one of the options from which
+students will choose their answer.
-You can also add "Tip" entries. Each "Tip" must be configured to link
-it to one or more of the choices. If the student chooses a choice, the
+You can also add **Tip** entries. Each Tip must be configured to link
+it to one or more of the choices. If the student selects a choice, the
+tip will be displayed.
+**Screenshots**
-Screenshot: Before attempting to answer the questions:
+Before attempting to answer the questions:

@@ -91,7 +162,7 @@ After successfully completing the questions:

-#### Rating MCQ
+#### Rating Questions
When constructing questions where the student rates some topic on the
scale from `1` to `5` (e.g. a Likert Scale), you can use the Rating
@@ -100,11 +171,10 @@ The `Low` and `High` settings specify the text shown next to the
lowest and highest valued choice.
Rating questions are a specialized type of MCQ, and the same
-instructions apply. You can also still add "Custom Choice" components
+instructions apply. You can also still add **Custom Choice** components
if you want additional choices to be available such as "I don't know".
-
-### Self-assessment Multiple Response Questions (MRQ)
+### Self-assessment Multiple Response Questions (MRQs)
Multiple Response Questions are set up similarly to MCQs. The answers
are rendered as checkboxes. Unlike MCQs where only a single answer can
@@ -113,24 +183,26 @@ time.
MRQ questions have these configurable settings:
-* Question - The question to ask the student
-* Required Choices - For any choices selected here, if the student
+* **Question** - The question to ask the student
+* **Required Choices** - For any choices selected here, if the student
does *not* select that choice, they will lose marks.
-* Ignored Choices - For any choices selected here, the student will
+* **Ignored Choices** - For any choices selected here, the student will
always be considered correct whether they choose this choice or not.
* Message - A feedback message to display to the student after they
have made their choice.
-* Weight - The weight is used when computing total grade/score of
+* **Weight** - The weight is used when computing total grade/score of
the problem builder block. The larger the weight, the more influence this
question will have on the grade. Value of zero means this question
has no influence on the grade (float, defaults to `1`).
-* Hide Result - If set to True, the feedback icons next to each
- choice will not be displayed (This is false by default).
+* **Hide Result** - If set to `True`, the feedback icons next to each
+ choice will not be displayed (This is `False` by default).
-The "Custom Choice" and "Tip" components work the same way as they
+The **Custom Choice** and **Tip** components work the same way as they
do when used with MCQs (see above).
-Screenshot - Before attempting to answer the questions:
+**Screenshots**
+
+Before attempting to answer the questions:

@@ -146,24 +218,33 @@ After successfully completing the questions:

+Other Components
+================
+
### Tables
-The problem builder table allows you to present answers to multiple
-free-form questions in a concise way. Once you create an "Answer
-Recap Table" inside a Mentoring component in Studio, you will be
-able to add columns to the table. Each column has an optional
-"Header" setting that you can use to add a header to that column.
-Each column can contain one or more "Answer Recap" element, as
-well as HTML components.
+Tables allow you to present answers to multiple free-form questions in
+a concise way. Once you create an **Answer Recap Table** inside a
+Mentoring component in Studio, you will be able to add columns to the
+table. Each column has an optional **Header** setting that you can use
+to add a header to that column. Each column can contain one or more
+**Answer Recap** elements, as well as HTML components.
Screenshot:

+### "Dashboard" Self-Assessment Summary Block
+
+[Instructions for using the "Dashboard" Self-Assessment Summary Block](Dashboard.md)
+
+Configuration Options
+====================
+
### Maximum Attempts
-You can set the number of maximum attempts for the unit completion by
-setting the Max. Attempts option of the Mentoring component.
+You can limit the number of times students are allowed to complete a
+Mentoring component by setting the **Max. attempts allowed** option.
Before submitting an answer for the first time:
@@ -173,12 +254,8 @@ After submitting a wrong answer two times:

-### Custom tip popup window size
+### Custom Window Size for Tip Popups
-You can specify With and Height attributes of any Tip component to
-customize the popup window size. The value of those attribute should
-be valid CSS (e.g. `50px`).
-
-### "Dashboard" Self-Assessment Summary Block
-
-[Instructions for using the "Dashboard" Self-Assessment Summary Block](Dashboard.md)
+You can specify **Width** and **Height** attributes of any Tip
+component to customize the popup window size. The value of those
+attributes should be valid CSS (e.g. `50px`).
diff --git a/doc/img/review-step-no-attempts-remaining-extended-feedback-off.png b/doc/img/review-step-no-attempts-remaining-extended-feedback-off.png
new file mode 100644
index 0000000000000000000000000000000000000000..e20798fb65cc50e84dc9f8f698d1a5cfdfe19f6d
GIT binary patch
literal 30642
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
literal 0
HcmV?d00001
diff --git a/doc/img/review-step-no-attempts-remaining-extended-feedback-on.png b/doc/img/review-step-no-attempts-remaining-extended-feedback-on.png
new file mode 100644
index 0000000000000000000000000000000000000000..556b330983fd45de37e2d440c4cbc10ae9aad883
GIT binary patch
literal 35444
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