Commit 3ebf2810 by Braden MacDonald

Flexible visual representation of dashboard, using SVG

parent 531b70d4
......@@ -17,11 +17,19 @@
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
"""
Dashboard: Summarize the results of self-assessments done by a student with Problem Builder MCQ
blocks.
The author of this block specifies a list of Problem Builder blocks containing MCQs. This block
will then display a table summarizing the values that the student chose for each of those MCQs.
"""
# Imports ###########################################################
import json
import logging
from .dashboard_visual import DashboardVisualData
from .mcq import MCQBlock
from .sub_api import sub_api
from xblock.core import XBlock
......@@ -40,8 +48,8 @@ log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
""" A no-op to mark strings that we need to translate """
return text
# Classes ###########################################################
......@@ -64,17 +72,26 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock):
"This should be an ordered list of the url_names of each mentoring block whose multiple choice question "
"values are to be shown on this dashboard. The list should be in JSON format. Example: {example_here}"
).format(example_here='["2754b8afc03a439693b9887b6f1d9e36", "215028f7df3d4c68b14fb5fea4da7053"]'),
scope=Scope.content,
scope=Scope.settings,
default=""
)
color_codes = Dict(
display_name=_("Color Coding"),
help=_(
"You can optionally set a color for each expected value. Example: {example_here}"
).format(example_here='{ "1": "red", "2": "yellow", 3: "green", 4: "lightskyblue" }')
).format(example_here='{ "1": "red", "2": "yellow", 3: "green", 4: "lightskyblue" }'),
scope=Scope.content,
)
visual_rules = String(
display_name=_("Visual Representation"),
default="",
help=_("Optional: Enter the JSON configuration of the visual representation desired (Advanced)."),
scope=Scope.content,
multiline_editor=True,
resettable_editor=False,
)
editable_fields = ('display_name', 'mentoring_ids', 'color_codes')
editable_fields = ('display_name', 'mentoring_ids', 'color_codes', 'visual_rules')
def get_mentoring_blocks(self):
"""
......@@ -127,18 +144,46 @@ class DashboardBlock(StudioEditableXBlockMixin, XBlock):
block['has_average'] = True
blocks.append(block)
visual_repr = None
if self.visual_rules:
try:
rules_parsed = json.loads(self.visual_rules)
except ValueError:
pass # JSON errors should be shown as part of validation
else:
visual_repr = DashboardVisualData(blocks, rules_parsed)
return loader.render_template('templates/html/dashboard.html', {
'blocks': blocks,
'display_name': self.display_name,
'visual_repr': visual_repr,
})
def fallback_view(self, view_name, context=None):
context = context or {}
context['self'] = self
def student_view(self, context=None): # pylint: disable=unused-argument
"""
Standard view of this XBlock.
"""
if not self.mentoring_ids:
return Fragment(u"<h1>{}</h1><p>{}</p>".format(self.display_name, _("Not configured.")))
fragment = Fragment(self.generate_content())
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/dashboard.css'))
return fragment
\ No newline at end of file
return fragment
def validate_field_data(self, validation, data):
"""
Validate this block's field data.
"""
super(DashboardBlock, self).validate_field_data(validation, data)
def add_error(msg):
validation.add(ValidationMessage(ValidationMessage.ERROR, msg))
if data.visual_rules:
try:
rules = json.loads(data.visual_rules)
except ValueError as e:
add_error(_(u"Visual rules contains an error: {error}").format(error=e))
else:
if not isinstance(rules, dict):
add_error(_(u"Visual rules should be a JSON dictionary/object: {...}"))
# -*- 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/>.
#
"""
Visual Representation of Dashboard State.
Consists of a series of images, layered on top of each other, where the appearance of each layer
can be tied to the average value of the student's response to a particular Problem Builder
block.
For example, each layer can have its color turn green if the student's average value on MCQs in
a specific Problem Builder block was at least 3.
"""
import ast
import operator as op
class DashboardVisualData(object):
"""
Data about the visual representation of a dashboard.
"""
def __init__(self, blocks, rules):
"""
Construct the data required for the optional visual representation of the dashboard.
Data format accepted for rules is like:
{
"images": [
"step1.png",
"step2.png",
"step3.png",
"step4.png",
"step5.png",
"step6.png",
"step7.png"
],
"overlay": "overlay.png",
"colorRules": [
{"if": "x < 1", "hueRotate": "20"},
{"if": "x < 2", "hueRotate": "80"},
{"blur": "x / 2", "saturate": "0.4"}
],
"width": "500",
"height": "500"
}
"""
# Images is a list of images, one per PB block, in the same order as 'blocks'
# All images are rendered layered on top of each other, and can be hidden,
# shown, colorized, faded, etc. based on the average answer value for that PB block.
images = rules.get("images", [])
# Overlay is an optional images drawn on top, with no effects applied
overlay = rules.get("overlay")
# Background is an optional images drawn on the bottom, with no effects applied
background = rules.get("background")
# Color rules specify how the average value of the PB block affects that block's image.
# Each rule is evaluated in order, and the first matching rule is used.
# Each rule can have an "if" expression that is evaluated and if true, that rule will
# be used.
colorRules = rules.get("colorRules", [])
# Width and height of the image:
self.width = int(rules.get("width", 400))
self.height = int(rules.get("height", 400))
self.layers = []
if background:
self.layers.append({"url": background})
for idx, block in enumerate(blocks):
if not block.get("has_average"):
continue # We only use blocks with numeric averages for the visual representation
# Now we build the 'layer_data' information to pass on to the template:
try:
layer_data = {"url": images[idx], "id": "layer{}".format(idx)}
except IndexError:
break
# Check if a color rule applies:
x = block["average"]
for rule in colorRules:
condition = rule.get("if")
if condition and not self._safe_eval_expression(condition, x):
continue # This rule does not apply
# Rule does apply:
layer_data["has_filter"] = True
for key in ("hueRotate", "blur", "saturate"):
if key in rule:
layer_data[key] = self._safe_eval_expression(rule[key], x)
break
self.layers.append(layer_data)
if overlay:
self.layers.append({"url": overlay})
@staticmethod
def _safe_eval_expression(expr, x=0):
"""
Safely evaluate a mathematical or boolean expression involving the value x
>>> _safe_eval_expression('2*x', x=3)
6
>>> _safe_eval_expression('x >= 0 and x < 2', x=3)
False
We use python syntax for the expressions, so the parsing and evaluation is mostly done
using Python's built-in asbstract syntax trees and operator implementations.
The expression can only contain: numbers, mathematical operators, boolean operators,
comparisons, and a placeholder variable called "x"
"""
# supported operators:
operators = {
# Allow +, -, *, /, %, negative:
ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, ast.Mod: op.mod, ast.USub: op.neg,
# Allow comparison:
ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge,
}
def eval_(node):
""" Recursive evaluation of syntax tree node 'node' """
if isinstance(node, ast.Num): # <number>
return node.n
elif isinstance(node, ast.BinOp): # <left> <operator> <right>
return operators[type(node.op)](eval_(node.left), eval_(node.right))
elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1
return operators[type(node.op)](eval_(node.operand))
elif isinstance(node, ast.Name) and node.id == "x":
return x
elif isinstance(node, ast.BoolOp): # Boolean operator: either "and" or "or" with two or more values
if type(node.op) == ast.And:
return all(eval_(val) for val in node.values)
else: # Or:
for val in node.values:
result = eval_(val)
if result:
return result
return result # or returns the final value even if it's falsy
elif isinstance(node, ast.Compare): # A comparison expression, e.g. "3 > 2" or "5 < x < 10"
left = eval_(node.left)
for comparison_op, right_expr in zip(node.ops, node.comparators):
right = eval_(right_expr)
if not operators[type(comparison_op)](left, right):
return False
left = right
return True
else:
raise TypeError(node)
return eval_(ast.parse(expr, mode='eval').body)
......@@ -27,7 +27,6 @@ except ImportError:
sub_api = None # We are probably in the workbench. Don't use the submissions API
class SubmittingXBlockMixin(object):
""" Simplifies use of the submissions API by an XBlock """
@property
......
{% load i18n %}
<div class="pb-dashboard">
<h1>{{display_name}}</h1>
{% if visual_repr %}
<div class="pb-dashboard-visual">
<svg width="{{visual_repr.width}}" height="{{visual_repr.height}}">
<!-- Filter definitions -->
{% for layer in visual_repr.layers %}
{% if layer.has_filter %}
<filter id="{{layer.id}}">
<feColorMatrix in="SourceGraphic" type="hueRotate" values="{% if layer.hueRotate %}{{layer.hueRotate}}{% else %}0{% endif %}" result="hued" />
<feColorMatrix in="hued" type="saturate" values="{% if 'saturate' in layer %}{{layer.saturate}}{% else %}1{% endif %}" result="sat" />
<feGaussianBlur stdDeviation="{% if layer.blur %}{{layer.blur}}{% else %}0{% endif %}" in="sat" />
</filter>
{% endif %}
{% endfor %}
<!-- Layer images -->
{% for layer in visual_repr.layers %}
<image xlink:href="{{layer.url}}" x="0" y="0" height="100%" width="100%" {% if layer.has_filter %}style="filter: url(#{{layer.id}});"{% endif %} />
{% endfor %}
</svg>
</div>
{% endif %}
<table>
{% for block in blocks %}
<thead>
......
"""
Unit tests for DashboardVisualData
"""
from mentoring.dashboard_visual import DashboardVisualData
from mock import MagicMock, Mock
import unittest
from xblock.field_data import DictFieldData
class TestDashboardVisualData(unittest.TestCase):
"""
Test DashboardVisualData with some mocked data
"""
def test_construct_data(self):
"""
Test parsing of data and creation of SVG filter data.
"""
blocks = [
{
'display_name': 'Block 1',
'mcqs': [],
'has_average': True,
'average': 0,
},
{
'display_name': 'Block 2',
'mcqs': [],
'has_average': True,
'average': 1.3,
},
{
'display_name': 'Block 3',
'mcqs': [],
'has_average': True,
'average': 30.8,
},
]
rules = {
"images": [
"step1.png",
"step2.png",
"step3.png",
],
"background": "background.png",
"overlay": "overlay.png",
"colorRules": [
{"if": "x < 1", "hueRotate": "20"},
{"if": "x < 2", "hueRotate": "80", "blur": "1"},
{"blur": "x / 2", "saturate": "0.4"}
],
"width": "500",
"height": "500"
}
data = DashboardVisualData(blocks, rules)
self.assertEqual(len(data.layers), 5)
self.assertEqual(data.layers[0]["url"], "background.png")
self.assertEqual(data.layers[4]["url"], "overlay.png")
self.assertEqual(data.width, 500)
self.assertEqual(data.height, 500)
# Check the three middle layers built from the average values:
# Step 1, average is 0 - first colorRule should match
self.assertEqual(data.layers[1]["url"], "step1.png")
self.assertEqual(data.layers[1]["has_filter"], True)
self.assertEqual(data.layers[1]["hueRotate"], 20)
# Step 2, average is 1.3 - second colorRule should match
self.assertEqual(data.layers[2]["url"], "step2.png")
self.assertEqual(data.layers[2]["has_filter"], True)
self.assertEqual(data.layers[2]["hueRotate"], 80)
self.assertEqual(data.layers[2]["blur"], 1)
# Step 3, average is 30.8 - final colorRule should match
self.assertEqual(data.layers[3]["url"], "step3.png")
self.assertEqual(data.layers[3]["has_filter"], True)
self.assertEqual(data.layers[3]["blur"], 30.8/2)
self.assertEqual(data.layers[3]["saturate"], 0.4)
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