Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
problem-builder
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
OpenEdx
problem-builder
Commits
3ebf2810
Commit
3ebf2810
authored
Mar 25, 2015
by
Braden MacDonald
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Flexible visual representation of dashboard, using SVG
parent
531b70d4
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
314 additions
and
11 deletions
+314
-11
mentoring/dashboard.py
+54
-10
mentoring/dashboard_visual.py
+163
-0
mentoring/sub_api.py
+0
-1
mentoring/templates/html/dashboard.html
+23
-0
mentoring/tests/unit/test_dashboard_visual.py
+74
-0
No files found.
mentoring/dashboard.py
View file @
3ebf2810
...
...
@@ -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: {...}"
))
mentoring/dashboard_visual.py
0 → 100644
View file @
3ebf2810
# -*- 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
)
mentoring/sub_api.py
View file @
3ebf2810
...
...
@@ -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
...
...
mentoring/templates/html/dashboard.html
View file @
3ebf2810
{% 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>
...
...
mentoring/tests/unit/test_dashboard_visual.py
0 → 100644
View file @
3ebf2810
"""
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
)
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment