From 19d3cb3870bdce8dffced594bda0314daa316417 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Fri, 12 Oct 2012 13:52:48 -0400 Subject: [PATCH] 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 --- common/lib/capa/capa/capa_problem.py | 3 +- common/lib/capa/capa/chem/chemcalc.py | 94 +++++++++++++++---- common/lib/capa/capa/inputtypes.py | 31 ++++++ common/lib/capa/capa/responsetypes.py | 2 +- .../capa/templates/chemicalequationinput.html | 42 +++++++++ common/static/js/capa/README | 1 + .../js/capa/chemical_equation_preview.js | 12 +++ lms/djangoapps/courseware/module_render.py | 41 ++++++++ lms/urls.py | 10 ++ 9 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 common/lib/capa/capa/templates/chemicalequationinput.html create mode 100644 common/static/js/capa/README create mode 100644 common/static/js/capa/chemical_equation_preview.js diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 252b536927..ca78f635e3 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -55,7 +55,8 @@ entry_types = ['textline', 'radiogroup', 'checkboxgroup', 'filesubmission', - 'javascriptinput',] + 'javascriptinput', + 'chemicalequationinput'] # extra things displayed after "show answers" is pressed solution_types = ['solution'] diff --git a/common/lib/capa/capa/chem/chemcalc.py b/common/lib/capa/capa/chem/chemcalc.py index 1df8302b37..79a788404d 100644 --- a/common/lib/capa/capa/chem/chemcalc.py +++ b/common/lib/capa/capa/chem/chemcalc.py @@ -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 . + ''' + def err(s): + "Render as an error span" + return '{0}'.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'{0}'.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 diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 49cc91f343..220c606daf 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -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: + + + + 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) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 6857d504ec..097c04fcd3 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -867,7 +867,7 @@ def sympy_check2(): """}] response_tag = 'customresponse' - allowed_inputfields = ['textline', 'textbox'] + allowed_inputfields = ['textline', 'textbox', 'chemicalequationinput'] def setup_response(self): xml = self.xml diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html new file mode 100644 index 0000000000..f705ec3d06 --- /dev/null +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -0,0 +1,42 @@ +
+
+ + % if status == 'unsubmitted': +
+ % elif status == 'correct': +
+ % elif status == 'incorrect': +
+ % elif status == 'incomplete': +
+ % endif + + + +

+ % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

+ +
+ +
+ + +

+ +% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
+% endif +
diff --git a/common/static/js/capa/README b/common/static/js/capa/README new file mode 100644 index 0000000000..bb698ef00e --- /dev/null +++ b/common/static/js/capa/README @@ -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) diff --git a/common/static/js/capa/chemical_equation_preview.js b/common/static/js/capa/chemical_equation_preview.js new file mode 100644 index 0000000000..9c5c6cd6bc --- /dev/null +++ b/common/static/js/capa/chemical_equation_preview.js @@ -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("" + response.error + ""); + } else { + preview_div.html(response.preview); + } + }); + }); +}).call(this); diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 22ab6df67b..1e45822ebf 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -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)) + + + diff --git a/lms/urls.py b/lms/urls.py index 662e41235e..2ea02e25c2 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -141,6 +141,16 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/modx/(?P.*?)/(?P[^/]*)$', '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[^/]+/[^/]+/[^/]+)/xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.xqueue_callback', name='xqueue_callback'),