Commit 015fe14d by Tim Krones

Merge pull request #77 from open-craft/sb-plot-block

Step Builder: New block type: "Plot"
parents 8d8a8f96 bc2a12e8
......@@ -185,7 +185,7 @@ class RatingBlock(MCQBlock):
list_values_provider=QuestionnaireAbstractBlock.choice_values_provider,
list_style='set', # Underered, unique items. Affects the UI editor.
)
editable_fields = MCQBlock.editable_fields + ('low', 'high')
editable_fields = MCQBlock.editable_fields + ('low', 'high', 'name')
@property
def all_choice_values(self):
......
......@@ -1062,6 +1062,11 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes
'active_step': self.active_step
}
def author_preview_view(self, context):
context = context.copy() if context else {}
context['author_preview_view'] = True
return super(MentoringWithExplicitStepsBlock, self).author_preview_view(context)
def author_edit_view(self, context):
"""
Add some HTML to the author view that allows authors to add child blocks.
......
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
import json
import logging
from lazy.lazy import lazy
from xblock.core import XBlock
from xblock.fields import String, Scope
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import (
StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin
)
from .sub_api import sub_api
loader = ResourceLoader(__name__)
log = logging.getLogger(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
def _normalize_id(key):
"""
Helper method to normalize a key to avoid issues where some keys have version/branch and others don't.
e.g. self.scope_ids.usage_id != self.runtime.get_block(self.scope_ids.usage_id).scope_ids.usage_id
"""
if hasattr(key, "for_branch"):
key = key.for_branch(None)
if hasattr(key, "for_version"):
key = key.for_version(None)
return key
@XBlock.needs('i18n')
@XBlock.wants('user')
class PlotBlock(StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin, XBlock):
"""
XBlock that displays plot that summarizes answers to scale questions.
"""
CATEGORY = 'sb-plot'
STUDIO_LABEL = _(u"Plot")
# Settings
display_name = String(
display_name=_("Plot title"),
default="Plot",
scope=Scope.content
)
plot_label = String(
display_name=_("Plot label"),
help=_("Label for default overlay that shows student's answers to scale questions"),
default="yours",
scope=Scope.content
)
point_color_default = String(
display_name=_("Point color (default overlay)"),
help=_("Point color to use for default overlay"),
default="green",
scope=Scope.content
)
point_color_average = String(
display_name=_("Point color (average overlay)"),
help=_("Point color to use for average overlay"),
default="blue",
scope=Scope.content
)
q1_label = String(
display_name=_("Quadrant I"),
help=_(
"Label for the first quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q1",
scope=Scope.content
)
q2_label = String(
display_name=_("Quadrant II"),
help=_(
"Label for the second quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q2",
scope=Scope.content
)
q3_label = String(
display_name=_("Quadrant III"),
help=_(
"Label for the third quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q3",
scope=Scope.content
)
q4_label = String(
display_name=_("Quadrant IV"),
help=_(
"Label for the fourth quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q4",
scope=Scope.content
)
claims = String(
display_name=_("Claims and associated questions"),
help=_(
'Claims and questions that should be included in the plot. '
'Each line defines a triple of the form "claim, q1, q2", '
'where "claim" is arbitrary text that represents a claim, '
'and "q1" and "q2" are IDs of scale questions. '
),
default="",
multiline_editor=True,
resettable_editor=False
)
editable_fields = (
'display_name', 'plot_label', 'point_color_default', 'point_color_average',
'q1_label', 'q2_label', 'q3_label', 'q4_label', 'claims'
)
@lazy
def course_key_str(self):
location = _normalize_id(self.location)
return unicode(location.course_key)
@property
def default_claims(self):
return self._get_claims(self._get_default_response)
@property
def average_claims(self):
return self._get_claims(self._get_average_response)
def _get_claims(self, response_function):
if not self.claims:
return []
mentoring_block = self.get_parent().get_parent()
question_ids, questions = mentoring_block.question_ids, mentoring_block.questions
claims = []
for line in self.claims.split('\n'):
claim, q1, q2 = line.split(', ')
r1, r2 = None, None
for question_id, question in zip(question_ids, questions):
if question.name == q1:
r1 = response_function(question, question_id)
if question.name == q2:
r2 = response_function(question, question_id)
if r1 is not None and r2 is not None:
break
claims.append([claim, r1, r2])
return claims
def _get_default_response(self, question, question_id):
# 1. Obtain block_type for question
question_type = question.scope_ids.block_type
# 2. Obtain latest submission for question
student_dict = {
'student_id': self.runtime.anonymous_student_id,
'course_id': self.course_key_str,
'item_id': question_id,
'item_type': question_type,
}
submissions = sub_api.get_submissions(student_dict, limit=1)
# 3. Extract response from latest submission for question
answer_cache = {}
for submission in submissions:
answer = self._get_answer(question, submission, answer_cache)
return int(answer)
def _get_average_response(self, question, question_id):
# 1. Obtain block_type for question
question_type = question.scope_ids.block_type
# 2. Obtain latest submissions for question
submissions = sub_api.get_all_submissions(self.course_key_str, question_id, question_type)
# 3. Extract responses from latest submissions for question and sum them up
answer_cache = {}
response_total = 0
num_submissions = 0 # Can't use len(submissions) because submissions is a generator
for submission in submissions:
answer = self._get_answer(question, submission, answer_cache)
response_total += int(answer)
num_submissions += 1
# 4. Calculate average response for question
if num_submissions:
return response_total / float(num_submissions)
def _get_answer(self, block, submission, answer_cache):
"""
Return answer associated with `submission` to `block`.
`answer_cache` is a dict that is reset for each block.
"""
answer = submission['answer']
# Convert from answer ID to answer label
if answer not in answer_cache:
answer_cache[answer] = block.get_submission_display(answer)
return answer_cache[answer]
def default_claims_json(self):
return json.dumps(self.default_claims)
def average_claims_json(self):
return json.dumps(self.average_claims)
@XBlock.json_handler
def get_data(self, data, suffix):
return {
'default_claims': self.default_claims,
'average_claims': self.average_claims,
}
def author_preview_view(self, context):
return Fragment(
u"<p>{}</p>".format(
_(u"This block displays a plot that summarizes answers to scale questions.")
)
)
def mentoring_view(self, context):
return self.student_view(context)
def student_view(self, context=None):
""" Student View """
context = context.copy() if context else {}
context['hide_header'] = True
context['self'] = self
fragment = Fragment()
fragment.add_content(loader.render_template('templates/html/plot.html', context))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/plot.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/d3.min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/plot.js'))
fragment.initialize_js('PlotBlock')
return fragment
.quadrants label {
font-weight: bold;
}
.overlays {
float: right;
}
.quadrants input, .overlays input {
background-color: rgb(204, 204, 204);
}
......@@ -303,6 +303,7 @@ function MentoringWithStepsBlock(runtime, element) {
function showActiveStep() {
var step = steps[activeStep];
step.updatePlots();
$(step.element).show();
}
......
function PlotBlock(runtime, element) {
// Plot
// Define margins
var margins = {top: 20, right: 20, bottom: 20, left: 20};
// Define width and height of SVG viewport
var width = 440,
height = 440;
// Define dimensions of plot area
var plotWidth = width - margins.left - margins.right,
plotHeight = height - margins.top - margins.bottom;
// Preselect target DOM element for plot.
// This is necessary because when using a CSS selector,
// d3.select will select the *first* element that matches the selector (in document traversal order),
// which leads to unintended consequences when multiple plot blocks are present.
var plotTarget = $(element).find('.sb-plot').get(0);
// Create SVG viewport with nested group for plot area
var svgContainer = d3.select(plotTarget)
.append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + margins.left + ", " + margins.right + ")");
// Create scales to use for axes and data
var xScale = d3.scale.linear()
.domain([0, 100])
.range([0, plotWidth]);
var yScale = d3.scale.linear()
.domain([100, 0])
.range([0, plotHeight]);
// Create axes
var xAxis = d3.svg.axis()
.scale(xScale)
.orient("bottom");
var yAxis = d3.svg.axis()
.scale(yScale)
.orient("left");
// Create SVG group elements for axes and call the xAxis and yAxis functions
var xAxisGroup = svgContainer.append("g")
.attr("transform", "translate(0, " + plotHeight / 2 + ")")
.call(xAxis);
var yAxisGroup = svgContainer.append("g")
.attr("transform", "translate(" + plotWidth / 2 + ", 0)")
.call(yAxis);
// Buttons
var defaultButton = $('.plot-default', element),
averageButton = $('.plot-average', element),
quadrantsButton = $('.plot-quadrants', element);
// Claims
var defaultClaims = defaultButton.data('claims'),
averageClaims = averageButton.data('claims');
// Colors
var defaultColor = defaultButton.data('point-color'),
averageColor = averageButton.data('point-color');
// Quadrant labels
var q1Label = quadrantsButton.data('q1-label'),
q2Label = quadrantsButton.data('q2-label'),
q3Label = quadrantsButton.data('q3-label'),
q4Label = quadrantsButton.data('q4-label');
// Event handlers
function toggleOverlay(claims, color, klass, refresh) {
var selector = "." + klass,
selection = svgContainer.selectAll(selector);
if (selection.empty()) {
showOverlay(selection, claims, color, klass);
} else {
hideOverlay(selection);
if (refresh) {
toggleOverlay(claims, color, klass);
}
}
}
function showOverlay(selection, claims, color, klass) {
selection
.data(claims)
.enter()
.append("circle")
.attr("class", klass)
.attr("title", function(d) {
return d[0] + ": " + d[1] + ", " + d[2];
})
.attr("cx", function(d) {
return xScale(d[1]);
})
.attr("cy", function(d) {
return yScale(d[2]);
})
.attr("r", 5)
.style("fill", color);
}
function hideOverlay(selection) {
selection.remove();
}
function toggleBorderColor(button, color, refresh) {
var $button = $(button),
overlayOn = $button.data("overlay-on");
if (overlayOn && !refresh) {
$button.css("border-color", "rgb(237, 237, 237)"); // Default color: grey
$button.data("overlay-on", false);
} else {
$button.css("border-color", color);
$button.data("overlay-on", true);
}
}
function toggleQuadrantLabels() {
var selection = svgContainer.selectAll(".quadrant-label"),
quadrantLabelsOn = quadrantsButton.val() === 'On';
if (quadrantLabelsOn) {
selection.remove();
quadrantsButton.val("Off");
quadrantsButton.css("border-color", "red");
} else {
var labels = [
[0.75 * plotWidth, 0, q1Label],
[0.25 * plotWidth, 0, q2Label],
[0.25 * plotWidth, plotHeight, q3Label],
[0.75 * plotWidth, plotHeight, q4Label]
];
selection.data(labels)
.enter()
.append("text")
.attr("class", 'quadrant-label')
.attr("x", function(d) {
return d[0];
})
.attr("y", function(d) {
return d[1];
})
.text(function(d) {
return d[2];
})
.attr("text-anchor", "middle")
.attr("font-family", "sans-serif")
.attr("font-size", "16px")
.attr("fill", "black");
quadrantsButton.val("On");
quadrantsButton.css("border-color", "green");
}
}
defaultButton.on('click', function(event, refresh) {
toggleOverlay(defaultClaims, defaultColor, 'claim-default', refresh);
toggleBorderColor(this, defaultColor, refresh);
});
averageButton.on('click', function(event) {
toggleOverlay(averageClaims, averageColor, 'claim-average');
toggleBorderColor(this, averageColor);
});
quadrantsButton.on('click', function() {
toggleQuadrantLabels();
});
// Quadrant labels are off initially; color of button for toggling them should reflect this
quadrantsButton.css("border-color", "red");
// API
var dataXHR;
return {
update: function() {
var handlerUrl = runtime.handlerUrl(element, 'get_data');
if (dataXHR) {
dataXHR.abort();
}
dataXHR = $.post(handlerUrl, JSON.stringify({}))
.success(function(response) {
defaultClaims = response.default_claims;
averageClaims = response.average_claims;
// Default overlay should be visible initially.
// Might still be visible from a previous attempt;
// in that case, we refresh it:
defaultButton.trigger('click', 'refresh');
// Average overlay should be hidden initially.
// This is the default when (re-)loading the page from scratch.
// However, the overlay might still be visible from a previous attempt;
// in that case, we hide it:
var selection = svgContainer.selectAll('.claim-average');
if (!selection.empty()) {
hideOverlay(selection);
toggleBorderColor(averageButton, averageColor);
}
});
}
};
}
function MentoringStepBlock(runtime, element) {
var children = runtime.children(element);
var plots = [];
for (var i in children) {
var child = children[i];
var blockType = $(child.element).data('block-type');
if (blockType === 'sb-plot') {
plots.push(child);
}
}
var submitXHR, resultsXHR;
function callIfExists(obj, fn) {
......@@ -89,6 +98,15 @@ function MentoringStepBlock(runtime, element) {
hasQuestion: function() {
return $('.sb-step', element).data('has-question')
},
updatePlots: function() {
if (plots) {
for (var i in plots) {
var plot = plots[i];
plot.update();
}
}
}
};
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -37,6 +37,7 @@ from .message import (
)
from problem_builder.mixins import EnumerableChildMixin, MessageParentMixin, StepParentMixin
from problem_builder.mrq import MRQBlock
from problem_builder.plot import PlotBlock
from problem_builder.table import MentoringTableBlock
......@@ -146,7 +147,7 @@ class MentoringStepBlock(
return [
NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'),
MCQBlock, RatingBlock, MRQBlock, HtmlBlockShim,
AnswerRecapBlock, MentoringTableBlock,
AnswerRecapBlock, MentoringTableBlock, PlotBlock
] + additional_blocks
@property
......@@ -224,16 +225,16 @@ class MentoringStepBlock(
fragment.initialize_js('StepEdit')
return fragment
def student_view(self, context=None):
""" Student View """
return self._render_view(context, 'student_view')
def mentoring_view(self, context=None):
""" Mentoring View """
return self._render_view(context, 'mentoring_view')
def _render_view(self, context, view):
""" Actually renders a view """
rendering_for_studio = False
if context: # Workbench does not provide context
rendering_for_studio = context.get('author_preview_view')
fragment = Fragment()
child_contents = []
......@@ -242,10 +243,18 @@ class MentoringStepBlock(
if child is None: # child should not be None but it can happen due to bugs or permission issues
child_contents.append(u"<p>[{}]</p>".format(self._(u"Error: Unable to load child component.")))
else:
child_fragment = self._render_child_fragment(child, context, view)
fragment.add_frag_resources(child_fragment)
child_contents.append(child_fragment.content)
if rendering_for_studio and isinstance(child, PlotBlock):
# Don't use view to render plot blocks in Studio.
# This is necessary because:
# - student_view of plot block uses submissions API to retrieve results,
# which causes "SubmissionRequestError" in Studio.
# - author_preview_view does not supply JS code for plot that JS code for step depends on
# (step calls "update" on plot to get latest data during rendering).
child_contents.append(u"<p>{}</p>".format(child.display_name))
else:
child_fragment = self._render_child_fragment(child, context, view)
fragment.add_frag_resources(child_fragment)
child_contents.append(child_fragment.content)
fragment.add_content(loader.render_template('templates/html/step.html', {
'self': self,
......
<div class="sb-plot">
<div class="quadrants">
<label>
Quadrant labels
<input type="button"
class="plot-quadrants"
data-q1-label="{{ self.q1_label }}"
data-q2-label="{{ self.q2_label }}"
data-q3-label="{{ self.q3_label }}"
data-q4-label="{{ self.q4_label }}"
value="Off">
</label>
</div>
<div class="overlays">
<h3>Compare your plot to others!</h3>
<input type="button"
class="plot-default"
data-claims="{{ self.default_claims_json }}"
data-point-color="{{ self.point_color_default }}"
data-overlay-on="false"
value="{{ self.plot_label }}"
/>
<input type="button"
class="plot-average"
data-claims="{{ self.average_claims_json }}"
data-point-color="{{ self.point_color_average }}"
data-overlay-on="false"
value="Average"
/>
</div>
</div>
<step-builder url_name="step-builder" display_name="Step Builder">
<sb-step display_name="First step">
<pb-rating name="rating_1_1"
low="Disagree"
high="Agree"
question="How much do you agree?"
correct_choices='["1", "2", "3", "4","5"]'>
</pb-rating>
<pb-rating name="rating_1_2"
low="Not important"
high="Very important"
question="How important do you think this is?"
correct_choices='["1", "2", "3", "4","5"]'>
</pb-rating>
</sb-step>
<sb-step display_name="Second step">
<pb-rating name="rating_2_1"
low="Disagree"
high="Agree"
question="How much do you agree?"
correct_choices='["1", "2", "3", "4","5"]'>
</pb-rating>
<pb-rating name="rating_2_2"
low="Not important"
high="Very important"
question="How important do you think this is?"
correct_choices='["1", "2", "3", "4","5"]'>
</pb-rating>
</sb-step>
<sb-step display_name="Last step">
<sb-plot plot_label="Custom plot label"
point_color_default="orange"
point_color_average="purple"
q1_label="Custom Q1 label"
q2_label="Custom Q2 label"
q3_label="Custom Q3 label"
q4_label="Custom Q4 label"
claims="2 + 2 = 5, rating_1_1, rating_1_2&#10;The answer to everything is 42, rating_2_1, rating_2_2">
</sb-plot>
</sb-step>
<sb-review-step></sb-review-step>
</step-builder>
<step-builder url_name="step-builder" display_name="Step Builder">
<sb-step display_name="First step">
<pb-rating name="rating_1_1"
low="Disagree"
high="Agree"
question="How much do you agree?"
correct_choices='["1", "2", "3", "4","5"]'>
</pb-rating>
<pb-rating name="rating_1_2"
low="Not important"
high="Very important"
question="How important do you think this is?"
correct_choices='["1", "2", "3", "4","5"]'>
</pb-rating>
</sb-step>
<sb-step display_name="Last step">
<sb-plot></sb-plot>
</sb-step>
<sb-review-step></sb-review-step>
</step-builder>
......@@ -108,7 +108,7 @@ class TestMentoringStep(unittest.TestCase):
block = MentoringStepBlock(Mock(), DictFieldData({}), Mock())
self.assertEqual(
self.get_allowed_blocks(block),
['pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap', 'pb-table']
['pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap', 'pb-table', 'sb-plot']
)
from sys import modules
xmodule_mock = Mock()
......@@ -122,6 +122,6 @@ class TestMentoringStep(unittest.TestCase):
self.assertEqual(
self.get_allowed_blocks(block), [
'pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap',
'pb-table', 'video', 'imagemodal'
'pb-table', 'sb-plot', 'video', 'imagemodal'
]
)
......@@ -45,6 +45,8 @@ BLOCKS = [
'sb-step = problem_builder.step:MentoringStepBlock',
'sb-review-step = problem_builder.step:ReviewStepBlock',
'sb-plot = problem_builder.plot:PlotBlock',
'pb-table = problem_builder.table:MentoringTableBlock',
'pb-column = problem_builder.table:MentoringTableColumn',
'pb-answer = problem_builder.answer:AnswerBlock',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment