diff --git a/brew-formulas.txt b/brew-formulas.txt
index 0aed9645d0..b5b555e2a0 100644
--- a/brew-formulas.txt
+++ b/brew-formulas.txt
@@ -7,3 +7,4 @@ python
yuicompressor
node
graphviz
+mysql
diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html
index c153da22fe..302d4d7aa5 100644
--- a/common/djangoapps/pipeline_mako/templates/static_content.html
+++ b/common/djangoapps/pipeline_mako/templates/static_content.html
@@ -3,8 +3,7 @@ from staticfiles.storage import staticfiles_storage
from pipeline_mako import compressed_css, compressed_js
%>
-<%def name='url(file)'>
-<%
+<%def name='url(file)'><%
try:
url = staticfiles_storage.url(file)
except:
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index de29b5e664..0b2250f98d 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -21,26 +21,26 @@ Each input type takes the xml tree as 'element', the previous answer as 'value',
graded status as'status'
"""
-# TODO: there is a lot of repetitive "grab these elements from xml attributes, with these defaults,
-# put them in the context" code. Refactor so class just specifies required and optional attrs (with
-# defaults for latter), and InputTypeBase does the right thing.
+# TODO: make hints do something
+
+# TODO: make all inputtypes actually render msg
+
+# TODO: remove unused fields (e.g. 'hidden' in a few places)
+
+# TODO: add validators so that content folks get better error messages.
-# TODO: Quoting and unquoting is handled in a pretty ad-hoc way. Also something that could be done
-# properly once in InputTypeBase.
# Possible todo: make inline the default for textlines and other "one-line" inputs. It probably
# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a
# general css and layout strategy for capa, document it, then implement it.
-
-
+from collections import namedtuple
import json
import logging
from lxml import etree
import re
import shlex # for splitting quoted strings
import sys
-import xml.sax.saxutils as saxutils
from registry import TagRegistry
@@ -50,6 +50,61 @@ log = logging.getLogger('mitx.' + __name__)
registry = TagRegistry()
+class Attribute(object):
+ """
+ Allows specifying required and optional attributes for input types.
+ """
+
+ # want to allow default to be None, but also allow required objects
+ _sentinel = object()
+
+ def __init__(self, name, default=_sentinel, transform=None, validate=None, render=True):
+ """
+ Define an attribute
+
+ name (str): then name of the attribute--should be alphanumeric (valid for an XML attribute)
+
+ default (any type): If not specified, this attribute is required. If specified, use this as the default value
+ if the attribute is not specified. Note that this value will not be transformed or validated.
+
+ transform (function str -> any type): If not None, will be called to transform the parsed value into an internal
+ representation.
+
+ validate (function str-or-return-type-of-tranform -> unit or exception): If not None, called to validate the
+ (possibly transformed) value of the attribute. Should raise ValueError with a helpful message if
+ the value is invalid.
+
+ render (bool): if False, don't include this attribute in the template context.
+ """
+ self.name = name
+ self.default = default
+ self.validate = validate
+ self.transform = transform
+ self.render = render
+
+ def parse_from_xml(self, element):
+ """
+ Given an etree xml element that should have this attribute, do the obvious thing:
+ - look for it. raise ValueError if not found and required.
+ - transform and validate. pass through any exceptions from transform or validate.
+ """
+ val = element.get(self.name)
+ if self.default == self._sentinel and val is None:
+ raise ValueError('Missing required attribute {0}.'.format(self.name))
+
+ if val is None:
+ # not required, so return default
+ return self.default
+
+ if self.transform is not None:
+ val = self.transform(val)
+
+ if self.validate is not None:
+ self.validate(val)
+
+ return val
+
+
class InputTypeBase(object):
"""
Abstract base class for input types.
@@ -102,9 +157,12 @@ class InputTypeBase(object):
self.status = state.get('status', 'unanswered')
- # Call subclass "constructor" -- means they don't have to worry about calling
- # super().__init__, and are isolated from changes to the input constructor interface.
try:
+ # Pre-parse and propcess all the declared requirements.
+ self.process_requirements()
+
+ # Call subclass "constructor" -- means they don't have to worry about calling
+ # super().__init__, and are isolated from changes to the input constructor interface.
self.setup()
except Exception as err:
# Something went wrong: add xml to message, but keep the traceback
@@ -112,6 +170,36 @@ class InputTypeBase(object):
raise Exception, msg, sys.exc_info()[2]
+ @classmethod
+ def get_attributes(cls):
+ """
+ Should return a list of Attribute objects (see docstring there for details). Subclasses should override. e.g.
+
+ return [Attribute('unicorn', True), Attribute('num_dragons', 12, transform=int), ...]
+ """
+ return []
+
+
+ def process_requirements(self):
+ """
+ Subclasses can declare lists of required and optional attributes. This
+ function parses the input xml and pulls out those attributes. This
+ isolates most simple input types from needing to deal with xml parsing at all.
+
+ Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set
+ self.to_render, containing the names of attributes that should be included in the context by default.
+ """
+ # Use local dicts and sets so that if there are exceptions, we don't end up in a partially-initialized state.
+ loaded = {}
+ to_render = set()
+ for a in self.get_attributes():
+ loaded[a.name] = a.parse_from_xml(self.xml)
+ if a.render:
+ to_render.add(a.name)
+
+ self.loaded_attributes = loaded
+ self.to_render = to_render
+
def setup(self):
"""
InputTypes should override this to do any needed initialization. It is called after the
@@ -122,14 +210,36 @@ class InputTypeBase(object):
"""
pass
+
def _get_render_context(self):
"""
- Abstract method. Subclasses should implement to return the dictionary
- of keys needed to render their template.
+ Should return a dictionary of keys needed to render the template for the input type.
(Separate from get_html to faciliate testing of logic separately from the rendering)
+
+ The default implementation gets the following rendering context: basic things like value, id, status, and msg,
+ as well as everything in self.loaded_attributes, and everything returned by self._extra_context().
+
+ This means that input types that only parse attributes and pass them to the template get everything they need,
+ and don't need to override this method.
"""
- raise NotImplementedError
+ context = {
+ 'id': self.id,
+ 'value': self.value,
+ 'status': self.status,
+ 'msg': self.msg,
+ }
+ context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render)
+ context.update(self._extra_context())
+ return context
+
+ def _extra_context(self):
+ """
+ Subclasses can override this to return extra context that should be passed to their templates for rendering.
+
+ This is useful when the input type requires computing new template variables from the parsed attributes.
+ """
+ return {}
def get_html(self):
"""
@@ -139,7 +249,9 @@ class InputTypeBase(object):
raise NotImplementedError("no rendering template specified for class {0}"
.format(self.__class__))
- html = self.system.render_template(self.template, self._get_render_context())
+ context = self._get_render_context()
+
+ html = self.system.render_template(self.template, context)
return etree.XML(html)
@@ -153,38 +265,38 @@ class OptionInput(InputTypeBase):
Example:
The location of the sky
+
+ # TODO: allow ordering to be randomized
"""
template = "optioninput.html"
tags = ['optioninput']
- def setup(self):
- # Extract the options...
- options = self.xml.get('options')
- if not options:
- raise ValueError("optioninput: Missing 'options' specification.")
-
+ @staticmethod
+ def parse_options(options):
+ """
+ Given options string, convert it into an ordered list of (option_id, option_description) tuples, where
+ id==description for now. TODO: make it possible to specify different id and descriptions.
+ """
# parse the set of possible options
- oset = shlex.shlex(options[1:-1])
- oset.quotes = "'"
- oset.whitespace = ","
- oset = [x[1:-1] for x in list(oset)]
+ lexer = shlex.shlex(options[1:-1])
+ lexer.quotes = "'"
+ # Allow options to be separated by whitespace as well as commas
+ lexer.whitespace = ", "
- # make ordered list with (key, value) same
- self.osetdict = [(oset[x], oset[x]) for x in range(len(oset))]
- # TODO: allow ordering to be randomized
+ # remove quotes
+ tokens = [x[1:-1] for x in list(lexer)]
- def _get_render_context(self):
+ # make list of (option_id, option_description), with description=id
+ return [(t, t) for t in tokens]
- context = {
- 'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'msg': self.msg,
- 'options': self.osetdict,
- 'inline': self.xml.get('inline',''),
- }
- return context
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert options to a convenient format.
+ """
+ return [Attribute('options', transform=cls.parse_options),
+ Attribute('inline', '')]
registry.register(OptionInput)
@@ -223,53 +335,50 @@ class ChoiceGroup(InputTypeBase):
# value. (VS: would be nice to make this less hackish).
if self.tag == 'choicegroup':
self.suffix = ''
- self.element_type = "radio"
+ self.html_input_type = "radio"
elif self.tag == 'radiogroup':
- self.element_type = "radio"
+ self.html_input_type = "radio"
self.suffix = '[]'
elif self.tag == 'checkboxgroup':
- self.element_type = "checkbox"
+ self.html_input_type = "checkbox"
self.suffix = '[]'
else:
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
- self.choices = extract_choices(self.xml)
+ self.choices = self.extract_choices(self.xml)
- def _get_render_context(self):
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'input_type': self.element_type,
- 'choices': self.choices,
- 'name_array_suffix': self.suffix}
- return context
+ def _extra_context(self):
+ return {'input_type': self.html_input_type,
+ 'choices': self.choices,
+ 'name_array_suffix': self.suffix}
-def extract_choices(element):
- '''
- Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and
- CheckboxGroup.
+ @staticmethod
+ def extract_choices(element):
+ '''
+ Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and
+ CheckboxGroup.
- returns list of (choice_name, choice_text) tuples
+ returns list of (choice_name, choice_text) tuples
- TODO: allow order of choices to be randomized, following lon-capa spec. Use
- "location" attribute, ie random, top, bottom.
- '''
+ TODO: allow order of choices to be randomized, following lon-capa spec. Use
+ "location" attribute, ie random, top, bottom.
+ '''
- choices = []
+ choices = []
- for choice in element:
- if choice.tag != 'choice':
- raise Exception(
- "[capa.inputtypes.extract_choices] Expected a tag; got %s instead"
- % choice.tag)
- choice_text = ''.join([etree.tostring(x) for x in choice])
- if choice.text is not None:
- # TODO: fix order?
- choice_text += choice.text
+ for choice in element:
+ if choice.tag != 'choice':
+ raise Exception(
+ "[capa.inputtypes.extract_choices] Expected a tag; got %s instead"
+ % choice.tag)
+ choice_text = ''.join([etree.tostring(x) for x in choice])
+ if choice.text is not None:
+ # TODO: fix order?
+ choice_text += choice.text
- choices.append((choice.get("name"), choice_text))
+ choices.append((choice.get("name"), choice_text))
- return choices
+ return choices
registry.register(ChoiceGroup)
@@ -292,33 +401,23 @@ class JavascriptInput(InputTypeBase):
template = "javascriptinput.html"
tags = ['javascriptinput']
+ @classmethod
+ def get_attributes(cls):
+ """
+ Register the attributes.
+ """
+ return [Attribute('params', None),
+ Attribute('problem_state', None),
+ Attribute('display_class', None),
+ Attribute('display_file', None),]
+
+
def setup(self):
# Need to provide a value that JSON can parse if there is no
# student-supplied value yet.
if self.value == "":
self.value = 'null'
- self.params = self.xml.get('params')
- self.problem_state = self.xml.get('problem_state')
- self.display_class = self.xml.get('display_class')
- self.display_file = self.xml.get('display_file')
-
-
- def _get_render_context(self):
- escapedict = {'"': '"'}
- value = saxutils.escape(self.value, escapedict)
- msg = saxutils.escape(self.msg, escapedict)
-
- context = {'id': self.id,
- 'params': self.params,
- 'display_file': self.display_file,
- 'display_class': self.display_class,
- 'problem_state': self.problem_state,
- 'value': value,
- 'evaluation': msg,
- }
- return context
-
registry.register(JavascriptInput)
@@ -326,51 +425,55 @@ registry.register(JavascriptInput)
class TextLine(InputTypeBase):
"""
+ A text line input. Can do math preview if "math"="1" is specified.
+ If the hidden attribute is specified, the textline is hidden and the input id is stored in a div with name equal
+ to the value of the hidden attribute. This is used e.g. for embedding simulations turned into questions.
"""
template = "textline.html"
tags = ['textline']
+
+ @classmethod
+ def get_attributes(cls):
+ """
+ Register the attributes.
+ """
+ return [
+ Attribute('size', None),
+
+
+ Attribute('hidden', False),
+ Attribute('inline', False),
+
+ # Attributes below used in setup(), not rendered directly.
+ Attribute('math', None, render=False),
+ # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
+ Attribute('dojs', None, render=False),
+ Attribute('preprocessorClassName', None, render=False),
+ Attribute('preprocessorSrc', None, render=False),
+ ]
+
+
def setup(self):
- self.size = self.xml.get('size')
+ self.do_math = bool(self.loaded_attributes['math'] or
+ self.loaded_attributes['dojs'])
- # if specified, then textline is hidden and input id is stored
- # in div with name=self.hidden.
- self.hidden = self.xml.get('hidden', False)
-
- self.inline = self.xml.get('inline', False)
-
- # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
- self.do_math = bool(self.xml.get('math') or self.xml.get('dojs'))
# TODO: do math checking using ajax instead of using js, so
# that we only have one math parser.
self.preprocessor = None
if self.do_math:
# Preprocessor to insert between raw input and Mathjax
- self.preprocessor = {'class_name': self.xml.get('preprocessorClassName',''),
- 'script_src': self.xml.get('preprocessorSrc','')}
- if '' in self.preprocessor.values():
+ self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'],
+ 'script_src': self.loaded_attributes['preprocessorSrc']}
+ if None in self.preprocessor.values():
self.preprocessor = None
-
- def _get_render_context(self):
- # Escape answers with quotes, so they don't crash the system!
- escapedict = {'"': '"'}
- value = saxutils.escape(self.value, escapedict)
-
- context = {'id': self.id,
- 'value': value,
- 'status': self.status,
- 'size': self.size,
- 'msg': self.msg,
- 'hidden': self.hidden,
- 'inline': self.inline,
- 'do_math': self.do_math,
- 'preprocessor': self.preprocessor,
- }
- return context
+ def _extra_context(self):
+ return {'do_math': self.do_math,
+ 'preprocessor': self.preprocessor,}
registry.register(TextLine)
@@ -388,13 +491,26 @@ class FileSubmission(InputTypeBase):
submitted_msg = ("Your file(s) have been submitted; as soon as your submission is"
" graded, this message will be replaced with the grader's feedback.")
- def setup(self):
- escapedict = {'"': '"'}
- self.allowed_files = json.dumps(self.xml.get('allowed_files', '').split())
- self.allowed_files = saxutils.escape(self.allowed_files, escapedict)
- self.required_files = json.dumps(self.xml.get('required_files', '').split())
- self.required_files = saxutils.escape(self.required_files, escapedict)
+ @staticmethod
+ def parse_files(files):
+ """
+ Given a string like 'a.py b.py c.out', split on whitespace and return as a json list.
+ """
+ return json.dumps(files.split())
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert the list of allowed files to a convenient format.
+ """
+ return [Attribute('allowed_files', '[]', transform=cls.parse_files),
+ Attribute('required_files', '[]', transform=cls.parse_files),]
+
+ def setup(self):
+ """
+ Do some magic to handle queueing status (render as "queued" instead of "incomplete"),
+ pull queue_len from the msg field. (TODO: get rid of the queue_len hack).
+ """
# Check if problem has been queued
self.queue_len = 0
# Flag indicating that the problem has been queued, 'msg' is length of queue
@@ -403,15 +519,8 @@ class FileSubmission(InputTypeBase):
self.queue_len = self.msg
self.msg = FileSubmission.submitted_msg
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'status': self.status,
- 'msg': self.msg,
- 'value': self.value,
- 'queue_len': self.queue_len,
- 'allowed_files': self.allowed_files,
- 'required_files': self.required_files,}
+ def _extra_context(self):
+ return {'queue_len': self.queue_len,}
return context
registry.register(FileSubmission)
@@ -431,13 +540,30 @@ class CodeInput(InputTypeBase):
# non-codemirror editor.
]
+ # pulled out for testing
+ submitted_msg = ("Submitted. As soon as your submission is"
+ " graded, this message will be replaced with the grader's feedback.")
+
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert options to a convenient format.
+ """
+ return [Attribute('rows', '30'),
+ Attribute('cols', '80'),
+ Attribute('hidden', ''),
+
+ # For CodeMirror
+ Attribute('mode', 'python'),
+ Attribute('linenumbers', 'true'),
+ # Template expects tabsize to be an int it can do math with
+ Attribute('tabsize', 4, transform=int),
+ ]
def setup(self):
- self.rows = self.xml.get('rows') or '30'
- self.cols = self.xml.get('cols') or '80'
- # if specified, then textline is hidden and id is stored in div of name given by hidden
- self.hidden = self.xml.get('hidden', '')
-
+ """
+ Implement special logic: handle queueing state, and default input.
+ """
# if no student input yet, then use the default input given by the problem
if not self.value:
self.value = self.xml.text
@@ -448,28 +574,11 @@ class CodeInput(InputTypeBase):
if self.status == 'incomplete':
self.status = 'queued'
self.queue_len = self.msg
- self.msg = 'Submitted to grader.'
+ self.msg = self.submitted_msg
- # For CodeMirror
- self.mode = self.xml.get('mode', 'python')
- self.linenumbers = self.xml.get('linenumbers', 'true')
- self.tabsize = int(self.xml.get('tabsize', '4'))
-
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'msg': self.msg,
- 'mode': self.mode,
- 'linenumbers': self.linenumbers,
- 'rows': self.rows,
- 'cols': self.cols,
- 'hidden': self.hidden,
- 'tabsize': self.tabsize,
- 'queue_len': self.queue_len,
- }
- return context
+ def _extra_context(self):
+ """Defined queue_len, add it """
+ return {'queue_len': self.queue_len,}
registry.register(CodeInput)
@@ -482,26 +591,19 @@ class Schematic(InputTypeBase):
template = "schematicinput.html"
tags = ['schematic']
- def setup(self):
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
- self.parts = self.xml.get('parts')
- self.analyses = self.xml.get('analyses')
- self.initial_value = self.xml.get('initial_value')
- self.submit_analyses = self.xml.get('submit_analyses')
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert options to a convenient format.
+ """
+ return [
+ Attribute('height', None),
+ Attribute('width', None),
+ Attribute('parts', None),
+ Attribute('analyses', None),
+ Attribute('initial_value', None),
+ Attribute('submit_analyses', None),]
-
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'value': self.value,
- 'initial_value': self.initial_value,
- 'status': self.status,
- 'width': self.width,
- 'height': self.height,
- 'parts': self.parts,
- 'analyses': self.analyses,
- 'submit_analyses': self.submit_analyses,}
return context
registry.register(Schematic)
@@ -522,12 +624,20 @@ class ImageInput(InputTypeBase):
template = "imageinput.html"
tags = ['imageinput']
- def setup(self):
- self.src = self.xml.get('src')
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
+ @classmethod
+ def get_attributes(cls):
+ """
+ Note: src, height, and width are all required.
+ """
+ return [Attribute('src'),
+ Attribute('height'),
+ Attribute('width'),]
- # if value is of the form [x,y] then parse it and send along coordinates of previous answer
+
+ def setup(self):
+ """
+ if value is of the form [x,y] then parse it and send along coordinates of previous answer
+ """
m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', ''))
if m:
# Note: we subtract 15 to compensate for the size of the dot on the screen.
@@ -537,19 +647,10 @@ class ImageInput(InputTypeBase):
(self.gx, self.gy) = (0, 0)
- def _get_render_context(self):
+ def _extra_context(self):
- context = {'id': self.id,
- 'value': self.value,
- 'height': self.height,
- 'width': self.width,
- 'src': self.src,
- 'gx': self.gx,
- 'gy': self.gy,
- 'status': self.status,
- 'msg': self.msg,
- }
- return context
+ return {'gx': self.gx,
+ 'gy': self.gy}
registry.register(ImageInput)
@@ -565,30 +666,18 @@ class Crystallography(InputTypeBase):
template = "crystallography.html"
tags = ['crystallography']
+ @classmethod
+ def get_attributes(cls):
+ """
+ Note: height, width are required.
+ """
+ return [Attribute('size', None),
+ Attribute('height'),
+ Attribute('width'),
- def setup(self):
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
- self.size = self.xml.get('size')
-
- # if specified, then textline is hidden and id is stored in div of name given by hidden
- self.hidden = self.xml.get('hidden', '')
-
- # Escape answers with quotes, so they don't crash the system!
- escapedict = {'"': '"'}
- self.value = saxutils.escape(self.value, escapedict)
-
- def _get_render_context(self):
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'size': self.size,
- 'msg': self.msg,
- 'hidden': self.hidden,
- 'width': self.width,
- 'height': self.height,
- }
- return context
+ # can probably be removed (textline should prob be always-hidden)
+ Attribute('hidden', ''),
+ ]
registry.register(Crystallography)
@@ -603,29 +692,16 @@ class VseprInput(InputTypeBase):
template = 'vsepr_input.html'
tags = ['vsepr_input']
- def setup(self):
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
-
- # Escape answers with quotes, so they don't crash the system!
- escapedict = {'"': '"'}
- self.value = saxutils.escape(self.value, escapedict)
-
- self.molecules = self.xml.get('molecules')
- self.geometries = self.xml.get('geometries')
-
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'msg': self.msg,
- 'width': self.width,
- 'height': self.height,
- 'molecules': self.molecules,
- 'geometries': self.geometries,
- }
- return context
+ @classmethod
+ def get_attributes(cls):
+ """
+ Note: height, width are required.
+ """
+ return [Attribute('height'),
+ Attribute('width'),
+ Attribute('molecules'),
+ Attribute('geometries'),
+ ]
registry.register(VseprInput)
@@ -646,17 +722,17 @@ class ChemicalEquationInput(InputTypeBase):
template = "chemicalequationinput.html"
tags = ['chemicalequationinput']
- def setup(self):
- self.size = self.xml.get('size', '20')
+ @classmethod
+ def get_attributes(cls):
+ """
+ Can set size of text field.
+ """
+ return [Attribute('size', '20'),]
- def _get_render_context(self):
- context = {
- 'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'size': self.size,
- 'previewer': '/static/js/capa/chemical_equation_preview.js',
- }
- return context
+ def _extra_context(self):
+ """
+ TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded.
+ """
+ return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
registry.register(ChemicalEquationInput)
diff --git a/common/lib/capa/capa/templates/crystallography.html b/common/lib/capa/capa/templates/crystallography.html
index f46e2f753a..2370f59dd2 100644
--- a/common/lib/capa/capa/templates/crystallography.html
+++ b/common/lib/capa/capa/templates/crystallography.html
@@ -19,7 +19,7 @@
% endif
- ${status}