Merge branch 'master' into feature/abarrett/annotatable_xmodule

This commit is contained in:
Arthur Barrett
2013-02-12 12:09:21 -05:00
437 changed files with 40905 additions and 4992 deletions

View File

@@ -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'''

View File

@@ -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('<','&lt;')
msg = "Error while executing script code: %s" % str(err).replace('<', '&lt;')
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)

View File

@@ -1 +0,0 @@

View File

@@ -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

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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']:

View 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>

View 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>

View 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>

View File

@@ -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'
)

View File

@@ -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.

View File

@@ -0,0 +1,4 @@
test_problem_display.js
test_problem_generator.js
test_problem_grader.js
xproblem.js

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()

View File

@@ -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')

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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))

View File

@@ -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>"

View File

@@ -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>

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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})

View 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]

View File

@@ -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

View File

@@ -6,6 +6,7 @@ from django.conf import settings
_CONTENTSTORE = None
def load_function(path):
"""
Load a function by name.

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View 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"}

View File

@@ -1,5 +1,6 @@
from lxml import etree
def check_html(html):
'''
Check whether the passed in html string can be parsed by lxml.

View File

@@ -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

View 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"):&nbsp; </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>

View File

@@ -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)

View File

@@ -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'

View File

@@ -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) ->

View File

@@ -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()

View 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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -8,5 +8,3 @@ for i in range(5):
TEST_DIR = TEST_DIR / 'test'
DATA_DIR = TEST_DIR / 'data'

View 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'

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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'))

View File

@@ -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."

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View 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())

View 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)

View 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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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?

View File

@@ -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)

View File

@@ -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.

View 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...

View File

@@ -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)

View File

@@ -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">

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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...

View File

@@ -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
})

View File

@@ -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