Add a chemicalequationinput with live preview
- architecturally slightly questionable: the preview ajax calls goes to an LMS view instead of an input type specific one. This needs to be fixed during the grand capa re-org, but there isn't time to do it right now. - also, I kind of like having a generic turn-a-formula-into-a-preview service available
This commit is contained in:
@@ -55,7 +55,8 @@ entry_types = ['textline',
|
||||
'radiogroup',
|
||||
'checkboxgroup',
|
||||
'filesubmission',
|
||||
'javascriptinput',]
|
||||
'javascriptinput',
|
||||
'chemicalequationinput']
|
||||
|
||||
# extra things displayed after "show answers" is pressed
|
||||
solution_types = ['solution']
|
||||
|
||||
@@ -125,8 +125,13 @@ def _merge_children(tree, tags):
|
||||
(group 1 2 3 4)
|
||||
It has to handle this recursively:
|
||||
(group 1 (group 2 (group 3 (group 4))))
|
||||
We do the cleanup of converting from the latter to the former (as a
|
||||
We do the cleanup of converting from the latter to the former.
|
||||
'''
|
||||
if tree is None:
|
||||
# There was a problem--shouldn't have empty trees (NOTE: see this with input e.g. 'H2O(', or 'Xe+').
|
||||
# Haven't grokked the code to tell if this is indeed the right thing to do.
|
||||
raise ParseException("Shouldn't have empty trees")
|
||||
|
||||
if type(tree) == str:
|
||||
return tree
|
||||
|
||||
@@ -195,14 +200,52 @@ def _render_to_html(tree):
|
||||
return children.replace(' ', '')
|
||||
|
||||
|
||||
def render_to_html(s):
|
||||
''' render a string to html '''
|
||||
status = _render_to_html(_get_final_tree(s))
|
||||
return status
|
||||
|
||||
def render_to_html(eq):
|
||||
'''
|
||||
Render a chemical equation string to html.
|
||||
|
||||
Renders each molecule separately, and returns invalid input wrapped in a <span>.
|
||||
'''
|
||||
def err(s):
|
||||
"Render as an error span"
|
||||
return '<span class="inline-error inline">{0}</span>'.format(s)
|
||||
|
||||
def render_arrow(arrow):
|
||||
"""Turn text arrows into pretty ones"""
|
||||
if arrow == '->':
|
||||
return u'\u2192'
|
||||
if arrow == '<->':
|
||||
return u'\u2194'
|
||||
return arrow
|
||||
|
||||
def render_expression(ex):
|
||||
"""
|
||||
Render a chemical expression--no arrows.
|
||||
"""
|
||||
try:
|
||||
return _render_to_html(_get_final_tree(ex))
|
||||
except ParseException:
|
||||
return err(ex)
|
||||
|
||||
def spanify(s):
|
||||
return u'<span class="math">{0}</span>'.format(s)
|
||||
|
||||
left, arrow, right = split_on_arrow(eq)
|
||||
if arrow == '':
|
||||
# only one side
|
||||
return spanify(render_expression(left))
|
||||
|
||||
|
||||
return spanify(render_expression(left) + render_arrow(arrow) + render_expression(right))
|
||||
|
||||
|
||||
def _get_final_tree(s):
|
||||
''' return final tree after merge and clean '''
|
||||
'''
|
||||
Return final tree after merge and clean.
|
||||
|
||||
Raises pyparsing.ParseException if s is invalid.
|
||||
'''
|
||||
tokenized = tokenizer.parseString(s)
|
||||
parsed = parser.parse(tokenized)
|
||||
merged = _merge_children(parsed, {'S','group'})
|
||||
@@ -227,14 +270,14 @@ def _check_equality(tuple1, tuple2):
|
||||
|
||||
|
||||
def compare_chemical_expression(s1, s2, ignore_state=False):
|
||||
''' It does comparison between two equations.
|
||||
''' It does comparison between two expressions.
|
||||
It uses divide_chemical_expression and check if division is 1
|
||||
'''
|
||||
return divide_chemical_expression(s1, s2, ignore_state) == 1
|
||||
|
||||
|
||||
def divide_chemical_expression(s1, s2, ignore_state=False):
|
||||
'''Compare two chemical equations for equivalence up to a multiplicative factor:
|
||||
'''Compare two chemical expressions for equivalence up to a multiplicative factor:
|
||||
|
||||
- If they are not the same chemicals, returns False.
|
||||
- If they are the same, "divide" s1 by s2 to returns a factor x such that s1 / s2 == x as a Fraction object.
|
||||
@@ -248,7 +291,7 @@ def divide_chemical_expression(s1, s2, ignore_state=False):
|
||||
|
||||
Implementation sketch:
|
||||
- extract factors and phases to standalone lists,
|
||||
- compare equations without factors and phases,
|
||||
- compare expressions without factors and phases,
|
||||
- divide lists of factors for each other and check
|
||||
for equality of every element in list,
|
||||
- return result of factor division
|
||||
@@ -294,7 +337,7 @@ def divide_chemical_expression(s1, s2, ignore_state=False):
|
||||
treedic['2 cleaned_mm_list'], treedic['2 factors'], treedic['2 phases'] = zip(
|
||||
*sorted(zip(treedic['2 cleaned_mm_list'], treedic['2 factors'], treedic['2 phases'])))
|
||||
|
||||
# check if equations are correct without factors
|
||||
# check if expressions are correct without factors
|
||||
if not _check_equality(treedic['1 cleaned_mm_list'], treedic['2 cleaned_mm_list']):
|
||||
return False
|
||||
|
||||
@@ -312,9 +355,26 @@ def divide_chemical_expression(s1, s2, ignore_state=False):
|
||||
return Fraction(treedic['1 factors'][0] / treedic['2 factors'][0])
|
||||
|
||||
|
||||
def split_on_arrow(eq):
|
||||
"""
|
||||
Split a string on an arrow. Returns left, arrow, right. If there is no arrow, returns the
|
||||
entire eq in left, and '' in arrow and right.
|
||||
|
||||
Return left, arrow, right.
|
||||
"""
|
||||
# order matters -- need to try <-> first
|
||||
arrows = ('<->', '->')
|
||||
for arrow in arrows:
|
||||
left, a, right = eq.partition(arrow)
|
||||
if a != '':
|
||||
return left, a, right
|
||||
|
||||
return eq, '', ''
|
||||
|
||||
|
||||
def chemical_equations_equal(eq1, eq2, exact=False):
|
||||
"""
|
||||
Check whether two chemical equations are the same.
|
||||
Check whether two chemical equations are the same. (equations have arrows)
|
||||
|
||||
If exact is False, then they are considered equal if they differ by a
|
||||
constant factor.
|
||||
@@ -333,19 +393,13 @@ def chemical_equations_equal(eq1, eq2, exact=False):
|
||||
|
||||
If there's a syntax error, we raise pyparsing.ParseException.
|
||||
"""
|
||||
# for now, we do a manual parse for the arrow.
|
||||
arrows = ('<->', '->') # order matters -- need to try <-> first
|
||||
def split_on_arrow(s):
|
||||
"""Split a string on an arrow. Returns left, arrow, right, or raises ParseException if there isn't an arrow"""
|
||||
for arrow in arrows:
|
||||
left, a, right = s.partition(arrow)
|
||||
if a != '':
|
||||
return left, a, right
|
||||
raise ParseException("Could not find arrow. Legal arrows: {0}".format(arrows))
|
||||
|
||||
left1, arrow1, right1 = split_on_arrow(eq1)
|
||||
left2, arrow2, right2 = split_on_arrow(eq2)
|
||||
|
||||
if arrow1 == '' or arrow2 == '':
|
||||
raise ParseException("Could not find arrow. Legal arrows: {0}".format(arrows))
|
||||
|
||||
# TODO: may want to be able to give student helpful feedback about why things didn't work.
|
||||
if arrow1 != arrow2:
|
||||
# arrows don't match
|
||||
|
||||
@@ -708,3 +708,34 @@ def imageinput(element, value, status, render_template, msg=''):
|
||||
return etree.XML(html)
|
||||
|
||||
_reg(imageinput)
|
||||
|
||||
|
||||
#--------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChemicalEquationInput(InputTypeBase):
|
||||
"""
|
||||
An input type for entering chemical equations. Supports live preview.
|
||||
|
||||
Example:
|
||||
|
||||
<chemicalequationinput size="50"/>
|
||||
|
||||
options: size -- width of the textbox.
|
||||
"""
|
||||
|
||||
template = "chemicalequationinput.html"
|
||||
tags = ['chemicalequationinput']
|
||||
|
||||
def _get_render_context(self):
|
||||
size = self.xml.get('size', '20')
|
||||
context = {
|
||||
'id': self.id,
|
||||
'value': self.value,
|
||||
'status': self.status,
|
||||
'size': size,
|
||||
'previewer': '/static/js/capa/chemical_equation_preview.js',
|
||||
}
|
||||
return context
|
||||
|
||||
register_input_class(ChemicalEquationInput)
|
||||
|
||||
@@ -867,7 +867,7 @@ def sympy_check2():
|
||||
</customresponse>"""}]
|
||||
|
||||
response_tag = 'customresponse'
|
||||
allowed_inputfields = ['textline', 'textbox']
|
||||
allowed_inputfields = ['textline', 'textbox', 'chemicalequationinput']
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
|
||||
42
common/lib/capa/capa/templates/chemicalequationinput.html
Normal file
42
common/lib/capa/capa/templates/chemicalequationinput.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<section id="chemicalequationinput_${id}" class="chemicalequationinput">
|
||||
<div class="script_placeholder" data-src="${previewer}"/>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<div class="equation">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
1
common/static/js/capa/README
Normal file
1
common/static/js/capa/README
Normal file
@@ -0,0 +1 @@
|
||||
These files really should be in the capa module, but we don't have a way to load js from there at the moment. (TODO)
|
||||
12
common/static/js/capa/chemical_equation_preview.js
Normal file
12
common/static/js/capa/chemical_equation_preview.js
Normal file
@@ -0,0 +1,12 @@
|
||||
(function () {
|
||||
var preview_div = $('.chemicalequationinput .equation');
|
||||
$('.chemicalequationinput input').bind("input", function(eventObject) {
|
||||
$.get("/preview/chemcalc/", {"formula" : this.value}, function(response) {
|
||||
if (response.error) {
|
||||
preview_div.html("<span class='error'>" + response.error + "</span>");
|
||||
} else {
|
||||
preview_div.html(response.preview);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).call(this);
|
||||
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import pyparsing
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
@@ -13,6 +14,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from capa.xqueue_interface import XQueueInterface
|
||||
from capa.chem import chemcalc
|
||||
from courseware.access import has_access
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from models import StudentModule, StudentModuleCache
|
||||
@@ -471,3 +473,42 @@ def modx_dispatch(request, dispatch, location, course_id):
|
||||
|
||||
# Return whatever the module wanted to return to the client/caller
|
||||
return HttpResponse(ajax_return)
|
||||
|
||||
def preview_chemcalc(request):
|
||||
"""
|
||||
Render an html preview of a chemical formula or equation. The fact that
|
||||
this is here is a bit of hack. See the note in lms/urls.py about why it's
|
||||
here. (Victor is to blame.)
|
||||
|
||||
request should be a GET, with a key 'formula' and value 'some formula string'.
|
||||
|
||||
Returns a json dictionary:
|
||||
{
|
||||
'preview' : 'the-preview-html' or ''
|
||||
'error' : 'the-error' or ''
|
||||
}
|
||||
"""
|
||||
if request.method != "GET":
|
||||
raise Http404
|
||||
|
||||
result = {'preview': '',
|
||||
'error': '' }
|
||||
formula = request.GET.get('formula')
|
||||
if formula is None:
|
||||
result['error'] = "No formula specified."
|
||||
|
||||
return HttpResponse(json.dumps(result))
|
||||
|
||||
try:
|
||||
result['preview'] = chemcalc.render_to_html(formula)
|
||||
except pyparsing.ParseException as p:
|
||||
result['error'] = "Couldn't parse formula: {0}".format(p)
|
||||
except Exception:
|
||||
# this is unexpected, so log
|
||||
log.warning("Error while previewing chemical formula", exc_info=True)
|
||||
result['error'] = "Error while rendering preview"
|
||||
|
||||
return HttpResponse(json.dumps(result))
|
||||
|
||||
|
||||
|
||||
|
||||
10
lms/urls.py
10
lms/urls.py
@@ -141,6 +141,16 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/modx/(?P<location>.*?)/(?P<dispatch>[^/]*)$',
|
||||
'courseware.module_render.modx_dispatch',
|
||||
name='modx_dispatch'),
|
||||
|
||||
# TODO (vshnayder): This is a hack. It creates a direct connection from
|
||||
# the LMS to capa functionality, and really wants to go through the
|
||||
# input types system so that previews can be context-specific.
|
||||
# Unfortunately, we don't have time to think through the right way to do
|
||||
# that (and implement it), and it's not a terrible thing to provide a
|
||||
# generic chemican-equation rendering service.
|
||||
url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc',
|
||||
name='preview_chemcalc'),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xqueue/(?P<userid>[^/]*)/(?P<id>.*?)/(?P<dispatch>[^/]*)$',
|
||||
'courseware.module_render.xqueue_callback',
|
||||
name='xqueue_callback'),
|
||||
|
||||
Reference in New Issue
Block a user