Commit 43b1a871 by Sven Marnach

First feature-complete version.

parent 49489588
__pycache__/
*.py[cod]
activetable_xblock.egg-info/**
"""TO-DO: Write a description of what this XBlock is."""
# -*- coding: utf-8 -*-
"""An XBlock with a tabular problem type that requires students to fill in some cells."""
from __future__ import absolute_import, division, unicode_literals
import pkg_resources
import textwrap
from xblock.core import XBlock
from xblock.fields import Scope, Integer
from xblock.fields import Dict, Float, Scope, String
from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
from .cells import NumericCell
from .parsers import ParseError, parse_table, parse_number_list
loader = ResourceLoader(__name__)
class ActiveTableXBlock(XBlock):
"""
TO-DO: document what your XBlock does.
"""
# Fields are defined on the class. You can access them in your code as
# self.<fieldname>.
class ActiveTableXBlock(StudioEditableXBlockMixin, XBlock):
"""An XBlock with a tabular problem type that requires students to fill in some cells."""
# TO-DO: delete count, and define your own fields.
count = Integer(
default=0, scope=Scope.user_state,
help="A simple counter, to show something happening",
table_definition = String(
display_name='Table definition',
help='The definition of the table in Python-like syntax.', # TODO(smarnach): proper help
scope=Scope.content,
multiline_editor=True,
resettable_editor=False,
default=textwrap.dedent("""\
[
['Column header 1', 'Column header 2'],
['Enter "answer" here:', String(answer='answer')],
[42, Numeric(answer=42, tolerance=0.0)],
]
""")
)
help_text = String(
display_name='Help text',
help='The text that gets displayed when clicking the "+help" button. If you do not '
'specify a text, the help feature is disabled.',
scope=Scope.content,
multiline_editor=True,
resettable_editor=False,
default='',
)
column_widths = String(
display_name='Column widths',
help='Set the width of the columns in pixels. The value should be a Python-like list of '
'numerical values. The total width of the table should not be more than 800. No value '
'will result in equal-width columns with a total width of 800 pixels.',
scope=Scope.content,
)
row_heights = String(
display_name='Row heights',
help='Set the heights of the rows in pixels. The value should be a Python-like list of '
'numerical values. Rows may grow higher than the specified value if the text in some cells '
'in the row is long enough to get wrapped in more than one line.',
scope=Scope.content,
)
default_tolerance = Float(
display_name='Default tolerance',
help='The tolerance in pecent that is used for numerical response cells you did not '
'specify an explicit tolerance for.',
scope=Scope.content,
default=1.0,
)
editable_fields = [
'table_definition', 'help_text', 'column_widths', 'row_heights', 'default_tolerance'
]
def resource_string(self, path):
"""Handy helper for getting resources from our kit."""
data = pkg_resources.resource_string(__name__, path)
return data.decode("utf8")
answers = Dict(scope=Scope.user_state)
def __init__(self, *args, **kwargs):
super(ActiveTableXBlock, self).__init__(*args, **kwargs)
self.thead = None
self.tbody = None
self._column_widths = None
self._row_heights = None
self.response_cells = None
def parse_fields(self):
"""Parse the user-provided fields into more processing-friendly structured data."""
if self.table_definition:
self.thead, self.tbody = parse_table(self.table_definition)
else:
self.thead = self.tbody = None
return
if self.column_widths:
self._column_widths = parse_number_list(self.column_widths)
else:
self._column_widths = [800 / len(self.thead)] * len(self.thead)
if self.row_heights:
self._row_heights = parse_number_list(self.row_heights)
else:
self._row_heights = [36] * (len(self.tbody) + 1)
self.response_cells = {}
for row, height in zip(self.tbody, self._row_heights[1:]):
row['height'] = height
for cell in row['cells']:
cell.id = 'cell_{}_{}'.format(cell.index, row['index'])
if not cell.is_static:
self.response_cells[cell.id] = cell
cell.value = self.answers.get(cell.id)
if isinstance(cell, NumericCell) and cell.abs_tolerance is None:
cell.set_tolerance(self.default_tolerance)
if cell.value is None:
cell.classes = 'active unchecked'
elif cell.check_response(cell.value):
cell.classes = 'active right-answer'
else:
cell.classes = 'active wrong-answer'
# TO-DO: change this view to display your data your own way.
def student_view(self, context=None):
"""
The primary view of the ActiveTableXBlock, shown to students
when viewing courses.
"""
html = self.resource_string("static/html/activetable.html")
frag = Fragment(html.format(self=self))
frag.add_css(self.resource_string("static/css/activetable.css"))
frag.add_javascript(self.resource_string("static/js/src/activetable.js"))
"""Render the table."""
self.parse_fields()
context = dict(
help_text=self.help_text,
total_width=sum(self._column_widths) if self._column_widths else None,
column_widths=self._column_widths,
head_height=self._row_heights[0] if self._row_heights else None,
thead=self.thead,
tbody=self.tbody,
)
html = loader.render_template('templates/html/activetable.html', context)
frag = Fragment(html)
frag.add_css(loader.load_unicode('static/css/activetable.css'))
frag.add_javascript(loader.load_unicode('static/js/src/activetable.js'))
frag.initialize_js('ActiveTableXBlock')
return frag
# TO-DO: change this handler to perform your own actions. You may need more
# than one handler, or you may not need any handlers at all.
@XBlock.json_handler
def increment_count(self, data, suffix=''):
"""
An example handler, which increments the data.
def check_answers(self, data, suffix=''):
"""Check the answers given by the student.
This handler is called when the "Check" button is clicked.
"""
# Just to show data coming in...
assert data['hello'] == 'world'
self.count += 1
return {"count": self.count}
# TO-DO: change this to create the scenarios you'd like to see in the
# workbench while developing your XBlock.
@staticmethod
def workbench_scenarios():
"""A canned scenario for display in the workbench."""
return [
("ActiveTableXBlock",
"""<vertical_demo>
<activetable/>
<activetable/>
<activetable/>
</vertical_demo>
"""),
]
self.parse_fields()
correct_dict = {
cell_id: self.response_cells[cell_id].check_response(value)
for cell_id, value in data.iteritems()
}
# Since the previous statement executed without error, the data is well-formed enough to be
# stored. We now know it's a dictionary and all the keys are valid cell ids.
self.answers = data
return correct_dict
def validate_field_data(self, validation, data):
def add_error(msg):
validation.add(ValidationMessage(ValidationMessage.ERROR, msg))
try:
parse_table(data.table_definition)
except ParseError as exc:
add_error('Problem with table definition: ' + exc.message)
if data.column_widths:
try:
parse_number_list(data.column_widths)
except ParseError as exc:
add_error('Problem with column widths: ' + exc.message)
if data.row_heights:
try:
parse_number_list(data.row_heights)
except ParseError as exc:
add_error('Problem with row heights: ' + exc.message)
# -*- coding: utf-8 -*-
"""Classes representing table cells.
These classes are used mainly as namespaces to dump all data associated with a cell. There is no
expectation that all attributes are set in __init__() or that attributes are controlled byt the
classes themselves.
"""
from __future__ import absolute_import, division, unicode_literals
class StaticCell(object):
"""A static cell with a fixed value in the table body."""
is_static = True
def __init__(self, value):
self.value = value
class NumericCell(object):
"""A numeric response cell."""
is_static = False
placeholder = 'numeric response'
def __init__(self, answer, tolerance=None, min_significant_digits=None, max_significant_digits=None):
"""Set the correct answer and the allowed relative tolerance in percent."""
self.answer = answer
self.set_tolerance(tolerance)
self.min_significant_digits = min_significant_digits
self.max_significant_digits = max_significant_digits
def set_tolerance(self, tolerance):
if tolerance is None:
self.abs_tolerance = None
else:
self.abs_tolerance = abs(self.answer) * tolerance / 100.0
def check_response(self, student_response):
"""Return a Boolean value indicating whether the student response is correct."""
try:
r = float(student_response)
except ValueError:
return False
if self.min_significant_digits or self.max_significant_digits:
d = len(decimal.Decimal(student_response).as_tuple().digits)
if self.min_significant_digits and d < self.min_significant_digits:
return False
if self.max_significant_digits and d > self.max_significant_digits:
return False
return abs(r - self.answer) <= self.abs_tolerance
class StringCell(object):
"""A string response cell."""
is_static = False
placeholder = 'text response'
def __init__(self, answer):
"""Set the correct answer."""
self.answer = answer
def check_response(self, student_response):
"""Return a Boolean value indicating whether the student response is correct."""
return student_response == self.answer
# -*- coding: utf-8 -*-
"""Parsers for structured text data entered by the user."""
from __future__ import absolute_import, division, unicode_literals
import ast
import numbers
from .cells import NumericCell, StaticCell, StringCell
class ParseError(Exception):
"""The table definition could not be parsed."""
def _ensure_type(node, expected_type):
"""Internal helper function for parse_table."""
if isinstance(node, expected_type):
return node
raise ParseError('the structure of the table definition is invalid')
def parse_table(table_definition):
"""Parse the table definition given by the user.
The string table_defintion is parsed as Python source code. The data is extracted from the
parse tree without executing it. The structure is rigidly validated; on error, ParseError is
thrown.
"""
try:
expr = ast.parse(table_definition.strip(), mode='eval')
except SyntaxError as exc:
raise ParseError(exc.msg)
row_iter = iter(_ensure_type(expr.body, ast.List).elts)
thead = []
for cell in _ensure_type(next(row_iter), ast.List).elts:
thead.append(_ensure_type(cell, ast.Str).s)
tbody = []
for i, row_node in enumerate(row_iter, 1):
cells = []
for j, cell_node in enumerate(_ensure_type(row_node, ast.List).elts):
if isinstance(cell_node, ast.Str):
cell = StaticCell(cell_node.s)
elif isinstance(cell_node, ast.Num):
cell = StaticCell(cell_node.n)
elif isinstance(cell_node, ast.Call):
cell = _parse_response_cell(cell_node)
else:
raise ParseError(
'invalid node in row {}, cell {}: {}'.format(i, j, type(cell_node).__name__)
)
cell.index = j
cells.append(cell)
if len(cells) != len(thead):
raise ParseError(
'row {} has a different number of columns than the previous rows ({} vs. {})'
.format(i, len(cells), len(thead))
)
tbody.append(dict(index=i, cells=cells))
return thead, tbody
def _parse_response_cell(cell_node):
"""Parse a single student response cell definition.
Response cells are written in function call syntax, either String(...) or Numeric(...). All
arguments must be keyword arguments.
"""
cell_type = _ensure_type(cell_node.func, ast.Name).id
if any((cell_node.args, cell_node.starargs, cell_node.kwargs)):
raise ParseError(
'all arguments to {} must be keyword arguments of the form name=value'.format(cell_type)
)
if cell_type == 'String':
cell_class = StringCell
kwargs = {kw.arg: _ensure_type(kw.value, ast.Str).s for kw in cell_node.keywords}
elif cell_type == 'Numeric':
cell_class = NumericCell
kwargs = {kw.arg: _ensure_type(kw.value, ast.Num).n for kw in cell_node.keywords}
else:
raise ParseError('invalid cell input type: {}'.format(cell_type))
try:
return cell_class(**kwargs)
except TypeError as exc:
raise ParseError(exc.message)
def parse_number_list(source):
"""Parse the given string as a Python list of numbers.
This is used to parse the column_widths and row_heights lists entered by the user.
"""
lst = ast.literal_eval(source)
if not isinstance(lst, list):
raise ParseError('not a list')
if not all(isinstance(x, numbers.Real) for x in lst):
raise ParseError('all entries must be numbers')
return lst
/* CSS for ActiveTableXBlock */
.activetable_block .count {
font-weight: bold;
.activetable_block table {
clear: both;
padding: 0;
margin: 0;
border-collapse: collapse;
}
.activetable_block p {
.activetable_block tr {
border-width: 0;
}
.activetable_block thead tr {
background: linear-gradient(#f0f0f0, #d0d0d0);
}
.activetable_block tr.odd {
background-color: #f0f0f0;
}
.activetable_block th, .activetable_block td {
line-height: 1.5em;
padding: 0 10px;
border: 1px solid #c0c0c0;
}
.activetable_block th {
text-align: left;
text-shadow: 0 1px 0 #ffffff;
}
/* cells that allow user input */
.activetable_block td.active {
padding: 0;
}
/* Cells that haven't been checked yet */
.activetable_block td.unchecked {
background-color: #ffffe0;
}
.activetable_block tr.odd td.unchecked {
background-color: #f0f0d0;
}
/* cells containing wrong answers after clicking "Check" */
.activetable_block td.wrong-answer {
background-color: #ffe0e0;
}
.activetable_block tr.odd td.wrong-answer {
background-color: #f0d0d0;
}
/* cells containing right answers after clicking "Check" */
.activetable_block td.right-answer {
background-color: #e0ffe0;
}
.activetable_block tr.odd td.right-answer {
background-color: #d0f0d0;
}
.activetable_block input[type="text"] {
font-size: 1em;
line-height: 1.5em;
border: 0;
padding: 0 10px;
margin: 0;
box-sizing: border-box;
display: block;
width: 100%;
background-color: transparent;
}
.activetable_block input[type="text"]:hover {
outline: 3px solid #a0a0ff;
}
.activetable_block input[type="text"]:focus {
outline: 3px solid #8080ff;
}
.activetable_block #activetable-help-text {
display: none;
padding: 5px 10px;
margin: 0 0 10px;
border: 1px solid #c0c0c0;
background-color: #f0f0f0;
}
.activetable_block #activetable-help-button {
float: right;
padding: 6px 10px 0;
color: #009fe6;
cursor: pointer;
}
.activetable_block #activetable-help-button:hover {
color: #bd9730;
}
<div class="activetable_block">
<p>ActiveTableXBlock: count is now
<span class='count'>{self.count}</span> (click me to increment).
</p>
</div>
/* Javascript for ActiveTableXBlock. */
function ActiveTableXBlock(runtime, element) {
function updateCount(result) {
$('.count', element).text(result.count);
}
var checkHandlerUrl = runtime.handlerUrl(element, 'check_answers');
var handlerUrl = runtime.handlerUrl(element, 'increment_count');
function markResponseCells(correct_dict) {
$.each(correct_dict, function(cell_id, correct) {
var $cell = $('#' + cell_id, element);
$cell.removeClass('right-answer wrong-answer unchecked');
if (correct) $cell.addClass('right-answer')
else $cell.addClass('wrong-answer');
})
}
$('p', element).click(function(eventObject) {
function checkAnswers(e) {
answers = {};
$('td.active', element).each(function() {
answers[this.id] = $('input', this).val();
});
$.ajax({
type: "POST",
url: handlerUrl,
data: JSON.stringify({"hello": "world"}),
success: updateCount
});
url: checkHandlerUrl,
data: JSON.stringify(answers),
success: markResponseCells,
});
}
$(function ($) {
/* Here's where you'd do things on page load. */
});
function toggleHelp(e) {
var $help_text = $('#activetable-help-text', element);
$help_text.toggle();
$(this).text($help_text.is(':visible') ? '-help' : '+help');
}
$('#activetable-help-button', element).click(toggleHelp);
$('.action .check', element).click(checkAnswers);
}
<div class="activetable_block">
{% if help_text %}
<div class="activetable-help" style="width: {{ total_width }}px;">
<a id="activetable-help-button">+help</a>
<p id="activetable-help-text">{{ help_text }}</p>
</div>
{% endif %}
{% if thead %}
<table id="activetable">
<colgroup>
{% for width in column_widths %}<col style="width: {{ width }}px;">{% endfor %}
</colgroup>
<thead>
<tr style="height: {{ head_height }}px;">
{% for cell in thead %}<th>{{ cell }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in tbody %}
<tr style="height: {{ row.height }}px;">
{% for cell in row.cells %}
<td class="{{ cell.classes }}" id="{{ cell.id }}">
{% if cell.is_static %}
{{ cell.value }}
{% else %}
<input type="text" style="height: {{ cell.height }}px;" size=1
value="{{ cell.value|default_if_none:'' }}" placeholder="{{ cell.placeholder }}">
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>This component isn't configured properly and can't be displayed.</p>
{% endif %}
<div class="status"></div>
<div class="status-message"></div>
<div class="action">
<button class="check Check"><span class="sr"> your answer</span><span class="check-label">Check</span></button>
</div>
</div>
......@@ -29,6 +29,7 @@ setup(
],
install_requires=[
'XBlock',
'xblock-utils',
],
entry_points={
'xblock.v1': [
......
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