Merge branch 'master' into feature/abarrett/annotatable_xmodule
This commit is contained in:
@@ -121,9 +121,9 @@ def evaluator(variables, functions, string, cs=False):
|
||||
# confusing. They may also conflict with variables if we ever allow e.g.
|
||||
# 5R instead of 5*R
|
||||
suffixes = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9,
|
||||
'T': 1e12,# 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
|
||||
'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
|
||||
'c': 1e-2, 'm': 1e-3, 'u': 1e-6,
|
||||
'n': 1e-9, 'p': 1e-12}# ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
|
||||
'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
|
||||
|
||||
def super_float(text):
|
||||
''' Like float, but with si extensions. 1k goes to 1000'''
|
||||
|
||||
@@ -75,7 +75,7 @@ global_context = {'random': random,
|
||||
'draganddrop': verifiers.draganddrop}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"]
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"]
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
@@ -453,7 +453,7 @@ class LoncapaProblem(object):
|
||||
exec code in context, context
|
||||
except Exception as err:
|
||||
log.exception("Error while execing script code: " + code)
|
||||
msg = "Error while executing script code: %s" % str(err).replace('<','<')
|
||||
msg = "Error while executing script code: %s" % str(err).replace('<', '<')
|
||||
raise responsetypes.LoncapaProblemError(msg)
|
||||
finally:
|
||||
sys.path = original_path
|
||||
@@ -502,7 +502,7 @@ class LoncapaProblem(object):
|
||||
'id': problemtree.get('id'),
|
||||
'feedback': {'message': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode,}}
|
||||
'hintmode': hintmode, }}
|
||||
|
||||
input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag)
|
||||
the_input = input_type_cls(self.system, problemtree, state)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -17,17 +17,17 @@ from nltk.tree import Tree
|
||||
ARROWS = ('<->', '->')
|
||||
|
||||
## Defines a simple pyparsing tokenizer for chemical equations
|
||||
elements = ['Ac','Ag','Al','Am','Ar','As','At','Au','B','Ba','Be',
|
||||
'Bh','Bi','Bk','Br','C','Ca','Cd','Ce','Cf','Cl','Cm',
|
||||
'Cn','Co','Cr','Cs','Cu','Db','Ds','Dy','Er','Es','Eu',
|
||||
'F','Fe','Fl','Fm','Fr','Ga','Gd','Ge','H','He','Hf',
|
||||
'Hg','Ho','Hs','I','In','Ir','K','Kr','La','Li','Lr',
|
||||
'Lu','Lv','Md','Mg','Mn','Mo','Mt','N','Na','Nb','Nd',
|
||||
'Ne','Ni','No','Np','O','Os','P','Pa','Pb','Pd','Pm',
|
||||
'Po','Pr','Pt','Pu','Ra','Rb','Re','Rf','Rg','Rh','Rn',
|
||||
'Ru','S','Sb','Sc','Se','Sg','Si','Sm','Sn','Sr','Ta',
|
||||
'Tb','Tc','Te','Th','Ti','Tl','Tm','U','Uuo','Uup',
|
||||
'Uus','Uut','V','W','Xe','Y','Yb','Zn','Zr']
|
||||
elements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be',
|
||||
'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm',
|
||||
'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu',
|
||||
'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf',
|
||||
'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr',
|
||||
'Lu', 'Lv', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd',
|
||||
'Ne', 'Ni', 'No', 'Np', 'O', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm',
|
||||
'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn',
|
||||
'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta',
|
||||
'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'U', 'Uuo', 'Uup',
|
||||
'Uus', 'Uut', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr']
|
||||
digits = map(str, range(10))
|
||||
symbols = list("[](){}^+-/")
|
||||
phases = ["(s)", "(l)", "(g)", "(aq)"]
|
||||
@@ -252,7 +252,7 @@ def _get_final_tree(s):
|
||||
'''
|
||||
tokenized = tokenizer.parseString(s)
|
||||
parsed = parser.parse(tokenized)
|
||||
merged = _merge_children(parsed, {'S','group'})
|
||||
merged = _merge_children(parsed, {'S', 'group'})
|
||||
final = _clean_parse_tree(merged)
|
||||
return final
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
# Used by responsetypes and capa_problem
|
||||
|
||||
|
||||
class CorrectMap(object):
|
||||
"""
|
||||
Stores map between answer_id and response evaluation result for each question
|
||||
@@ -152,6 +153,3 @@ class CorrectMap(object):
|
||||
if not isinstance(other_cmap, CorrectMap):
|
||||
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
|
||||
self.cmap.update(other_cmap.get_dict())
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ log = logging.getLogger('mitx.' + __name__)
|
||||
registry = TagRegistry()
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MathRenderer(object):
|
||||
tags = ['math']
|
||||
|
||||
@@ -77,6 +79,7 @@ registry.register(MathRenderer)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SolutionRenderer(object):
|
||||
'''
|
||||
A solution is just a <span>...</span> which is given an ID, that is used for displaying an
|
||||
@@ -97,4 +100,3 @@ class SolutionRenderer(object):
|
||||
return etree.XML(html)
|
||||
|
||||
registry.register(SolutionRenderer)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
registry = TagRegistry()
|
||||
|
||||
|
||||
class Attribute(object):
|
||||
"""
|
||||
Allows specifying required and optional attributes for input types.
|
||||
@@ -413,7 +414,7 @@ class JavascriptInput(InputTypeBase):
|
||||
return [Attribute('params', None),
|
||||
Attribute('problem_state', None),
|
||||
Attribute('display_class', None),
|
||||
Attribute('display_file', None),]
|
||||
Attribute('display_file', None), ]
|
||||
|
||||
|
||||
def setup(self):
|
||||
@@ -477,12 +478,13 @@ class TextLine(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
return {'do_math': self.do_math,
|
||||
'preprocessor': self.preprocessor,}
|
||||
'preprocessor': self.preprocessor, }
|
||||
|
||||
registry.register(TextLine)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileSubmission(InputTypeBase):
|
||||
"""
|
||||
Upload some files (e.g. for programming assignments)
|
||||
@@ -508,7 +510,7 @@ class FileSubmission(InputTypeBase):
|
||||
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),]
|
||||
Attribute('required_files', '[]', transform=cls.parse_files), ]
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
@@ -524,7 +526,7 @@ class FileSubmission(InputTypeBase):
|
||||
self.msg = FileSubmission.submitted_msg
|
||||
|
||||
def _extra_context(self):
|
||||
return {'queue_len': self.queue_len,}
|
||||
return {'queue_len': self.queue_len, }
|
||||
return context
|
||||
|
||||
registry.register(FileSubmission)
|
||||
@@ -582,7 +584,7 @@ class CodeInput(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
"""Defined queue_len, add it """
|
||||
return {'queue_len': self.queue_len,}
|
||||
return {'queue_len': self.queue_len, }
|
||||
|
||||
registry.register(CodeInput)
|
||||
|
||||
@@ -606,7 +608,7 @@ class Schematic(InputTypeBase):
|
||||
Attribute('parts', None),
|
||||
Attribute('analyses', None),
|
||||
Attribute('initial_value', None),
|
||||
Attribute('submit_analyses', None),]
|
||||
Attribute('submit_analyses', None), ]
|
||||
|
||||
return context
|
||||
|
||||
@@ -614,6 +616,7 @@ registry.register(Schematic)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ImageInput(InputTypeBase):
|
||||
"""
|
||||
Clickable image as an input field. Element should specify the image source, height,
|
||||
@@ -635,7 +638,7 @@ class ImageInput(InputTypeBase):
|
||||
"""
|
||||
return [Attribute('src'),
|
||||
Attribute('height'),
|
||||
Attribute('width'),]
|
||||
Attribute('width'), ]
|
||||
|
||||
|
||||
def setup(self):
|
||||
@@ -660,6 +663,7 @@ registry.register(ImageInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Crystallography(InputTypeBase):
|
||||
"""
|
||||
An input for crystallography -- user selects 3 points on the axes, and we get a plane.
|
||||
@@ -728,18 +732,19 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
"""
|
||||
Can set size of text field.
|
||||
"""
|
||||
return [Attribute('size', '20'),]
|
||||
return [Attribute('size', '20'), ]
|
||||
|
||||
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',}
|
||||
return {'previewer': '/static/js/capa/chemical_equation_preview.js', }
|
||||
|
||||
registry.register(ChemicalEquationInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DragAndDropInput(InputTypeBase):
|
||||
"""
|
||||
Input for drag and drop problems. Allows student to drag and drop images and
|
||||
@@ -829,3 +834,108 @@ class DragAndDropInput(InputTypeBase):
|
||||
registry.register(DragAndDropInput)
|
||||
|
||||
#--------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EditAMoleculeInput(InputTypeBase):
|
||||
"""
|
||||
An input type for edit-a-molecule. Integrates with the molecule editor java applet.
|
||||
|
||||
Example:
|
||||
|
||||
<editamolecule size="50"/>
|
||||
|
||||
options: size -- width of the textbox.
|
||||
"""
|
||||
|
||||
template = "editamolecule.html"
|
||||
tags = ['editamoleculeinput']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Can set size of text field.
|
||||
"""
|
||||
return [Attribute('file'),
|
||||
Attribute('missing', None)]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/editamolecule.js',
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
registry.register(EditAMoleculeInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class DesignProtein2dInput(InputTypeBase):
|
||||
"""
|
||||
An input type for design of a protein in 2D. Integrates with the Protex java applet.
|
||||
|
||||
Example:
|
||||
|
||||
<designprotein2d width="800" hight="500" target_shape="E;NE;NW;W;SW;E;none" />
|
||||
"""
|
||||
|
||||
template = "designprotein2dinput.html"
|
||||
tags = ['designprotein2dinput']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: width, hight, and target_shape are required.
|
||||
"""
|
||||
return [Attribute('width'),
|
||||
Attribute('height'),
|
||||
Attribute('target_shape')
|
||||
]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/design-protein-2d.js',
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
registry.register(DesignProtein2dInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class EditAGeneInput(InputTypeBase):
|
||||
"""
|
||||
An input type for editing a gene. Integrates with the genex java applet.
|
||||
|
||||
Example:
|
||||
|
||||
<editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" />
|
||||
"""
|
||||
|
||||
template = "editageneinput.html"
|
||||
tags = ['editageneinput']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: width, hight, and dna_sequencee are required.
|
||||
"""
|
||||
return [Attribute('width'),
|
||||
Attribute('height'),
|
||||
Attribute('dna_sequence')
|
||||
]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/edit-a-gene.js',
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
registry.register(EditAGeneInput)
|
||||
|
||||
|
||||
@@ -186,9 +186,9 @@ class LoncapaResponse(object):
|
||||
tree = etree.Element('span')
|
||||
|
||||
# problem author can make this span display:inline
|
||||
if self.xml.get('inline',''):
|
||||
tree.set('class','inline')
|
||||
|
||||
if self.xml.get('inline', ''):
|
||||
tree.set('class', 'inline')
|
||||
|
||||
for item in self.xml:
|
||||
# call provided procedure to do the rendering
|
||||
item_xhtml = renderer(item)
|
||||
@@ -875,7 +875,8 @@ def sympy_check2():
|
||||
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography',
|
||||
'chemicalequationinput', 'vsepr_input',
|
||||
'drag_and_drop_input']
|
||||
'drag_and_drop_input', 'editamoleculeinput',
|
||||
'designprotein2dinput', 'editageneinput']
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
@@ -998,7 +999,7 @@ def sympy_check2():
|
||||
self.context['debug'] = self.system.DEBUG
|
||||
|
||||
# exec the check function
|
||||
if type(self.code) == str:
|
||||
if isinstance(self.code, basestring):
|
||||
try:
|
||||
exec self.code in self.context['global_context'], self.context
|
||||
correct = self.context['correct']
|
||||
@@ -1294,7 +1295,7 @@ class CodeResponse(LoncapaResponse):
|
||||
|
||||
# State associated with the queueing request
|
||||
queuestate = {'key': queuekey,
|
||||
'time': qtime,}
|
||||
'time': qtime, }
|
||||
|
||||
cmap = CorrectMap()
|
||||
if error:
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
<div id="input_${id}_preview" class="equation">
|
||||
</div>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
|
||||
35
common/lib/capa/capa/templates/designprotein2dinput.html
Normal file
35
common/lib/capa/capa/templates/designprotein2dinput.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<section id="designprotein2dinput_${id}" class="designprotein2dinput">
|
||||
<div class="script_placeholder" data-src="/static/js/capa/protex/protex.nocache.js"/>
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% 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="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<div id="protex_container"></div>
|
||||
<input type="hidden" name="target_shape" id="target_shape" value ="${target_shape}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
39
common/lib/capa/capa/templates/editageneinput.html
Normal file
39
common/lib/capa/capa/templates/editageneinput.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<section id="editageneinput_${id}" class="editageneinput">
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% 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
|
||||
|
||||
<object type="application/x-java-applet" id="applet_${id}" class="applet" width="${width}" height="${height}">
|
||||
<param name="archive" value="/static/applets/capa/genex.jar" />
|
||||
<param name="code" value="GX.GenexApplet.class" />
|
||||
<param name="DNA_SEQUENCE" value="${dna_sequence}" />
|
||||
Applet failed to run. No Java plug-in was found.
|
||||
</object>
|
||||
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
43
common/lib/capa/capa/templates/editamolecule.html
Normal file
43
common/lib/capa/capa/templates/editamolecule.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<section id="editamoleculeinput_${id}" class="editamoleculeinput">
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% 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
|
||||
|
||||
<div id="applet_${id}" class="applet" data-molfile-src="${file}" style="display:block;width:500px;height:400px">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<button id="reset_${id}" class="reset">Reset</button>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
<br/> <br/>
|
||||
|
||||
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
@@ -8,6 +8,7 @@ import xml.sax.saxutils as saxutils
|
||||
|
||||
TEST_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
def tst_render_template(template, context):
|
||||
"""
|
||||
A test version of render to template. Renders to the repr of the context, completely ignoring
|
||||
@@ -25,7 +26,7 @@ test_system = Mock(
|
||||
user=Mock(),
|
||||
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
|
||||
debug=True,
|
||||
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
anonymous_student_id = 'student'
|
||||
anonymous_student_id='student'
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from capa import customrender
|
||||
# just a handy shortcut
|
||||
lookup_tag = customrender.registry.get_class_for_tag
|
||||
|
||||
|
||||
def extract_context(xml):
|
||||
"""
|
||||
Given an xml element corresponding to the output of test_system.render_template, get back the
|
||||
@@ -15,9 +16,11 @@ def extract_context(xml):
|
||||
"""
|
||||
return eval(xml.text)
|
||||
|
||||
|
||||
def quote_attr(s):
|
||||
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
|
||||
|
||||
|
||||
class HelperTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure that our helper function works!
|
||||
@@ -50,7 +53,7 @@ class SolutionRenderTest(unittest.TestCase):
|
||||
# our test_system "renders" templates to a div with the repr of the context
|
||||
xml = renderer.get_html()
|
||||
context = extract_context(xml)
|
||||
self.assertEqual(context, {'id' : 'solution_12'})
|
||||
self.assertEqual(context, {'id': 'solution_12'})
|
||||
|
||||
|
||||
class MathRenderTest(unittest.TestCase):
|
||||
@@ -65,12 +68,11 @@ class MathRenderTest(unittest.TestCase):
|
||||
renderer = lookup_tag('math')(test_system, element)
|
||||
|
||||
self.assertEqual(renderer.mathstr, mathjax_out)
|
||||
|
||||
|
||||
def test_parsing(self):
|
||||
self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]')
|
||||
self.check_parse('$abc', '$abc')
|
||||
self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]')
|
||||
|
||||
|
||||
|
||||
# NOTE: not testing get_html yet because I don't understand why it's doing what it's doing.
|
||||
|
||||
|
||||
4
common/lib/capa/capa/tests/test_files/js/.gitignore
vendored
Normal file
4
common/lib/capa/capa/tests/test_files/js/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
test_problem_display.js
|
||||
test_problem_generator.js
|
||||
test_problem_grader.js
|
||||
xproblem.js
|
||||
@@ -1,49 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var MinimaxProblemDisplay, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
MinimaxProblemDisplay = (function(_super) {
|
||||
|
||||
__extends(MinimaxProblemDisplay, _super);
|
||||
|
||||
function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
|
||||
this.state = state;
|
||||
this.submission = submission;
|
||||
this.evaluation = evaluation;
|
||||
this.container = container;
|
||||
this.submissionField = submissionField;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters);
|
||||
}
|
||||
|
||||
MinimaxProblemDisplay.prototype.render = function() {};
|
||||
|
||||
MinimaxProblemDisplay.prototype.createSubmission = function() {
|
||||
var id, value, _ref, _results;
|
||||
this.newSubmission = {};
|
||||
if (this.submission != null) {
|
||||
_ref = this.submission;
|
||||
_results = [];
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
_results.push(this.newSubmission[id] = value);
|
||||
}
|
||||
return _results;
|
||||
}
|
||||
};
|
||||
|
||||
MinimaxProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return this.newSubmission;
|
||||
};
|
||||
|
||||
return MinimaxProblemDisplay;
|
||||
|
||||
})(XProblemDisplay);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.TestProblemDisplay = TestProblemDisplay;
|
||||
|
||||
}).call(this);
|
||||
@@ -1,29 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var TestProblemGenerator, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
TestProblemGenerator = (function(_super) {
|
||||
|
||||
__extends(TestProblemGenerator, _super);
|
||||
|
||||
function TestProblemGenerator(seed, parameters) {
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
TestProblemGenerator.__super__.constructor.call(this, seed, this.parameters);
|
||||
}
|
||||
|
||||
TestProblemGenerator.prototype.generate = function() {
|
||||
this.problemState.value = this.parameters.value;
|
||||
return this.problemState;
|
||||
};
|
||||
|
||||
return TestProblemGenerator;
|
||||
|
||||
})(XProblemGenerator);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.generatorClass = TestProblemGenerator;
|
||||
|
||||
}).call(this);
|
||||
@@ -1,50 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var TestProblemGrader, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
TestProblemGrader = (function(_super) {
|
||||
|
||||
__extends(TestProblemGrader, _super);
|
||||
|
||||
function TestProblemGrader(submission, problemState, parameters) {
|
||||
this.submission = submission;
|
||||
this.problemState = problemState;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
TestProblemGrader.__super__.constructor.call(this, this.submission, this.problemState, this.parameters);
|
||||
}
|
||||
|
||||
TestProblemGrader.prototype.solve = function() {
|
||||
return this.solution = {
|
||||
0: this.problemState.value
|
||||
};
|
||||
};
|
||||
|
||||
TestProblemGrader.prototype.grade = function() {
|
||||
var allCorrect, id, value, valueCorrect, _ref;
|
||||
if (!(this.solution != null)) {
|
||||
this.solve();
|
||||
}
|
||||
allCorrect = true;
|
||||
_ref = this.solution;
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
valueCorrect = this.submission != null ? value === this.submission[id] : false;
|
||||
this.evaluation[id] = valueCorrect;
|
||||
if (!valueCorrect) {
|
||||
allCorrect = false;
|
||||
}
|
||||
}
|
||||
return allCorrect;
|
||||
};
|
||||
|
||||
return TestProblemGrader;
|
||||
|
||||
})(XProblemGrader);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.graderClass = TestProblemGrader;
|
||||
|
||||
}).call(this);
|
||||
@@ -1,78 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var XProblemDisplay, XProblemGenerator, XProblemGrader, root;
|
||||
|
||||
XProblemGenerator = (function() {
|
||||
|
||||
function XProblemGenerator(seed, parameters) {
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
this.random = new MersenneTwister(seed);
|
||||
this.problemState = {};
|
||||
}
|
||||
|
||||
XProblemGenerator.prototype.generate = function() {
|
||||
return console.error("Abstract method called: XProblemGenerator.generate");
|
||||
};
|
||||
|
||||
return XProblemGenerator;
|
||||
|
||||
})();
|
||||
|
||||
XProblemDisplay = (function() {
|
||||
|
||||
function XProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
|
||||
this.state = state;
|
||||
this.submission = submission;
|
||||
this.evaluation = evaluation;
|
||||
this.container = container;
|
||||
this.submissionField = submissionField;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
}
|
||||
|
||||
XProblemDisplay.prototype.render = function() {
|
||||
return console.error("Abstract method called: XProblemDisplay.render");
|
||||
};
|
||||
|
||||
XProblemDisplay.prototype.updateSubmission = function() {
|
||||
return this.submissionField.val(JSON.stringify(this.getCurrentSubmission()));
|
||||
};
|
||||
|
||||
XProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return console.error("Abstract method called: XProblemDisplay.getCurrentSubmission");
|
||||
};
|
||||
|
||||
return XProblemDisplay;
|
||||
|
||||
})();
|
||||
|
||||
XProblemGrader = (function() {
|
||||
|
||||
function XProblemGrader(submission, problemState, parameters) {
|
||||
this.submission = submission;
|
||||
this.problemState = problemState;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
this.solution = null;
|
||||
this.evaluation = {};
|
||||
}
|
||||
|
||||
XProblemGrader.prototype.solve = function() {
|
||||
return console.error("Abstract method called: XProblemGrader.solve");
|
||||
};
|
||||
|
||||
XProblemGrader.prototype.grade = function() {
|
||||
return console.error("Abstract method called: XProblemGrader.grade");
|
||||
};
|
||||
|
||||
return XProblemGrader;
|
||||
|
||||
})();
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.XProblemGenerator = XProblemGenerator;
|
||||
|
||||
root.XProblemDisplay = XProblemDisplay;
|
||||
|
||||
root.XProblemGrader = XProblemGrader;
|
||||
|
||||
}).call(this);
|
||||
@@ -31,6 +31,7 @@ lookup_tag = inputtypes.registry.get_class_for_tag
|
||||
def quote_attr(s):
|
||||
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
|
||||
|
||||
|
||||
class OptionInputTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure option inputs work
|
||||
@@ -100,7 +101,7 @@ class ChoiceGroupTest(unittest.TestCase):
|
||||
'input_type': expected_input_type,
|
||||
'choices': [('foil1', '<text>This is foil One.</text>'),
|
||||
('foil2', '<text>This is foil Two.</text>'),
|
||||
('foil3', 'This is foil Three.'),],
|
||||
('foil3', 'This is foil Three.'), ],
|
||||
'name_array_suffix': expected_suffix, # what is this for??
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@ class JavascriptInputTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': '3',}
|
||||
state = {'value': '3', }
|
||||
the_input = lookup_tag('javascriptinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
@@ -149,7 +150,7 @@ class JavascriptInputTest(unittest.TestCase):
|
||||
'params': params,
|
||||
'display_file': display_file,
|
||||
'display_class': display_class,
|
||||
'problem_state': problem_state,}
|
||||
'problem_state': problem_state, }
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
@@ -165,7 +166,7 @@ class TextLineTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee',}
|
||||
state = {'value': 'BumbleBee', }
|
||||
the_input = lookup_tag('textline')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
@@ -193,7 +194,7 @@ class TextLineTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee',}
|
||||
state = {'value': 'BumbleBee', }
|
||||
the_input = lookup_tag('textline')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
@@ -231,7 +232,7 @@ class FileSubmissionTest(unittest.TestCase):
|
||||
|
||||
state = {'value': 'BumbleBee.py',
|
||||
'status': 'incomplete',
|
||||
'feedback' : {'message': '3'}, }
|
||||
'feedback': {'message': '3'}, }
|
||||
input_class = lookup_tag('filesubmission')
|
||||
the_input = input_class(test_system, element, state)
|
||||
|
||||
@@ -275,7 +276,7 @@ class CodeInputTest(unittest.TestCase):
|
||||
|
||||
state = {'value': 'print "good evening"',
|
||||
'status': 'incomplete',
|
||||
'feedback' : {'message': '3'}, }
|
||||
'feedback': {'message': '3'}, }
|
||||
|
||||
input_class = lookup_tag('codeinput')
|
||||
the_input = input_class(test_system, element, state)
|
||||
@@ -488,7 +489,7 @@ class ChemicalEquationTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'H2OYeah',}
|
||||
state = {'value': 'H2OYeah', }
|
||||
the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
@@ -16,6 +16,7 @@ from capa.correctmap import CorrectMap
|
||||
from capa.util import convert_files_to_filenames
|
||||
from capa.xqueue_interface import dateformat
|
||||
|
||||
|
||||
class MultiChoiceTest(unittest.TestCase):
|
||||
def test_MC_grade(self):
|
||||
multichoice_file = os.path.dirname(__file__) + "/test_files/multichoice.xml"
|
||||
@@ -295,16 +296,16 @@ class CodeResponseTest(unittest.TestCase):
|
||||
old_cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(1000+i, datetime.now())
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now())
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
|
||||
# Message format common to external graders
|
||||
grader_msg = '<span>MESSAGE</span>' # Must be valid XML
|
||||
correct_score_msg = json.dumps({'correct':True, 'score':1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct':False, 'score':0, 'msg': grader_msg})
|
||||
grader_msg = '<span>MESSAGE</span>' # Must be valid XML
|
||||
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
|
||||
|
||||
xserver_msgs = {'correct': correct_score_msg,
|
||||
'incorrect': incorrect_score_msg,}
|
||||
'incorrect': incorrect_score_msg, }
|
||||
|
||||
# Incorrect queuekey, state should not be updated
|
||||
for correctness in ['correct', 'incorrect']:
|
||||
@@ -325,7 +326,7 @@ class CodeResponseTest(unittest.TestCase):
|
||||
|
||||
new_cmap = CorrectMap()
|
||||
new_cmap.update(old_cmap)
|
||||
npoints = 1 if correctness=='correct' else 0
|
||||
npoints = 1 if correctness == 'correct' else 0
|
||||
new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None)
|
||||
|
||||
test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i)
|
||||
@@ -361,7 +362,7 @@ class CodeResponseTest(unittest.TestCase):
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
latest_timestamp = datetime.now()
|
||||
queuestate = CodeResponseTest.make_queuestate(1000+i, latest_timestamp)
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp)
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate))
|
||||
test_lcp.correct_map.update(cmap)
|
||||
|
||||
@@ -412,6 +413,7 @@ class ChoiceResponseTest(unittest.TestCase):
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_4_1'), 'correct')
|
||||
|
||||
|
||||
class JavascriptResponseTest(unittest.TestCase):
|
||||
|
||||
def test_jr_grade(self):
|
||||
@@ -424,4 +426,3 @@ class JavascriptResponseTest(unittest.TestCase):
|
||||
|
||||
self.assertEquals(test_lcp.grade_answers(incorrect_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
|
||||
|
||||
@@ -51,15 +51,17 @@ def convert_files_to_filenames(answers):
|
||||
new_answers = dict()
|
||||
for answer_id in answers.keys():
|
||||
answer = answers[answer_id]
|
||||
if is_list_of_files(answer): # Files are stored as a list, even if one file
|
||||
if is_list_of_files(answer): # Files are stored as a list, even if one file
|
||||
new_answers[answer_id] = [f.name for f in answer]
|
||||
else:
|
||||
new_answers[answer_id] = answers[answer_id]
|
||||
return new_answers
|
||||
|
||||
|
||||
def is_list_of_files(files):
|
||||
return isinstance(files, list) and all(is_file(f) for f in files)
|
||||
|
||||
|
||||
def is_file(file_to_test):
|
||||
'''
|
||||
Duck typing to check if 'file_to_test' is a File object
|
||||
@@ -79,11 +81,10 @@ def find_with_default(node, path, default):
|
||||
|
||||
Returns:
|
||||
node.find(path).text if the find succeeds, default otherwise.
|
||||
|
||||
|
||||
"""
|
||||
v = node.find(path)
|
||||
if v is not None:
|
||||
return v.text
|
||||
else:
|
||||
return default
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import requests
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
dateformat = '%Y%m%d%H%M%S'
|
||||
|
||||
|
||||
def make_hashkey(seed):
|
||||
'''
|
||||
Generate a string key by hashing
|
||||
@@ -29,9 +30,9 @@ def make_xheader(lms_callback_url, lms_key, queue_name):
|
||||
'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string)
|
||||
}
|
||||
"""
|
||||
return json.dumps({ 'lms_callback_url': lms_callback_url,
|
||||
return json.dumps({'lms_callback_url': lms_callback_url,
|
||||
'lms_key': lms_key,
|
||||
'queue_name': queue_name })
|
||||
'queue_name': queue_name})
|
||||
|
||||
|
||||
def parse_xreply(xreply):
|
||||
@@ -96,18 +97,18 @@ class XQueueInterface(object):
|
||||
|
||||
|
||||
def _login(self):
|
||||
payload = { 'username': self.auth['username'],
|
||||
'password': self.auth['password'] }
|
||||
payload = {'username': self.auth['username'],
|
||||
'password': self.auth['password']}
|
||||
return self._http_post(self.url + '/xqueue/login/', payload)
|
||||
|
||||
|
||||
def _send_to_queue(self, header, body, files_to_upload):
|
||||
payload = {'xqueue_header': header,
|
||||
'xqueue_body' : body}
|
||||
'xqueue_body': body}
|
||||
files = {}
|
||||
if files_to_upload is not None:
|
||||
for f in files_to_upload:
|
||||
files.update({ f.name: f })
|
||||
files.update({f.name: f})
|
||||
|
||||
return self._http_post(self.url + '/xqueue/submit/', payload, files=files)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from logging.handlers import SysLogHandler
|
||||
|
||||
LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
|
||||
|
||||
def get_logger_config(log_dir,
|
||||
logging_env="no_env",
|
||||
tracking_filename="tracking.log",
|
||||
@@ -13,7 +14,8 @@ def get_logger_config(log_dir,
|
||||
syslog_addr=None,
|
||||
debug=False,
|
||||
local_loglevel='INFO',
|
||||
console_loglevel=None):
|
||||
console_loglevel=None,
|
||||
service_variant=None):
|
||||
|
||||
"""
|
||||
|
||||
@@ -38,14 +40,21 @@ def get_logger_config(log_dir,
|
||||
if console_loglevel is None or console_loglevel not in LOG_LEVELS:
|
||||
console_loglevel = 'DEBUG' if debug else 'INFO'
|
||||
|
||||
if service_variant is None:
|
||||
# default to a blank string so that if SERVICE_VARIANT is not
|
||||
# set we will not log to a sub directory
|
||||
service_variant = ''
|
||||
|
||||
hostname = platform.node().split(".")[0]
|
||||
syslog_format = ("[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
syslog_format = ("[service_variant={service_variant}]"
|
||||
"[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
"[{hostname} %(process)d] [%(filename)s:%(lineno)d] "
|
||||
"- %(message)s").format(
|
||||
logging_env=logging_env, hostname=hostname)
|
||||
"- %(message)s").format(service_variant=service_variant,
|
||||
logging_env=logging_env,
|
||||
hostname=hostname)
|
||||
|
||||
handlers = ['console', 'local'] if debug else ['console',
|
||||
'syslogger-remote', 'local']
|
||||
'syslogger-remote', 'local']
|
||||
|
||||
logger_config = {
|
||||
'version': 1,
|
||||
@@ -78,11 +87,6 @@ def get_logger_config(log_dir,
|
||||
}
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': handlers,
|
||||
'propagate': True,
|
||||
'level': 'INFO'
|
||||
},
|
||||
'tracking': {
|
||||
'handlers': ['tracking'],
|
||||
'level': 'DEBUG',
|
||||
@@ -93,16 +97,6 @@ def get_logger_config(log_dir,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'mitx': {
|
||||
'handlers': handlers,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'keyedcache': {
|
||||
'handlers': handlers,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +122,9 @@ def get_logger_config(log_dir,
|
||||
},
|
||||
})
|
||||
else:
|
||||
# for production environments we will only
|
||||
# log INFO and up
|
||||
logger_config['loggers']['']['level'] = 'INFO'
|
||||
logger_config['handlers'].update({
|
||||
'local': {
|
||||
'level': local_loglevel,
|
||||
|
||||
71
common/lib/sample-post.py
Normal file
71
common/lib/sample-post.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# A simple script demonstrating how to have an external program post problem
|
||||
# responses to an edx server.
|
||||
#
|
||||
# ***** NOTE *****
|
||||
# This is not intended as a stable public API. In fact, it is almost certainly
|
||||
# going to change. If you use this for some reason, be prepared to change your
|
||||
# code.
|
||||
#
|
||||
# We will be working to define a stable public API for external programs. We
|
||||
# don't have have one yet (Feb 2013).
|
||||
|
||||
|
||||
import requests
|
||||
import sys
|
||||
import getpass
|
||||
|
||||
def prompt(msg, default=None, safe=False):
|
||||
d = ' [{0}]'.format(default) if default is not None else ''
|
||||
prompt = 'Enter {msg}{default}: '.format(msg=msg, default=d)
|
||||
if not safe:
|
||||
print prompt
|
||||
x = sys.stdin.readline().strip()
|
||||
else:
|
||||
x = getpass.getpass(prompt=prompt)
|
||||
if x == '' and default is not None:
|
||||
return default
|
||||
return x
|
||||
|
||||
server = 'https://www.edx.org'
|
||||
course_id = 'HarvardX/PH207x/2012_Fall'
|
||||
location = 'i4x://HarvardX/PH207x/problem/ex_practice_2'
|
||||
|
||||
#server = prompt('Server (no trailing slash)', 'http://127.0.0.1:8000')
|
||||
#course_id = prompt('Course id', 'MITx/7012x/2013_Spring')
|
||||
#location = prompt('problem location', 'i4x://MITx/7012x/problem/example_upload_answer')
|
||||
value = prompt('value to upload')
|
||||
|
||||
username = prompt('username on server', 'victor@edx.org')
|
||||
password = prompt('password', 'abc123', safe=True)
|
||||
|
||||
print "get csrf cookie"
|
||||
session = requests.session()
|
||||
r = session.get(server + '/')
|
||||
r.raise_for_status()
|
||||
|
||||
# print session.cookies
|
||||
|
||||
# for some reason, the server expects a header containing the csrf cookie, not just the
|
||||
# cookie itself.
|
||||
session.headers['X-CSRFToken'] = session.cookies['csrftoken']
|
||||
# for https, need a referer header
|
||||
session.headers['Referer'] = server + '/'
|
||||
login_url = '/'.join([server, 'login'])
|
||||
|
||||
print "log in"
|
||||
r = session.post(login_url, {'email': 'victor@edx.org', 'password': 'Secret!', 'remember': 'false'})
|
||||
#print "request headers: ", r.request.headers
|
||||
#print "response headers: ", r.headers
|
||||
r.raise_for_status()
|
||||
|
||||
url = '/'.join([server, 'courses', course_id, 'modx', location, 'problem_check'])
|
||||
data = {'input_{0}_2_1'.format(location.replace('/','-').replace(':','').replace('--','-')): value}
|
||||
#data = {'input_i4x-MITx-7012x-problem-example_upload_answer_2_1': value}
|
||||
|
||||
print "Posting to '{0}': {1}".format(url, data)
|
||||
|
||||
r = session.post(url, data)
|
||||
r.raise_for_status()
|
||||
|
||||
print ("To see the uploaded answer, go to {server}/courses/{course_id}/jump_to/{location}"
|
||||
.format(server=server, course_id=course_id, location=location))
|
||||
@@ -3,7 +3,8 @@ A handy util to print a django-debug-screen-like stack trace with
|
||||
values of local variables.
|
||||
"""
|
||||
|
||||
import sys, traceback
|
||||
import sys
|
||||
import traceback
|
||||
from django.utils.encoding import smart_unicode
|
||||
|
||||
|
||||
@@ -48,5 +49,3 @@ def supertrace(max_len=160):
|
||||
print s
|
||||
except:
|
||||
print "<ERROR WHILE PRINTING VALUE>"
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jasmine-jquery.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.cookie.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/CodeMirror/codemirror.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/jquery.tinymce.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/tiny_mce.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/RequireJS.js"></script>
|
||||
|
||||
@@ -20,17 +20,21 @@ setup(
|
||||
"book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"chapter = xmodule.seq_module:SequenceDescriptor",
|
||||
"combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
|
||||
"conditional = xmodule.conditional_module:ConditionalDescriptor",
|
||||
"course = xmodule.course_module:CourseDescriptor",
|
||||
"customtag = xmodule.template_module:CustomTagDescriptor",
|
||||
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"html = xmodule.html_module:HtmlDescriptor",
|
||||
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"error = xmodule.error_module:ErrorDescriptor",
|
||||
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
"randomize = xmodule.randomize_module:RandomizeDescriptor",
|
||||
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
|
||||
"sequential = xmodule.seq_module:SequenceDescriptor",
|
||||
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
|
||||
"vertical = xmodule.vertical_module:VerticalDescriptor",
|
||||
"video = xmodule.video_module:VideoDescriptor",
|
||||
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
|
||||
@@ -51,7 +51,7 @@ class ABTestModule(XModule):
|
||||
|
||||
def get_shared_state(self):
|
||||
return json.dumps({'group': self.group})
|
||||
|
||||
|
||||
def get_child_descriptors(self):
|
||||
active_locations = set(self.definition['data']['group_content'][self.group])
|
||||
return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations]
|
||||
@@ -171,7 +171,7 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
|
||||
group_elem.append(etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
|
||||
return xml_object
|
||||
|
||||
|
||||
|
||||
|
||||
def has_dynamic_children(self):
|
||||
return True
|
||||
|
||||
@@ -2,6 +2,7 @@ import cgi
|
||||
import datetime
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
@@ -25,6 +26,24 @@ log = logging.getLogger("mitx.courseware")
|
||||
#-----------------------------------------------------------------------------
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
|
||||
# Generated this many different variants of problems with rerandomize=per_student
|
||||
NUM_RANDOMIZATION_BINS = 20
|
||||
|
||||
|
||||
def randomization_bin(seed, problem_id):
|
||||
"""
|
||||
Pick a randomization bin for the problem given the user's seed and a problem id.
|
||||
|
||||
We do this because we only want e.g. 20 randomizations of a problem to make analytics
|
||||
interesting. To avoid having sets of students that always get the same problems,
|
||||
we'll combine the system's per-student seed with the problem id in picking the bin.
|
||||
"""
|
||||
h = hashlib.sha1()
|
||||
h.update(str(seed))
|
||||
h.update(str(problem_id))
|
||||
# get the first few digits of the hash, convert to an int, then mod.
|
||||
return int(h.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS
|
||||
|
||||
|
||||
def only_one(lst, default="", process=lambda x: x):
|
||||
"""
|
||||
@@ -138,13 +157,9 @@ class CapaModule(XModule):
|
||||
|
||||
if self.rerandomize == 'never':
|
||||
self.seed = 1
|
||||
elif self.rerandomize == "per_student" and hasattr(self.system, 'id'):
|
||||
# TODO: This line is badly broken:
|
||||
# (1) We're passing student ID to xmodule.
|
||||
# (2) There aren't bins of students. -- we only want 10 or 20 randomizations, and want to assign students
|
||||
# to these bins, and may not want cohorts. So e.g. hash(your-id, problem_id) % num_bins.
|
||||
# - analytics really needs small number of bins.
|
||||
self.seed = system.id
|
||||
elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
|
||||
# see comment on randomization_bin
|
||||
self.seed = randomization_bin(system.seed, self.location.url)
|
||||
else:
|
||||
self.seed = None
|
||||
|
||||
@@ -270,7 +285,7 @@ class CapaModule(XModule):
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
state=None, # Tabula rasa
|
||||
state=None, # Tabula rasa
|
||||
seed=self.seed, system=self.system)
|
||||
|
||||
# Prepend a scary warning to the student
|
||||
@@ -289,7 +304,7 @@ class CapaModule(XModule):
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
log.exception(err)
|
||||
raise
|
||||
|
||||
@@ -302,7 +317,7 @@ class CapaModule(XModule):
|
||||
# check button is context-specific.
|
||||
|
||||
# Put a "Check" button if unlimited attempts or still some left
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts-1:
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts - 1:
|
||||
check_button = "Check"
|
||||
else:
|
||||
# Will be final check so let user know that
|
||||
@@ -356,7 +371,7 @@ class CapaModule(XModule):
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>"
|
||||
|
||||
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
|
||||
return self.system.replace_urls(html, self.metadata['data_dir'], course_namespace=self.location)
|
||||
return self.system.replace_urls(html)
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
@@ -389,38 +404,54 @@ class CapaModule(XModule):
|
||||
})
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def is_past_due(self):
|
||||
"""
|
||||
Is it now past this problem's due date, including grace period?
|
||||
"""
|
||||
return (self.close_date is not None and
|
||||
datetime.datetime.utcnow() > self.close_date)
|
||||
|
||||
def closed(self):
|
||||
''' Is the student still allowed to submit answers? '''
|
||||
if self.attempts == self.max_attempts:
|
||||
return True
|
||||
if self.close_date is not None and datetime.datetime.utcnow() > self.close_date:
|
||||
if self.is_past_due():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_completed(self):
|
||||
# used by conditional module
|
||||
# return self.answer_available()
|
||||
return self.lcp.done
|
||||
|
||||
def is_attempted(self):
|
||||
# used by conditional module
|
||||
return self.attempts > 0
|
||||
|
||||
def answer_available(self):
|
||||
''' Is the user allowed to see an answer?
|
||||
'''
|
||||
Is the user allowed to see an answer?
|
||||
'''
|
||||
if self.show_answer == '':
|
||||
return False
|
||||
|
||||
if self.show_answer == "never":
|
||||
elif self.show_answer == "never":
|
||||
return False
|
||||
|
||||
# Admins can see the answer, unless the problem explicitly prevents it
|
||||
if self.system.user_is_staff:
|
||||
elif self.system.user_is_staff:
|
||||
# This is after the 'never' check because admins can see the answer
|
||||
# unless the problem explicitly prevents it
|
||||
return True
|
||||
|
||||
if self.show_answer == 'attempted':
|
||||
elif self.show_answer == 'attempted':
|
||||
return self.attempts > 0
|
||||
|
||||
if self.show_answer == 'answered':
|
||||
elif self.show_answer == 'answered':
|
||||
# NOTE: this is slightly different from 'attempted' -- resetting the problems
|
||||
# makes lcp.done False, but leaves attempts unchanged.
|
||||
return self.lcp.done
|
||||
|
||||
if self.show_answer == 'closed':
|
||||
elif self.show_answer == 'closed':
|
||||
return self.closed()
|
||||
|
||||
if self.show_answer == 'always':
|
||||
elif self.show_answer == 'past_due':
|
||||
return self.is_past_due()
|
||||
elif self.show_answer == 'always':
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -461,7 +492,7 @@ class CapaModule(XModule):
|
||||
new_answers = dict()
|
||||
for answer_id in answers:
|
||||
try:
|
||||
new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)}
|
||||
new_answer = {answer_id: self.system.replace_urls(answers[answer_id])}
|
||||
except TypeError:
|
||||
log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id]))
|
||||
new_answer = {answer_id: answers[answer_id]}
|
||||
@@ -532,9 +563,9 @@ class CapaModule(XModule):
|
||||
current_time = datetime.datetime.now()
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
waittime_between_requests = self.system.xqueue['waittime']
|
||||
if (current_time-prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
|
||||
try:
|
||||
old_state = self.lcp.get_state()
|
||||
@@ -567,7 +598,7 @@ class CapaModule(XModule):
|
||||
event_info['attempts'] = self.attempts
|
||||
self.system.track_function('save_problem_check', event_info)
|
||||
|
||||
if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback
|
||||
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.system.psychometrics_handler(self.get_instance_state())
|
||||
|
||||
# render problem into HTML
|
||||
@@ -669,18 +700,18 @@ class CapaDescriptor(RawDescriptor):
|
||||
# TODO (vshnayder): do problems have any other metadata? Do they
|
||||
# actually use type and points?
|
||||
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
|
||||
|
||||
|
||||
def get_context(self):
|
||||
_context = RawDescriptor.get_context(self)
|
||||
_context.update({'markdown': self.metadata.get('markdown', '')})
|
||||
return _context
|
||||
|
||||
|
||||
@property
|
||||
def editable_metadata_fields(self):
|
||||
"""Remove metadata from the editable fields since it has its own editor"""
|
||||
subset = super(CapaDescriptor,self).editable_metadata_fields
|
||||
subset = super(CapaDescriptor, self).editable_metadata_fields
|
||||
if 'markdown' in subset:
|
||||
subset.remove('markdown')
|
||||
subset.remove('markdown')
|
||||
return subset
|
||||
|
||||
|
||||
|
||||
@@ -19,21 +19,17 @@ from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
import self_assessment_module
|
||||
import open_ended_module
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from .stringify import stringify_children
|
||||
from combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
# Set the default number of max attempts. Should be 1 for production
|
||||
# Set higher for debugging/testing
|
||||
# attempts specified in xml definition overrides this.
|
||||
MAX_ATTEMPTS = 10000
|
||||
|
||||
# Set maximum available number of points.
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
VERSION_TUPLES = (
|
||||
('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module),
|
||||
)
|
||||
|
||||
DEFAULT_VERSION = 1
|
||||
DEFAULT_VERSION = str(DEFAULT_VERSION)
|
||||
|
||||
class CombinedOpenEndedModule(XModule):
|
||||
"""
|
||||
@@ -114,438 +110,68 @@ class CombinedOpenEndedModule(XModule):
|
||||
|
||||
"""
|
||||
|
||||
self.system = system
|
||||
self.system.set('location', location)
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
#We need to set the location here so the child modules can use it
|
||||
system.set('location', location)
|
||||
self.version = self.metadata.get('version', DEFAULT_VERSION)
|
||||
if not isinstance(self.version, basestring):
|
||||
try:
|
||||
self.version = str(self.version)
|
||||
except:
|
||||
log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION))
|
||||
self.version = DEFAULT_VERSION
|
||||
|
||||
#Tells the system which xml definition to load
|
||||
self.current_task_number = instance_state.get('current_task_number', 0)
|
||||
#This loads the states of the individual children
|
||||
self.task_states = instance_state.get('task_states', [])
|
||||
#Overall state of the combined open ended module
|
||||
self.state = instance_state.get('state', self.INITIAL)
|
||||
versions = [i[0] for i in VERSION_TUPLES]
|
||||
descriptors = [i[1] for i in VERSION_TUPLES]
|
||||
modules = [i[2] for i in VERSION_TUPLES]
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
|
||||
#Allow reset is true if student has failed the criteria to move to the next child task
|
||||
self.allow_reset = instance_state.get('ready_to_reset', False)
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
try:
|
||||
rubric_feedback = rubric_renderer.render_rubric(stringify_children(definition['rubric']))
|
||||
except RubricParsingError:
|
||||
log.error("Failed to parse rubric in location: {1}".format(location))
|
||||
raise
|
||||
#Static data is passed to the child modules to render
|
||||
self.static_data = {
|
||||
'max_score': self._max_score,
|
||||
'max_attempts': self.max_attempts,
|
||||
'prompt': definition['prompt'],
|
||||
'rubric': definition['rubric'],
|
||||
'display_name': self.display_name
|
||||
version_index = versions.index(self.version)
|
||||
except:
|
||||
log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION))
|
||||
self.version = DEFAULT_VERSION
|
||||
version_index = versions.index(self.version)
|
||||
|
||||
static_data = {
|
||||
'rewrite_content_links' : self.rewrite_content_links,
|
||||
}
|
||||
|
||||
self.task_xml = definition['task_xml']
|
||||
self.setup_next_task()
|
||||
|
||||
def get_tag_name(self, xml):
|
||||
"""
|
||||
Gets the tag name of a given xml block.
|
||||
Input: XML string
|
||||
Output: The name of the root tag
|
||||
"""
|
||||
tag = etree.fromstring(xml).tag
|
||||
return tag
|
||||
|
||||
def overwrite_state(self, current_task_state):
|
||||
"""
|
||||
Overwrites an instance state and sets the latest response to the current response. This is used
|
||||
to ensure that the student response is carried over from the first child to the rest.
|
||||
Input: Task state json string
|
||||
Output: Task state json string
|
||||
"""
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
|
||||
loaded_task_state = json.loads(current_task_state)
|
||||
if loaded_task_state['state'] == self.INITIAL:
|
||||
loaded_task_state['state'] = self.ASSESSING
|
||||
loaded_task_state['created'] = True
|
||||
loaded_task_state['history'].append({'answer': last_response})
|
||||
current_task_state = json.dumps(loaded_task_state)
|
||||
return current_task_state
|
||||
|
||||
def child_modules(self):
|
||||
"""
|
||||
Returns the constructors associated with the child modules in a dictionary. This makes writing functions
|
||||
simpler (saves code duplication)
|
||||
Input: None
|
||||
Output: A dictionary of dictionaries containing the descriptor functions and module functions
|
||||
"""
|
||||
child_modules = {
|
||||
'openended': open_ended_module.OpenEndedModule,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentModule,
|
||||
}
|
||||
child_descriptors = {
|
||||
'openended': open_ended_module.OpenEndedDescriptor,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentDescriptor,
|
||||
}
|
||||
children = {
|
||||
'modules': child_modules,
|
||||
'descriptors': child_descriptors,
|
||||
}
|
||||
return children
|
||||
|
||||
def setup_next_task(self, reset=False):
|
||||
"""
|
||||
Sets up the next task for the module. Creates an instance state if none exists, carries over the answer
|
||||
from the last instance state to the next if needed.
|
||||
Input: A boolean indicating whether or not the reset function is calling.
|
||||
Output: Boolean True (not useful right now)
|
||||
"""
|
||||
current_task_state = None
|
||||
if len(self.task_states) > self.current_task_number:
|
||||
current_task_state = self.task_states[self.current_task_number]
|
||||
|
||||
self.current_task_xml = self.task_xml[self.current_task_number]
|
||||
|
||||
if self.current_task_number > 0:
|
||||
self.allow_reset = self.check_allow_reset()
|
||||
if self.allow_reset:
|
||||
self.current_task_number = self.current_task_number - 1
|
||||
|
||||
current_task_type = self.get_tag_name(self.current_task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
child_task_module = children['modules'][current_task_type]
|
||||
|
||||
self.current_task_descriptor = children['descriptors'][current_task_type](self.system)
|
||||
|
||||
#This is the xml object created from the xml definition of the current task
|
||||
etree_xml = etree.fromstring(self.current_task_xml)
|
||||
|
||||
#This sends the etree_xml object through the descriptor module of the current task, and
|
||||
#returns the xml parsed by the descriptor
|
||||
self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
if current_task_state is None and self.current_task_number == 0:
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
elif current_task_state is None and self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
current_task_state=json.dumps({
|
||||
'state' : self.ASSESSING,
|
||||
'version' : self.STATE_VERSION,
|
||||
'max_score' : self._max_score,
|
||||
'attempts' : 0,
|
||||
'created' : True,
|
||||
'history' : [{'answer' : str(last_response)}],
|
||||
})
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
else:
|
||||
if self.current_task_number > 0 and not reset:
|
||||
current_task_state = self.overwrite_state(current_task_state)
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
|
||||
log.debug(current_task_state)
|
||||
return True
|
||||
|
||||
def check_allow_reset(self):
|
||||
"""
|
||||
Checks to see if the student has passed the criteria to move to the next module. If not, sets
|
||||
allow_reset to true and halts the student progress through the tasks.
|
||||
Input: None
|
||||
Output: the allow_reset attribute of the current module.
|
||||
"""
|
||||
if not self.allow_reset:
|
||||
if self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
current_response_data = self.get_current_attributes(self.current_task_number)
|
||||
|
||||
if(current_response_data['min_score_to_attempt'] > last_response_data['score']
|
||||
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
|
||||
self.state = self.DONE
|
||||
self.allow_reset = True
|
||||
|
||||
return self.allow_reset
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
Generates a context dictionary that is used to render html.
|
||||
Input: None
|
||||
Output: A dictionary that can be rendered into the combined open ended template.
|
||||
"""
|
||||
task_html = self.get_html_base()
|
||||
#set context variables and render template
|
||||
|
||||
context = {
|
||||
'items': [{'content': task_html}],
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'allow_reset': self.allow_reset,
|
||||
'state': self.state,
|
||||
'task_count': len(self.task_xml),
|
||||
'task_number': self.current_task_number + 1,
|
||||
'status': self.get_status(),
|
||||
'display_name': self.display_name
|
||||
}
|
||||
|
||||
return context
|
||||
self.child_descriptor = descriptors[version_index](self.system)
|
||||
self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['xml_string']), self.system)
|
||||
self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
|
||||
instance_state = json.dumps(instance_state), metadata = self.metadata, static_data= static_data)
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Gets HTML for rendering.
|
||||
Input: None
|
||||
Output: rendered html
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_nonsystem(self):
|
||||
"""
|
||||
Gets HTML for rendering via AJAX. Does not use system, because system contains some additional
|
||||
html, which is not appropriate for returning via ajax calls.
|
||||
Input: None
|
||||
Output: HTML rendered directly via Mako
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_base(self):
|
||||
"""
|
||||
Gets the HTML associated with the current child task
|
||||
Input: None
|
||||
Output: Child task HTML
|
||||
"""
|
||||
self.update_task_states()
|
||||
html = self.current_task.get_html(self.system)
|
||||
return_html = rewrite_links(html, self.rewrite_content_links)
|
||||
return return_html
|
||||
|
||||
def get_current_attributes(self, task_number):
|
||||
"""
|
||||
Gets the min and max score to attempt attributes of the specified task.
|
||||
Input: The number of the task.
|
||||
Output: The minimum and maximum scores needed to move on to the specified task.
|
||||
"""
|
||||
task_xml = self.task_xml[task_number]
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
return {'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt}
|
||||
|
||||
def get_last_response(self, task_number):
|
||||
"""
|
||||
Returns data associated with the specified task number, such as the last response, score, etc.
|
||||
Input: The number of the task.
|
||||
Output: A dictionary that contains information about the specified task.
|
||||
"""
|
||||
last_response = ""
|
||||
task_state = self.task_states[task_number]
|
||||
task_xml = self.task_xml[task_number]
|
||||
task_type = self.get_tag_name(task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
|
||||
task_descriptor = children['descriptors'][task_type](self.system)
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
|
||||
task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor,
|
||||
self.static_data, instance_state=task_state)
|
||||
last_response = task.latest_answer()
|
||||
last_score = task.latest_score()
|
||||
last_post_assessment = task.latest_post_assessment(self.system)
|
||||
last_post_feedback = ""
|
||||
if task_type == "openended":
|
||||
last_post_assessment = task.latest_post_assessment(self.system, short_feedback=False, join_feedback=False)
|
||||
if isinstance(last_post_assessment, list):
|
||||
eval_list = []
|
||||
for i in xrange(0, len(last_post_assessment)):
|
||||
eval_list.append(task.format_feedback_with_evaluation(self.system, last_post_assessment[i]))
|
||||
last_post_evaluation = "".join(eval_list)
|
||||
else:
|
||||
last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment)
|
||||
last_post_assessment = last_post_evaluation
|
||||
last_correctness = task.is_last_response_correct()
|
||||
max_score = task.max_score()
|
||||
state = task.state
|
||||
last_response_dict = {
|
||||
'response': last_response,
|
||||
'score': last_score,
|
||||
'post_assessment': last_post_assessment,
|
||||
'type': task_type,
|
||||
'max_score': max_score,
|
||||
'state': state,
|
||||
'human_state': task.HUMAN_NAMES[state],
|
||||
'correct': last_correctness,
|
||||
'min_score_to_attempt': min_score_to_attempt,
|
||||
'max_score_to_attempt': max_score_to_attempt,
|
||||
}
|
||||
|
||||
return last_response_dict
|
||||
|
||||
def update_task_states(self):
|
||||
"""
|
||||
Updates the task state of the combined open ended module with the task state of the current child module.
|
||||
Input: None
|
||||
Output: boolean indicating whether or not the task state changed.
|
||||
"""
|
||||
changed = False
|
||||
if not self.allow_reset:
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
current_task_state = json.loads(self.task_states[self.current_task_number])
|
||||
if current_task_state['state'] == self.DONE:
|
||||
self.current_task_number += 1
|
||||
if self.current_task_number >= (len(self.task_xml)):
|
||||
self.state = self.DONE
|
||||
self.current_task_number = len(self.task_xml) - 1
|
||||
else:
|
||||
self.state = self.INITIAL
|
||||
changed = True
|
||||
self.setup_next_task()
|
||||
return changed
|
||||
|
||||
def update_task_states_ajax(self, return_html):
|
||||
"""
|
||||
Runs the update task states function for ajax calls. Currently the same as update_task_states
|
||||
Input: The html returned by the handle_ajax function of the child
|
||||
Output: New html that should be rendered
|
||||
"""
|
||||
changed = self.update_task_states()
|
||||
if changed:
|
||||
#return_html=self.get_html()
|
||||
pass
|
||||
return return_html
|
||||
|
||||
def get_results(self, get):
|
||||
"""
|
||||
Gets the results of a given grader via ajax.
|
||||
Input: AJAX get dictionary
|
||||
Output: Dictionary to be rendered via ajax that contains the result html.
|
||||
"""
|
||||
task_number = int(get['task_number'])
|
||||
self.update_task_states()
|
||||
response_dict = self.get_last_response(task_number)
|
||||
context = {'results': response_dict['post_assessment'], 'task_number': task_number + 1}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
return {'html': html, 'success': True}
|
||||
return self.child_module.get_html()
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
"get" is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress': 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
|
||||
handlers = {
|
||||
'next_problem': self.next_problem,
|
||||
'reset': self.reset,
|
||||
'get_results': self.get_results
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return_html = self.current_task.handle_ajax(dispatch, get, self.system)
|
||||
return self.update_task_states_ajax(return_html)
|
||||
|
||||
d = handlers[dispatch](get)
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def next_problem(self, get):
|
||||
"""
|
||||
Called via ajax to advance to the next problem.
|
||||
Input: AJAX get request.
|
||||
Output: Dictionary to be rendered
|
||||
"""
|
||||
self.update_task_states()
|
||||
return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
|
||||
|
||||
def reset(self, get):
|
||||
"""
|
||||
If resetting is allowed, reset the state of the combined open ended module.
|
||||
Input: AJAX get dictionary
|
||||
Output: AJAX dictionary to tbe rendered
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
if not self.allow_reset:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
if self.attempts > self.max_attempts:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
self.allow_reset = False
|
||||
for i in xrange(0, len(self.task_xml)):
|
||||
self.current_task_number = i
|
||||
self.setup_next_task(reset=True)
|
||||
self.current_task.reset(self.system)
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
self.current_task_number = 0
|
||||
self.allow_reset = False
|
||||
self.setup_next_task()
|
||||
return {'success': True, 'html': self.get_html_nonsystem()}
|
||||
return self.child_module.handle_ajax(dispatch, get)
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Returns the current instance state. The module can be recreated from the instance state.
|
||||
Input: None
|
||||
Output: A dictionary containing the instance state.
|
||||
"""
|
||||
return self.child_module.get_instance_state()
|
||||
|
||||
state = {
|
||||
'version': self.STATE_VERSION,
|
||||
'current_task_number': self.current_task_number,
|
||||
'state': self.state,
|
||||
'task_states': self.task_states,
|
||||
'attempts': self.attempts,
|
||||
'ready_to_reset': self.allow_reset,
|
||||
}
|
||||
def get_score(self):
|
||||
return self.child_module.get_score()
|
||||
|
||||
return json.dumps(state)
|
||||
def max_score(self):
|
||||
return self.child_module.max_score()
|
||||
|
||||
def get_status(self):
|
||||
"""
|
||||
Gets the status panel to be displayed at the top right.
|
||||
Input: None
|
||||
Output: The status html to be rendered
|
||||
"""
|
||||
status = []
|
||||
for i in xrange(0, self.current_task_number + 1):
|
||||
task_data = self.get_last_response(i)
|
||||
task_data.update({'task_number': i + 1})
|
||||
status.append(task_data)
|
||||
context = {'status_list': status}
|
||||
status_html = self.system.render_template("combined_open_ended_status.html", context)
|
||||
def get_progress(self):
|
||||
return self.child_module.get_progress()
|
||||
|
||||
return status_html
|
||||
@property
|
||||
def due_date(self):
|
||||
return self.child_module.due_date
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.child_module.display_name
|
||||
|
||||
|
||||
class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
@@ -575,20 +201,8 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
expected_children = ['task', 'rubric', 'prompt']
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child))
|
||||
|
||||
def parse_task(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}
|
||||
return {'xml_string' : etree.tostring(xml_object), 'metadata' : xml_object.attrib}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
@@ -603,4 +217,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
return elt
|
||||
725
common/lib/xmodule/xmodule/combined_open_ended_modulev1.py
Normal file
725
common/lib/xmodule/xmodule/combined_open_ended_modulev1.py
Normal file
@@ -0,0 +1,725 @@
|
||||
import copy
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from path import path
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .capa_module import only_one, ComplexEncoder
|
||||
from .editing_module import EditingDescriptor
|
||||
from .html_checker import check_html
|
||||
from progress import Progress
|
||||
from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
import self_assessment_module
|
||||
import open_ended_module
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from .stringify import stringify_children
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import datetime
|
||||
from timeparse import parse_timedelta
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
# Set the default number of max attempts. Should be 1 for production
|
||||
# Set higher for debugging/testing
|
||||
# attempts specified in xml definition overrides this.
|
||||
MAX_ATTEMPTS = 10000
|
||||
|
||||
# Set maximum available number of points.
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
#The highest score allowed for the overall xmodule and for each rubric point
|
||||
MAX_SCORE_ALLOWED = 3
|
||||
|
||||
#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress
|
||||
#Metadata overrides this.
|
||||
IS_SCORED = False
|
||||
|
||||
#If true, then default behavior is to require a file upload or pasted link from a student for this problem.
|
||||
#Metadata overrides this.
|
||||
ACCEPT_FILE_UPLOAD = False
|
||||
|
||||
#Contains all reasonable bool and case combinations of True
|
||||
TRUE_DICT = ["True", True, "TRUE", "true"]
|
||||
|
||||
HUMAN_TASK_TYPE = {
|
||||
'selfassessment' : "Self Assessment",
|
||||
'openended' : "External Grader",
|
||||
}
|
||||
|
||||
class CombinedOpenEndedV1Module():
|
||||
"""
|
||||
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
|
||||
It transitions between problems, and support arbitrary ordering.
|
||||
Each combined open ended module contains one or multiple "child" modules.
|
||||
Child modules track their own state, and can transition between states. They also implement get_html and
|
||||
handle_ajax.
|
||||
The combined open ended module transitions between child modules as appropriate, tracks its own state, and passess
|
||||
ajax requests from the browser to the child module or handles them itself (in the cases of reset and next problem)
|
||||
ajax actions implemented by all children are:
|
||||
'save_answer' -- Saves the student answer
|
||||
'save_assessment' -- Saves the student assessment (or external grader assessment)
|
||||
'save_post_assessment' -- saves a post assessment (hint, feedback on feedback, etc)
|
||||
ajax actions implemented by combined open ended module are:
|
||||
'reset' -- resets the whole combined open ended module and returns to the first child module
|
||||
'next_problem' -- moves to the next child module
|
||||
'get_results' -- gets results from a given child module
|
||||
|
||||
Types of children. Task is synonymous with child module, so each combined open ended module
|
||||
incorporates multiple children (tasks):
|
||||
openendedmodule
|
||||
selfassessmentmodule
|
||||
"""
|
||||
STATE_VERSION = 1
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
INTERMEDIATE_DONE = 'intermediate_done'
|
||||
DONE = 'done'
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js_module_name = "CombinedOpenEnded"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs):
|
||||
|
||||
"""
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block:
|
||||
|
||||
Sample file:
|
||||
<combinedopenended attempts="10000" max_score="1">
|
||||
<rubric>
|
||||
Blah blah rubric.
|
||||
</rubric>
|
||||
<prompt>
|
||||
Some prompt.
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment>
|
||||
<hintprompt>
|
||||
What hint about this problem would you give to someone?
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Save Succcesful. Thanks for participating!
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
</task>
|
||||
<task>
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="1">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf",
|
||||
"problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
|
||||
"""
|
||||
|
||||
self.metadata = metadata
|
||||
self.display_name = metadata.get('display_name', "Open Ended")
|
||||
self.rewrite_content_links = static_data.get('rewrite_content_links',"")
|
||||
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
#We need to set the location here so the child modules can use it
|
||||
system.set('location', location)
|
||||
self.system = system
|
||||
|
||||
#Tells the system which xml definition to load
|
||||
self.current_task_number = instance_state.get('current_task_number', 0)
|
||||
#This loads the states of the individual children
|
||||
self.task_states = instance_state.get('task_states', [])
|
||||
#Overall state of the combined open ended module
|
||||
self.state = instance_state.get('state', self.INITIAL)
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
|
||||
#Allow reset is true if student has failed the criteria to move to the next child task
|
||||
self.allow_reset = instance_state.get('ready_to_reset', False)
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT
|
||||
self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
|
||||
|
||||
display_due_date_string = self.metadata.get('due', None)
|
||||
if display_due_date_string is not None:
|
||||
try:
|
||||
self.display_due_date = dateutil.parser.parse(display_due_date_string)
|
||||
except ValueError:
|
||||
log.error("Could not parse due date {0} for location {1}".format(display_due_date_string, location))
|
||||
raise
|
||||
else:
|
||||
self.display_due_date = None
|
||||
|
||||
grace_period_string = self.metadata.get('graceperiod', None)
|
||||
if grace_period_string is not None and self.display_due_date:
|
||||
try:
|
||||
self.grace_period = parse_timedelta(grace_period_string)
|
||||
self.close_date = self.display_due_date + self.grace_period
|
||||
except:
|
||||
log.error("Error parsing the grace period {0} for location {1}".format(grace_period_string, location))
|
||||
raise
|
||||
else:
|
||||
self.grace_period = None
|
||||
self.close_date = self.display_due_date
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_string = stringify_children(definition['rubric'])
|
||||
rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED, self._max_score)
|
||||
|
||||
#Static data is passed to the child modules to render
|
||||
self.static_data = {
|
||||
'max_score': self._max_score,
|
||||
'max_attempts': self.max_attempts,
|
||||
'prompt': definition['prompt'],
|
||||
'rubric': definition['rubric'],
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
'close_date' : self.close_date,
|
||||
}
|
||||
|
||||
self.task_xml = definition['task_xml']
|
||||
self.location = location
|
||||
self.setup_next_task()
|
||||
|
||||
def get_tag_name(self, xml):
|
||||
"""
|
||||
Gets the tag name of a given xml block.
|
||||
Input: XML string
|
||||
Output: The name of the root tag
|
||||
"""
|
||||
tag = etree.fromstring(xml).tag
|
||||
return tag
|
||||
|
||||
def overwrite_state(self, current_task_state):
|
||||
"""
|
||||
Overwrites an instance state and sets the latest response to the current response. This is used
|
||||
to ensure that the student response is carried over from the first child to the rest.
|
||||
Input: Task state json string
|
||||
Output: Task state json string
|
||||
"""
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
|
||||
loaded_task_state = json.loads(current_task_state)
|
||||
if loaded_task_state['state'] == self.INITIAL:
|
||||
loaded_task_state['state'] = self.ASSESSING
|
||||
loaded_task_state['created'] = True
|
||||
loaded_task_state['history'].append({'answer': last_response})
|
||||
current_task_state = json.dumps(loaded_task_state)
|
||||
return current_task_state
|
||||
|
||||
def child_modules(self):
|
||||
"""
|
||||
Returns the constructors associated with the child modules in a dictionary. This makes writing functions
|
||||
simpler (saves code duplication)
|
||||
Input: None
|
||||
Output: A dictionary of dictionaries containing the descriptor functions and module functions
|
||||
"""
|
||||
child_modules = {
|
||||
'openended': open_ended_module.OpenEndedModule,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentModule,
|
||||
}
|
||||
child_descriptors = {
|
||||
'openended': open_ended_module.OpenEndedDescriptor,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentDescriptor,
|
||||
}
|
||||
children = {
|
||||
'modules': child_modules,
|
||||
'descriptors': child_descriptors,
|
||||
}
|
||||
return children
|
||||
|
||||
def setup_next_task(self, reset=False):
|
||||
"""
|
||||
Sets up the next task for the module. Creates an instance state if none exists, carries over the answer
|
||||
from the last instance state to the next if needed.
|
||||
Input: A boolean indicating whether or not the reset function is calling.
|
||||
Output: Boolean True (not useful right now)
|
||||
"""
|
||||
current_task_state = None
|
||||
if len(self.task_states) > self.current_task_number:
|
||||
current_task_state = self.task_states[self.current_task_number]
|
||||
|
||||
self.current_task_xml = self.task_xml[self.current_task_number]
|
||||
|
||||
if self.current_task_number > 0:
|
||||
self.allow_reset = self.check_allow_reset()
|
||||
if self.allow_reset:
|
||||
self.current_task_number = self.current_task_number - 1
|
||||
|
||||
current_task_type = self.get_tag_name(self.current_task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
child_task_module = children['modules'][current_task_type]
|
||||
|
||||
self.current_task_descriptor = children['descriptors'][current_task_type](self.system)
|
||||
|
||||
#This is the xml object created from the xml definition of the current task
|
||||
etree_xml = etree.fromstring(self.current_task_xml)
|
||||
|
||||
#This sends the etree_xml object through the descriptor module of the current task, and
|
||||
#returns the xml parsed by the descriptor
|
||||
self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
if current_task_state is None and self.current_task_number == 0:
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
elif current_task_state is None and self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
current_task_state = json.dumps({
|
||||
'state': self.ASSESSING,
|
||||
'version': self.STATE_VERSION,
|
||||
'max_score': self._max_score,
|
||||
'attempts': 0,
|
||||
'created': True,
|
||||
'history': [{'answer': last_response}],
|
||||
})
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
else:
|
||||
if self.current_task_number > 0 and not reset:
|
||||
current_task_state = self.overwrite_state(current_task_state)
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
|
||||
return True
|
||||
|
||||
def check_allow_reset(self):
|
||||
"""
|
||||
Checks to see if the student has passed the criteria to move to the next module. If not, sets
|
||||
allow_reset to true and halts the student progress through the tasks.
|
||||
Input: None
|
||||
Output: the allow_reset attribute of the current module.
|
||||
"""
|
||||
if not self.allow_reset:
|
||||
if self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
current_response_data = self.get_current_attributes(self.current_task_number)
|
||||
|
||||
if(current_response_data['min_score_to_attempt'] > last_response_data['score']
|
||||
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
|
||||
self.state = self.DONE
|
||||
self.allow_reset = True
|
||||
|
||||
return self.allow_reset
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
Generates a context dictionary that is used to render html.
|
||||
Input: None
|
||||
Output: A dictionary that can be rendered into the combined open ended template.
|
||||
"""
|
||||
task_html = self.get_html_base()
|
||||
#set context variables and render template
|
||||
|
||||
context = {
|
||||
'items': [{'content': task_html}],
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'allow_reset': self.allow_reset,
|
||||
'state': self.state,
|
||||
'task_count': len(self.task_xml),
|
||||
'task_number': self.current_task_number + 1,
|
||||
'status': self.get_status(),
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Gets HTML for rendering.
|
||||
Input: None
|
||||
Output: rendered html
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_nonsystem(self):
|
||||
"""
|
||||
Gets HTML for rendering via AJAX. Does not use system, because system contains some additional
|
||||
html, which is not appropriate for returning via ajax calls.
|
||||
Input: None
|
||||
Output: HTML rendered directly via Mako
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_base(self):
|
||||
"""
|
||||
Gets the HTML associated with the current child task
|
||||
Input: None
|
||||
Output: Child task HTML
|
||||
"""
|
||||
self.update_task_states()
|
||||
html = self.current_task.get_html(self.system)
|
||||
return_html = rewrite_links(html, self.rewrite_content_links)
|
||||
return return_html
|
||||
|
||||
def get_current_attributes(self, task_number):
|
||||
"""
|
||||
Gets the min and max score to attempt attributes of the specified task.
|
||||
Input: The number of the task.
|
||||
Output: The minimum and maximum scores needed to move on to the specified task.
|
||||
"""
|
||||
task_xml = self.task_xml[task_number]
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
return {'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt}
|
||||
|
||||
def get_last_response(self, task_number):
|
||||
"""
|
||||
Returns data associated with the specified task number, such as the last response, score, etc.
|
||||
Input: The number of the task.
|
||||
Output: A dictionary that contains information about the specified task.
|
||||
"""
|
||||
last_response = ""
|
||||
task_state = self.task_states[task_number]
|
||||
task_xml = self.task_xml[task_number]
|
||||
task_type = self.get_tag_name(task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
|
||||
task_descriptor = children['descriptors'][task_type](self.system)
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
|
||||
task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor,
|
||||
self.static_data, instance_state=task_state)
|
||||
last_response = task.latest_answer()
|
||||
last_score = task.latest_score()
|
||||
last_post_assessment = task.latest_post_assessment(self.system)
|
||||
last_post_feedback = ""
|
||||
if task_type == "openended":
|
||||
last_post_assessment = task.latest_post_assessment(self.system, short_feedback=False, join_feedback=False)
|
||||
if isinstance(last_post_assessment, list):
|
||||
eval_list = []
|
||||
for i in xrange(0, len(last_post_assessment)):
|
||||
eval_list.append(task.format_feedback_with_evaluation(self.system, last_post_assessment[i]))
|
||||
last_post_evaluation = "".join(eval_list)
|
||||
else:
|
||||
last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment)
|
||||
last_post_assessment = last_post_evaluation
|
||||
last_correctness = task.is_last_response_correct()
|
||||
max_score = task.max_score()
|
||||
state = task.state
|
||||
if task_type in HUMAN_TASK_TYPE:
|
||||
human_task_name = HUMAN_TASK_TYPE[task_type]
|
||||
else:
|
||||
human_task_name = task_type
|
||||
|
||||
if state in task.HUMAN_NAMES:
|
||||
human_state = task.HUMAN_NAMES[state]
|
||||
else:
|
||||
human_state = state
|
||||
last_response_dict = {
|
||||
'response': last_response,
|
||||
'score': last_score,
|
||||
'post_assessment': last_post_assessment,
|
||||
'type': task_type,
|
||||
'max_score': max_score,
|
||||
'state': state,
|
||||
'human_state': human_state,
|
||||
'human_task': human_task_name,
|
||||
'correct': last_correctness,
|
||||
'min_score_to_attempt': min_score_to_attempt,
|
||||
'max_score_to_attempt': max_score_to_attempt,
|
||||
}
|
||||
|
||||
return last_response_dict
|
||||
|
||||
def update_task_states(self):
|
||||
"""
|
||||
Updates the task state of the combined open ended module with the task state of the current child module.
|
||||
Input: None
|
||||
Output: boolean indicating whether or not the task state changed.
|
||||
"""
|
||||
changed = False
|
||||
if not self.allow_reset:
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
current_task_state = json.loads(self.task_states[self.current_task_number])
|
||||
if current_task_state['state'] == self.DONE:
|
||||
self.current_task_number += 1
|
||||
if self.current_task_number >= (len(self.task_xml)):
|
||||
self.state = self.DONE
|
||||
self.current_task_number = len(self.task_xml) - 1
|
||||
else:
|
||||
self.state = self.INITIAL
|
||||
changed = True
|
||||
self.setup_next_task()
|
||||
return changed
|
||||
|
||||
def update_task_states_ajax(self, return_html):
|
||||
"""
|
||||
Runs the update task states function for ajax calls. Currently the same as update_task_states
|
||||
Input: The html returned by the handle_ajax function of the child
|
||||
Output: New html that should be rendered
|
||||
"""
|
||||
changed = self.update_task_states()
|
||||
if changed:
|
||||
#return_html=self.get_html()
|
||||
pass
|
||||
return return_html
|
||||
|
||||
def get_results(self, get):
|
||||
"""
|
||||
Gets the results of a given grader via ajax.
|
||||
Input: AJAX get dictionary
|
||||
Output: Dictionary to be rendered via ajax that contains the result html.
|
||||
"""
|
||||
task_number = int(get['task_number'])
|
||||
self.update_task_states()
|
||||
response_dict = self.get_last_response(task_number)
|
||||
context = {'results': response_dict['post_assessment'], 'task_number': task_number + 1}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
"get" is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress': 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
|
||||
handlers = {
|
||||
'next_problem': self.next_problem,
|
||||
'reset': self.reset,
|
||||
'get_results': self.get_results
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return_html = self.current_task.handle_ajax(dispatch, get, self.system)
|
||||
return self.update_task_states_ajax(return_html)
|
||||
|
||||
d = handlers[dispatch](get)
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def next_problem(self, get):
|
||||
"""
|
||||
Called via ajax to advance to the next problem.
|
||||
Input: AJAX get request.
|
||||
Output: Dictionary to be rendered
|
||||
"""
|
||||
self.update_task_states()
|
||||
return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
|
||||
|
||||
def reset(self, get):
|
||||
"""
|
||||
If resetting is allowed, reset the state of the combined open ended module.
|
||||
Input: AJAX get dictionary
|
||||
Output: AJAX dictionary to tbe rendered
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
if not self.allow_reset:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
if self.attempts > self.max_attempts:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
self.allow_reset = False
|
||||
for i in xrange(0, len(self.task_xml)):
|
||||
self.current_task_number = i
|
||||
self.setup_next_task(reset=True)
|
||||
self.current_task.reset(self.system)
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
self.current_task_number = 0
|
||||
self.allow_reset = False
|
||||
self.setup_next_task()
|
||||
return {'success': True, 'html': self.get_html_nonsystem()}
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Returns the current instance state. The module can be recreated from the instance state.
|
||||
Input: None
|
||||
Output: A dictionary containing the instance state.
|
||||
"""
|
||||
|
||||
state = {
|
||||
'version': self.STATE_VERSION,
|
||||
'current_task_number': self.current_task_number,
|
||||
'state': self.state,
|
||||
'task_states': self.task_states,
|
||||
'attempts': self.attempts,
|
||||
'ready_to_reset': self.allow_reset,
|
||||
}
|
||||
|
||||
return json.dumps(state)
|
||||
|
||||
def get_status(self):
|
||||
"""
|
||||
Gets the status panel to be displayed at the top right.
|
||||
Input: None
|
||||
Output: The status html to be rendered
|
||||
"""
|
||||
status = []
|
||||
for i in xrange(0, self.current_task_number + 1):
|
||||
task_data = self.get_last_response(i)
|
||||
task_data.update({'task_number': i + 1})
|
||||
status.append(task_data)
|
||||
context = {'status_list': status}
|
||||
status_html = self.system.render_template("combined_open_ended_status.html", context)
|
||||
|
||||
return status_html
|
||||
|
||||
def check_if_done_and_scored(self):
|
||||
"""
|
||||
Checks if the object is currently in a finished state (either student didn't meet criteria to move
|
||||
to next step, in which case they are in the allow_reset state, or they are done with the question
|
||||
entirely, in which case they will be in the self.DONE state), and if it is scored or not.
|
||||
@return: Boolean corresponding to the above.
|
||||
"""
|
||||
return (self.state == self.DONE or self.allow_reset) and self.is_scored
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Score the student received on the problem, or None if there is no
|
||||
score.
|
||||
|
||||
Returns:
|
||||
dictionary
|
||||
{'score': integer, from 0 to get_max_score(),
|
||||
'total': get_max_score()}
|
||||
"""
|
||||
max_score = None
|
||||
score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
score = last_response['score']
|
||||
|
||||
score_dict = {
|
||||
'score': score,
|
||||
'total': max_score,
|
||||
}
|
||||
|
||||
return score_dict
|
||||
|
||||
def max_score(self):
|
||||
''' Maximum score. Two notes:
|
||||
|
||||
* This is generic; in abstract, a problem could be 3/5 points on one
|
||||
randomization, and 5/7 on another
|
||||
'''
|
||||
max_score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
return max_score
|
||||
|
||||
def get_progress(self):
|
||||
''' Return a progress.Progress object that represents how far the
|
||||
student has gone in this module. Must be implemented to get correct
|
||||
progress tracking behavior in nesting modules like sequence and
|
||||
vertical.
|
||||
|
||||
If this module has no notion of progress, return None.
|
||||
'''
|
||||
progress_object = Progress(self.current_task_number, len(self.task_xml))
|
||||
|
||||
return progress_object
|
||||
|
||||
|
||||
class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding combined open ended questions
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = CombinedOpenEndedV1Module
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the individual tasks, the rubric, and the prompt, and parse
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
expected_children = ['task', 'rubric', 'prompt']
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child))
|
||||
|
||||
def parse_task(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('combinedopenended')
|
||||
|
||||
def add_child(k):
|
||||
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
|
||||
child_node = etree.fromstring(child_str)
|
||||
elt.append(child_node)
|
||||
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
from lxml import etree
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RubricParsingError(Exception):
|
||||
pass
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
|
||||
class CombinedOpenEndedRubric(object):
|
||||
|
||||
@@ -23,15 +26,45 @@ class CombinedOpenEndedRubric(object):
|
||||
Output:
|
||||
html: the html that corresponds to the xml given
|
||||
'''
|
||||
success = False
|
||||
try:
|
||||
rubric_categories = self.extract_categories(rubric_xml)
|
||||
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
|
||||
max_score = max(max_scores)
|
||||
html = self.system.render_template('open_ended_rubric.html',
|
||||
{'categories' : rubric_categories,
|
||||
{'categories': rubric_categories,
|
||||
'has_score': self.has_score,
|
||||
'view_only': self.view_only})
|
||||
'view_only': self.view_only,
|
||||
'max_score': max_score})
|
||||
success = True
|
||||
except:
|
||||
raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml))
|
||||
return html
|
||||
error_message = "[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml)
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
return success, html
|
||||
|
||||
def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed, max_score):
|
||||
success, rubric_feedback = self.render_rubric(rubric_string)
|
||||
if not success:
|
||||
error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url())
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
rubric_categories = self.extract_categories(rubric_string)
|
||||
total = 0
|
||||
for category in rubric_categories:
|
||||
total = total + len(category['options']) - 1
|
||||
if len(category['options']) > (max_score_allowed + 1):
|
||||
error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}".format(
|
||||
len(category['options']), max_score_allowed)
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
if total != max_score:
|
||||
error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}".format(
|
||||
max_score, location, total)
|
||||
log.error(error_msg)
|
||||
raise RubricParsingError(error_msg)
|
||||
|
||||
def extract_categories(self, element):
|
||||
'''
|
||||
@@ -40,8 +73,8 @@ class CombinedOpenEndedRubric(object):
|
||||
options: [{text: "Option 1 Name", points: 0}, {text:"Option 2 Name", points: 5}]
|
||||
},
|
||||
{ category: "Category 2 Name",
|
||||
options: [{text: "Option 1 Name", points: 0},
|
||||
{text: "Option 2 Name", points: 1},
|
||||
options: [{text: "Option 1 Name", points: 0},
|
||||
{text: "Option 2 Name", points: 1},
|
||||
{text: "Option 3 Name", points: 2]}]
|
||||
|
||||
'''
|
||||
@@ -57,7 +90,7 @@ class CombinedOpenEndedRubric(object):
|
||||
|
||||
|
||||
def extract_category(self, category):
|
||||
'''
|
||||
'''
|
||||
construct an individual category
|
||||
{category: "Category 1 Name",
|
||||
options: [{text: "Option 1 text", points: 1},
|
||||
@@ -90,7 +123,7 @@ class CombinedOpenEndedRubric(object):
|
||||
autonumbering = True
|
||||
# parse options
|
||||
for option in optionsxml:
|
||||
if option.tag != 'option':
|
||||
if option.tag != 'option':
|
||||
raise RubricParsingError("[extract_category]: expected option tag, got {0} instead".format(option.tag))
|
||||
else:
|
||||
pointstr = option.get("points")
|
||||
@@ -107,7 +140,7 @@ class CombinedOpenEndedRubric(object):
|
||||
cur_points = cur_points + 1
|
||||
else:
|
||||
raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly defined.")
|
||||
|
||||
|
||||
selected = score == points
|
||||
optiontext = option.text
|
||||
options.append({'text': option.text, 'points': points, 'selected': selected})
|
||||
|
||||
153
common/lib/xmodule/xmodule/conditional_module.py
Normal file
153
common/lib/xmodule/xmodule/conditional_module.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
|
||||
class ConditionalModule(XModule):
|
||||
'''
|
||||
Blocks child module from showing unless certain conditions are met.
|
||||
|
||||
Example:
|
||||
|
||||
<conditional condition="require_completed" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional condition="require_attempted" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
'''
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
|
||||
js_module_name = "Conditional"
|
||||
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
|
||||
|
||||
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
|
||||
"""
|
||||
In addition to the normal XModule init, provide:
|
||||
|
||||
self.condition = string describing condition required
|
||||
|
||||
"""
|
||||
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
|
||||
self.contents = None
|
||||
self.condition = self.metadata.get('condition', '')
|
||||
self._get_required_modules()
|
||||
children = self.get_display_items()
|
||||
if children:
|
||||
self.icon_class = children[0].get_icon_class()
|
||||
#log.debug('conditional module required=%s' % self.required_modules_list)
|
||||
|
||||
def _get_required_modules(self):
|
||||
self.required_modules = []
|
||||
for descriptor in self.descriptor.get_required_module_descriptors():
|
||||
module = self.system.get_module(descriptor)
|
||||
self.required_modules.append(module)
|
||||
#log.debug('required_modules=%s' % (self.required_modules))
|
||||
|
||||
def is_condition_satisfied(self):
|
||||
self._get_required_modules()
|
||||
|
||||
if self.condition == 'require_completed':
|
||||
# all required modules must be completed, as determined by
|
||||
# the modules .is_completed() method
|
||||
for module in self.required_modules:
|
||||
#log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers)
|
||||
#log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state)
|
||||
if not hasattr(module, 'is_completed'):
|
||||
raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module)
|
||||
if not module.is_completed():
|
||||
log.debug('conditional module: %s not completed' % module)
|
||||
return False
|
||||
else:
|
||||
log.debug('conditional module: %s IS completed' % module)
|
||||
return True
|
||||
elif self.condition == 'require_attempted':
|
||||
# all required modules must be attempted, as determined by
|
||||
# the modules .is_attempted() method
|
||||
for module in self.required_modules:
|
||||
if not hasattr(module, 'is_attempted'):
|
||||
raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module)
|
||||
if not module.is_attempted():
|
||||
log.debug('conditional module: %s not attempted' % module)
|
||||
return False
|
||||
else:
|
||||
log.debug('conditional module: %s IS attempted' % module)
|
||||
return True
|
||||
else:
|
||||
raise Exception('Error in conditional module: unknown condition "%s"' % self.condition)
|
||||
|
||||
return True
|
||||
|
||||
def get_html(self):
|
||||
self.is_condition_satisfied()
|
||||
return self.system.render_template('conditional_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'id': self.id,
|
||||
'ajax_url': self.system.ajax_url,
|
||||
})
|
||||
|
||||
def handle_ajax(self, dispatch, post):
|
||||
'''
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
'''
|
||||
#log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch)
|
||||
|
||||
if not self.is_condition_satisfied():
|
||||
context = {'module': self}
|
||||
html = self.system.render_template('conditional_module.html', context)
|
||||
return json.dumps({'html': html})
|
||||
|
||||
if self.contents is None:
|
||||
self.contents = [child.get_html() for child in self.get_display_items()]
|
||||
|
||||
# for now, just deal with one child
|
||||
html = self.contents[0]
|
||||
|
||||
return json.dumps({'html': html})
|
||||
|
||||
|
||||
class ConditionalDescriptor(SequenceDescriptor):
|
||||
module_class = ConditionalModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ConditionalDescriptor, self).__init__(*args, **kwargs)
|
||||
|
||||
required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')]
|
||||
self.required_module_locations = []
|
||||
for rm in required_module_list:
|
||||
try:
|
||||
(tag, name) = rm
|
||||
except Exception as err:
|
||||
msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required')
|
||||
log.warning(msg)
|
||||
self.system.error_tracker(msg)
|
||||
continue
|
||||
loc = self.location.dict()
|
||||
loc['category'] = tag
|
||||
loc['name'] = name
|
||||
self.required_module_locations.append(Location(loc))
|
||||
log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations)
|
||||
|
||||
def get_required_module_descriptors(self):
|
||||
"""Returns a list of XModuleDescritpor instances upon which this module depends, but are
|
||||
not children of this module"""
|
||||
return [self.system.load_item(loc) for loc in self.required_module_locations]
|
||||
@@ -11,15 +11,16 @@ from xmodule.modulestore import Location
|
||||
from .django import contentstore
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class StaticContent(object):
|
||||
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None):
|
||||
self.location = loc
|
||||
self.name = name #a display string which can be edited, and thus not part of the location which needs to be fixed
|
||||
self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed
|
||||
self.content_type = content_type
|
||||
self.data = data
|
||||
self.last_modified_at = last_modified_at
|
||||
self.thumbnail_location = Location(thumbnail_location) if thumbnail_location is not None else None
|
||||
# optional information about where this file was imported from. This is needed to support import/export
|
||||
# optional information about where this file was imported from. This is needed to support import/export
|
||||
# cycles
|
||||
self.import_path = import_path
|
||||
|
||||
@@ -29,7 +30,7 @@ class StaticContent(object):
|
||||
|
||||
@staticmethod
|
||||
def generate_thumbnail_name(original_name):
|
||||
return ('{0}'+XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0])
|
||||
return ('{0}' + XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0])
|
||||
|
||||
@staticmethod
|
||||
def compute_location(org, course, name, revision=None, is_thumbnail=False):
|
||||
@@ -41,7 +42,7 @@ class StaticContent(object):
|
||||
|
||||
def get_url_path(self):
|
||||
return StaticContent.get_url_path_from_location(self.location)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_url_path_from_location(location):
|
||||
if location is not None:
|
||||
@@ -56,15 +57,15 @@ class StaticContent(object):
|
||||
|
||||
@staticmethod
|
||||
def get_id_from_location(location):
|
||||
return { 'tag':location.tag, 'org' : location.org, 'course' : location.course,
|
||||
'category' : location.category, 'name' : location.name,
|
||||
'revision' : location.revision}
|
||||
return {'tag': location.tag, 'org': location.org, 'course': location.course,
|
||||
'category': location.category, 'name': location.name,
|
||||
'revision': location.revision}
|
||||
@staticmethod
|
||||
def get_location_from_path(path):
|
||||
# remove leading / character if it is there one
|
||||
if path.startswith('/'):
|
||||
path = path[1:]
|
||||
|
||||
|
||||
return Location(path.split('/'))
|
||||
|
||||
@staticmethod
|
||||
@@ -77,7 +78,7 @@ class StaticContent(object):
|
||||
return StaticContent.get_url_path_from_location(loc)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ContentStore(object):
|
||||
'''
|
||||
@@ -95,14 +96,14 @@ class ContentStore(object):
|
||||
|
||||
[
|
||||
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'ff1532598830e3feac91c2449eaa60d6'},
|
||||
|
||||
....
|
||||
@@ -117,7 +118,7 @@ class ContentStore(object):
|
||||
thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name)
|
||||
|
||||
thumbnail_file_location = StaticContent.compute_location(content.location.org, content.location.course,
|
||||
thumbnail_name, is_thumbnail = True)
|
||||
thumbnail_name, is_thumbnail=True)
|
||||
|
||||
# if we're uploading an image, then let's generate a thumbnail so that we can
|
||||
# serve it up when needed without having to rescale on the fly
|
||||
@@ -129,7 +130,7 @@ class ContentStore(object):
|
||||
# @todo: move the thumbnail size to a configuration setting?!?
|
||||
im = Image.open(StringIO.StringIO(content.data))
|
||||
|
||||
# I've seen some exceptions from the PIL library when trying to save palletted
|
||||
# I've seen some exceptions from the PIL library when trying to save palletted
|
||||
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
|
||||
im = im.convert('RGB')
|
||||
size = 128, 128
|
||||
@@ -139,7 +140,7 @@ class ContentStore(object):
|
||||
thumbnail_file.seek(0)
|
||||
|
||||
# store this thumbnail as any other piece of content
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
'image/jpeg', thumbnail_file)
|
||||
|
||||
contentstore().save(thumbnail_content)
|
||||
@@ -149,7 +150,3 @@ class ContentStore(object):
|
||||
logging.exception("Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e)))
|
||||
|
||||
return thumbnail_content, thumbnail_file_location
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
|
||||
_CONTENTSTORE = None
|
||||
|
||||
|
||||
def load_function(path):
|
||||
"""
|
||||
Load a function by name.
|
||||
|
||||
@@ -17,14 +17,14 @@ import os
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, **kwargs):
|
||||
logging.debug( 'Using MongoDB for static content serving at host={0} db={1}'.format(host,db))
|
||||
logging.debug('Using MongoDB for static content serving at host={0} db={1}'.format(host, db))
|
||||
_db = Connection(host=host, port=port, **kwargs)[db]
|
||||
|
||||
if user is not None and password is not None:
|
||||
_db.authenticate(user, password)
|
||||
|
||||
self.fs = gridfs.GridFS(_db)
|
||||
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
|
||||
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
|
||||
|
||||
|
||||
def save(self, content):
|
||||
@@ -33,24 +33,24 @@ class MongoContentStore(ContentStore):
|
||||
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
|
||||
self.delete(id)
|
||||
|
||||
with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type,
|
||||
with self.fs.new_file(_id=id, filename=content.get_url_path(), content_type=content.content_type,
|
||||
displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp:
|
||||
|
||||
fp.write(content.data)
|
||||
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def delete(self, id):
|
||||
if self.fs.exists({"_id" : id}):
|
||||
if self.fs.exists({"_id": id}):
|
||||
self.fs.delete(id)
|
||||
|
||||
def find(self, location):
|
||||
id = StaticContent.get_id_from_location(location)
|
||||
try:
|
||||
with self.fs.get(id) as fp:
|
||||
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
|
||||
fp.uploadDate, thumbnail_location = fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
|
||||
import_path = fp.import_path if hasattr(fp, 'import_path') else None)
|
||||
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
|
||||
fp.uploadDate, thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
|
||||
import_path=fp.import_path if hasattr(fp, 'import_path') else None)
|
||||
except NoFile:
|
||||
raise NotFoundError()
|
||||
|
||||
@@ -76,25 +76,25 @@ class MongoContentStore(ContentStore):
|
||||
self.export(asset_location, output_directory)
|
||||
|
||||
def get_all_content_thumbnails_for_course(self, location):
|
||||
return self._get_all_content_for_course(location, get_thumbnails = True)
|
||||
return self._get_all_content_for_course(location, get_thumbnails=True)
|
||||
|
||||
def get_all_content_for_course(self, location):
|
||||
return self._get_all_content_for_course(location, get_thumbnails = False)
|
||||
return self._get_all_content_for_course(location, get_thumbnails=False)
|
||||
|
||||
def _get_all_content_for_course(self, location, get_thumbnails = False):
|
||||
def _get_all_content_for_course(self, location, get_thumbnails=False):
|
||||
'''
|
||||
Returns a list of all static assets for a course. The return format is a list of dictionary elements. Example:
|
||||
|
||||
[
|
||||
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'ff1532598830e3feac91c2449eaa60d6'},
|
||||
|
||||
....
|
||||
@@ -102,10 +102,7 @@ class MongoContentStore(ContentStore):
|
||||
]
|
||||
'''
|
||||
course_filter = Location(XASSET_LOCATION_TAG, category="asset" if not get_thumbnails else "thumbnail",
|
||||
course=location.course,org=location.org)
|
||||
course=location.course, org=location.org)
|
||||
# 'borrow' the function 'location_to_query' from the Mongo modulestore implementation
|
||||
items = self.fs_files.find(location_to_query(course_filter))
|
||||
return list(items)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -147,37 +147,37 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
"""
|
||||
Return a dict which is a copy of the default grading policy
|
||||
"""
|
||||
default = {"GRADER" : [
|
||||
default = {"GRADER": [
|
||||
{
|
||||
"type" : "Homework",
|
||||
"min_count" : 12,
|
||||
"drop_count" : 2,
|
||||
"short_label" : "HW",
|
||||
"weight" : 0.15
|
||||
"type": "Homework",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"short_label": "HW",
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type" : "Lab",
|
||||
"min_count" : 12,
|
||||
"drop_count" : 2,
|
||||
"weight" : 0.15
|
||||
"type": "Lab",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type" : "Midterm Exam",
|
||||
"short_label" : "Midterm",
|
||||
"min_count" : 1,
|
||||
"drop_count" : 0,
|
||||
"weight" : 0.3
|
||||
"type": "Midterm Exam",
|
||||
"short_label": "Midterm",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.3
|
||||
},
|
||||
{
|
||||
"type" : "Final Exam",
|
||||
"short_label" : "Final",
|
||||
"min_count" : 1,
|
||||
"drop_count" : 0,
|
||||
"weight" : 0.4
|
||||
"type": "Final Exam",
|
||||
"short_label": "Final",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.4
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS" : {
|
||||
"Pass" : 0.5
|
||||
"GRADE_CUTOFFS": {
|
||||
"Pass": 0.5
|
||||
}}
|
||||
return copy.deepcopy(default)
|
||||
|
||||
@@ -230,8 +230,8 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
# bleh, have to parse the XML here to just pull out the url_name attribute
|
||||
# I don't think it's stored anywhere in the instance.
|
||||
course_file = StringIO(xml_data.encode('ascii','ignore'))
|
||||
xml_obj = etree.parse(course_file,parser=edx_xml_parser).getroot()
|
||||
course_file = StringIO(xml_data.encode('ascii', 'ignore'))
|
||||
xml_obj = etree.parse(course_file, parser=edx_xml_parser).getroot()
|
||||
|
||||
policy_dir = None
|
||||
url_name = xml_obj.get('url_name', xml_obj.get('slug'))
|
||||
@@ -329,7 +329,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
def raw_grader(self, value):
|
||||
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
|
||||
self._grading_policy['RAW_GRADER'] = value
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADER'] = value
|
||||
self.definition['data'].setdefault('grading_policy', {})['GRADER'] = value
|
||||
|
||||
@property
|
||||
def grade_cutoffs(self):
|
||||
@@ -338,7 +338,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
@grade_cutoffs.setter
|
||||
def grade_cutoffs(self, value):
|
||||
self._grading_policy['GRADE_CUTOFFS'] = value
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
|
||||
self.definition['data'].setdefault('grading_policy', {})['GRADE_CUTOFFS'] = value
|
||||
|
||||
|
||||
@property
|
||||
@@ -377,7 +377,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
Return list of topic ids defined in course policy.
|
||||
"""
|
||||
topics = self.metadata.get("discussion_topics", {})
|
||||
return [d["id"] for d in topics.values()]
|
||||
return [d["id"] for d in topics.values()]
|
||||
|
||||
|
||||
@property
|
||||
@@ -436,10 +436,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
scale = 300.0 # about a year
|
||||
if announcement:
|
||||
days = (now - announcement).days
|
||||
score = -exp(-days/scale)
|
||||
score = -exp(-days / scale)
|
||||
else:
|
||||
days = (now - start).days
|
||||
score = exp(days/scale)
|
||||
score = exp(days / scale)
|
||||
return score
|
||||
|
||||
def _sorting_dates(self):
|
||||
@@ -501,16 +501,16 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
xmoduledescriptors.append(s)
|
||||
|
||||
# The xmoduledescriptors included here are only the ones that have scores.
|
||||
section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) }
|
||||
section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)}
|
||||
|
||||
section_format = s.metadata.get('format', "")
|
||||
graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description]
|
||||
graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
|
||||
|
||||
all_descriptors.extend(xmoduledescriptors)
|
||||
all_descriptors.append(s)
|
||||
|
||||
return { 'graded_sections' : graded_sections,
|
||||
'all_descriptors' : all_descriptors,}
|
||||
return {'graded_sections': graded_sections,
|
||||
'all_descriptors': all_descriptors, }
|
||||
|
||||
|
||||
@staticmethod
|
||||
@@ -636,7 +636,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# *end* of the same day, not the same time. It's going to be used as the
|
||||
# end of the exam overall, so we don't want the exam to disappear too soon.
|
||||
# It's also used optionally as the registration end date, so time matters there too.
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
if self.last_eligible_appointment_date is None:
|
||||
raise ValueError("Last appointment date must be specified")
|
||||
self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0)
|
||||
@@ -648,7 +648,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
raise ValueError("First appointment date must be before last appointment date")
|
||||
if self.registration_end_date > self.last_eligible_appointment_date:
|
||||
raise ValueError("Registration end date must be before last appointment date")
|
||||
|
||||
self.exam_url = exam_info.get('Exam_URL')
|
||||
|
||||
def _try_parse_time(self, key):
|
||||
"""
|
||||
@@ -704,6 +704,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_test_center_exam(self, exam_series_code):
|
||||
exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code]
|
||||
return exams[0] if len(exams) == 1 else None
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.display_name
|
||||
@@ -715,4 +719,3 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
@property
|
||||
def org(self):
|
||||
return self.location.org
|
||||
|
||||
|
||||
@@ -231,47 +231,6 @@ div.result-container {
|
||||
}
|
||||
}
|
||||
|
||||
div.result-container, section.open-ended-child {
|
||||
.rubric {
|
||||
margin-bottom:25px;
|
||||
tr {
|
||||
margin:10px 0px;
|
||||
height: 100%;
|
||||
}
|
||||
td {
|
||||
padding: 20px 0px 25px 0px;
|
||||
margin: 10px 0px;
|
||||
height: 100%;
|
||||
}
|
||||
th {
|
||||
padding: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
label,
|
||||
.view-only {
|
||||
margin:2px;
|
||||
position: relative;
|
||||
padding: 10px 15px 25px 15px;
|
||||
width: 145px;
|
||||
height:100%;
|
||||
display: inline-block;
|
||||
min-height: 50px;
|
||||
min-width: 50px;
|
||||
background-color: #CCC;
|
||||
font-size: .85em;
|
||||
}
|
||||
.grade {
|
||||
position: absolute;
|
||||
bottom:0px;
|
||||
right:0px;
|
||||
margin:10px;
|
||||
}
|
||||
.selected-grade {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.open-ended-child {
|
||||
@media print {
|
||||
@@ -442,6 +401,14 @@ section.open-ended-child {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
div.short-form-response {
|
||||
background: #F6F6F6;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
height: 200px;
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
.grader-status {
|
||||
padding: 9px;
|
||||
@@ -577,11 +544,6 @@ section.open-ended-child {
|
||||
}
|
||||
|
||||
.submission_feedback {
|
||||
// background: #F3F3F3;
|
||||
// border: 1px solid #ddd;
|
||||
// @include border-radius(3px);
|
||||
// padding: 8px 12px;
|
||||
// margin-top: 10px;
|
||||
@include inline-block;
|
||||
font-style: italic;
|
||||
margin: 8px 0 0 10px;
|
||||
|
||||
@@ -52,13 +52,17 @@ em, i {
|
||||
}
|
||||
|
||||
strong, b {
|
||||
font-style: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p + p, ul + p, ol + p {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 40px;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
@@ -117,7 +121,7 @@ th {
|
||||
table td, th {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc !important;
|
||||
border: 1px solid #ccc;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from xmodule.raw_module import RawDescriptor
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class DiscussionModule(XModule):
|
||||
js = {'coffee':
|
||||
[resource_string(__name__, 'js/src/time.coffee'),
|
||||
@@ -30,6 +31,7 @@ class DiscussionModule(XModule):
|
||||
self.title = xml_data.attrib['for']
|
||||
self.discussion_category = xml_data.attrib['discussion_category']
|
||||
|
||||
|
||||
class DiscussionDescriptor(RawDescriptor):
|
||||
module_class = DiscussionModule
|
||||
template_dir_name = "discussion"
|
||||
|
||||
@@ -8,12 +8,14 @@ log = logging.getLogger(__name__)
|
||||
|
||||
ErrorLog = namedtuple('ErrorLog', 'tracker errors')
|
||||
|
||||
|
||||
def exc_info_to_str(exc_info):
|
||||
"""Given some exception info, convert it into a string using
|
||||
the traceback.format_exception() function.
|
||||
"""
|
||||
return ''.join(traceback.format_exception(*exc_info))
|
||||
|
||||
|
||||
def in_exception_handler():
|
||||
'''Is there an active exception?'''
|
||||
return sys.exc_info() != (None, None, None)
|
||||
@@ -44,6 +46,7 @@ def make_error_tracker():
|
||||
|
||||
return ErrorLog(error_tracker, errors)
|
||||
|
||||
|
||||
def null_error_tracker(msg):
|
||||
'''A dummy error tracker that just ignores the messages'''
|
||||
pass
|
||||
|
||||
@@ -49,6 +49,7 @@ def invalid_args(func, argdict):
|
||||
if keywords: return set() # All accepted
|
||||
return set(argdict) - set(args)
|
||||
|
||||
|
||||
def grader_from_conf(conf):
|
||||
"""
|
||||
This creates a CourseGrader from a configuration (such as in course_settings.py).
|
||||
@@ -80,7 +81,7 @@ def grader_from_conf(conf):
|
||||
subgrader_class = SingleSectionGrader
|
||||
else:
|
||||
raise ValueError("Configuration has no appropriate grader class.")
|
||||
|
||||
|
||||
bad_args = invalid_args(subgrader_class.__init__, subgraderconf)
|
||||
# See note above concerning 'name'.
|
||||
if bad_args.issuperset({name}):
|
||||
@@ -90,7 +91,7 @@ def grader_from_conf(conf):
|
||||
log.warning("Invalid arguments for a subgrader: %s", bad_args)
|
||||
for key in bad_args:
|
||||
del subgraderconf[key]
|
||||
|
||||
|
||||
subgrader = subgrader_class(**subgraderconf)
|
||||
subgraders.append((subgrader, subgrader.category, weight))
|
||||
|
||||
@@ -210,13 +211,13 @@ class SingleSectionGrader(CourseGrader):
|
||||
break
|
||||
|
||||
if foundScore or generate_random_scores:
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2,15)
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2, 15)
|
||||
possible = random.randint(earned, 15)
|
||||
else: # We found the score
|
||||
else: # We found the score
|
||||
earned = foundScore.earned
|
||||
possible = foundScore.possible
|
||||
|
||||
|
||||
percent = earned / float(possible)
|
||||
detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name,
|
||||
percent=percent,
|
||||
@@ -245,7 +246,7 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
min_count defines how many assignments are expected throughout the course. Placeholder
|
||||
scores (of 0) will be inserted if the number of matching sections in the course is < min_count.
|
||||
If there number of matching sections in the course is > min_count, min_count will be ignored.
|
||||
|
||||
|
||||
show_only_average is to suppress the display of each assignment in this grader and instead
|
||||
only show the total score of this grader in the breakdown.
|
||||
|
||||
@@ -257,7 +258,7 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
|
||||
short_label is similar to section_type, but shorter. For example, for Homework it would be
|
||||
"HW".
|
||||
|
||||
|
||||
starting_index is the first number that will appear. For example, starting_index=3 and
|
||||
min_count = 2 would produce the labels "Assignment 3", "Assignment 4"
|
||||
|
||||
@@ -296,16 +297,16 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
breakdown = []
|
||||
for i in range(max(self.min_count, len(scores))):
|
||||
if i < len(scores) or generate_random_scores:
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2,15)
|
||||
possible = random.randint(earned, 15)
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2, 15)
|
||||
possible = random.randint(earned, 15)
|
||||
section_name = "Generated"
|
||||
|
||||
|
||||
else:
|
||||
earned = scores[i].earned
|
||||
possible = scores[i].possible
|
||||
section_name = scores[i].section
|
||||
|
||||
|
||||
percentage = earned / float(possible)
|
||||
summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
@@ -318,7 +319,7 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, section_type=self.section_type)
|
||||
|
||||
short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label=self.short_label)
|
||||
|
||||
|
||||
breakdown.append({'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category})
|
||||
|
||||
total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count)
|
||||
@@ -328,13 +329,13 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
|
||||
total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, section_type=self.section_type)
|
||||
total_label = "{short_label} Avg".format(short_label=self.short_label)
|
||||
|
||||
|
||||
if self.show_only_average:
|
||||
breakdown = []
|
||||
|
||||
|
||||
if not self.hide_average:
|
||||
breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True})
|
||||
|
||||
|
||||
return {'percent': total_percent,
|
||||
'section_breakdown': breakdown,
|
||||
#No grade_breakdown here
|
||||
|
||||
129
common/lib/xmodule/xmodule/grading_service_module.py
Normal file
129
common/lib/xmodule/xmodule/grading_service_module.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# This class gives a common interface for logging into the grading controller
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
|
||||
from xmodule.combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from lxml import etree
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GradingServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GradingService(object):
|
||||
"""
|
||||
Interface to staff grading backend.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
self.username = config['username']
|
||||
self.password = config['password']
|
||||
self.url = config['url']
|
||||
self.login_url = self.url + '/login/'
|
||||
self.session = requests.session()
|
||||
self.system = config['system']
|
||||
|
||||
def _login(self):
|
||||
"""
|
||||
Log into the staff grading service.
|
||||
|
||||
Raises requests.exceptions.HTTPError if something goes wrong.
|
||||
|
||||
Returns the decoded json dict of the response.
|
||||
"""
|
||||
response = self.session.post(self.login_url,
|
||||
{'username': self.username,
|
||||
'password': self.password, })
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json
|
||||
|
||||
def post(self, url, data, allow_redirects=False):
|
||||
"""
|
||||
Make a post request to the grading controller
|
||||
"""
|
||||
try:
|
||||
op = lambda: self.session.post(url, data=data,
|
||||
allow_redirects=allow_redirects)
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
raise GradingServiceError, str(err), sys.exc_info()[2]
|
||||
|
||||
return r.text
|
||||
|
||||
def get(self, url, params, allow_redirects=False):
|
||||
"""
|
||||
Make a get request to the grading controller
|
||||
"""
|
||||
log.debug(params)
|
||||
op = lambda: self.session.get(url,
|
||||
allow_redirects=allow_redirects,
|
||||
params=params)
|
||||
try:
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
raise GradingServiceError, str(err), sys.exc_info()[2]
|
||||
|
||||
return r.text
|
||||
|
||||
|
||||
def _try_with_login(self, operation):
|
||||
"""
|
||||
Call operation(), which should return a requests response object. If
|
||||
the request fails with a 'login_required' error, call _login() and try
|
||||
the operation again.
|
||||
|
||||
Returns the result of operation(). Does not catch exceptions.
|
||||
"""
|
||||
response = operation()
|
||||
if (response.json
|
||||
and response.json.get('success') == False
|
||||
and response.json.get('error') == 'login_required'):
|
||||
# apparrently we aren't logged in. Try to fix that.
|
||||
r = self._login()
|
||||
if r and not r.get('success'):
|
||||
log.warning("Couldn't log into staff_grading backend. Response: %s",
|
||||
r)
|
||||
# try again
|
||||
response = operation()
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
def _render_rubric(self, response, view_only=False):
|
||||
"""
|
||||
Given an HTTP Response with the key 'rubric', render out the html
|
||||
required to display the rubric and put it back into the response
|
||||
|
||||
returns the updated response as a dictionary that can be serialized later
|
||||
|
||||
"""
|
||||
try:
|
||||
response_json = json.loads(response)
|
||||
except:
|
||||
response_json = response
|
||||
|
||||
try:
|
||||
if 'rubric' in response_json:
|
||||
rubric = response_json['rubric']
|
||||
rubric_renderer = CombinedOpenEndedRubric(self.system, view_only)
|
||||
success, rubric_html = rubric_renderer.render_rubric(rubric)
|
||||
response_json['rubric'] = rubric_html
|
||||
return response_json
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError, RubricParsingError:
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
return {'success': False,
|
||||
'error': 'Error displaying submission'}
|
||||
except ValueError:
|
||||
log.exception("Error parsing response: {0}".format(response))
|
||||
return {'success': False,
|
||||
'error': "Error displaying submission"}
|
||||
@@ -1,5 +1,6 @@
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def check_html(html):
|
||||
'''
|
||||
Check whether the passed in html string can be parsed by lxml.
|
||||
|
||||
@@ -133,7 +133,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
|
||||
# TODO (ichuang): remove this after migration
|
||||
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
|
||||
definition['filename'] = [ filepath, filename ]
|
||||
definition['filename'] = [filepath, filename]
|
||||
|
||||
return definition
|
||||
|
||||
@@ -180,6 +180,7 @@ class AboutDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
template_dir_name = "about"
|
||||
|
||||
|
||||
class StaticTabDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
@@ -187,6 +188,7 @@ class StaticTabDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
template_dir_name = "statictab"
|
||||
|
||||
|
||||
class CourseInfoDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
|
||||
123
common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html
Normal file
123
common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<section class="course-content">
|
||||
<section class="xmodule_display xmodule_CombinedOpenEndedModule" data-type="CombinedOpenEnded">
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE" data-allow_reset="False" data-state="assessing" data-task-count="2" data-task-number="1">
|
||||
|
||||
<h2>Problem 1</h2>
|
||||
<div class="status-container">
|
||||
<h4>Status</h4>
|
||||
<div class="status-elements">
|
||||
<section id="combined-open-ended-status" class="combined-open-ended-status">
|
||||
|
||||
<div class="statusitem" data-status-number="0">
|
||||
|
||||
Step 1 (Problem complete) : 1 / 1
|
||||
<span class="correct" id="status"></span>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="statusitem statusitem-current" data-status-number="1">
|
||||
|
||||
Step 2 (Being scored) : None / 1
|
||||
<span class="grading" id="status"></span>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="item-container">
|
||||
<h4>Problem</h4>
|
||||
<div class="problem-container">
|
||||
<div class="item"><section id="openended_open_ended" class="open-ended-child" data-state="assessing" data-child-type="openended"><div class="error"></div>
|
||||
<div class="prompt">
|
||||
|
||||
Some prompt.
|
||||
|
||||
</div>
|
||||
<textarea rows="30" cols="80" name="answer" class="answer short-form-response" id="input_open_ended" disabled="disabled">Test submission. Yaaaaaaay!</textarea><div class="message-wrapper"></div>
|
||||
<div class="grader-status">
|
||||
<span class="grading" id="status_open_ended">Submitted for grading.</span>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="button" value="Submit assessment" class="submit-button" name="show" style="display: none;"><input name="skip" class="skip-button" type="button" value="Skip Post-Assessment" style="display: none;"><div class="open-ended-action"></div>
|
||||
|
||||
<span id="answer_open_ended"></span>
|
||||
</section></div>
|
||||
</div>
|
||||
|
||||
|
||||
<input type="button" value="Reset" class="reset-button" name="reset" style="display: none;">
|
||||
<input type="button" value="Next Step" class="next-step-button" name="reset" style="display: none;">
|
||||
</div>
|
||||
|
||||
<a name="results">
|
||||
<div class="result-container">
|
||||
</div>
|
||||
</a></section><a name="results">
|
||||
|
||||
|
||||
</a></section><a name="results">
|
||||
|
||||
</a><div><a name="results">
|
||||
</a><a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">Edit</a> /
|
||||
<a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" onclick="javascript:getlog('i4x_MITx_6_002x_combinedopenended_CombinedOE', {
|
||||
'location': 'i4x://MITx/6.002x/combinedopenended/CombinedOE',
|
||||
'xqa_key': 'KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A',
|
||||
'category': 'CombinedOpenEndedModule',
|
||||
'user': 'blah'
|
||||
})" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA</a>
|
||||
</div>
|
||||
<div><a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_trig">Staff Debug Info</a></div>
|
||||
|
||||
<section id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" class="modal xqa-modal" style="width:80%; left:20%; height:80%; overflow:auto">
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
<h2>edX Content Quality Assessment</h2>
|
||||
</header>
|
||||
|
||||
<form id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_form" class="xqa_form">
|
||||
<label>Comment</label>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_entry" type="text" placeholder="comment">
|
||||
<label>Tag</label>
|
||||
<span style="color:black;vertical-align: -10pt">Optional tag (eg "done" or "broken"): </span>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_tag" type="text" placeholder="tag" style="width:80px;display:inline">
|
||||
<div class="submit">
|
||||
<button name="submit" type="submit">Add comment</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log_data"></div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="modal staff-modal" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" style="width:80%; left:20%; height:80%; overflow:auto;">
|
||||
<div class="inner-wrapper" style="color:black">
|
||||
<header>
|
||||
<h2>Staff Debug</h2>
|
||||
</header>
|
||||
<div class="staff_info" style="display:block">
|
||||
is_released = <font color="red">Yes!</font>
|
||||
location = i4x://MITx/6.002x/combinedopenended/CombinedOE
|
||||
github = <a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml</a>
|
||||
definition = <pre>None</pre>
|
||||
metadata = {
|
||||
"showanswer": "attempted",
|
||||
"display_name": "Problem 1",
|
||||
"graceperiod": "1 day 12 hours 59 minutes 59 seconds",
|
||||
"xqa_key": "KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A",
|
||||
"rerandomize": "never",
|
||||
"start": "2012-09-05T12:00",
|
||||
"attempts": "10000",
|
||||
"data_dir": "content-mit-6002x",
|
||||
"max_score": "1"
|
||||
}
|
||||
category = CombinedOpenEndedModule
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_setup"></div>
|
||||
</section>
|
||||
@@ -0,0 +1,111 @@
|
||||
describe 'CombinedOpenEnded', ->
|
||||
beforeEach ->
|
||||
spyOn Logger, 'log'
|
||||
# load up some fixtures
|
||||
loadFixtures 'combined-open-ended.html'
|
||||
jasmine.Clock.useMock()
|
||||
@element = $('.course-content')
|
||||
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
spyOn(Collapsible, 'setCollapsibles')
|
||||
@combined = new CombinedOpenEnded @element
|
||||
|
||||
it 'set the element', ->
|
||||
expect(@combined.element).toEqual @element
|
||||
|
||||
it 'get the correct values from data fields', ->
|
||||
expect(@combined.ajax_url).toEqual '/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE'
|
||||
expect(@combined.state).toEqual 'assessing'
|
||||
expect(@combined.task_count).toEqual 2
|
||||
expect(@combined.task_number).toEqual 1
|
||||
|
||||
it 'subelements are made collapsible', ->
|
||||
expect(Collapsible.setCollapsibles).toHaveBeenCalled()
|
||||
|
||||
|
||||
describe 'poll', ->
|
||||
beforeEach =>
|
||||
# setup the spies
|
||||
@combined = new CombinedOpenEnded @element
|
||||
spyOn(@combined, 'reload').andCallFake -> return 0
|
||||
window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5
|
||||
|
||||
it 'polls at the correct intervals', =>
|
||||
fakeResponseContinue = state: 'not done'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseContinue)
|
||||
@combined.poll()
|
||||
expect(window.setTimeout).toHaveBeenCalledWith(@combined.poll, 10000)
|
||||
expect(window.queuePollerID).toBe(5)
|
||||
|
||||
it 'polling stops properly', =>
|
||||
fakeResponseDone = state: "done"
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseDone)
|
||||
@combined.poll()
|
||||
expect(window.queuePollerID).toBeUndefined()
|
||||
expect(window.setTimeout).not.toHaveBeenCalled()
|
||||
|
||||
describe 'rebind', ->
|
||||
beforeEach ->
|
||||
@combined = new CombinedOpenEnded @element
|
||||
spyOn(@combined, 'queueing').andCallFake -> return 0
|
||||
spyOn(@combined, 'skip_post_assessment').andCallFake -> return 0
|
||||
window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5
|
||||
|
||||
it 'when our child is in an assessing state', ->
|
||||
@combined.child_state = 'assessing'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.submit_button.val()).toBe("Submit assessment")
|
||||
expect(@combined.queueing).toHaveBeenCalled()
|
||||
|
||||
it 'when our child state is initial', ->
|
||||
@combined.child_state = 'initial'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBeUndefined()
|
||||
expect(@combined.submit_button.val()).toBe("Submit")
|
||||
|
||||
it 'when our child state is post_assessment', ->
|
||||
@combined.child_state = 'post_assessment'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.submit_button.val()).toBe("Submit post-assessment")
|
||||
|
||||
it 'when our child state is done', ->
|
||||
spyOn(@combined, 'next_problem').andCallFake ->
|
||||
@combined.child_state = 'done'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.next_problem).toHaveBeenCalled()
|
||||
|
||||
describe 'next_problem', ->
|
||||
beforeEach ->
|
||||
@combined = new CombinedOpenEnded @element
|
||||
@combined.child_state = 'done'
|
||||
|
||||
it 'handling a successful call', ->
|
||||
fakeResponse =
|
||||
success: true
|
||||
html: "dummy html"
|
||||
allow_reset: false
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, val, callback) -> callback(fakeResponse)
|
||||
spyOn(@combined, 'reinitialize')
|
||||
spyOn(@combined, 'rebind')
|
||||
@combined.next_problem()
|
||||
expect($.postWithPrefix).toHaveBeenCalled()
|
||||
expect(@combined.reinitialize).toHaveBeenCalledWith(@combined.element)
|
||||
expect(@combined.rebind).toHaveBeenCalled()
|
||||
expect(@combined.answer_area.val()).toBe('')
|
||||
expect(@combined.child_state).toBe('initial')
|
||||
|
||||
it 'handling an unsuccessful call', ->
|
||||
fakeResponse =
|
||||
success: false
|
||||
error: 'This is an error'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, val, callback) -> callback(fakeResponse)
|
||||
@combined.next_problem()
|
||||
expect(@combined.errors_area.html()).toBe(fakeResponse.error)
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
if createPlayer
|
||||
return new VideoPlayer(video: context.video)
|
||||
|
||||
spyOn(window, 'onunload')
|
||||
|
||||
# Stub jQuery.cookie
|
||||
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
|
||||
|
||||
@@ -11,8 +11,9 @@ class @Problem
|
||||
$(selector, @el)
|
||||
|
||||
bind: =>
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
if MathJax?
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
|
||||
window.update_schematics()
|
||||
|
||||
@@ -31,8 +32,9 @@ class @Problem
|
||||
|
||||
# Dynamath
|
||||
@$('input.math').keyup(@refreshMath)
|
||||
@$('input.math').each (index, element) =>
|
||||
MathJax.Hub.Queue [@refreshMath, null, element]
|
||||
if MathJax?
|
||||
@$('input.math').each (index, element) =>
|
||||
MathJax.Hub.Queue [@refreshMath, null, element]
|
||||
|
||||
updateProgress: (response) =>
|
||||
if response.progress_changed
|
||||
@@ -140,15 +142,15 @@ class @Problem
|
||||
allowed_files = $(element).data("allowed_files")
|
||||
for file in element.files
|
||||
if allowed_files.length != 0 and file.name not in allowed_files
|
||||
unallowed_file_submitted = true
|
||||
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
|
||||
unallowed_file_submitted = true
|
||||
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
|
||||
if file.name in required_files
|
||||
required_files.splice(required_files.indexOf(file.name), 1)
|
||||
required_files.splice(required_files.indexOf(file.name), 1)
|
||||
if file.size > max_filesize
|
||||
file_too_large = true
|
||||
errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)'
|
||||
fd.append(element.id, file)
|
||||
if element.files.length == 0
|
||||
if element.files.length == 0
|
||||
file_not_selected = true
|
||||
fd.append(element.id, '') # In case we want to allow submissions with no file
|
||||
if required_files.length != 0
|
||||
@@ -157,7 +159,7 @@ class @Problem
|
||||
else
|
||||
fd.append(element.id, element.value)
|
||||
|
||||
|
||||
|
||||
if file_not_selected
|
||||
errors.push 'You did not select any files to submit'
|
||||
|
||||
@@ -230,8 +232,9 @@ class @Problem
|
||||
showMethod = @inputtypeShowAnswerMethods[cls]
|
||||
showMethod(inputtype, display, answers) if showMethod?
|
||||
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
if MathJax?
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
|
||||
@$('.show').val 'Hide Answer'
|
||||
@el.addClass 'showed'
|
||||
@@ -273,7 +276,7 @@ class @Problem
|
||||
preprocessor_tag = "inputtype_" + elid
|
||||
mathjax_preprocessor = @inputtypeDisplays[preprocessor_tag]
|
||||
|
||||
if jax = MathJax.Hub.getAllJax(target)[0]
|
||||
if MathJax? and jax = MathJax.Hub.getAllJax(target)[0]
|
||||
eqn = $(element).val()
|
||||
if mathjax_preprocessor
|
||||
eqn = mathjax_preprocessor(eqn)
|
||||
@@ -286,7 +289,8 @@ class @Problem
|
||||
$("##{element.id}_dynamath").val(jax.root.toMathML '')
|
||||
catch exception
|
||||
throw exception unless exception.restart
|
||||
MathJax.Callback.After [@refreshMath, jax], exception.restart
|
||||
if MathJax?
|
||||
MathJax.Callback.After [@refreshMath, jax], exception.restart
|
||||
|
||||
refreshAnswers: =>
|
||||
@$('input.schematic').each (index, element) ->
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
class @Rubric
|
||||
constructor: () ->
|
||||
|
||||
# finds the scores for each rubric category
|
||||
@get_score_list: () =>
|
||||
# find the number of categories:
|
||||
num_categories = $('table.rubric tr').length
|
||||
|
||||
score_lst = []
|
||||
# get the score for each one
|
||||
for i in [0..(num_categories-2)]
|
||||
score = $("input[name='score-selection-#{i}']:checked").val()
|
||||
score_lst.push(score)
|
||||
|
||||
return score_lst
|
||||
|
||||
@get_total_score: () ->
|
||||
score_lst = @get_score_list()
|
||||
tot = 0
|
||||
for score in score_lst
|
||||
tot += parseInt(score)
|
||||
return tot
|
||||
|
||||
@check_complete: () ->
|
||||
# check to see whether or not any categories have not been scored
|
||||
num_categories = $('table.rubric tr').length
|
||||
# -2 because we want to skip the header
|
||||
for i in [0..(num_categories-2)]
|
||||
score = $("input[name='score-selection-#{i}']:checked").val()
|
||||
if score == undefined
|
||||
return false
|
||||
return true
|
||||
|
||||
class @CombinedOpenEnded
|
||||
constructor: (element) ->
|
||||
@element=element
|
||||
@@ -12,6 +45,7 @@ class @CombinedOpenEnded
|
||||
@state = @el.data('state')
|
||||
@task_count = @el.data('task-count')
|
||||
@task_number = @el.data('task-number')
|
||||
@accept_file_upload = @el.data('accept-file-upload')
|
||||
|
||||
@allow_reset = @el.data('allow_reset')
|
||||
@reset_button = @$('.reset-button')
|
||||
@@ -44,6 +78,8 @@ class @CombinedOpenEnded
|
||||
@skip_button = @$('.skip-button')
|
||||
@skip_button.click @skip_post_assessment
|
||||
|
||||
@file_upload_area = @$('.file-upload')
|
||||
@can_upload_files = false
|
||||
@open_ended_child= @$('.open-ended-child')
|
||||
|
||||
@find_assessment_elements()
|
||||
@@ -55,6 +91,16 @@ class @CombinedOpenEnded
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
show_results_current: () =>
|
||||
data = {'task_number' : @task_number-1}
|
||||
$.postWithPrefix "#{@ajax_url}/get_results", data, (response) =>
|
||||
if response.success
|
||||
@results_container.after(response.html).remove()
|
||||
@results_container = $('div.result-container')
|
||||
@submit_evaluation_button = $('.submit-evaluation-button')
|
||||
@submit_evaluation_button.click @message_post
|
||||
Collapsible.setCollapsibles(@results_container)
|
||||
|
||||
show_results: (event) =>
|
||||
status_item = $(event.target).parent().parent()
|
||||
status_number = status_item.data('status-number')
|
||||
@@ -67,7 +113,7 @@ class @CombinedOpenEnded
|
||||
@submit_evaluation_button.click @message_post
|
||||
Collapsible.setCollapsibles(@results_container)
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
@gentle_alert response.error
|
||||
|
||||
message_post: (event)=>
|
||||
Logger.log 'message_post', @answers
|
||||
@@ -108,22 +154,28 @@ class @CombinedOpenEnded
|
||||
@submit_button.show()
|
||||
@reset_button.hide()
|
||||
@next_problem_button.hide()
|
||||
@hide_file_upload()
|
||||
@hint_area.attr('disabled', false)
|
||||
if @child_state == 'done'
|
||||
@rubric_wrapper.hide()
|
||||
if @child_type=="openended"
|
||||
@skip_button.hide()
|
||||
if @allow_reset=="True"
|
||||
@show_results_current
|
||||
@reset_button.show()
|
||||
@submit_button.hide()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hint_area.attr('disabled', true)
|
||||
else if @child_state == 'initial'
|
||||
@answer_area.attr("disabled", false)
|
||||
@submit_button.prop('value', 'Submit')
|
||||
@submit_button.click @save_answer
|
||||
@setup_file_upload()
|
||||
else if @child_state == 'assessing'
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hide_file_upload()
|
||||
@submit_button.prop('value', 'Submit assessment')
|
||||
@submit_button.click @save_assessment
|
||||
if @child_type == "openended"
|
||||
@@ -134,6 +186,7 @@ class @CombinedOpenEnded
|
||||
@skip_button.show()
|
||||
@skip_post_assessment()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@submit_button.prop('value', 'Submit post-assessment')
|
||||
if @child_type=="selfassessment"
|
||||
@submit_button.click @save_hint
|
||||
@@ -142,6 +195,7 @@ class @CombinedOpenEnded
|
||||
else if @child_state == 'done'
|
||||
@rubric_wrapper.hide()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hint_area.attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
if @child_type=="openended"
|
||||
@@ -149,6 +203,7 @@ class @CombinedOpenEnded
|
||||
if @task_number<@task_count
|
||||
@next_problem()
|
||||
else
|
||||
@show_results_current()
|
||||
@reset_button.show()
|
||||
|
||||
|
||||
@@ -160,25 +215,49 @@ class @CombinedOpenEnded
|
||||
|
||||
save_answer: (event) =>
|
||||
event.preventDefault()
|
||||
max_filesize = 2*1000*1000 #2MB
|
||||
if @child_state == 'initial'
|
||||
data = {'student_answer' : @answer_area.val()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
files = ""
|
||||
if @can_upload_files == true
|
||||
files = $('.file-upload-box')[0].files[0]
|
||||
if files != undefined
|
||||
if files.size > max_filesize
|
||||
@can_upload_files = false
|
||||
files = ""
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
@can_upload_files = false
|
||||
|
||||
fd = new FormData()
|
||||
fd.append('student_answer', @answer_area.val())
|
||||
fd.append('student_file', files)
|
||||
fd.append('can_upload_files', @can_upload_files)
|
||||
|
||||
settings =
|
||||
type: "POST"
|
||||
data: fd
|
||||
processData: false
|
||||
contentType: false
|
||||
success: (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
@answer_area.html(response.student_response)
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
else
|
||||
@gentle_alert response.error
|
||||
|
||||
$.ajaxWithPrefix("#{@ajax_url}/save_answer",settings)
|
||||
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
save_assessment: (event) =>
|
||||
event.preventDefault()
|
||||
if @child_state == 'assessing'
|
||||
checked_assessment = @$('input[name="grade-selection"]:checked')
|
||||
data = {'assessment' : checked_assessment.val()}
|
||||
if @child_state == 'assessing' && Rubric.check_complete()
|
||||
checked_assessment = Rubric.get_total_score()
|
||||
data = {'assessment' : checked_assessment}
|
||||
$.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
|
||||
if response.success
|
||||
@child_state = response.state
|
||||
@@ -260,6 +339,7 @@ class @CombinedOpenEnded
|
||||
@gentle_alert "Moved to next step."
|
||||
else
|
||||
@gentle_alert "Your score did not meet the criteria to move to the next step."
|
||||
@show_results_current()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@@ -285,3 +365,28 @@ class @CombinedOpenEnded
|
||||
location.reload()
|
||||
else
|
||||
window.queuePollerID = window.setTimeout(@poll, 10000)
|
||||
|
||||
setup_file_upload: =>
|
||||
if window.File and window.FileReader and window.FileList and window.Blob
|
||||
if @accept_file_upload == "True"
|
||||
@can_upload_files = true
|
||||
@file_upload_area.html('<input type="file" class="file-upload-box">')
|
||||
@file_upload_area.show()
|
||||
else
|
||||
@gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.'
|
||||
|
||||
hide_file_upload: =>
|
||||
if @accept_file_upload == "True"
|
||||
@file_upload_area.hide()
|
||||
|
||||
replace_text_inputs: =>
|
||||
answer_class = @answer_area.attr('class')
|
||||
answer_id = @answer_area.attr('id')
|
||||
answer_val = @answer_area.val()
|
||||
new_text = ''
|
||||
new_text = "<div class='#{answer_class}' id='#{answer_id}'>#{answer_val}</div>"
|
||||
@answer_area.replaceWith(new_text)
|
||||
|
||||
# wrap this so that it can be mocked
|
||||
reload: ->
|
||||
location.reload()
|
||||
|
||||
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
@@ -0,0 +1,26 @@
|
||||
class @Conditional
|
||||
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('.conditional-wrapper')
|
||||
@id = @el.data('problem-id')
|
||||
@element_id = @el.attr('id')
|
||||
@url = @el.data('url')
|
||||
@render()
|
||||
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
updateProgress: (response) =>
|
||||
if response.progress_changed
|
||||
@el.attr progress: response.progress_status
|
||||
@el.trigger('progressChanged')
|
||||
|
||||
render: (content) ->
|
||||
if content
|
||||
@el.html(content)
|
||||
XModule.loadModules(@el)
|
||||
else
|
||||
$.postWithPrefix "#{@url}/conditional_get", (response) =>
|
||||
@el.html(response.html)
|
||||
XModule.loadModules(@el)
|
||||
|
||||
@@ -10,7 +10,8 @@ class @HTMLEditingDescriptor
|
||||
lineWrapping: true
|
||||
})
|
||||
|
||||
$(@advanced_editor.getWrapperElement()).addClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
@$advancedEditorWrapper = $(@advanced_editor.getWrapperElement())
|
||||
@$advancedEditorWrapper.addClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
|
||||
# This is a workaround for the fact that tinyMCE's baseURL property is not getting correctly set on AWS
|
||||
# instances (like sandbox). It is not necessary to explicitly set baseURL when running locally.
|
||||
@@ -43,16 +44,21 @@ class @HTMLEditingDescriptor
|
||||
theme_advanced_blockformats : "p,pre,h1,h2,h3",
|
||||
width: '100%',
|
||||
height: '400px',
|
||||
setup : HTMLEditingDescriptor.setupTinyMCE,
|
||||
setup : @setupTinyMCE,
|
||||
# Cannot get access to tinyMCE Editor instance (for focusing) until after it is rendered.
|
||||
# The tinyMCE callback passes in the editor as a paramter.
|
||||
init_instance_callback: @focusVisualEditor
|
||||
})
|
||||
|
||||
@showingVisualEditor = true
|
||||
# Doing these find operations within onSwitchEditor leads to sporadic failures on Chrome (version 20 and older).
|
||||
$element = $(element)
|
||||
@$htmlTab = $element.find('.html-tab')
|
||||
@$visualTab = $element.find('.visual-tab')
|
||||
|
||||
@element.on('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
|
||||
@setupTinyMCE: (ed) ->
|
||||
setupTinyMCE: (ed) =>
|
||||
ed.addButton('wrapAsCode', {
|
||||
title : 'Code',
|
||||
image : '/static/images/ico-tinymce-code.png',
|
||||
@@ -67,19 +73,23 @@ class @HTMLEditingDescriptor
|
||||
command.setActive('wrapAsCode', e.nodeName == 'CODE')
|
||||
)
|
||||
|
||||
onSwitchEditor: (e)=>
|
||||
@visualEditor = ed
|
||||
|
||||
onSwitchEditor: (e) =>
|
||||
e.preventDefault();
|
||||
|
||||
if not $(e.currentTarget).hasClass('current')
|
||||
$('.editor-tabs .current', @element).removeClass('current')
|
||||
$(e.currentTarget).addClass('current')
|
||||
$('table.mceToolbar', @element).toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
$(@advanced_editor.getWrapperElement()).toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
$currentTarget = $(e.currentTarget)
|
||||
if not $currentTarget.hasClass('current')
|
||||
$currentTarget.addClass('current')
|
||||
@$mceToolbar.toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
@$advancedEditorWrapper.toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
|
||||
visualEditor = @getVisualEditor()
|
||||
if $(e.currentTarget).attr('data-tab') is 'visual'
|
||||
if $currentTarget.data('tab') is 'visual'
|
||||
@$htmlTab.removeClass('current')
|
||||
@showVisualEditor(visualEditor)
|
||||
else
|
||||
@$visualTab.removeClass('current')
|
||||
@showAdvancedEditor(visualEditor)
|
||||
|
||||
# Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing.
|
||||
@@ -97,19 +107,24 @@ class @HTMLEditingDescriptor
|
||||
# In order for isDirty() to return true ONLY if edits have been made after setting the text,
|
||||
# both the startContent must be sync'ed up and the dirty flag set to false.
|
||||
visualEditor.startContent = visualEditor.getContent({format: "raw", no_events: 1});
|
||||
visualEditor.isNotDirty = true
|
||||
@focusVisualEditor(visualEditor)
|
||||
@showingVisualEditor = true
|
||||
|
||||
focusVisualEditor: (visualEditor) ->
|
||||
focusVisualEditor: (visualEditor) =>
|
||||
visualEditor.focus()
|
||||
# Need to mark editor as not dirty both when it is initially created and when we switch back to it.
|
||||
visualEditor.isNotDirty = true
|
||||
if not @$mceToolbar?
|
||||
@$mceToolbar = $(@element).find('table.mceToolbar')
|
||||
|
||||
getVisualEditor: ->
|
||||
getVisualEditor: () ->
|
||||
###
|
||||
Returns the instance of TinyMCE.
|
||||
This is different from the textarea that exists in the HTML template (@tiny_mce_textarea.
|
||||
|
||||
Pulled out as a helper method for unit test.
|
||||
###
|
||||
return tinyMCE.get($('.tiny-mce', this.element).attr('id'))
|
||||
return @visualEditor
|
||||
|
||||
save: ->
|
||||
@element.off('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# This is a simple class that just hides the error container
|
||||
# and message container when they are empty
|
||||
# Can (and should be) expanded upon when our problem list
|
||||
# becomes more sophisticated
|
||||
class @PeerGrading
|
||||
constructor: (element) ->
|
||||
@peer_grading_container = $('.peer-grading')
|
||||
@use_single_location = @peer_grading_container.data('use-single-location')
|
||||
@peer_grading_outer_container = $('.peer-grading-container')
|
||||
@ajax_url = @peer_grading_container.data('ajax-url')
|
||||
@error_container = $('.error-container')
|
||||
@error_container.toggle(not @error_container.is(':empty'))
|
||||
|
||||
@message_container = $('.message-container')
|
||||
@message_container.toggle(not @message_container.is(':empty'))
|
||||
|
||||
@problem_button = $('.problem-button')
|
||||
@problem_button.click @show_results
|
||||
|
||||
@problem_list = $('.problem-list')
|
||||
@construct_progress_bar()
|
||||
|
||||
if @use_single_location
|
||||
@activate_problem()
|
||||
|
||||
construct_progress_bar: () =>
|
||||
problems = @problem_list.find('tr').next()
|
||||
problems.each( (index, element) =>
|
||||
problem = $(element)
|
||||
progress_bar = problem.find('.progress-bar')
|
||||
bar_value = parseInt(problem.data('graded'))
|
||||
bar_max = parseInt(problem.data('required')) + bar_value
|
||||
progress_bar.progressbar({value: bar_value, max: bar_max})
|
||||
)
|
||||
|
||||
show_results: (event) =>
|
||||
location_to_fetch = $(event.target).data('location')
|
||||
data = {'location' : location_to_fetch}
|
||||
$.postWithPrefix "#{@ajax_url}problem", data, (response) =>
|
||||
if response.success
|
||||
@peer_grading_outer_container.after(response.html).remove()
|
||||
backend = new PeerGradingProblemBackend(@ajax_url, false)
|
||||
new PeerGradingProblem(backend)
|
||||
else
|
||||
@gentle_alert response.error
|
||||
|
||||
activate_problem: () =>
|
||||
backend = new PeerGradingProblemBackend(@ajax_url, false)
|
||||
new PeerGradingProblem(backend)
|
||||
@@ -0,0 +1,435 @@
|
||||
##################################
|
||||
#
|
||||
# This is the JS that renders the peer grading problem page.
|
||||
# Fetches the correct problem and/or calibration essay
|
||||
# and sends back the grades
|
||||
#
|
||||
# Should not be run when we don't have a location to send back
|
||||
# to the server
|
||||
#
|
||||
# PeerGradingProblemBackend -
|
||||
# makes all the ajax requests and provides a mock interface
|
||||
# for testing purposes
|
||||
#
|
||||
# PeerGradingProblem -
|
||||
# handles the rendering and user interactions with the interface
|
||||
#
|
||||
##################################
|
||||
class @PeerGradingProblemBackend
|
||||
constructor: (ajax_url, mock_backend) ->
|
||||
@mock_backend = mock_backend
|
||||
@ajax_url = ajax_url
|
||||
@mock_cnt = 0
|
||||
|
||||
post: (cmd, data, callback) ->
|
||||
if @mock_backend
|
||||
callback(@mock(cmd, data))
|
||||
else
|
||||
# if this post request fails, the error callback will catch it
|
||||
$.post(@ajax_url + cmd, data, callback)
|
||||
.error => callback({success: false, error: "Error occured while performing this operation"})
|
||||
|
||||
mock: (cmd, data) ->
|
||||
if cmd == 'is_student_calibrated'
|
||||
# change to test each version
|
||||
response =
|
||||
success: true
|
||||
calibrated: @mock_cnt >= 2
|
||||
else if cmd == 'show_calibration_essay'
|
||||
#response =
|
||||
# success: false
|
||||
# error: "There was an error"
|
||||
@mock_cnt++
|
||||
response =
|
||||
success: true
|
||||
submission_id: 1
|
||||
submission_key: 'abcd'
|
||||
student_response: '''
|
||||
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.
|
||||
|
||||
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
|
||||
'''
|
||||
prompt: '''
|
||||
<h2>S11E3: Metal Bands</h2>
|
||||
<p>Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.</p>
|
||||
<p>* Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled? </p>
|
||||
<p>This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.</p>
|
||||
'''
|
||||
rubric: '''
|
||||
<table class="rubric"><tbody><tr><th>Purpose</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-0" value="0"><label for="score-0-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-1" value="1"><label for="score-0-1">Unclear purpose or main idea</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-2" value="2"><label for="score-0-2">Communicates an identifiable purpose and/or main idea for an audience</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-3" value="3"><label for="score-0-3">Achieves a clear and distinct purpose for a targeted audience and communicates main ideas with effectively used techniques to introduce and represent ideas and insights</label>
|
||||
</td>
|
||||
</tr><tr><th>Organization</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-0" value="0"><label for="score-1-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-1" value="1"><label for="score-1-1">Organization is unclear; introduction, body, and/or conclusion are underdeveloped, missing or confusing.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-2" value="2"><label for="score-1-2">Organization is occasionally unclear; introduction, body or conclusion may be underdeveloped.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-3" value="3"><label for="score-1-3">Organization is clear and easy to follow; introduction, body and conclusion are defined and aligned with purpose.</label>
|
||||
</td>
|
||||
</tr></tbody></table>
|
||||
'''
|
||||
max_score: 4
|
||||
else if cmd == 'get_next_submission'
|
||||
response =
|
||||
success: true
|
||||
submission_id: 1
|
||||
submission_key: 'abcd'
|
||||
student_response: '''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec tristique ante. Proin at mauris sapien, quis varius leo. Morbi laoreet leo nisi. Morbi aliquam lacus ante. Cras iaculis velit sed diam mattis a fermentum urna luctus. Duis consectetur nunc vitae felis facilisis eget vulputate risus viverra. Cras consectetur ullamcorper lobortis. Nam eu gravida lorem. Nulla facilisi. Nullam quis felis enim. Mauris orci lectus, dictum id cursus in, vulputate in massa.
|
||||
|
||||
Phasellus non varius sem. Nullam commodo lacinia odio sit amet egestas. Donec ullamcorper sapien sagittis arcu volutpat placerat. Phasellus ut pretium ante. Nam dictum pulvinar nibh dapibus tristique. Sed at tellus mi, fringilla convallis justo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus tristique rutrum nulla sed eleifend. Praesent at nunc arcu. Mauris condimentum faucibus nibh, eget commodo quam viverra sed. Morbi in tincidunt dolor. Morbi sed augue et augue interdum fermentum.
|
||||
|
||||
Curabitur tristique purus ac arcu consequat cursus. Cras diam felis, dignissim quis placerat at, aliquet ac metus. Mauris vulputate est eu nibh imperdiet varius. Cras aliquet rhoncus elit a laoreet. Mauris consectetur erat et erat scelerisque eu faucibus dolor consequat. Nam adipiscing sagittis nisl, eu mollis massa tempor ac. Nulla scelerisque tempus blandit. Phasellus ac ipsum eros, id posuere arcu. Nullam non sapien arcu. Vivamus sit amet lorem justo, ac tempus turpis. Suspendisse pharetra gravida imperdiet. Pellentesque lacinia mi eu elit luctus pellentesque. Sed accumsan libero a magna elementum varius. Nunc eget pellentesque metus. '''
|
||||
prompt: '''
|
||||
<h2>S11E3: Metal Bands</h2>
|
||||
<p>Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.</p>
|
||||
<p>* Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled? </p>
|
||||
<p>This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.</p>
|
||||
'''
|
||||
rubric: '''
|
||||
<table class="rubric"><tbody><tr><th>Purpose</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-0" value="0"><label for="score-0-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-1" value="1"><label for="score-0-1">Unclear purpose or main idea</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-2" value="2"><label for="score-0-2">Communicates an identifiable purpose and/or main idea for an audience</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-3" value="3"><label for="score-0-3">Achieves a clear and distinct purpose for a targeted audience and communicates main ideas with effectively used techniques to introduce and represent ideas and insights</label>
|
||||
</td>
|
||||
</tr><tr><th>Organization</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-0" value="0"><label for="score-1-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-1" value="1"><label for="score-1-1">Organization is unclear; introduction, body, and/or conclusion are underdeveloped, missing or confusing.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-2" value="2"><label for="score-1-2">Organization is occasionally unclear; introduction, body or conclusion may be underdeveloped.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-3" value="3"><label for="score-1-3">Organization is clear and easy to follow; introduction, body and conclusion are defined and aligned with purpose.</label>
|
||||
</td>
|
||||
</tr></tbody></table>
|
||||
'''
|
||||
max_score: 4
|
||||
else if cmd == 'save_calibration_essay'
|
||||
response =
|
||||
success: true
|
||||
actual_score: 2
|
||||
else if cmd == 'save_grade'
|
||||
response =
|
||||
success: true
|
||||
|
||||
return response
|
||||
|
||||
class @PeerGradingProblem
|
||||
constructor: (backend) ->
|
||||
@prompt_wrapper = $('.prompt-wrapper')
|
||||
@backend = backend
|
||||
|
||||
|
||||
# get the location of the problem
|
||||
@location = $('.peer-grading').data('location')
|
||||
# prevent this code from trying to run
|
||||
# when we don't have a location
|
||||
if(!@location)
|
||||
return
|
||||
|
||||
# get the other elements we want to fill in
|
||||
@submission_container = $('.submission-container')
|
||||
@prompt_container = $('.prompt-container')
|
||||
@rubric_container = $('.rubric-container')
|
||||
@flag_student_container = $('.flag-student-container')
|
||||
@calibration_panel = $('.calibration-panel')
|
||||
@grading_panel = $('.grading-panel')
|
||||
@content_panel = $('.content-panel')
|
||||
@grading_message = $('.grading-message')
|
||||
@grading_message.hide()
|
||||
|
||||
@grading_wrapper =$('.grading-wrapper')
|
||||
@calibration_feedback_panel = $('.calibration-feedback')
|
||||
@interstitial_page = $('.interstitial-page')
|
||||
@interstitial_page.hide()
|
||||
|
||||
@error_container = $('.error-container')
|
||||
|
||||
@submission_key_input = $("input[name='submission-key']")
|
||||
@essay_id_input = $("input[name='essay-id']")
|
||||
@feedback_area = $('.feedback-area')
|
||||
|
||||
@score_selection_container = $('.score-selection-container')
|
||||
@rubric_selection_container = $('.rubric-selection-container')
|
||||
@grade = null
|
||||
@calibration = null
|
||||
|
||||
@submit_button = $('.submit-button')
|
||||
@action_button = $('.action-button')
|
||||
@calibration_feedback_button = $('.calibration-feedback-button')
|
||||
@interstitial_page_button = $('.interstitial-page-button')
|
||||
@flag_student_checkbox = $('.flag-checkbox')
|
||||
|
||||
Collapsible.setCollapsibles(@content_panel)
|
||||
|
||||
# Set up the click event handlers
|
||||
@action_button.click -> history.back()
|
||||
@calibration_feedback_button.click =>
|
||||
@calibration_feedback_panel.hide()
|
||||
@grading_wrapper.show()
|
||||
@is_calibrated_check()
|
||||
|
||||
@interstitial_page_button.click =>
|
||||
@interstitial_page.hide()
|
||||
@is_calibrated_check()
|
||||
|
||||
@is_calibrated_check()
|
||||
|
||||
|
||||
##########
|
||||
#
|
||||
# Ajax calls to the backend
|
||||
#
|
||||
##########
|
||||
is_calibrated_check: () =>
|
||||
@backend.post('is_student_calibrated', {location: @location}, @calibration_check_callback)
|
||||
|
||||
fetch_calibration_essay: () =>
|
||||
@backend.post('show_calibration_essay', {location: @location}, @render_calibration)
|
||||
|
||||
fetch_submission_essay: () =>
|
||||
@backend.post('get_next_submission', {location: @location}, @render_submission)
|
||||
|
||||
|
||||
construct_data: () ->
|
||||
data =
|
||||
rubric_scores: Rubric.get_score_list()
|
||||
score: Rubric.get_total_score()
|
||||
location: @location
|
||||
submission_id: @essay_id_input.val()
|
||||
submission_key: @submission_key_input.val()
|
||||
feedback: @feedback_area.val()
|
||||
submission_flagged: @flag_student_checkbox.is(':checked')
|
||||
return data
|
||||
|
||||
|
||||
submit_calibration_essay: ()=>
|
||||
data = @construct_data()
|
||||
@backend.post('save_calibration_essay', data, @calibration_callback)
|
||||
|
||||
submit_grade: () =>
|
||||
data = @construct_data()
|
||||
@backend.post('save_grade', data, @submission_callback)
|
||||
|
||||
|
||||
##########
|
||||
#
|
||||
# Callbacks for various events
|
||||
#
|
||||
##########
|
||||
|
||||
# called after we perform an is_student_calibrated check
|
||||
calibration_check_callback: (response) =>
|
||||
if response.success
|
||||
# if we haven't been calibrating before
|
||||
if response.calibrated and (@calibration == null or @calibration == false)
|
||||
@calibration = false
|
||||
@fetch_submission_essay()
|
||||
# If we were calibrating before and no longer need to,
|
||||
# show the interstitial page
|
||||
else if response.calibrated and @calibration == true
|
||||
@calibration = false
|
||||
@render_interstitial_page()
|
||||
else
|
||||
@calibration = true
|
||||
@fetch_calibration_essay()
|
||||
else if response.error
|
||||
@render_error(response.error)
|
||||
else
|
||||
@render_error("Error contacting the grading service")
|
||||
|
||||
|
||||
# called after we submit a calibration score
|
||||
calibration_callback: (response) =>
|
||||
if response.success
|
||||
@render_calibration_feedback(response)
|
||||
else if response.error
|
||||
@render_error(response.error)
|
||||
else
|
||||
@render_error("Error saving calibration score")
|
||||
|
||||
# called after we submit a submission score
|
||||
submission_callback: (response) =>
|
||||
if response.success
|
||||
@is_calibrated_check()
|
||||
@grading_message.fadeIn()
|
||||
@grading_message.html("<p>Grade sent successfully.</p>")
|
||||
else
|
||||
if response.error
|
||||
@render_error(response.error)
|
||||
else
|
||||
@render_error("Error occurred while submitting grade")
|
||||
|
||||
# called after a grade is selected on the interface
|
||||
graded_callback: (event) =>
|
||||
# check to see whether or not any categories have not been scored
|
||||
if Rubric.check_complete()
|
||||
# show button if we have scores for all categories
|
||||
@show_submit_button()
|
||||
@grade = Rubric.get_total_score()
|
||||
|
||||
|
||||
|
||||
##########
|
||||
#
|
||||
# Rendering methods and helpers
|
||||
#
|
||||
##########
|
||||
# renders a calibration essay
|
||||
render_calibration: (response) =>
|
||||
if response.success
|
||||
|
||||
# load in all the data
|
||||
@submission_container.html("<h3>Training Essay</h3>")
|
||||
@render_submission_data(response)
|
||||
# TODO: indicate that we're in calibration mode
|
||||
@calibration_panel.addClass('current-state')
|
||||
@grading_panel.removeClass('current-state')
|
||||
|
||||
# Display the right text
|
||||
# both versions of the text are written into the template itself
|
||||
# we only need to show/hide the correct ones at the correct time
|
||||
@calibration_panel.find('.calibration-text').show()
|
||||
@grading_panel.find('.calibration-text').show()
|
||||
@calibration_panel.find('.grading-text').hide()
|
||||
@grading_panel.find('.grading-text').hide()
|
||||
@flag_student_container.hide()
|
||||
|
||||
@submit_button.unbind('click')
|
||||
@submit_button.click @submit_calibration_essay
|
||||
|
||||
else if response.error
|
||||
@render_error(response.error)
|
||||
else
|
||||
@render_error("An error occurred while retrieving the next calibration essay")
|
||||
|
||||
# Renders a student submission to be graded
|
||||
render_submission: (response) =>
|
||||
if response.success
|
||||
@submit_button.hide()
|
||||
@submission_container.html("<h3>Submitted Essay</h3>")
|
||||
@render_submission_data(response)
|
||||
|
||||
@calibration_panel.removeClass('current-state')
|
||||
@grading_panel.addClass('current-state')
|
||||
|
||||
# Display the correct text
|
||||
# both versions of the text are written into the template itself
|
||||
# we only need to show/hide the correct ones at the correct time
|
||||
@calibration_panel.find('.calibration-text').hide()
|
||||
@grading_panel.find('.calibration-text').hide()
|
||||
@calibration_panel.find('.grading-text').show()
|
||||
@grading_panel.find('.grading-text').show()
|
||||
@flag_student_container.show()
|
||||
|
||||
@submit_button.unbind('click')
|
||||
@submit_button.click @submit_grade
|
||||
else if response.error
|
||||
@render_error(response.error)
|
||||
else
|
||||
@render_error("An error occured when retrieving the next submission.")
|
||||
|
||||
|
||||
make_paragraphs: (text) ->
|
||||
paragraph_split = text.split(/\n\s*\n/)
|
||||
new_text = ''
|
||||
for paragraph in paragraph_split
|
||||
new_text += "<p>#{paragraph}</p>"
|
||||
return new_text
|
||||
|
||||
# render common information between calibration and grading
|
||||
render_submission_data: (response) =>
|
||||
@content_panel.show()
|
||||
@error_container.hide()
|
||||
|
||||
@submission_container.append(@make_paragraphs(response.student_response))
|
||||
@prompt_container.html(response.prompt)
|
||||
@rubric_selection_container.html(response.rubric)
|
||||
@submission_key_input.val(response.submission_key)
|
||||
@essay_id_input.val(response.submission_id)
|
||||
@setup_score_selection(response.max_score)
|
||||
|
||||
@submit_button.hide()
|
||||
@action_button.hide()
|
||||
@calibration_feedback_panel.hide()
|
||||
|
||||
|
||||
render_calibration_feedback: (response) =>
|
||||
# display correct grade
|
||||
@calibration_feedback_panel.slideDown()
|
||||
calibration_wrapper = $('.calibration-feedback-wrapper')
|
||||
calibration_wrapper.html("<p>The score you gave was: #{@grade}. The actual score is: #{response.actual_score}</p>")
|
||||
|
||||
|
||||
score = parseInt(@grade)
|
||||
actual_score = parseInt(response.actual_score)
|
||||
|
||||
if score == actual_score
|
||||
calibration_wrapper.append("<p>Congratulations! Your score matches the actual score!</p>")
|
||||
else
|
||||
calibration_wrapper.append("<p>Please try to understand the grading critera better to be more accurate next time.</p>")
|
||||
|
||||
# disable score selection and submission from the grading interface
|
||||
$("input[name='score-selection']").attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
|
||||
render_interstitial_page: () =>
|
||||
@content_panel.hide()
|
||||
@interstitial_page.show()
|
||||
|
||||
render_error: (error_message) =>
|
||||
@error_container.show()
|
||||
@calibration_feedback_panel.hide()
|
||||
@error_container.html(error_message)
|
||||
@content_panel.hide()
|
||||
@action_button.show()
|
||||
|
||||
show_submit_button: () =>
|
||||
@submit_button.show()
|
||||
|
||||
setup_score_selection: (max_score) =>
|
||||
# And now hook up an event handler again
|
||||
$("input[class='score-selection']").change @graded_callback
|
||||
@@ -20,7 +20,10 @@
|
||||
return module
|
||||
|
||||
catch error
|
||||
console.error "Unable to load #{moduleType}: #{error.message}" if console
|
||||
if window.console and console.log
|
||||
console.error "Unable to load #{moduleType}: #{error.message}"
|
||||
else
|
||||
throw error
|
||||
|
||||
###
|
||||
Load all modules on the page of the specified type.
|
||||
|
||||
@@ -34,7 +34,7 @@ class MakoModuleDescriptor(XModuleDescriptor):
|
||||
"""
|
||||
return {'module': self,
|
||||
'metadata': self.metadata,
|
||||
'editable_metadata_fields' : self.editable_metadata_fields
|
||||
'editable_metadata_fields': self.editable_metadata_fields
|
||||
}
|
||||
|
||||
def get_html(self):
|
||||
@@ -46,4 +46,3 @@ class MakoModuleDescriptor(XModuleDescriptor):
|
||||
def editable_metadata_fields(self):
|
||||
subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields]
|
||||
return subset
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ class ModuleStore(object):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_parent_locations(self, location, course_id):
|
||||
'''Find all locations that are the parents of this location in this
|
||||
'''Find all locations that are the parents of this location in this
|
||||
course. Needed for path_to_location().
|
||||
|
||||
returns an iterable of things that can be passed to Location.
|
||||
|
||||
@@ -67,7 +67,7 @@ class DraftModuleStore(ModuleStoreBase):
|
||||
TODO (vshnayder): this may want to live outside the modulestore eventually
|
||||
"""
|
||||
|
||||
# cdodge: we're forcing depth=0 here as the Draft store is not handling caching well
|
||||
# cdodge: we're forcing depth=0 here as the Draft store is not handling caching well
|
||||
try:
|
||||
return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, as_draft(location), depth=0))
|
||||
except ItemNotFoundError:
|
||||
|
||||
@@ -304,7 +304,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
if location.category == 'static_tab':
|
||||
course = self.get_course_for_item(item.location)
|
||||
existing_tabs = course.tabs or []
|
||||
existing_tabs.append({'type':'static_tab', 'name' : item.metadata.get('display_name'), 'url_slug' : item.location.name})
|
||||
existing_tabs.append({'type': 'static_tab', 'name': item.metadata.get('display_name'), 'url_slug': item.location.name})
|
||||
course.tabs = existing_tabs
|
||||
self.update_metadata(course.location, course.metadata)
|
||||
|
||||
@@ -423,7 +423,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
|
||||
def get_parent_locations(self, location, course_id):
|
||||
'''Find all locations that are the parents of this location in this
|
||||
'''Find all locations that are the parents of this location in this
|
||||
course. Needed for path_to_location().
|
||||
'''
|
||||
location = Location.ensure_fully_specified(location)
|
||||
|
||||
@@ -104,14 +104,14 @@ def path_to_location(modulestore, course_id, location):
|
||||
# module nested in more than one positional module will work.
|
||||
if n > 3:
|
||||
position_list = []
|
||||
for path_index in range(2, n-1):
|
||||
for path_index in range(2, n - 1):
|
||||
category = path[path_index].category
|
||||
if category == 'sequential' or category == 'videosequence':
|
||||
section_desc = modulestore.get_instance(course_id, path[path_index])
|
||||
child_locs = [c.location for c in section_desc.get_children()]
|
||||
# positions are 1-indexed, and should be strings to be consistent with
|
||||
# url parsing.
|
||||
position_list.append(str(child_locs.index(path[path_index+1]) + 1))
|
||||
position_list.append(str(child_locs.index(path[path_index + 1]) + 1))
|
||||
position = "_".join(position_list)
|
||||
|
||||
return (course_id, chapter, section, position)
|
||||
|
||||
@@ -3,6 +3,7 @@ from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
|
||||
|
||||
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
@@ -13,7 +14,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
if not modulestore.has_item(dest_location):
|
||||
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
|
||||
|
||||
# verify that the dest_location really is an empty course, which means only one
|
||||
# verify that the dest_location really is an empty course, which means only one
|
||||
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
|
||||
|
||||
if len(dest_modules) != 1:
|
||||
@@ -31,12 +32,12 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
original_loc = Location(module.location)
|
||||
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org,
|
||||
course = dest_location.course)
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org,
|
||||
course = dest_location.course, name=dest_location.name)
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course, name=dest_location.name)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
@@ -48,8 +49,8 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
new_children = []
|
||||
for child_loc_url in module.definition['children']:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(tag = dest_location.tag, org = dest_location.org,
|
||||
course = dest_location.course)
|
||||
child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
new_children = new_children + [child_loc.url()]
|
||||
|
||||
modulestore.update_children(module.location, new_children)
|
||||
@@ -63,8 +64,8 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
content = contentstore.find(thumb_loc)
|
||||
content.location = content.location._replace(org = dest_location.org,
|
||||
course = dest_location.course)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
|
||||
|
||||
@@ -76,13 +77,13 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
content = contentstore.find(asset_loc)
|
||||
content.location = content.location._replace(org = dest_location.org,
|
||||
course = dest_location.course)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
# be sure to update the pointer to the thumbnail
|
||||
if content.thumbnail_location is not None:
|
||||
content.thumbnail_location = content.thumbnail_location._replace(org = dest_location.org,
|
||||
course = dest_location.course)
|
||||
content.thumbnail_location = content.thumbnail_location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
|
||||
|
||||
@@ -90,6 +91,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_course(modulestore, contentstore, source_location):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
@@ -119,7 +121,7 @@ def delete_course(modulestore, contentstore, source_location):
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
for module in modules:
|
||||
if module.category != 'course': # save deleting the course module for last
|
||||
if module.category != 'course': # save deleting the course module for last
|
||||
print "Deleting {0}...".format(module.location)
|
||||
modulestore.delete_item(module.location)
|
||||
|
||||
@@ -127,4 +129,4 @@ def delete_course(modulestore, contentstore, source_location):
|
||||
print "Deleting {0}...".format(source_location)
|
||||
modulestore.delete_item(source_location)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -8,5 +8,3 @@ for i in range(5):
|
||||
TEST_DIR = TEST_DIR / 'test'
|
||||
|
||||
DATA_DIR = TEST_DIR / 'data'
|
||||
|
||||
|
||||
|
||||
126
common/lib/xmodule/xmodule/modulestore/tests/factories.py
Normal file
126
common/lib/xmodule/xmodule/modulestore/tests/factories.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from factory import Factory
|
||||
from time import gmtime
|
||||
from uuid import uuid4
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.timeparse import stringify_time
|
||||
|
||||
|
||||
def XMODULE_COURSE_CREATION(class_to_create, **kwargs):
|
||||
return XModuleCourseFactory._create(class_to_create, **kwargs)
|
||||
|
||||
|
||||
def XMODULE_ITEM_CREATION(class_to_create, **kwargs):
|
||||
return XModuleItemFactory._create(class_to_create, **kwargs)
|
||||
|
||||
|
||||
class XModuleCourseFactory(Factory):
|
||||
"""
|
||||
Factory for XModule courses.
|
||||
"""
|
||||
|
||||
ABSTRACT_FACTORY = True
|
||||
_creation_function = (XMODULE_COURSE_CREATION,)
|
||||
|
||||
@classmethod
|
||||
def _create(cls, target_class, *args, **kwargs):
|
||||
# This logic was taken from the create_new_course method in
|
||||
# cms/djangoapps/contentstore/views.py
|
||||
template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
|
||||
org = kwargs.get('org')
|
||||
number = kwargs.get('number')
|
||||
display_name = kwargs.get('display_name')
|
||||
location = Location('i4x', org, number,
|
||||
'course', Location.clean(display_name))
|
||||
|
||||
store = modulestore('direct')
|
||||
|
||||
# Write the data to the mongo datastore
|
||||
new_course = store.clone_item(template, location)
|
||||
|
||||
# This metadata code was copied from cms/djangoapps/contentstore/views.py
|
||||
if display_name is not None:
|
||||
new_course.metadata['display_name'] = display_name
|
||||
|
||||
new_course.metadata['data_dir'] = uuid4().hex
|
||||
new_course.metadata['start'] = stringify_time(gmtime())
|
||||
|
||||
new_course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
# Update the data in the mongo datastore
|
||||
store.update_metadata(new_course.location.url(), new_course.own_metadata)
|
||||
|
||||
return new_course
|
||||
|
||||
|
||||
class Course:
|
||||
pass
|
||||
|
||||
|
||||
class CourseFactory(XModuleCourseFactory):
|
||||
FACTORY_FOR = Course
|
||||
|
||||
template = 'i4x://edx/templates/course/Empty'
|
||||
org = 'MITx'
|
||||
number = '999'
|
||||
display_name = 'Robot Super Course'
|
||||
|
||||
|
||||
class XModuleItemFactory(Factory):
|
||||
"""
|
||||
Factory for XModule items.
|
||||
"""
|
||||
|
||||
ABSTRACT_FACTORY = True
|
||||
_creation_function = (XMODULE_ITEM_CREATION,)
|
||||
|
||||
@classmethod
|
||||
def _create(cls, target_class, *args, **kwargs):
|
||||
"""
|
||||
kwargs must include parent_location, template. Can contain display_name
|
||||
target_class is ignored
|
||||
"""
|
||||
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
parent_location = Location(kwargs.get('parent_location'))
|
||||
template = Location(kwargs.get('template'))
|
||||
display_name = kwargs.get('display_name')
|
||||
|
||||
store = modulestore('direct')
|
||||
|
||||
# This code was based off that in cms/djangoapps/contentstore/views.py
|
||||
parent = store.get_item(parent_location)
|
||||
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
|
||||
|
||||
new_item = store.clone_item(template, dest_location)
|
||||
|
||||
# TODO: This needs to be deleted when we have proper storage for static content
|
||||
new_item.metadata['data_dir'] = parent.metadata['data_dir']
|
||||
|
||||
# replace the display name with an optional parameter passed in from the caller
|
||||
if display_name is not None:
|
||||
new_item.metadata['display_name'] = display_name
|
||||
|
||||
store.update_metadata(new_item.location.url(), new_item.own_metadata)
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
|
||||
|
||||
return new_item
|
||||
|
||||
|
||||
class Item:
|
||||
pass
|
||||
|
||||
|
||||
class ItemFactory(XModuleItemFactory):
|
||||
FACTORY_FOR = Item
|
||||
|
||||
parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
|
||||
template = 'i4x://edx/templates/chapter/Empty'
|
||||
display_name = 'Section One'
|
||||
@@ -61,6 +61,7 @@ invalid = ("foo", ["foo"], ["foo", "bar"],
|
||||
invalid_dict,
|
||||
invalid_dict2)
|
||||
|
||||
|
||||
def test_is_valid():
|
||||
for v in valid:
|
||||
assert_equals(Location.is_valid(v), True)
|
||||
@@ -68,6 +69,7 @@ def test_is_valid():
|
||||
for v in invalid:
|
||||
assert_equals(Location.is_valid(v), False)
|
||||
|
||||
|
||||
def test_dict():
|
||||
assert_equals("tag://org/course/category/name", Location(input_dict).url())
|
||||
assert_equals(dict(revision=None, **input_dict), Location(input_dict).dict())
|
||||
@@ -76,6 +78,7 @@ def test_dict():
|
||||
assert_equals("tag://org/course/category/name@revision", Location(input_dict).url())
|
||||
assert_equals(input_dict, Location(input_dict).dict())
|
||||
|
||||
|
||||
def test_list():
|
||||
assert_equals("tag://org/course/category/name", Location(input_list).url())
|
||||
assert_equals(input_list + [None], Location(input_list).list())
|
||||
@@ -115,17 +118,18 @@ def test_equality():
|
||||
)
|
||||
|
||||
# All the cleaning functions should do the same thing with these
|
||||
general_pairs = [ ('',''),
|
||||
general_pairs = [('', ''),
|
||||
(' ', '_'),
|
||||
('abc,', 'abc_'),
|
||||
('ab fg!@//\\aj', 'ab_fg_aj'),
|
||||
(u"ab\xA9", "ab_"), # no unicode allowed for now
|
||||
]
|
||||
|
||||
|
||||
def test_clean():
|
||||
pairs = general_pairs + [
|
||||
('a:b', 'a_b'), # no colons in non-name components
|
||||
('a-b', 'a-b'), # dashes ok
|
||||
('a-b', 'a-b'), # dashes ok
|
||||
('a.b', 'a.b'), # dot ok
|
||||
]
|
||||
for input, output in pairs:
|
||||
|
||||
@@ -3,6 +3,7 @@ from nose.tools import assert_equals, assert_raises, assert_not_equals, with_set
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
|
||||
|
||||
def check_path_to_location(modulestore):
|
||||
'''Make sure that path_to_location works: should be passed a modulestore
|
||||
with the toy and simple courses loaded.'''
|
||||
@@ -22,4 +23,3 @@ def check_path_to_location(modulestore):
|
||||
)
|
||||
for location in not_found:
|
||||
assert_raises(ItemNotFoundError, path_to_location, modulestore, course_id, location)
|
||||
|
||||
|
||||
@@ -102,4 +102,3 @@ class TestMongoModuleStore(object):
|
||||
def test_path_to_location(self):
|
||||
'''Make sure that path_to_location works'''
|
||||
check_path_to_location(self.store)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from xmodule.modulestore.xml_importer import import_from_xml
|
||||
from .test_modulestore import check_path_to_location
|
||||
from . import DATA_DIR
|
||||
|
||||
|
||||
class TestXMLModuleStore(object):
|
||||
def test_path_to_location(self):
|
||||
"""Make sure that path_to_location works properly"""
|
||||
@@ -12,5 +13,5 @@ class TestXMLModuleStore(object):
|
||||
print "Starting import"
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple'])
|
||||
print "finished import"
|
||||
|
||||
|
||||
check_path_to_location(modulestore)
|
||||
|
||||
@@ -73,7 +73,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
|
||||
# VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check)
|
||||
|
||||
# tags that really need unique names--they store (or should store) state.
|
||||
need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence')
|
||||
need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'timelimit')
|
||||
|
||||
attr = xml_data.attrib
|
||||
tag = xml_data.tag
|
||||
@@ -363,7 +363,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
# been imported into the cms from xml
|
||||
course_file = StringIO(clean_out_mako_templating(course_file.read()))
|
||||
|
||||
course_data = etree.parse(course_file,parser=edx_xml_parser).getroot()
|
||||
course_data = etree.parse(course_file, parser=edx_xml_parser).getroot()
|
||||
|
||||
org = course_data.get('org')
|
||||
|
||||
@@ -437,7 +437,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
self.load_extra_content(system, course_descriptor, 'course_info', self.data_dir / course_dir / 'info', course_dir, url_name)
|
||||
|
||||
# now import all static tabs which are expected to be stored in
|
||||
# in <content_dir>/tabs or <content_dir>/tabs/<url_name>
|
||||
# in <content_dir>/tabs or <content_dir>/tabs/<url_name>
|
||||
self.load_extra_content(system, course_descriptor, 'static_tab', self.data_dir / course_dir / 'tabs', course_dir, url_name)
|
||||
|
||||
self.load_extra_content(system, course_descriptor, 'custom_tag_template', self.data_dir / course_dir / 'custom_tags', course_dir, url_name)
|
||||
@@ -454,12 +454,12 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
|
||||
# then look in a override folder based on the course run
|
||||
if os.path.isdir(base_dir / url_name):
|
||||
self._load_extra_content(system, course_descriptor, category, base_dir / url_name, course_dir)
|
||||
self._load_extra_content(system, course_descriptor, category, base_dir / url_name, course_dir)
|
||||
|
||||
|
||||
def _load_extra_content(self, system, course_descriptor, category, path, course_dir):
|
||||
|
||||
for filepath in glob.glob(path/ '*'):
|
||||
for filepath in glob.glob(path / '*'):
|
||||
if not os.path.isdir(filepath):
|
||||
with open(filepath) as f:
|
||||
try:
|
||||
@@ -467,7 +467,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
|
||||
slug = os.path.splitext(os.path.basename(filepath))[0]
|
||||
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
|
||||
module = HtmlDescriptor(system, definition={'data' : html}, **{'location' : loc})
|
||||
module = HtmlDescriptor(system, definition={'data': html}, **{'location': loc})
|
||||
# VS[compat]:
|
||||
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
|
||||
# from the course policy
|
||||
@@ -555,7 +555,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
Return a dictionary of course_dir -> [(msg, exception_str)], for each
|
||||
course_dir where course loading failed.
|
||||
"""
|
||||
return dict( (k, self.errored_courses[k].errors) for k in self.errored_courses)
|
||||
return dict((k, self.errored_courses[k].errors) for k in self.errored_courses)
|
||||
|
||||
def update_item(self, location, data):
|
||||
"""
|
||||
@@ -590,7 +590,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
raise NotImplementedError("XMLModuleStores are read-only")
|
||||
|
||||
def get_parent_locations(self, location, course_id):
|
||||
'''Find all locations that are the parents of this location in this
|
||||
'''Find all locations that are the parents of this location in this
|
||||
course. Needed for path_to_location().
|
||||
|
||||
returns an iterable of things that can be passed to Location. This may
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from fs.osfs import OSFS
|
||||
from json import dumps
|
||||
|
||||
|
||||
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir):
|
||||
|
||||
@@ -23,8 +25,17 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix = ''):
|
||||
# export the grading policy
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
|
||||
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
|
||||
grading_policy.write(dumps(course.definition['data']['grading_policy']))
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix=''):
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
|
||||
@@ -33,7 +44,3 @@ def export_extra_content(export_fs, modulestore, course_location, category_type,
|
||||
for item in items:
|
||||
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
|
||||
item_file.write(item.definition['data'].encode('utf8'))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def import_static_content(modules, course_loc, course_data_path, static_content_store, target_location_namespace,
|
||||
subpath = 'static', verbose=False):
|
||||
|
||||
|
||||
def import_static_content(modules, course_loc, course_data_path, static_content_store, target_location_namespace,
|
||||
subpath='static', verbose=False):
|
||||
|
||||
remap_dict = {}
|
||||
|
||||
# now import all static assets
|
||||
@@ -36,7 +37,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
|
||||
with open(content_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
content = StaticContent(content_loc, filename, mime_type, data, import_path = fullname_with_subpath)
|
||||
content = StaticContent(content_loc, filename, mime_type, data, import_path=fullname_with_subpath)
|
||||
|
||||
# first let's save a thumbnail so we can get back a thumbnail location
|
||||
(thumbnail_content, thumbnail_location) = static_content_store.generate_thumbnail(content)
|
||||
@@ -50,11 +51,12 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
|
||||
#store the remapping information which will be needed to subsitute in the module data
|
||||
remap_dict[fullname_with_subpath] = content_loc.name
|
||||
except:
|
||||
raise
|
||||
raise
|
||||
|
||||
return remap_dict
|
||||
|
||||
def verify_content_links(module, base_dir, static_content_store, link, remap_dict = None):
|
||||
|
||||
def verify_content_links(module, base_dir, static_content_store, link, remap_dict=None):
|
||||
if link.startswith('/static/'):
|
||||
# yes, then parse out the name
|
||||
path = link[len('/static/'):]
|
||||
@@ -70,7 +72,7 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic
|
||||
with open(static_pathname, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
content = StaticContent(content_loc, filename, mime_type, data, import_path = path)
|
||||
content = StaticContent(content_loc, filename, mime_type, data, import_path=path)
|
||||
|
||||
# first let's save a thumbnail so we can get back a thumbnail location
|
||||
(thumbnail_content, thumbnail_location) = static_content_store.generate_thumbnail(content)
|
||||
@@ -79,20 +81,21 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
#then commit the content
|
||||
static_content_store.save(content)
|
||||
static_content_store.save(content)
|
||||
|
||||
new_link = StaticContent.get_url_path_from_location(content_loc)
|
||||
new_link = StaticContent.get_url_path_from_location(content_loc)
|
||||
|
||||
if remap_dict is not None:
|
||||
remap_dict[link] = new_link
|
||||
|
||||
return new_link
|
||||
return new_link
|
||||
except Exception, e:
|
||||
logging.exception('Skipping failed content load from {0}. Exception: {1}'.format(path, e))
|
||||
|
||||
return link
|
||||
|
||||
def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
def import_from_xml(store, data_dir, course_dirs=None,
|
||||
default_class='xmodule.raw_module.RawDescriptor',
|
||||
load_error_modules=True, static_content_store=None, target_location_namespace=None, verbose=False):
|
||||
"""
|
||||
@@ -108,7 +111,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
the policy.json. so we need to keep the original url_name during import
|
||||
|
||||
"""
|
||||
|
||||
|
||||
module_store = XMLModuleStore(
|
||||
data_dir,
|
||||
default_class=default_class,
|
||||
@@ -137,12 +140,12 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
module = remap_namespace(module, target_location_namespace)
|
||||
|
||||
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
|
||||
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
|
||||
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
|
||||
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
|
||||
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
|
||||
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
|
||||
# if there is *any* tabs - then there at least needs to be some predefined ones
|
||||
if module.tabs is None or len(module.tabs) == 0:
|
||||
module.tabs = [{"type": "courseware"},
|
||||
module.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
|
||||
@@ -159,13 +162,13 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
course_items.append(module)
|
||||
|
||||
|
||||
|
||||
# then import all the static content
|
||||
if static_content_store is not None:
|
||||
_namespace_rename = target_location_namespace if target_location_namespace is not None else course_location
|
||||
|
||||
|
||||
# first pass to find everything in /static/
|
||||
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
|
||||
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
|
||||
_namespace_rename, subpath='static', verbose=verbose)
|
||||
|
||||
# finally loop through all the modules
|
||||
@@ -188,18 +191,18 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
# cdodge: now go through any link references to '/static/' and make sure we've imported
|
||||
# it as a StaticContent asset
|
||||
try:
|
||||
try:
|
||||
remap_dict = {}
|
||||
|
||||
# use the rewrite_links as a utility means to enumerate through all links
|
||||
# in the module data. We use that to load that reference into our asset store
|
||||
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
|
||||
# do the rewrites natively in that code.
|
||||
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
|
||||
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
|
||||
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
|
||||
# no good, so we have to do this kludge
|
||||
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
|
||||
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path,
|
||||
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path,
|
||||
static_content_store, link, remap_dict))
|
||||
|
||||
for key in remap_dict.keys():
|
||||
@@ -219,17 +222,18 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
return module_store, course_items
|
||||
|
||||
|
||||
def remap_namespace(module, target_location_namespace):
|
||||
if target_location_namespace is None:
|
||||
return module
|
||||
|
||||
|
||||
# This looks a bit wonky as we need to also change the 'name' of the imported course to be what
|
||||
# the caller passed in
|
||||
if module.location.category != 'course':
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
else:
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course, name=target_location_namespace.name)
|
||||
|
||||
# then remap children pointers since they too will be re-namespaced
|
||||
@@ -238,15 +242,16 @@ def remap_namespace(module, target_location_namespace):
|
||||
new_locs = []
|
||||
for child in children_locs:
|
||||
child_loc = Location(child)
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
|
||||
new_locs.append(new_child_loc.url())
|
||||
|
||||
module.definition['children'] = new_locs
|
||||
module.definition['children'] = new_locs
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def validate_category_hierarchy(module_store, course_id, parent_category, expected_child_category):
|
||||
err_cnt = 0
|
||||
|
||||
@@ -265,7 +270,8 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect
|
||||
|
||||
return err_cnt
|
||||
|
||||
def validate_data_source_path_existence(path, is_err = True, extra_msg = None):
|
||||
|
||||
def validate_data_source_path_existence(path, is_err=True, extra_msg=None):
|
||||
_cnt = 0
|
||||
if not os.path.exists(path):
|
||||
print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if
|
||||
@@ -273,18 +279,19 @@ def validate_data_source_path_existence(path, is_err = True, extra_msg = None):
|
||||
_cnt = 1
|
||||
return _cnt
|
||||
|
||||
|
||||
def validate_data_source_paths(data_dir, course_dir):
|
||||
# check that there is a '/static/' directory
|
||||
course_path = data_dir / course_dir
|
||||
err_cnt = 0
|
||||
warn_cnt = 0
|
||||
err_cnt += validate_data_source_path_existence(course_path / 'static')
|
||||
warn_cnt += validate_data_source_path_existence(course_path / 'static/subs', is_err = False,
|
||||
extra_msg = 'Video captions (if they are used) will not work unless they are static/subs.')
|
||||
warn_cnt += validate_data_source_path_existence(course_path / 'static/subs', is_err=False,
|
||||
extra_msg='Video captions (if they are used) will not work unless they are static/subs.')
|
||||
return err_cnt, warn_cnt
|
||||
|
||||
|
||||
def perform_xlint(data_dir, course_dirs,
|
||||
def perform_xlint(data_dir, course_dirs,
|
||||
default_class='xmodule.raw_module.RawDescriptor',
|
||||
load_error_modules=True):
|
||||
err_cnt = 0
|
||||
@@ -308,9 +315,9 @@ def perform_xlint(data_dir, course_dirs,
|
||||
for err_log_entry in err_log.errors:
|
||||
msg = err_log_entry[0]
|
||||
if msg.startswith('ERROR:'):
|
||||
err_cnt+=1
|
||||
err_cnt += 1
|
||||
else:
|
||||
warn_cnt+=1
|
||||
warn_cnt += 1
|
||||
|
||||
# then count outright all courses that failed to load at all
|
||||
for err_log in module_store.errored_courses.itervalues():
|
||||
@@ -318,9 +325,9 @@ def perform_xlint(data_dir, course_dirs,
|
||||
msg = err_log_entry[0]
|
||||
print msg
|
||||
if msg.startswith('ERROR:'):
|
||||
err_cnt+=1
|
||||
err_cnt += 1
|
||||
else:
|
||||
warn_cnt+=1
|
||||
warn_cnt += 1
|
||||
|
||||
for course_id in module_store.modules.keys():
|
||||
# constrain that courses only have 'chapter' children
|
||||
@@ -345,6 +352,3 @@ def perform_xlint(data_dir, course_dirs,
|
||||
print "This course can be imported, but some errors may occur during the run of the course. It is recommend that you fix your courseware before importing"
|
||||
else:
|
||||
print "This course can be imported successfully."
|
||||
|
||||
|
||||
|
||||
|
||||
272
common/lib/xmodule/xmodule/open_ended_image_submission.py
Normal file
272
common/lib/xmodule/xmodule/open_ended_image_submission.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
This contains functions and classes used to evaluate if images are acceptable (do not show improper content, etc), and
|
||||
to send them to S3.
|
||||
"""
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
ENABLE_PIL = True
|
||||
except:
|
||||
ENABLE_PIL = False
|
||||
|
||||
from urlparse import urlparse
|
||||
import requests
|
||||
from boto.s3.connection import S3Connection
|
||||
from boto.s3.key import Key
|
||||
#TODO: Settings import is needed now in order to specify the URL and keys for amazon s3 (to upload images).
|
||||
#Eventually, the goal is to replace the global django settings import with settings specifically
|
||||
#for this module. There is no easy way to do this now, so piggybacking on the django settings
|
||||
#makes sense.
|
||||
from django.conf import settings
|
||||
import pickle
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#Domains where any image linked to can be trusted to have acceptable content.
|
||||
TRUSTED_IMAGE_DOMAINS = [
|
||||
'wikipedia',
|
||||
'edxuploads.s3.amazonaws.com',
|
||||
'wikimedia',
|
||||
]
|
||||
|
||||
#Suffixes that are allowed in image urls
|
||||
ALLOWABLE_IMAGE_SUFFIXES = [
|
||||
'jpg',
|
||||
'png',
|
||||
'gif',
|
||||
'jpeg'
|
||||
]
|
||||
|
||||
#Maximum allowed dimensions (x and y) for an uploaded image
|
||||
MAX_ALLOWED_IMAGE_DIM = 1500
|
||||
|
||||
#Dimensions to which image is resized before it is evaluated for color count, etc
|
||||
MAX_IMAGE_DIM = 150
|
||||
|
||||
#Maximum number of colors that should be counted in ImageProperties
|
||||
MAX_COLORS_TO_COUNT = 16
|
||||
|
||||
#Maximum number of colors allowed in an uploaded image
|
||||
MAX_COLORS = 400
|
||||
|
||||
|
||||
class ImageProperties(object):
|
||||
"""
|
||||
Class to check properties of an image and to validate if they are allowed.
|
||||
"""
|
||||
def __init__(self, image_data):
|
||||
"""
|
||||
Initializes class variables
|
||||
@param image: Image object (from PIL)
|
||||
@return: None
|
||||
"""
|
||||
self.image = Image.open(image_data)
|
||||
image_size = self.image.size
|
||||
self.image_too_large = False
|
||||
if image_size[0] > MAX_ALLOWED_IMAGE_DIM or image_size[1] > MAX_ALLOWED_IMAGE_DIM:
|
||||
self.image_too_large = True
|
||||
if image_size[0] > MAX_IMAGE_DIM or image_size[1] > MAX_IMAGE_DIM:
|
||||
self.image = self.image.resize((MAX_IMAGE_DIM, MAX_IMAGE_DIM))
|
||||
self.image_size = self.image.size
|
||||
|
||||
def count_colors(self):
|
||||
"""
|
||||
Counts the number of colors in an image, and matches them to the max allowed
|
||||
@return: boolean true if color count is acceptable, false otherwise
|
||||
"""
|
||||
colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
|
||||
if colors is None:
|
||||
color_count = MAX_COLORS_TO_COUNT
|
||||
else:
|
||||
color_count = len(colors)
|
||||
|
||||
too_many_colors = (color_count <= MAX_COLORS)
|
||||
return too_many_colors
|
||||
|
||||
def check_if_rgb_is_skin(self, rgb):
|
||||
"""
|
||||
Checks if a given input rgb tuple/list is a skin tone
|
||||
@param rgb: RGB tuple
|
||||
@return: Boolean true false
|
||||
"""
|
||||
colors_okay = False
|
||||
try:
|
||||
r = rgb[0]
|
||||
g = rgb[1]
|
||||
b = rgb[2]
|
||||
check_r = (r > 60)
|
||||
check_g = (r * 0.4) < g < (r * 0.85)
|
||||
check_b = (r * 0.2) < b < (r * 0.7)
|
||||
colors_okay = check_r and check_b and check_g
|
||||
except:
|
||||
pass
|
||||
|
||||
return colors_okay
|
||||
|
||||
def get_skin_ratio(self):
|
||||
"""
|
||||
Gets the ratio of skin tone colors in an image
|
||||
@return: True if the ratio is low enough to be acceptable, false otherwise
|
||||
"""
|
||||
colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
|
||||
is_okay = True
|
||||
if colors is not None:
|
||||
skin = sum([count for count, rgb in colors if self.check_if_rgb_is_skin(rgb)])
|
||||
total_colored_pixels = sum([count for count, rgb in colors])
|
||||
bad_color_val = float(skin) / total_colored_pixels
|
||||
if bad_color_val > .4:
|
||||
is_okay = False
|
||||
|
||||
return is_okay
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Does all available checks on an image to ensure that it is okay (size, skin ratio, colors)
|
||||
@return: Boolean indicating whether or not image passes all checks
|
||||
"""
|
||||
image_is_okay = False
|
||||
try:
|
||||
#image_is_okay = self.count_colors() and self.get_skin_ratio() and not self.image_too_large
|
||||
image_is_okay = not self.image_too_large
|
||||
except:
|
||||
log.exception("Could not run image tests.")
|
||||
|
||||
if not ENABLE_PIL:
|
||||
image_is_okay = True
|
||||
|
||||
#log.debug("Image OK: {0}".format(image_is_okay))
|
||||
|
||||
return image_is_okay
|
||||
|
||||
|
||||
class URLProperties(object):
|
||||
"""
|
||||
Checks to see if a URL points to acceptable content. Added to check if students are submitting reasonable
|
||||
links to the peer grading image functionality of the external grading service.
|
||||
"""
|
||||
def __init__(self, url_string):
|
||||
self.url_string = url_string
|
||||
|
||||
def check_if_parses(self):
|
||||
"""
|
||||
Check to see if a URL parses properly
|
||||
@return: success (True if parses, false if not)
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
self.parsed_url = urlparse(self.url_string)
|
||||
success = True
|
||||
except:
|
||||
pass
|
||||
|
||||
return success
|
||||
|
||||
def check_suffix(self):
|
||||
"""
|
||||
Checks the suffix of a url to make sure that it is allowed
|
||||
@return: True if suffix is okay, false if not
|
||||
"""
|
||||
good_suffix = False
|
||||
for suffix in ALLOWABLE_IMAGE_SUFFIXES:
|
||||
if self.url_string.endswith(suffix):
|
||||
good_suffix = True
|
||||
break
|
||||
return good_suffix
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Runs all available url tests
|
||||
@return: True if URL passes tests, false if not.
|
||||
"""
|
||||
url_is_okay = self.check_suffix() and self.check_if_parses() and self.check_domain()
|
||||
return url_is_okay
|
||||
|
||||
def check_domain(self):
|
||||
"""
|
||||
Checks to see if url is from a trusted domain
|
||||
"""
|
||||
success = False
|
||||
for domain in TRUSTED_IMAGE_DOMAINS:
|
||||
if domain in self.url_string:
|
||||
success = True
|
||||
return success
|
||||
return success
|
||||
|
||||
|
||||
def run_url_tests(url_string):
|
||||
"""
|
||||
Creates a URLProperties object and runs all tests
|
||||
@param url_string: A URL in string format
|
||||
@return: Boolean indicating whether or not URL has passed all tests
|
||||
"""
|
||||
url_properties = URLProperties(url_string)
|
||||
return url_properties.run_tests()
|
||||
|
||||
|
||||
def run_image_tests(image):
|
||||
"""
|
||||
Runs all available image tests
|
||||
@param image: PIL Image object
|
||||
@return: Boolean indicating whether or not all tests have been passed
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
image_properties = ImageProperties(image)
|
||||
success = image_properties.run_tests()
|
||||
except:
|
||||
log.exception("Cannot run image tests in combined open ended xmodule. May be an issue with a particular image,"
|
||||
"or an issue with the deployment configuration of PIL/Pillow")
|
||||
return success
|
||||
|
||||
|
||||
def upload_to_s3(file_to_upload, keyname):
|
||||
'''
|
||||
Upload file to S3 using provided keyname.
|
||||
|
||||
Returns:
|
||||
public_url: URL to access uploaded file
|
||||
'''
|
||||
|
||||
#This commented out code is kept here in case we change the uploading method and require images to be
|
||||
#converted before they are sent to S3.
|
||||
#TODO: determine if commented code is needed and remove
|
||||
#im = Image.open(file_to_upload)
|
||||
#out_im = cStringIO.StringIO()
|
||||
#im.save(out_im, 'PNG')
|
||||
|
||||
try:
|
||||
conn = S3Connection(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
|
||||
bucketname = str(settings.AWS_STORAGE_BUCKET_NAME)
|
||||
bucket = conn.create_bucket(bucketname.lower())
|
||||
|
||||
k = Key(bucket)
|
||||
k.key = keyname
|
||||
k.set_metadata('filename', file_to_upload.name)
|
||||
k.set_contents_from_file(file_to_upload)
|
||||
|
||||
#This commented out code is kept here in case we change the uploading method and require images to be
|
||||
#converted before they are sent to S3.
|
||||
#k.set_contents_from_string(out_im.getvalue())
|
||||
#k.set_metadata("Content-Type", 'images/png')
|
||||
|
||||
k.set_acl("public-read")
|
||||
public_url = k.generate_url(60 * 60 * 24 * 365) # URL timeout in seconds.
|
||||
|
||||
return True, public_url
|
||||
except:
|
||||
error_message = "Could not connect to S3."
|
||||
log.exception(error_message)
|
||||
return False, error_message
|
||||
|
||||
|
||||
def get_from_s3(s3_public_url):
|
||||
"""
|
||||
Gets an image from a given S3 url
|
||||
@param s3_public_url: The URL where an image is located
|
||||
@return: The image data
|
||||
"""
|
||||
r = requests.get(s3_public_url, timeout=2)
|
||||
data = r.text
|
||||
return data
|
||||
@@ -38,6 +38,7 @@ from combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
|
||||
class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
"""
|
||||
The open ended module supports all external open ended grader problems.
|
||||
@@ -258,7 +259,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
"""
|
||||
new_score_msg = self._parse_score_msg(score_msg, system)
|
||||
if not new_score_msg['valid']:
|
||||
score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
|
||||
new_score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
|
||||
|
||||
self.record_latest_score(new_score_msg['score'])
|
||||
self.record_latest_post_assessment(score_msg)
|
||||
@@ -300,7 +301,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
|
||||
# We want to display available feedback in a particular order.
|
||||
# This dictionary specifies which goes first--lower first.
|
||||
priorities = {# These go at the start of the feedback
|
||||
priorities = { # These go at the start of the feedback
|
||||
'spelling': 0,
|
||||
'grammar': 1,
|
||||
# needs to be after all the other feedback
|
||||
@@ -378,12 +379,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
Return error message or feedback template
|
||||
"""
|
||||
|
||||
log.debug(response_items)
|
||||
rubric_feedback=""
|
||||
rubric_feedback = ""
|
||||
feedback = self._convert_longform_feedback_to_html(response_items)
|
||||
if response_items['rubric_scores_complete']==True:
|
||||
if response_items['rubric_scores_complete'] == True:
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_feedback = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
success, rubric_feedback = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
|
||||
if not response_items['success']:
|
||||
return system.render_template("open_ended_error.html",
|
||||
@@ -393,7 +393,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'grader_type': response_items['grader_type'],
|
||||
'score': "{0} / {1}".format(response_items['score'], self.max_score()),
|
||||
'feedback': feedback,
|
||||
'rubric_feedback' : rubric_feedback
|
||||
'rubric_feedback': rubric_feedback
|
||||
})
|
||||
|
||||
return feedback_template
|
||||
@@ -406,6 +406,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'score': Numeric value (floating point is okay) to assign to answer
|
||||
'msg': grader_msg
|
||||
'feedback' : feedback from grader
|
||||
'grader_type': what type of grader resulted in this score
|
||||
'grader_id': id of the grader
|
||||
'submission_id' : id of the submission
|
||||
'success': whether or not this submission was successful
|
||||
'rubric_scores': a list of rubric scores
|
||||
'rubric_scores_complete': boolean if rubric scores are complete
|
||||
'rubric_xml': the xml of the rubric in string format
|
||||
}
|
||||
|
||||
Returns (valid_score_msg, correct, score, msg):
|
||||
@@ -437,7 +444,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
log.error(error_message)
|
||||
fail['feedback'] = error_message
|
||||
return fail
|
||||
#This is to support peer grading
|
||||
#This is to support peer grading
|
||||
if isinstance(score_result['score'], list):
|
||||
feedback_items = []
|
||||
for i in xrange(0, len(score_result['score'])):
|
||||
@@ -448,8 +455,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'success': score_result['success'],
|
||||
'grader_id': score_result['grader_id'][i],
|
||||
'submission_id': score_result['submission_id'],
|
||||
'rubric_scores_complete' : score_result['rubric_scores_complete'][i],
|
||||
'rubric_xml' : score_result['rubric_xml'][i],
|
||||
'rubric_scores_complete': score_result['rubric_scores_complete'][i],
|
||||
'rubric_xml': score_result['rubric_xml'][i],
|
||||
}
|
||||
feedback_items.append(self._format_feedback(new_score_result, system))
|
||||
if join_feedback:
|
||||
@@ -476,7 +483,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
if not self.history:
|
||||
return ""
|
||||
|
||||
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system, join_feedback=join_feedback)
|
||||
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system,
|
||||
join_feedback=join_feedback)
|
||||
if not short_feedback:
|
||||
return feedback_dict['feedback'] if feedback_dict['valid'] else ''
|
||||
if feedback_dict['valid']:
|
||||
@@ -541,24 +549,31 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
@param system: modulesystem
|
||||
@return: Success indicator
|
||||
"""
|
||||
if self.attempts > self.max_attempts:
|
||||
# If too many attempts, prevent student from saving answer and
|
||||
# seeing rubric. In normal use, students shouldn't see this because
|
||||
# they won't see the reset button once they're out of attempts.
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
# Once we close the problem, we should not allow students
|
||||
# to save answers
|
||||
closed, msg = self.check_if_closed()
|
||||
if closed:
|
||||
return msg
|
||||
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.send_to_grader(get['student_answer'], system)
|
||||
self.change_state(self.ASSESSING)
|
||||
success, get = self.append_image_to_student_answer(get)
|
||||
error_message = ""
|
||||
if success:
|
||||
get['student_answer'] = OpenEndedModule.sanitize_html(get['student_answer'])
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.send_to_grader(get['student_answer'], system)
|
||||
self.change_state(self.ASSESSING)
|
||||
else:
|
||||
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
|
||||
|
||||
return {'success': True, }
|
||||
return {
|
||||
'success': True,
|
||||
'error': error_message,
|
||||
'student_response': get['student_answer']
|
||||
}
|
||||
|
||||
def update_score(self, get, system):
|
||||
"""
|
||||
@@ -602,8 +617,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'msg': post_assessment,
|
||||
'child_type': 'openended',
|
||||
'correct': correct,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
log.debug(context)
|
||||
html = system.render_template('open_ended.html', context)
|
||||
return html
|
||||
|
||||
@@ -657,5 +672,3 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from lxml.html.clean import Cleaner, autolink_html
|
||||
from path import path
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
import re
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
@@ -21,6 +23,7 @@ from .stringify import stringify_children
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from capa.util import *
|
||||
import open_ended_image_submission
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -35,6 +38,7 @@ MAX_ATTEMPTS = 1
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
|
||||
class OpenEndedChild(object):
|
||||
"""
|
||||
States:
|
||||
@@ -70,7 +74,7 @@ class OpenEndedChild(object):
|
||||
'done': 'Problem complete',
|
||||
}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor, static_data,
|
||||
def __init__(self, system, location, definition, descriptor, static_data,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
@@ -94,6 +98,8 @@ class OpenEndedChild(object):
|
||||
self.prompt = static_data['prompt']
|
||||
self.rubric = static_data['rubric']
|
||||
self.display_name = static_data['display_name']
|
||||
self.accept_file_upload = static_data['accept_file_upload']
|
||||
self.close_date = static_data['close_date']
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
@@ -112,8 +118,29 @@ class OpenEndedChild(object):
|
||||
"""
|
||||
pass
|
||||
|
||||
def closed(self):
|
||||
if self.close_date is not None and datetime.utcnow() > self.close_date:
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_if_closed(self):
|
||||
if self.closed():
|
||||
return True, {
|
||||
'success': False,
|
||||
'error': 'This problem is now closed.'
|
||||
}
|
||||
elif self.attempts > self.max_attempts:
|
||||
return True, {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
else:
|
||||
return False, {}
|
||||
|
||||
|
||||
|
||||
def latest_answer(self):
|
||||
"""None if not available"""
|
||||
"""Empty string if not available"""
|
||||
if not self.history:
|
||||
return ""
|
||||
return self.history[-1].get('answer', "")
|
||||
@@ -125,17 +152,31 @@ class OpenEndedChild(object):
|
||||
return self.history[-1].get('score')
|
||||
|
||||
def latest_post_assessment(self, system):
|
||||
"""None if not available"""
|
||||
"""Empty string if not available"""
|
||||
if not self.history:
|
||||
return ""
|
||||
return self.history[-1].get('post_assessment', "")
|
||||
|
||||
@staticmethod
|
||||
def sanitize_html(answer):
|
||||
try:
|
||||
answer = autolink_html(answer)
|
||||
cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True,
|
||||
host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS,
|
||||
whitelist_tags=set(['embed', 'iframe', 'a', 'img']))
|
||||
clean_html = cleaner.clean_html(answer)
|
||||
clean_html = re.sub(r'</p>$', '', re.sub(r'^<p>', '', clean_html))
|
||||
except:
|
||||
clean_html = answer
|
||||
return clean_html
|
||||
|
||||
def new_history_entry(self, answer):
|
||||
"""
|
||||
Adds a new entry to the history dictionary
|
||||
@param answer: The student supplied answer
|
||||
@return: None
|
||||
"""
|
||||
answer = OpenEndedChild.sanitize_html(answer)
|
||||
self.history.append({'answer': answer})
|
||||
|
||||
def record_latest_score(self, score):
|
||||
@@ -260,5 +301,112 @@ class OpenEndedChild(object):
|
||||
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
|
||||
return correctness
|
||||
|
||||
def upload_image_to_s3(self, image_data):
|
||||
"""
|
||||
Uploads an image to S3
|
||||
Image_data: InMemoryUploadedFileObject that responds to read() and seek()
|
||||
@return:Success and a URL corresponding to the uploaded object
|
||||
"""
|
||||
success = False
|
||||
s3_public_url = ""
|
||||
image_ok = False
|
||||
try:
|
||||
image_data.seek(0)
|
||||
image_ok = open_ended_image_submission.run_image_tests(image_data)
|
||||
except:
|
||||
log.exception("Could not create image and check it.")
|
||||
|
||||
if image_ok:
|
||||
image_key = image_data.name + datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
try:
|
||||
image_data.seek(0)
|
||||
success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key)
|
||||
except:
|
||||
log.exception("Could not upload image to S3.")
|
||||
|
||||
return success, image_ok, s3_public_url
|
||||
|
||||
def check_for_image_and_upload(self, get_data):
|
||||
"""
|
||||
Checks to see if an image was passed back in the AJAX query. If so, it will upload it to S3
|
||||
@param get_data: AJAX get data
|
||||
@return: Success, whether or not a file was in the get dictionary,
|
||||
and the html corresponding to the uploaded image
|
||||
"""
|
||||
has_file_to_upload = False
|
||||
uploaded_to_s3 = False
|
||||
image_tag = ""
|
||||
image_ok = False
|
||||
if 'can_upload_files' in get_data:
|
||||
if get_data['can_upload_files'] in ['true', '1']:
|
||||
has_file_to_upload = True
|
||||
file = get_data['student_file'][0]
|
||||
uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file)
|
||||
if uploaded_to_s3:
|
||||
image_tag = self.generate_image_tag_from_url(s3_public_url, file.name)
|
||||
|
||||
return has_file_to_upload, uploaded_to_s3, image_ok, image_tag
|
||||
|
||||
def generate_image_tag_from_url(self, s3_public_url, image_name):
|
||||
"""
|
||||
Makes an image tag from a given URL
|
||||
@param s3_public_url: URL of the image
|
||||
@param image_name: Name of the image
|
||||
@return: Boolean success, updated AJAX get data
|
||||
"""
|
||||
image_template = """
|
||||
<a href="{0}" target="_blank">{1}</a>
|
||||
""".format(s3_public_url, image_name)
|
||||
return image_template
|
||||
|
||||
def append_image_to_student_answer(self, get_data):
|
||||
"""
|
||||
Adds an image to a student answer after uploading it to S3
|
||||
@param get_data: AJAx get data
|
||||
@return: Boolean success, updated AJAX get data
|
||||
"""
|
||||
overall_success = False
|
||||
if not self.accept_file_upload:
|
||||
#If the question does not accept file uploads, do not do anything
|
||||
return True, get_data
|
||||
|
||||
has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(get_data)
|
||||
if uploaded_to_s3 and has_file_to_upload and image_ok:
|
||||
get_data['student_answer'] += image_tag
|
||||
overall_success = True
|
||||
elif has_file_to_upload and not uploaded_to_s3 and image_ok:
|
||||
#In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely
|
||||
#a config issue (development vs deployment). For now, just treat this as a "success"
|
||||
log.exception("Student AJAX post to combined open ended xmodule indicated that it contained an image, "
|
||||
"but the image was not able to be uploaded to S3. This could indicate a config"
|
||||
"issue with this deployment, but it could also indicate a problem with S3 or with the"
|
||||
"student image itself.")
|
||||
overall_success = True
|
||||
elif not has_file_to_upload:
|
||||
#If there is no file to upload, probably the student has embedded the link in the answer text
|
||||
success, get_data['student_answer'] = self.check_for_url_in_text(get_data['student_answer'])
|
||||
overall_success = success
|
||||
|
||||
#log.debug("Has file: {0} Uploaded: {1} Image Ok: {2}".format(has_file_to_upload, uploaded_to_s3, image_ok))
|
||||
|
||||
return overall_success, get_data
|
||||
|
||||
def check_for_url_in_text(self, string):
|
||||
"""
|
||||
Checks for urls in a string
|
||||
@param string: Arbitrary string
|
||||
@return: Boolean success, the edited string
|
||||
"""
|
||||
success = False
|
||||
links = re.findall(r'(https?://\S+)', string)
|
||||
if len(links) > 0:
|
||||
for link in links:
|
||||
success = open_ended_image_submission.run_url_tests(link)
|
||||
if not success:
|
||||
string = re.sub(link, '', string)
|
||||
else:
|
||||
string = re.sub(link, self.generate_image_tag_from_url(link, link), string)
|
||||
success = True
|
||||
|
||||
return success, string
|
||||
|
||||
539
common/lib/xmodule/xmodule/peer_grading_module.py
Normal file
539
common/lib/xmodule/xmodule/peer_grading_module.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
This module provides an interface on the grading-service backend
|
||||
for peer grading
|
||||
|
||||
Use peer_grading_service() to get the version specified
|
||||
in settings.PEER_GRADING_INTERFACE
|
||||
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
from lxml import etree
|
||||
|
||||
import copy
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
from lxml.html import rewrite_links
|
||||
import os
|
||||
|
||||
from pkg_resources import resource_string
|
||||
from .capa_module import only_one, ComplexEncoder
|
||||
from .editing_module import EditingDescriptor
|
||||
from .html_checker import check_html
|
||||
from progress import Progress
|
||||
from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from peer_grading_service import peer_grading_service, GradingServiceError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USE_FOR_SINGLE_LOCATION = False
|
||||
LINK_TO_LOCATION = ""
|
||||
TRUE_DICT = [True, "True", "true", "TRUE"]
|
||||
MAX_SCORE = 1
|
||||
IS_GRADED = True
|
||||
|
||||
|
||||
class PeerGradingModule(XModule):
|
||||
_VERSION = 1
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
|
||||
resource_string(__name__, 'js/src/peergrading/peer_grading_problem.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js_module_name = "PeerGrading"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
#We need to set the location here so the child modules can use it
|
||||
system.set('location', location)
|
||||
self.system = system
|
||||
self.peer_gs = peer_grading_service(self.system)
|
||||
|
||||
self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION)
|
||||
if isinstance(self.use_for_single_location, basestring):
|
||||
self.use_for_single_location = (self.use_for_single_location in TRUE_DICT)
|
||||
|
||||
self.is_graded = self.metadata.get('is_graded', IS_GRADED)
|
||||
if isinstance(self.is_graded, basestring):
|
||||
self.is_graded = (self.is_graded in TRUE_DICT)
|
||||
|
||||
self.link_to_location = self.metadata.get('link_to_location', USE_FOR_SINGLE_LOCATION)
|
||||
if self.use_for_single_location == True:
|
||||
#This will raise an exception if the location is invalid
|
||||
link_to_location_object = Location(self.link_to_location)
|
||||
|
||||
self.ajax_url = self.system.ajax_url
|
||||
if not self.ajax_url.endswith("/"):
|
||||
self.ajax_url = self.ajax_url + "/"
|
||||
|
||||
self.student_data_for_location = instance_state.get('student_data_for_location', {})
|
||||
self.max_grade = instance_state.get('max_grade', MAX_SCORE)
|
||||
if not isinstance(self.max_grade, (int, long)):
|
||||
#This could result in an exception, but not wrapping in a try catch block so it moves up the stack
|
||||
self.max_grade = int(self.max_grade)
|
||||
|
||||
def _err_response(self, msg):
|
||||
"""
|
||||
Return a HttpResponse with a json dump with success=False, and the given error message.
|
||||
"""
|
||||
return {'success': False, 'error': msg}
|
||||
|
||||
def _check_required(self, get, required):
|
||||
actual = set(get.keys())
|
||||
missing = required - actual
|
||||
if len(missing) > 0:
|
||||
return False, "Missing required keys: {0}".format(', '.join(missing))
|
||||
else:
|
||||
return True, ""
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Needs to be implemented by inheritors. Renders the HTML that students see.
|
||||
@return:
|
||||
"""
|
||||
if not self.use_for_single_location:
|
||||
return self.peer_grading()
|
||||
else:
|
||||
return self.peer_grading_problem({'location': self.link_to_location})['html']
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
Needs to be implemented by child modules. Handles AJAX events.
|
||||
@return:
|
||||
"""
|
||||
handlers = {
|
||||
'get_next_submission': self.get_next_submission,
|
||||
'show_calibration_essay': self.show_calibration_essay,
|
||||
'is_student_calibrated': self.is_student_calibrated,
|
||||
'save_grade': self.save_grade,
|
||||
'save_calibration_essay': self.save_calibration_essay,
|
||||
'problem': self.peer_grading_problem,
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
d = handlers[dispatch](get)
|
||||
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def query_data_for_location(self):
|
||||
student_id = self.system.anonymous_student_id
|
||||
location = self.system.location
|
||||
success = False
|
||||
response = {}
|
||||
|
||||
try:
|
||||
response = self.peer_gs.get_data_for_location(location, student_id)
|
||||
count_graded = response['count_graded']
|
||||
count_required = response['count_required']
|
||||
success = True
|
||||
except GradingServiceError:
|
||||
log.exception("Error getting location data from controller for location {0}, student {1}"
|
||||
.format(location, student_id))
|
||||
|
||||
return success, response
|
||||
|
||||
def get_progress(self):
|
||||
pass
|
||||
|
||||
def get_score(self):
|
||||
if not self.use_for_single_location or not self.is_graded:
|
||||
return None
|
||||
|
||||
try:
|
||||
count_graded = self.student_data_for_location['count_graded']
|
||||
count_required = self.student_data_for_location['count_required']
|
||||
except:
|
||||
success, response = self.query_data_for_location()
|
||||
if not success:
|
||||
log.exception("No instance data found and could not get data from controller for loc {0} student {1}".format(
|
||||
self.system.location, self.system.anonymous_student_id
|
||||
))
|
||||
return None
|
||||
count_graded = response['count_graded']
|
||||
count_required = response['count_required']
|
||||
if count_required > 0 and count_graded >= count_required:
|
||||
self.student_data_for_location = response
|
||||
|
||||
score_dict = {
|
||||
'score': int(count_graded >= count_required),
|
||||
'total': self.max_grade,
|
||||
}
|
||||
|
||||
return score_dict
|
||||
|
||||
def max_score(self):
|
||||
''' Maximum score. Two notes:
|
||||
|
||||
* This is generic; in abstract, a problem could be 3/5 points on one
|
||||
randomization, and 5/7 on another
|
||||
'''
|
||||
max_grade = None
|
||||
if self.use_for_single_location and self.is_graded:
|
||||
max_grade = self.max_grade
|
||||
return max_grade
|
||||
|
||||
def get_next_submission(self, get):
|
||||
"""
|
||||
Makes a call to the grading controller for the next essay that should be graded
|
||||
Returns a json dict with the following keys:
|
||||
|
||||
'success': bool
|
||||
|
||||
'submission_id': a unique identifier for the submission, to be passed back
|
||||
with the grade.
|
||||
|
||||
'submission': the submission, rendered as read-only html for grading
|
||||
|
||||
'rubric': the rubric, also rendered as html.
|
||||
|
||||
'submission_key': a key associated with the submission for validation reasons
|
||||
|
||||
'error': if success is False, will have an error message with more info.
|
||||
"""
|
||||
required = set(['location'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
location = get['location']
|
||||
|
||||
try:
|
||||
response = self.peer_gs.get_next_submission(location, grader_id)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error getting next submission. server url: {0} location: {1}, grader_id: {2}"
|
||||
.format(self.peer_gs.url, location, grader_id))
|
||||
return {'success': False,
|
||||
'error': 'Could not connect to grading service'}
|
||||
|
||||
def save_grade(self, get):
|
||||
"""
|
||||
Saves the grade of a given submission.
|
||||
Input:
|
||||
The request should have the following keys:
|
||||
location - problem location
|
||||
submission_id - id associated with this submission
|
||||
submission_key - submission key given for validation purposes
|
||||
score - the grade that was given to the submission
|
||||
feedback - the feedback from the student
|
||||
Returns
|
||||
A json object with the following keys:
|
||||
success: bool indicating whether the save was a success
|
||||
error: if there was an error in the submission, this is the error message
|
||||
"""
|
||||
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', 'submission_flagged'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get.get('location')
|
||||
submission_id = get.get('submission_id')
|
||||
score = get.get('score')
|
||||
feedback = get.get('feedback')
|
||||
submission_key = get.get('submission_key')
|
||||
rubric_scores = get.getlist('rubric_scores[]')
|
||||
submission_flagged = get.get('submission_flagged')
|
||||
|
||||
try:
|
||||
response = self.peer_gs.save_grade(location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores, submission_flagged)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("""Error saving grade. server url: {0}, location: {1}, submission_id:{2},
|
||||
submission_key: {3}, score: {4}"""
|
||||
.format(self.peer_gs.url,
|
||||
location, submission_id, submission_key, score)
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Could not connect to grading service'
|
||||
}
|
||||
|
||||
def is_student_calibrated(self, get):
|
||||
"""
|
||||
Calls the grading controller to see if the given student is calibrated
|
||||
on the given problem
|
||||
|
||||
Input:
|
||||
In the request, we need the following arguments:
|
||||
location - problem location
|
||||
|
||||
Returns:
|
||||
Json object with the following keys
|
||||
success - bool indicating whether or not the call was successful
|
||||
calibrated - true if the grader has fully calibrated and can now move on to grading
|
||||
- false if the grader is still working on calibration problems
|
||||
total_calibrated_on_so_far - the number of calibration essays for this problem
|
||||
that this grader has graded
|
||||
"""
|
||||
|
||||
required = set(['location'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get['location']
|
||||
|
||||
try:
|
||||
response = self.peer_gs.is_student_calibrated(location, grader_id)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error from grading service. server url: {0}, grader_id: {0}, location: {1}"
|
||||
.format(self.peer_gs.url, grader_id, location))
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Could not connect to grading service'
|
||||
}
|
||||
|
||||
def show_calibration_essay(self, get):
|
||||
"""
|
||||
Fetch the next calibration essay from the grading controller and return it
|
||||
Inputs:
|
||||
In the request
|
||||
location - problem location
|
||||
|
||||
Returns:
|
||||
A json dict with the following keys
|
||||
'success': bool
|
||||
|
||||
'submission_id': a unique identifier for the submission, to be passed back
|
||||
with the grade.
|
||||
|
||||
'submission': the submission, rendered as read-only html for grading
|
||||
|
||||
'rubric': the rubric, also rendered as html.
|
||||
|
||||
'submission_key': a key associated with the submission for validation reasons
|
||||
|
||||
'error': if success is False, will have an error message with more info.
|
||||
|
||||
"""
|
||||
|
||||
required = set(['location'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get['location']
|
||||
try:
|
||||
response = self.peer_gs.show_calibration_essay(location, grader_id)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error from grading service. server url: {0}, location: {0}"
|
||||
.format(self.peer_gs.url, location))
|
||||
return {'success': False,
|
||||
'error': 'Could not connect to grading service'}
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError:
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
return {'success': False,
|
||||
'error': 'Error displaying submission'}
|
||||
|
||||
|
||||
def save_calibration_essay(self, get):
|
||||
"""
|
||||
Saves the grader's grade of a given calibration.
|
||||
Input:
|
||||
The request should have the following keys:
|
||||
location - problem location
|
||||
submission_id - id associated with this submission
|
||||
submission_key - submission key given for validation purposes
|
||||
score - the grade that was given to the submission
|
||||
feedback - the feedback from the student
|
||||
Returns
|
||||
A json object with the following keys:
|
||||
success: bool indicating whether the save was a success
|
||||
error: if there was an error in the submission, this is the error message
|
||||
actual_score: the score that the instructor gave to this calibration essay
|
||||
|
||||
"""
|
||||
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get.get('location')
|
||||
calibration_essay_id = get.get('submission_id')
|
||||
submission_key = get.get('submission_key')
|
||||
score = get.get('score')
|
||||
feedback = get.get('feedback')
|
||||
rubric_scores = get.getlist('rubric_scores[]')
|
||||
|
||||
try:
|
||||
response = self.peer_gs.save_calibration_essay(location, grader_id, calibration_essay_id,
|
||||
submission_key, score, feedback, rubric_scores)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error saving calibration grade, location: {0}, submission_id: {1}, submission_key: {2}, grader_id: {3}".format(location, submission_id, submission_key, grader_id))
|
||||
return self._err_response('Could not connect to grading service')
|
||||
|
||||
def peer_grading(self, get=None):
|
||||
'''
|
||||
Show a peer grading interface
|
||||
'''
|
||||
|
||||
# call problem list service
|
||||
success = False
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
try:
|
||||
problem_list_json = self.peer_gs.get_problem_list(self.system.course_id, self.system.anonymous_student_id)
|
||||
problem_list_dict = problem_list_json
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
|
||||
problem_list = problem_list_dict['problem_list']
|
||||
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
# catch error if if the json loads fails
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
|
||||
ajax_url = self.ajax_url
|
||||
html = self.system.render_template('peer_grading/peer_grading.html', {
|
||||
'course_id': self.system.course_id,
|
||||
'ajax_url': ajax_url,
|
||||
'success': success,
|
||||
'problem_list': problem_list,
|
||||
'error_text': error_text,
|
||||
# Checked above
|
||||
'staff_access': False,
|
||||
'use_single_location': self.use_for_single_location,
|
||||
})
|
||||
|
||||
return html
|
||||
|
||||
def peer_grading_problem(self, get=None):
|
||||
'''
|
||||
Show individual problem interface
|
||||
'''
|
||||
if get == None or get.get('location') == None:
|
||||
if not self.use_for_single_location:
|
||||
#This is an error case, because it must be set to use a single location to be called without get parameters
|
||||
return {'html': "", 'success': False}
|
||||
problem_location = self.link_to_location
|
||||
|
||||
elif get.get('location') is not None:
|
||||
problem_location = get.get('location')
|
||||
|
||||
ajax_url = self.ajax_url
|
||||
html = self.system.render_template('peer_grading/peer_grading_problem.html', {
|
||||
'view_html': '',
|
||||
'problem_location': problem_location,
|
||||
'course_id': self.system.course_id,
|
||||
'ajax_url': ajax_url,
|
||||
# Checked above
|
||||
'staff_access': False,
|
||||
'use_single_location': self.use_for_single_location,
|
||||
})
|
||||
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Returns the current instance state. The module can be recreated from the instance state.
|
||||
Input: None
|
||||
Output: A dictionary containing the instance state.
|
||||
"""
|
||||
|
||||
state = {
|
||||
'student_data_for_location': self.student_data_for_location,
|
||||
}
|
||||
|
||||
return json.dumps(state)
|
||||
|
||||
|
||||
class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding combined open ended questions
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = PeerGradingModule
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "peer_grading"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the individual tasks, the rubric, and the prompt, and parse
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
log.debug("In definition")
|
||||
expected_children = []
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
raise ValueError("Peer grading definition must include at least one '{0}' tag".format(child))
|
||||
|
||||
def parse_task(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('peergrading')
|
||||
|
||||
def add_child(k):
|
||||
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
|
||||
child_node = etree.fromstring(child_str)
|
||||
elt.append(child_node)
|
||||
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
166
common/lib/xmodule/xmodule/peer_grading_service.py
Normal file
166
common/lib/xmodule/xmodule/peer_grading_service.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
|
||||
#TODO: Settings import is needed now in order to specify the URL where to find the peer grading service.
|
||||
#Eventually, the goal is to replace the global django settings import with settings specifically
|
||||
#for this xmodule. There is no easy way to do this now, so piggybacking on the django settings
|
||||
#makes sense.
|
||||
from django.conf import settings
|
||||
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from lxml import etree
|
||||
from grading_service_module import GradingService, GradingServiceError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GradingServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PeerGradingService(GradingService):
|
||||
"""
|
||||
Interface with the grading controller for peer grading
|
||||
"""
|
||||
def __init__(self, config, system):
|
||||
config['system'] = system
|
||||
super(PeerGradingService, self).__init__(config)
|
||||
self.get_next_submission_url = self.url + '/get_next_submission/'
|
||||
self.save_grade_url = self.url + '/save_grade/'
|
||||
self.is_student_calibrated_url = self.url + '/is_student_calibrated/'
|
||||
self.show_calibration_essay_url = self.url + '/show_calibration_essay/'
|
||||
self.save_calibration_essay_url = self.url + '/save_calibration_essay/'
|
||||
self.get_problem_list_url = self.url + '/get_problem_list/'
|
||||
self.get_notifications_url = self.url + '/get_notifications/'
|
||||
self.get_data_for_location_url = self.url + '/get_data_for_location/'
|
||||
self.system = system
|
||||
|
||||
def get_data_for_location(self, problem_location, student_id):
|
||||
response = self.get(self.get_data_for_location_url,
|
||||
{'location': problem_location, 'student_id': student_id})
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
response = self.get(self.get_next_submission_url,
|
||||
{'location': problem_location, 'grader_id': grader_id})
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores, submission_flagged):
|
||||
data = {'grader_id': grader_id,
|
||||
'submission_id': submission_id,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'submission_key': submission_key,
|
||||
'location': location,
|
||||
'rubric_scores': rubric_scores,
|
||||
'rubric_scores_complete': True,
|
||||
'submission_flagged': submission_flagged}
|
||||
return self.try_to_decode(self.post(self.save_grade_url, data))
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
params = {'problem_id': problem_location, 'student_id': grader_id}
|
||||
return self.try_to_decode(self.get(self.is_student_calibrated_url, params))
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
params = {'problem_id': problem_location, 'student_id': grader_id}
|
||||
response = self.get(self.show_calibration_essay_url, params)
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id, calibration_essay_id, submission_key,
|
||||
score, feedback, rubric_scores):
|
||||
data = {'location': problem_location,
|
||||
'student_id': grader_id,
|
||||
'calibration_essay_id': calibration_essay_id,
|
||||
'submission_key': submission_key,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'rubric_scores[]': rubric_scores,
|
||||
'rubric_scores_complete': True}
|
||||
return self.try_to_decode(self.post(self.save_calibration_essay_url, data))
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_problem_list_url, params)
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def get_notifications(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_notifications_url, params)
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def try_to_decode(self, text):
|
||||
try:
|
||||
text = json.loads(text)
|
||||
except:
|
||||
pass
|
||||
return text
|
||||
|
||||
"""
|
||||
This is a mock peer grading service that can be used for unit tests
|
||||
without making actual service calls to the grading controller
|
||||
"""
|
||||
|
||||
|
||||
class MockPeerGradingService(object):
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id,
|
||||
score, feedback, submission_key):
|
||||
return json.dumps({'success': True})
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True, 'calibrated': True})
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': '',
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id,
|
||||
calibration_essay_id, submission_key, score, feedback):
|
||||
return {'success': True, 'actual_score': 2}
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'problem_list': [
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1',
|
||||
'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5}),
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2',
|
||||
'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5})
|
||||
]})
|
||||
|
||||
_service = None
|
||||
|
||||
|
||||
def peer_grading_service(system):
|
||||
"""
|
||||
Return a peer grading service instance--if settings.MOCK_PEER_GRADING is True,
|
||||
returns a mock one, otherwise a real one.
|
||||
|
||||
Caches the result, so changing the setting after the first call to this
|
||||
function will have no effect.
|
||||
"""
|
||||
global _service
|
||||
if _service is not None:
|
||||
return _service
|
||||
|
||||
if settings.MOCK_PEER_GRADING:
|
||||
_service = MockPeerGradingService()
|
||||
else:
|
||||
_service = PeerGradingService(settings.PEER_GRADING_INTERFACE, system)
|
||||
|
||||
return _service
|
||||
121
common/lib/xmodule/xmodule/randomize_module.py
Normal file
121
common/lib/xmodule/xmodule/randomize_module.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
|
||||
from xmodule.mako_module import MakoModuleDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
|
||||
class RandomizeModule(XModule):
|
||||
"""
|
||||
Chooses a random child module. Chooses the same one every time for each student.
|
||||
|
||||
Example:
|
||||
<randomize>
|
||||
<problem url_name="problem1" />
|
||||
<problem url_name="problem2" />
|
||||
<problem url_name="problem3" />
|
||||
</randomize>
|
||||
|
||||
User notes:
|
||||
|
||||
- If you're randomizing amongst graded modules, each of them MUST be worth the same
|
||||
number of points. Otherwise, the earth will be overrun by monsters from the
|
||||
deeps. You have been warned.
|
||||
|
||||
Technical notes:
|
||||
- There is more dark magic in this code than I'd like. The whole varying-children +
|
||||
grading interaction is a tangle between super and subclasses of descriptors and
|
||||
modules.
|
||||
"""
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
# NOTE: calling self.get_children() creates a circular reference--
|
||||
# it calls get_child_descriptors() internally, but that doesn't work until
|
||||
# we've picked a choice
|
||||
num_choices = len(self.descriptor.get_children())
|
||||
|
||||
self.choice = None
|
||||
if instance_state is not None:
|
||||
state = json.loads(instance_state)
|
||||
self.choice = state.get('choice', None)
|
||||
if self.choice > num_choices:
|
||||
# Oops. Children changed. Reset.
|
||||
self.choice = None
|
||||
|
||||
if self.choice is None:
|
||||
# choose one based on the system seed, or randomly if that's not available
|
||||
if num_choices > 0:
|
||||
if system.seed is not None:
|
||||
self.choice = system.seed % num_choices
|
||||
else:
|
||||
self.choice = random.randrange(0, num_choices)
|
||||
|
||||
if self.choice is not None:
|
||||
self.child_descriptor = self.descriptor.get_children()[self.choice]
|
||||
# Now get_children() should return a list with one element
|
||||
log.debug("children of randomize module (should be only 1): %s",
|
||||
self.get_children())
|
||||
self.child = self.get_children()[0]
|
||||
else:
|
||||
self.child_descriptor = None
|
||||
self.child = None
|
||||
|
||||
|
||||
def get_instance_state(self):
|
||||
return json.dumps({'choice': self.choice})
|
||||
|
||||
|
||||
def get_child_descriptors(self):
|
||||
"""
|
||||
For grading--return just the chosen child.
|
||||
"""
|
||||
if self.child_descriptor is None:
|
||||
return []
|
||||
|
||||
return [self.child_descriptor]
|
||||
|
||||
|
||||
def get_html(self):
|
||||
if self.child is None:
|
||||
# raise error instead? In fact, could complain on descriptor load...
|
||||
return "<div>Nothing to randomize between</div>"
|
||||
|
||||
return self.child.get_html()
|
||||
|
||||
def get_icon_class(self):
|
||||
return self.child.get_icon_class() if self.child else 'other'
|
||||
|
||||
|
||||
class RandomizeDescriptor(SequenceDescriptor):
|
||||
# the editing interface can be the same as for sequences -- just a container
|
||||
module_class = RandomizeModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('randomize')
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
def has_dynamic_children(self):
|
||||
"""
|
||||
Grading needs to know that only one of the children is actually "real". This
|
||||
makes it use module.get_child_descriptors().
|
||||
"""
|
||||
return True
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It
|
||||
@@ -13,7 +14,7 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
"""
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
return {'data': etree.tostring(xml_object, pretty_print=True,encoding='unicode')}
|
||||
return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
try:
|
||||
|
||||
@@ -25,6 +25,7 @@ from combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
|
||||
class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
"""
|
||||
A Self Assessment module that allows students to write open-ended responses,
|
||||
@@ -80,6 +81,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
'state': self.state,
|
||||
'allow_reset': self._allow_reset(),
|
||||
'child_type': 'selfassessment',
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
html = system.render_template('self_assessment_prompt.html', context)
|
||||
@@ -106,6 +108,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
log.debug(get)
|
||||
before = self.get_progress()
|
||||
d = handlers[dispatch](get, system)
|
||||
after = self.get_progress()
|
||||
@@ -122,8 +125,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
if self.state == self.INITIAL:
|
||||
return ''
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_html = rubric_renderer.render_rubric(self.rubric)
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, False)
|
||||
success, rubric_html = rubric_renderer.render_rubric(self.rubric)
|
||||
|
||||
# we'll render it
|
||||
context = {'rubric': rubric_html,
|
||||
@@ -187,26 +190,29 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
Dictionary with keys 'success' and either 'error' (if not success),
|
||||
or 'rubric_html' (if success).
|
||||
"""
|
||||
# Check to see if attempts are less than max
|
||||
if self.attempts > self.max_attempts:
|
||||
# If too many attempts, prevent student from saving answer and
|
||||
# seeing rubric. In normal use, students shouldn't see this because
|
||||
# they won't see the reset button once they're out of attempts.
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
# Check to see if this problem is closed
|
||||
closed, msg = self.check_if_closed()
|
||||
if closed:
|
||||
return msg
|
||||
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
error_message = ""
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
success, get = self.append_image_to_student_answer(get)
|
||||
if success:
|
||||
get['student_answer'] = SelfAssessmentModule.sanitize_html(get['student_answer'])
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
else:
|
||||
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'rubric_html': self.get_rubric_html(system)
|
||||
'success': success,
|
||||
'rubric_html': self.get_rubric_html(system),
|
||||
'error': error_message,
|
||||
'student_response': get['student_answer'],
|
||||
}
|
||||
|
||||
def save_assessment(self, get, system):
|
||||
|
||||
@@ -88,8 +88,8 @@ class SequenceModule(XModule):
|
||||
'type': child.get_icon_class(),
|
||||
'id': child.id,
|
||||
}
|
||||
if childinfo['title']=='':
|
||||
childinfo['title'] = child.metadata.get('display_name','')
|
||||
if childinfo['title'] == '':
|
||||
childinfo['title'] = child.metadata.get('display_name', '')
|
||||
contents.append(childinfo)
|
||||
|
||||
params = {'items': contents,
|
||||
@@ -116,7 +116,7 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
|
||||
mako_template = 'widgets/sequence-edit.html'
|
||||
module_class = SequenceModule
|
||||
|
||||
stores_state = True # For remembering where in the sequence the student is
|
||||
stores_state = True # For remembering where in the sequence the student is
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]}
|
||||
js_module_name = "SequenceDescriptor"
|
||||
@@ -140,4 +140,3 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from itertools import chain
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def stringify_children(node):
|
||||
'''
|
||||
Return all contents of an xml tree, without the outside tags.
|
||||
|
||||
@@ -5,6 +5,7 @@ from mako.template import Template
|
||||
from xmodule.modulestore.django import modulestore
|
||||
import logging
|
||||
|
||||
|
||||
class CustomTagModule(XModule):
|
||||
"""
|
||||
This module supports tags of the form
|
||||
@@ -81,4 +82,3 @@ class CustomTagDescriptor(RawDescriptor):
|
||||
to export them in a file with yet another layer of indirection.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ test_system = ModuleSystem(
|
||||
# "render" to just the context...
|
||||
render_template=lambda template, context: str(context),
|
||||
replace_urls=Mock(),
|
||||
user=Mock(),
|
||||
user=Mock(is_staff=False),
|
||||
filestore=Mock(),
|
||||
debug=True,
|
||||
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
anonymous_student_id = 'student'
|
||||
anonymous_student_id='student'
|
||||
)
|
||||
|
||||
|
||||
@@ -85,4 +85,3 @@ class ModelsTest(unittest.TestCase):
|
||||
except:
|
||||
exception_happened = True
|
||||
self.assertTrue(exception_happened)
|
||||
|
||||
|
||||
211
common/lib/xmodule/xmodule/tests/test_capa_module.py
Normal file
211
common/lib/xmodule/xmodule/tests/test_capa_module.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import datetime
|
||||
import json
|
||||
from mock import Mock
|
||||
from pprint import pprint
|
||||
import unittest
|
||||
|
||||
from xmodule.capa_module import CapaModule
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
|
||||
from . import test_system
|
||||
|
||||
|
||||
class CapaFactory(object):
|
||||
"""
|
||||
A helper class to create problem modules with various parameters for testing.
|
||||
"""
|
||||
|
||||
sample_problem_xml = """<?xml version="1.0"?>
|
||||
<problem>
|
||||
<text>
|
||||
<p>What is pi, to two decimal placs?</p>
|
||||
</text>
|
||||
<numericalresponse answer="3.14">
|
||||
<textline math="1" size="30"/>
|
||||
</numericalresponse>
|
||||
</problem>
|
||||
"""
|
||||
|
||||
num = 0
|
||||
@staticmethod
|
||||
def next_num():
|
||||
CapaFactory.num += 1
|
||||
return CapaFactory.num
|
||||
|
||||
@staticmethod
|
||||
def create(graceperiod=None,
|
||||
due=None,
|
||||
max_attempts=None,
|
||||
showanswer=None,
|
||||
rerandomize=None,
|
||||
force_save_button=None,
|
||||
attempts=None,
|
||||
problem_state=None,
|
||||
):
|
||||
"""
|
||||
All parameters are optional, and are added to the created problem if specified.
|
||||
|
||||
Arguments:
|
||||
graceperiod:
|
||||
due:
|
||||
max_attempts:
|
||||
showanswer:
|
||||
force_save_button:
|
||||
rerandomize: all strings, as specified in the policy for the problem
|
||||
|
||||
problem_state: a dict to to be serialized into the instance_state of the
|
||||
module.
|
||||
|
||||
attempts: also added to instance state. Will be converted to an int.
|
||||
"""
|
||||
definition = {'data': CapaFactory.sample_problem_xml, }
|
||||
location = Location(["i4x", "edX", "capa_test", "problem",
|
||||
"SampleProblem{0}".format(CapaFactory.next_num())])
|
||||
metadata = {}
|
||||
if graceperiod is not None:
|
||||
metadata['graceperiod'] = graceperiod
|
||||
if due is not None:
|
||||
metadata['due'] = due
|
||||
if max_attempts is not None:
|
||||
metadata['attempts'] = max_attempts
|
||||
if showanswer is not None:
|
||||
metadata['showanswer'] = showanswer
|
||||
if force_save_button is not None:
|
||||
metadata['force_save_button'] = force_save_button
|
||||
if rerandomize is not None:
|
||||
metadata['rerandomize'] = rerandomize
|
||||
|
||||
|
||||
descriptor = Mock(weight="1")
|
||||
instance_state_dict = {}
|
||||
if problem_state is not None:
|
||||
instance_state_dict = problem_state
|
||||
if attempts is not None:
|
||||
# converting to int here because I keep putting "0" and "1" in the tests
|
||||
# since everything else is a string.
|
||||
instance_state_dict['attempts'] = int(attempts)
|
||||
if len(instance_state_dict) > 0:
|
||||
instance_state = json.dumps(instance_state_dict)
|
||||
else:
|
||||
instance_state = None
|
||||
|
||||
module = CapaModule(test_system, location,
|
||||
definition, descriptor,
|
||||
instance_state, None, metadata=metadata)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
|
||||
class CapaModuleTest(unittest.TestCase):
|
||||
|
||||
|
||||
def setUp(self):
|
||||
now = datetime.datetime.now()
|
||||
day_delta = datetime.timedelta(days=1)
|
||||
self.yesterday_str = str(now - day_delta)
|
||||
self.today_str = str(now)
|
||||
self.tomorrow_str = str(now + day_delta)
|
||||
|
||||
# in the capa grace period format, not in time delta format
|
||||
self.two_day_delta_str = "2 days"
|
||||
|
||||
def test_import(self):
|
||||
module = CapaFactory.create()
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
|
||||
other_module = CapaFactory.create()
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
self.assertNotEqual(module.url_name, other_module.url_name,
|
||||
"Factory should be creating unique names for each problem")
|
||||
|
||||
def test_showanswer_default(self):
|
||||
"""
|
||||
Make sure the show answer logic does the right thing.
|
||||
"""
|
||||
# default, no due date, showanswer 'closed', so problem is open, and show_answer
|
||||
# not visible.
|
||||
problem = CapaFactory.create()
|
||||
self.assertFalse(problem.answer_available())
|
||||
|
||||
|
||||
def test_showanswer_attempted(self):
|
||||
problem = CapaFactory.create(showanswer='attempted')
|
||||
self.assertFalse(problem.answer_available())
|
||||
problem.attempts = 1
|
||||
self.assertTrue(problem.answer_available())
|
||||
|
||||
|
||||
def test_showanswer_closed(self):
|
||||
|
||||
# can see after attempts used up, even with due date in the future
|
||||
used_all_attempts = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="1",
|
||||
due=self.tomorrow_str)
|
||||
self.assertTrue(used_all_attempts.answer_available())
|
||||
|
||||
|
||||
# can see after due date
|
||||
after_due_date = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str)
|
||||
self.assertTrue(after_due_date.answer_available())
|
||||
|
||||
|
||||
# can't see because attempts left
|
||||
attempts_left_open = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.tomorrow_str)
|
||||
self.assertFalse(attempts_left_open.answer_available())
|
||||
|
||||
# Can't see because grace period hasn't expired
|
||||
still_in_grace = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str,
|
||||
graceperiod=self.two_day_delta_str)
|
||||
self.assertFalse(still_in_grace.answer_available())
|
||||
|
||||
|
||||
|
||||
def test_showanswer_past_due(self):
|
||||
"""
|
||||
With showanswer="past_due" should only show answer after the problem is closed
|
||||
for everyone--e.g. after due date + grace period.
|
||||
"""
|
||||
|
||||
# can see after attempts used up, even with due date in the future
|
||||
used_all_attempts = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="1",
|
||||
due=self.tomorrow_str)
|
||||
self.assertFalse(used_all_attempts.answer_available())
|
||||
|
||||
|
||||
# can see after due date
|
||||
past_due_date = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str)
|
||||
self.assertTrue(past_due_date.answer_available())
|
||||
|
||||
|
||||
# can't see because attempts left
|
||||
attempts_left_open = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.tomorrow_str)
|
||||
self.assertFalse(attempts_left_open.answer_available())
|
||||
|
||||
# Can't see because grace period hasn't expired, even though have no more
|
||||
# attempts.
|
||||
still_in_grace = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="1",
|
||||
due=self.yesterday_str,
|
||||
graceperiod=self.two_day_delta_str)
|
||||
self.assertFalse(still_in_grace.answer_available())
|
||||
348
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
Normal file
348
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
Normal file
@@ -0,0 +1,348 @@
|
||||
import json
|
||||
from mock import Mock, MagicMock, ANY
|
||||
import unittest
|
||||
|
||||
from xmodule.openendedchild import OpenEndedChild
|
||||
from xmodule.open_ended_module import OpenEndedModule
|
||||
from xmodule.combined_open_ended_modulev1 import CombinedOpenEndedV1Module
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
from datetime import datetime
|
||||
|
||||
from . import test_system
|
||||
"""
|
||||
Tests for the various pieces of the CombinedOpenEndedGrading system
|
||||
|
||||
OpenEndedChild
|
||||
OpenEndedModule
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class OpenEndedChildTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
metadata = json.dumps({'attempts': '10'})
|
||||
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
<option>Second option</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
max_score = 1
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload': False,
|
||||
'close_date': None
|
||||
}
|
||||
definition = Mock()
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.openendedchild = OpenEndedChild(test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
|
||||
def test_latest_answer_empty(self):
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
def test_latest_score_empty(self):
|
||||
answer = self.openendedchild.latest_score()
|
||||
self.assertEqual(answer, None)
|
||||
|
||||
|
||||
def test_latest_post_assessment_empty(self):
|
||||
answer = self.openendedchild.latest_post_assessment(test_system)
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
def test_new_history_entry(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, new_answer)
|
||||
|
||||
new_answer = "Newer Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(new_answer, answer)
|
||||
|
||||
def test_record_latest_score(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
new_score = 3
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.latest_score()
|
||||
self.assertEqual(score, 3)
|
||||
|
||||
new_score = 4
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.latest_score()
|
||||
self.assertEqual(score, 4)
|
||||
|
||||
|
||||
def test_record_latest_post_assessment(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
|
||||
post_assessment = "Post assessment"
|
||||
self.openendedchild.record_latest_post_assessment(post_assessment)
|
||||
self.assertEqual(post_assessment,
|
||||
self.openendedchild.latest_post_assessment(test_system))
|
||||
|
||||
def test_get_score(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
|
||||
score = self.openendedchild.get_score()
|
||||
self.assertEqual(score['score'], 0)
|
||||
self.assertEqual(score['total'], self.static_data['max_score'])
|
||||
|
||||
new_score = 4
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.get_score()
|
||||
self.assertEqual(score['score'], new_score)
|
||||
self.assertEqual(score['total'], self.static_data['max_score'])
|
||||
|
||||
|
||||
def test_reset(self):
|
||||
self.openendedchild.reset(test_system)
|
||||
state = json.loads(self.openendedchild.get_instance_state())
|
||||
self.assertEqual(state['state'], OpenEndedChild.INITIAL)
|
||||
|
||||
|
||||
def test_is_last_response_correct(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(self.static_data['max_score'])
|
||||
self.assertEqual(self.openendedchild.is_last_response_correct(),
|
||||
'correct')
|
||||
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(0)
|
||||
self.assertEqual(self.openendedchild.is_last_response_correct(),
|
||||
'incorrect')
|
||||
|
||||
|
||||
class OpenEndedModuleTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
metadata = json.dumps({'attempts': '10'})
|
||||
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
|
||||
rubric = etree.XML('''<rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric>''')
|
||||
max_score = 4
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload': False,
|
||||
'rewrite_content_links' : "",
|
||||
'close_date': None,
|
||||
}
|
||||
|
||||
oeparam = etree.XML('''
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
''')
|
||||
definition = {'oeparam': oeparam}
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
test_system.location = self.location
|
||||
self.mock_xqueue = MagicMock()
|
||||
self.mock_xqueue.send_to_queue.return_value = (None, "Message")
|
||||
test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1}
|
||||
self.openendedmodule = OpenEndedModule(test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
def test_message_post(self):
|
||||
get = {'feedback': 'feedback text',
|
||||
'submission_id': '1',
|
||||
'grader_id': '1',
|
||||
'score': 3}
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = {
|
||||
'feedback': get['feedback'],
|
||||
'submission_id': int(get['submission_id']),
|
||||
'grader_id': int(get['grader_id']),
|
||||
'score': get['score'],
|
||||
'student_info': json.dumps(student_info)
|
||||
}
|
||||
|
||||
result = self.openendedmodule.message_post(get, test_system)
|
||||
self.assertTrue(result['success'])
|
||||
# make sure it's actually sending something we want to the queue
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
|
||||
|
||||
state = json.loads(self.openendedmodule.get_instance_state())
|
||||
self.assertIsNotNone(state['state'], OpenEndedModule.DONE)
|
||||
|
||||
def test_send_to_grader(self):
|
||||
submission = "This is a student submission"
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = self.openendedmodule.payload.copy()
|
||||
contents.update({
|
||||
'student_info': json.dumps(student_info),
|
||||
'student_response': submission,
|
||||
'max_score': self.max_score
|
||||
})
|
||||
result = self.openendedmodule.send_to_grader(submission, test_system)
|
||||
self.assertTrue(result)
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
score_msg = {
|
||||
'correct': True,
|
||||
'score': 4,
|
||||
'msg': 'Grader Message',
|
||||
'feedback': "Grader Feedback"
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': score_msg}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
feedback = {
|
||||
"success": True,
|
||||
"feedback": "Grader Feedback"
|
||||
}
|
||||
score_msg = {
|
||||
'correct': True,
|
||||
'score': 4,
|
||||
'msg': 'Grader Message',
|
||||
'feedback': json.dumps(feedback),
|
||||
'grader_type': 'IN',
|
||||
'grader_id': '1',
|
||||
'submission_id': '1',
|
||||
'success': True,
|
||||
'rubric_scores': [0],
|
||||
'rubric_scores_complete': True,
|
||||
'rubric_xml': etree.tostring(self.rubric)
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': json.dumps(score_msg)}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
|
||||
def test_latest_post_assessment(self):
|
||||
self.update_score_single()
|
||||
assessment = self.openendedmodule.latest_post_assessment(test_system)
|
||||
self.assertFalse(assessment == '')
|
||||
# check for errors
|
||||
self.assertFalse('errors' in assessment)
|
||||
|
||||
def test_update_score(self):
|
||||
self.update_score_single()
|
||||
score = self.openendedmodule.latest_score()
|
||||
self.assertEqual(score, 4)
|
||||
|
||||
|
||||
class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "open_ended", "combinedopenended",
|
||||
"SampleQuestion"])
|
||||
|
||||
prompt = "<prompt>This is a question prompt</prompt>"
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
<option>Second option</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
max_score = 1
|
||||
|
||||
metadata = {'attempts': '10', 'max_score': max_score}
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload' : False,
|
||||
'rewrite_content_links' : "",
|
||||
'close_date' : "",
|
||||
}
|
||||
|
||||
oeparam = etree.XML('''
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
''')
|
||||
|
||||
task_xml1 = '''
|
||||
<selfassessment>
|
||||
<hintprompt>
|
||||
What hint about this problem would you give to someone?
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Save Succcesful. Thanks for participating!
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
'''
|
||||
task_xml2 = '''
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="1">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>'''
|
||||
definition = {'prompt': etree.XML(prompt), 'rubric': etree.XML(rubric), 'task_xml': [task_xml1, task_xml2]}
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.combinedoe = CombinedOpenEndedV1Module(test_system, self.location, self.definition, self.descriptor, static_data = self.static_data, metadata=self.metadata)
|
||||
|
||||
def test_get_tag_name(self):
|
||||
name = self.combinedoe.get_tag_name("<t>Tag</t>")
|
||||
self.assertEqual(name, "t")
|
||||
|
||||
def test_get_last_response(self):
|
||||
response_dict = self.combinedoe.get_last_response(0)
|
||||
self.assertEqual(response_dict['type'], "selfassessment")
|
||||
self.assertEqual(response_dict['max_score'], self.max_score)
|
||||
self.assertEqual(response_dict['state'], CombinedOpenEndedV1Module.INITIAL)
|
||||
|
||||
def test_update_task_states(self):
|
||||
changed = self.combinedoe.update_task_states()
|
||||
self.assertFalse(changed)
|
||||
|
||||
current_task = self.combinedoe.current_task
|
||||
current_task.change_state(CombinedOpenEndedV1Module.DONE)
|
||||
changed = self.combinedoe.update_task_states()
|
||||
|
||||
self.assertTrue(changed)
|
||||
121
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
121
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
from path import path
|
||||
import unittest
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
from lxml import etree
|
||||
from mock import Mock, patch
|
||||
from collections import defaultdict
|
||||
|
||||
from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
|
||||
from xmodule.xml_module import is_pointer_tag
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .test_export import DATA_DIR
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'conditional' # name of directory with course data
|
||||
|
||||
from . import test_system
|
||||
|
||||
|
||||
class DummySystem(ImportSystem):
|
||||
|
||||
@patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS())
|
||||
def __init__(self, load_error_modules):
|
||||
|
||||
xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules)
|
||||
course_id = "/".join([ORG, COURSE, 'test_run'])
|
||||
course_dir = "test_dir"
|
||||
policy = {}
|
||||
error_tracker = Mock()
|
||||
parent_tracker = Mock()
|
||||
|
||||
super(DummySystem, self).__init__(
|
||||
xmlstore,
|
||||
course_id,
|
||||
course_dir,
|
||||
policy,
|
||||
error_tracker,
|
||||
parent_tracker,
|
||||
load_error_modules=load_error_modules,
|
||||
)
|
||||
|
||||
def render_template(self, template, context):
|
||||
raise Exception("Shouldn't be called")
|
||||
|
||||
|
||||
|
||||
class ConditionalModuleTest(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def get_system(load_error_modules=True):
|
||||
'''Get a dummy system'''
|
||||
return DummySystem(load_error_modules)
|
||||
|
||||
def get_course(self, name):
|
||||
"""Get a test course by directory name. If there's more than one, error."""
|
||||
print "Importing {0}".format(name)
|
||||
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
|
||||
courses = modulestore.get_courses()
|
||||
self.modulestore = modulestore
|
||||
self.assertEquals(len(courses), 1)
|
||||
return courses[0]
|
||||
|
||||
def test_conditional_module(self):
|
||||
"""Make sure that conditional module works"""
|
||||
|
||||
print "Starting import"
|
||||
course = self.get_course('conditional')
|
||||
|
||||
print "Course: ", course
|
||||
print "id: ", course.id
|
||||
|
||||
instance_states = dict(problem=None)
|
||||
shared_state = None
|
||||
|
||||
def inner_get_module(descriptor):
|
||||
if isinstance(descriptor, Location):
|
||||
location = descriptor
|
||||
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
|
||||
location = descriptor.location
|
||||
instance_state = instance_states.get(location.category, None)
|
||||
print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state)
|
||||
return descriptor.xmodule_constructor(test_system)(instance_state, shared_state)
|
||||
|
||||
location = Location(["i4x", "edX", "cond_test", "conditional", "condone"])
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
|
||||
return text
|
||||
test_system.replace_urls = replace_urls
|
||||
test_system.get_module = inner_get_module
|
||||
|
||||
module = inner_get_module(location)
|
||||
print "module: ", module
|
||||
print "module definition: ", module.definition
|
||||
print "module children: ", module.get_children()
|
||||
print "module display items (children): ", module.get_display_items()
|
||||
|
||||
html = module.get_html()
|
||||
print "html type: ", type(html)
|
||||
print "html: ", html
|
||||
html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}"
|
||||
self.assertEqual(html, html_expect)
|
||||
|
||||
gdi = module.get_display_items()
|
||||
print "gdi=", gdi
|
||||
|
||||
ajax = json.loads(module.handle_ajax('', ''))
|
||||
self.assertTrue('xmodule.conditional_module' in ajax['html'])
|
||||
print "ajax: ", ajax
|
||||
|
||||
# now change state of the capa problem to make it completed
|
||||
instance_states['problem'] = json.dumps({'attempts': 1})
|
||||
|
||||
ajax = json.loads(module.handle_ajax('', ''))
|
||||
self.assertTrue('This is a secret' in ajax['html'])
|
||||
print "post-attempt ajax: ", ajax
|
||||
@@ -3,11 +3,13 @@ from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.content import ContentStore
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
class Content:
|
||||
def __init__(self, location, content_type):
|
||||
self.location = location
|
||||
self.content_type = content_type
|
||||
|
||||
|
||||
class ContentTest(unittest.TestCase):
|
||||
def test_thumbnail_none(self):
|
||||
# We had a bug where a thumbnail location of None was getting transformed into a Location tuple, with
|
||||
@@ -22,4 +24,4 @@ class ContentTest(unittest.TestCase):
|
||||
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters.jpg'), None)
|
||||
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
|
||||
self.assertIsNone(thumbnail_content)
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg'), thumbnail_file_location)
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg'), thumbnail_file_location)
|
||||
|
||||
@@ -27,6 +27,7 @@ def strip_metadata(descriptor, key):
|
||||
for d in descriptor.get_children():
|
||||
strip_metadata(d, key)
|
||||
|
||||
|
||||
def strip_filenames(descriptor):
|
||||
"""
|
||||
Recursively strips 'filename' from all children's definitions.
|
||||
@@ -119,12 +120,12 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
|
||||
def test_selfassessment_roundtrip(self):
|
||||
#Test selfassessment xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR,"self_assessment")
|
||||
self.check_export_roundtrip(DATA_DIR, "self_assessment")
|
||||
|
||||
def test_graphicslidertool_roundtrip(self):
|
||||
#Test graphicslidertool xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR,"graphic_slider_tool")
|
||||
self.check_export_roundtrip(DATA_DIR, "graphic_slider_tool")
|
||||
|
||||
def test_exam_registration_roundtrip(self):
|
||||
# Test exam_registration xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR,"test_exam_registration")
|
||||
self.check_export_roundtrip(DATA_DIR, "test_exam_registration")
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from xmodule import graders
|
||||
from xmodule.graders import Score, aggregate_scores
|
||||
|
||||
|
||||
class GradesheetTest(unittest.TestCase):
|
||||
|
||||
def test_weighted_grading(self):
|
||||
@@ -217,4 +218,3 @@ class GraderTest(unittest.TestCase):
|
||||
|
||||
#TODO: How do we test failure cases? The parser only logs an error when
|
||||
#it can't parse something. Maybe it should throw exceptions?
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ class BaseCourseTestCase(unittest.TestCase):
|
||||
self.assertEquals(len(courses), 1)
|
||||
return courses[0]
|
||||
|
||||
|
||||
class ImportTestCase(BaseCourseTestCase):
|
||||
|
||||
def test_fallback(self):
|
||||
@@ -323,7 +324,7 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
|
||||
self.assertEqual(len(sections), 4)
|
||||
|
||||
for i in (2,3):
|
||||
for i in (2, 3):
|
||||
video = sections[i]
|
||||
# Name should be 'video_{hash}'
|
||||
print "video {0} url_name: {1}".format(i, video.url_name)
|
||||
@@ -379,5 +380,3 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
# and finally...
|
||||
course.metadata['cohort_config'] = {'cohorted': True}
|
||||
self.assertTrue(course.is_cohorted)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from xmodule import x_module
|
||||
|
||||
from . import test_system
|
||||
|
||||
|
||||
class ProgressTest(unittest.TestCase):
|
||||
''' Test that basic Progress objects work. A Progress represents a
|
||||
fraction between 0 and 1.
|
||||
|
||||
54
common/lib/xmodule/xmodule/tests/test_randomize_module.py
Normal file
54
common/lib/xmodule/xmodule/tests/test_randomize_module.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import unittest
|
||||
from time import strptime
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
from mock import Mock, patch
|
||||
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'test_course'
|
||||
|
||||
START = '2013-01-01T01:00:00'
|
||||
|
||||
|
||||
from test_course_module import DummySystem as DummyImportSystem
|
||||
from . import test_system
|
||||
|
||||
|
||||
class RandomizeModuleTestCase(unittest.TestCase):
|
||||
"""Make sure the randomize module works"""
|
||||
@staticmethod
|
||||
def get_dummy_course(start):
|
||||
"""Get a dummy course"""
|
||||
|
||||
system = DummyImportSystem(load_error_modules=True)
|
||||
|
||||
def to_attrb(n, v):
|
||||
return '' if v is None else '{0}="{1}"'.format(n, v).lower()
|
||||
|
||||
start_xml = '''
|
||||
<course org="{org}" course="{course}"
|
||||
graceperiod="1 day" url_name="test"
|
||||
start="{start}"
|
||||
>
|
||||
<chapter url="hi" url_name="ch" display_name="CH">
|
||||
<randomize url_name="my_randomize">
|
||||
<html url_name="a" display_name="A">Two houses, ...</html>
|
||||
<html url_name="b" display_name="B">Three houses, ...</html>
|
||||
</randomize>
|
||||
</chapter>
|
||||
</course>
|
||||
'''.format(org=ORG, course=COURSE, start=start)
|
||||
|
||||
return system.process_xml(start_xml)
|
||||
|
||||
def test_import(self):
|
||||
"""
|
||||
Just make sure descriptor loads without error
|
||||
"""
|
||||
descriptor = self.get_dummy_course(START)
|
||||
|
||||
# TODO: add tests that create a module and check. Passing state is a good way to
|
||||
# check that child access works...
|
||||
@@ -8,10 +8,19 @@ from lxml import etree
|
||||
|
||||
from . import test_system
|
||||
|
||||
|
||||
class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
definition = {'rubric': 'A rubric',
|
||||
'prompt': 'Who?',
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
|
||||
prompt = etree.XML("<prompt>This is sample prompt text.</prompt>")
|
||||
definition = {'rubric': rubric,
|
||||
'prompt': prompt,
|
||||
'submitmessage': 'Shall we submit now?',
|
||||
'hintprompt': 'Consider this...',
|
||||
}
|
||||
@@ -23,47 +32,48 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
descriptor = Mock()
|
||||
|
||||
def test_import(self):
|
||||
def setUp(self):
|
||||
state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"],
|
||||
'scores': [0, 1],
|
||||
'hints': ['o hai'],
|
||||
'state': SelfAssessmentModule.INITIAL,
|
||||
'attempts': 2})
|
||||
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
|
||||
prompt = etree.XML("<prompt>Text</prompt>")
|
||||
static_data = {
|
||||
'max_attempts': 10,
|
||||
'rubric': etree.XML(rubric),
|
||||
'prompt': prompt,
|
||||
'rubric': etree.XML(self.rubric),
|
||||
'prompt': self.prompt,
|
||||
'max_score': 1,
|
||||
'display_name': "Name"
|
||||
'display_name': "Name",
|
||||
'accept_file_upload': False,
|
||||
'close_date': None
|
||||
}
|
||||
|
||||
module = SelfAssessmentModule(test_system, self.location,
|
||||
self.module = SelfAssessmentModule(test_system, self.location,
|
||||
self.definition, self.descriptor,
|
||||
static_data, state, metadata=self.metadata)
|
||||
static_data,
|
||||
state, metadata=self.metadata)
|
||||
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
def test_get_html(self):
|
||||
html = self.module.get_html(test_system)
|
||||
self.assertTrue("This is sample prompt text" in html)
|
||||
|
||||
def test_self_assessment_flow(self):
|
||||
|
||||
self.assertEqual(self.module.get_score()['score'], 0)
|
||||
|
||||
self.module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.ASSESSING)
|
||||
|
||||
self.module.save_assessment({'assessment': '0'}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
|
||||
module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.assertEqual(module.state, module.ASSESSING)
|
||||
|
||||
module.save_assessment({'assessment': '0'}, test_system)
|
||||
self.assertEqual(module.state, module.DONE)
|
||||
|
||||
d = module.reset({})
|
||||
d = self.module.reset({})
|
||||
self.assertTrue(d['success'])
|
||||
self.assertEqual(module.state, module.INITIAL)
|
||||
self.assertEqual(self.module.state, self.module.INITIAL)
|
||||
|
||||
# if we now assess as right, skip the REQUEST_HINT state
|
||||
module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
module.save_assessment({'assessment': '1'}, test_system)
|
||||
self.assertEqual(module.state, module.DONE)
|
||||
self.module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
self.module.save_assessment({'assessment': '1'}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
@@ -2,6 +2,7 @@ from nose.tools import assert_equals, assert_true, assert_false
|
||||
from lxml import etree
|
||||
from xmodule.stringify import stringify_children
|
||||
|
||||
|
||||
def test_stringify():
|
||||
text = 'Hi <div x="foo">there <span>Bruce</span><b>!</b></div>'
|
||||
html = '''<html a="b" foo="bar">{0}</html>'''.format(text)
|
||||
@@ -9,6 +10,7 @@ def test_stringify():
|
||||
out = stringify_children(xml)
|
||||
assert_equals(out, text)
|
||||
|
||||
|
||||
def test_stringify_again():
|
||||
html = """<html name="Voltage Source Answer" >A voltage source is non-linear!
|
||||
<div align="center">
|
||||
|
||||
162
common/lib/xmodule/xmodule/timelimit_module.py
Normal file
162
common/lib/xmodule/xmodule/timelimit_module.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from lxml import etree
|
||||
from time import time
|
||||
|
||||
from xmodule.editing_module import XMLEditingDescriptor
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class TimeLimitModule(XModule):
|
||||
'''
|
||||
Wrapper module which imposes a time constraint for the completion of its child.
|
||||
'''
|
||||
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None,
|
||||
shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
self.rendered = False
|
||||
self.beginning_at = None
|
||||
self.ending_at = None
|
||||
self.accommodation_code = None
|
||||
|
||||
if instance_state is not None:
|
||||
state = json.loads(instance_state)
|
||||
|
||||
if 'beginning_at' in state:
|
||||
self.beginning_at = state['beginning_at']
|
||||
if 'ending_at' in state:
|
||||
self.ending_at = state['ending_at']
|
||||
if 'accommodation_code' in state:
|
||||
self.accommodation_code = state['accommodation_code']
|
||||
|
||||
# For a timed activity, we are only interested here
|
||||
# in time-related accommodations, and these should be disjoint.
|
||||
# (For proctored exams, it is possible to have multiple accommodations
|
||||
# apply to an exam, so they require accommodating a multi-choice.)
|
||||
TIME_ACCOMMODATION_CODES = (('NONE', 'No Time Accommodation'),
|
||||
('ADDHALFTIME', 'Extra Time - 1 1/2 Time'),
|
||||
('ADD30MIN', 'Extra Time - 30 Minutes'),
|
||||
('DOUBLE', 'Extra Time - Double Time'),
|
||||
('TESTING', 'Extra Time -- Large amount for testing purposes')
|
||||
)
|
||||
|
||||
def _get_accommodated_duration(self, duration):
|
||||
'''
|
||||
Get duration for activity, as adjusted for accommodations.
|
||||
Input and output are expressed in seconds.
|
||||
'''
|
||||
if self.accommodation_code is None or self.accommodation_code == 'NONE':
|
||||
return duration
|
||||
elif self.accommodation_code == 'ADDHALFTIME':
|
||||
# TODO: determine what type to return
|
||||
return int(duration * 1.5)
|
||||
elif self.accommodation_code == 'ADD30MIN':
|
||||
return (duration + (30 * 60))
|
||||
elif self.accommodation_code == 'DOUBLE':
|
||||
return (duration * 2)
|
||||
elif self.accommodation_code == 'TESTING':
|
||||
# when testing, set timer to run for a week at a time.
|
||||
return 3600 * 24 * 7
|
||||
|
||||
@property
|
||||
def has_begun(self):
|
||||
return self.beginning_at is not None
|
||||
|
||||
@property
|
||||
def has_ended(self):
|
||||
if not self.ending_at:
|
||||
return False
|
||||
return self.ending_at < time()
|
||||
|
||||
def begin(self, duration):
|
||||
'''
|
||||
Sets the starting time and ending time for the activity,
|
||||
based on the duration provided (in seconds).
|
||||
'''
|
||||
self.beginning_at = time()
|
||||
modified_duration = self._get_accommodated_duration(duration)
|
||||
self.ending_at = self.beginning_at + modified_duration
|
||||
|
||||
def get_end_time_in_ms(self):
|
||||
return int(self.ending_at * 1000)
|
||||
|
||||
def get_instance_state(self):
|
||||
state = {}
|
||||
if self.beginning_at:
|
||||
state['beginning_at'] = self.beginning_at
|
||||
if self.ending_at:
|
||||
state['ending_at'] = self.ending_at
|
||||
if self.accommodation_code:
|
||||
state['accommodation_code'] = self.accommodation_code
|
||||
return json.dumps(state)
|
||||
|
||||
def get_html(self):
|
||||
self.render()
|
||||
return self.content
|
||||
|
||||
def get_progress(self):
|
||||
''' Return the total progress, adding total done and total available.
|
||||
(assumes that each submodule uses the same "units" for progress.)
|
||||
'''
|
||||
# TODO: Cache progress or children array?
|
||||
children = self.get_children()
|
||||
progresses = [child.get_progress() for child in children]
|
||||
progress = reduce(Progress.add_counts, progresses)
|
||||
return progress
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
raise NotFoundError('Unexpected dispatch type')
|
||||
|
||||
def render(self):
|
||||
if self.rendered:
|
||||
return
|
||||
# assumes there is one and only one child, so it only renders the first child
|
||||
children = self.get_display_items()
|
||||
if children:
|
||||
child = children[0]
|
||||
self.content = child.get_html()
|
||||
self.rendered = True
|
||||
|
||||
def get_icon_class(self):
|
||||
children = self.get_children()
|
||||
if children:
|
||||
return children[0].get_icon_class()
|
||||
else:
|
||||
return "other"
|
||||
|
||||
class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor):
|
||||
|
||||
module_class = TimeLimitModule
|
||||
|
||||
# For remembering when a student started, and when they should end
|
||||
stores_state = True
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
children = []
|
||||
for child in xml_object:
|
||||
try:
|
||||
children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url())
|
||||
except Exception as e:
|
||||
log.exception("Unable to load child when parsing TimeLimit wrapper. Continuing...")
|
||||
if system.error_tracker is not None:
|
||||
system.error_tracker("ERROR: " + str(e))
|
||||
continue
|
||||
return {'children': children}
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('timelimit')
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
Helper functions for handling time in the format we like.
|
||||
"""
|
||||
import time
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
TIME_FORMAT = "%Y-%m-%dT%H:%M"
|
||||
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
|
||||
def parse_time(time_str):
|
||||
"""
|
||||
Takes a time string in TIME_FORMAT
|
||||
@@ -15,8 +19,29 @@ def parse_time(time_str):
|
||||
"""
|
||||
return time.strptime(time_str, TIME_FORMAT)
|
||||
|
||||
|
||||
def stringify_time(time_struct):
|
||||
"""
|
||||
Convert a time struct to a string
|
||||
"""
|
||||
return time.strftime(TIME_FORMAT, time_struct)
|
||||
|
||||
def parse_timedelta(time_str):
|
||||
"""
|
||||
time_str: A string with the following components:
|
||||
<D> day[s] (optional)
|
||||
<H> hour[s] (optional)
|
||||
<M> minute[s] (optional)
|
||||
<S> second[s] (optional)
|
||||
|
||||
Returns a datetime.timedelta parsed from the string
|
||||
"""
|
||||
parts = TIMEDELTA_REGEX.match(time_str)
|
||||
if not parts:
|
||||
return
|
||||
parts = parts.groupdict()
|
||||
time_params = {}
|
||||
for (name, param) in parts.iteritems():
|
||||
if param:
|
||||
time_params[name] = int(param)
|
||||
return timedelta(**time_params)
|
||||
|
||||
@@ -4,8 +4,8 @@ def lazyproperty(fn):
|
||||
"""
|
||||
Use this decorator for lazy generation of properties that
|
||||
are expensive to compute. From http://stackoverflow.com/a/3013910/86828
|
||||
|
||||
|
||||
|
||||
|
||||
Example:
|
||||
class Test(object):
|
||||
|
||||
@@ -13,7 +13,7 @@ def lazyproperty(fn):
|
||||
def a(self):
|
||||
print 'generating "a"'
|
||||
return range(5)
|
||||
|
||||
|
||||
Interactive Session:
|
||||
>>> t = Test()
|
||||
>>> t.__dict__
|
||||
@@ -26,11 +26,11 @@ def lazyproperty(fn):
|
||||
>>> t.a
|
||||
[0, 1, 2, 3, 4]
|
||||
"""
|
||||
|
||||
|
||||
attr_name = '_lazy_' + fn.__name__
|
||||
@property
|
||||
def _lazyprop(self):
|
||||
if not hasattr(self, attr_name):
|
||||
setattr(self, attr_name, fn(self))
|
||||
return getattr(self, attr_name)
|
||||
return _lazyprop
|
||||
return _lazyprop
|
||||
|
||||
@@ -47,4 +47,6 @@ class VerticalDescriptor(SequenceDescriptor):
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
|
||||
js_module_name = "VerticalDescriptor"
|
||||
|
||||
|
||||
# TODO (victor): Does this need its own definition_to_xml method? Otherwise it looks
|
||||
# like verticals will get exported as sequentials...
|
||||
|
||||
@@ -6,7 +6,7 @@ from pkg_resources import resource_string, resource_listdir
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
@@ -121,12 +121,23 @@ class VideoModule(XModule):
|
||||
return self.youtube
|
||||
|
||||
def get_html(self):
|
||||
if isinstance(modulestore(), MongoModuleStore) :
|
||||
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
|
||||
else:
|
||||
if isinstance(modulestore(), XMLModuleStore):
|
||||
# VS[compat]
|
||||
# cdodge: filesystem static content support.
|
||||
caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir'])
|
||||
else:
|
||||
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
|
||||
|
||||
# We normally let JS parse this, but in the case that we need a hacked
|
||||
# out <object> player because YouTube has broken their <iframe> API for
|
||||
# the third time in a year, we need to extract it server side.
|
||||
normal_speed_video_id = None # The 1.0 speed video
|
||||
|
||||
# video_list() example:
|
||||
# "0.75:nugHYNiD3fI,1.0:7m8pab1MfYY,1.25:3CxdPGXShq8,1.50:F-D7bOFCnXA"
|
||||
for video_id_str in self.video_list().split(","):
|
||||
if video_id_str.startswith("1.0:"):
|
||||
normal_speed_video_id = video_id_str.split(":")[1]
|
||||
|
||||
return self.system.render_template('video.html', {
|
||||
'streams': self.video_list(),
|
||||
@@ -140,7 +151,8 @@ class VideoModule(XModule):
|
||||
'caption_asset_path': caption_asset_path,
|
||||
'show_captions': self.show_captions,
|
||||
'start': self.start_time,
|
||||
'end': self.end_time
|
||||
'end': self.end_time,
|
||||
'normal_speed_video_id': normal_speed_video_id
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -109,16 +109,16 @@ class HTMLSnippet(object):
|
||||
All of these will be loaded onto the page in the CMS
|
||||
"""
|
||||
# cdodge: We've moved the xmodule.coffee script from an outside directory into the xmodule area of common
|
||||
# this means we need to make sure that all xmodules include this dependency which had been previously implicitly
|
||||
# this means we need to make sure that all xmodules include this dependency which had been previously implicitly
|
||||
# fulfilled in a different area of code
|
||||
js = cls.js
|
||||
|
||||
|
||||
if js is None:
|
||||
js = {}
|
||||
|
||||
if 'coffee' not in js:
|
||||
js['coffee'] = []
|
||||
|
||||
|
||||
js['coffee'].append(resource_string(__name__, 'js/src/xmodule.coffee'))
|
||||
|
||||
return js
|
||||
@@ -406,7 +406,7 @@ class ResourceTemplates(object):
|
||||
log.warning("Skipping unknown template file %s" % template_file)
|
||||
continue
|
||||
template_content = resource_string(__name__, os.path.join(dirname, template_file))
|
||||
template = yaml.load(template_content)
|
||||
template = yaml.safe_load(template_content)
|
||||
templates.append(Template(**template))
|
||||
|
||||
return templates
|
||||
@@ -538,7 +538,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
|
||||
def start(self, value):
|
||||
if isinstance(value, time.struct_time):
|
||||
self.metadata['start'] = stringify_time(value)
|
||||
|
||||
|
||||
@property
|
||||
def days_early_for_beta(self):
|
||||
"""
|
||||
@@ -585,6 +585,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
|
||||
self._inherited_metadata.add(attr)
|
||||
self.metadata[attr] = metadata[attr]
|
||||
|
||||
def get_required_module_descriptors(self):
|
||||
"""Returns a list of XModuleDescritpor instances upon which this module depends, but are
|
||||
not children of this module"""
|
||||
return []
|
||||
|
||||
def get_children(self):
|
||||
"""Returns a list of XModuleDescriptor instances for the children of
|
||||
this module"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user