Commit 31b4b455 by Will Daly

Add schema validation for input from Studio JavaScript

Use text inputs for dates
Make feedback prompt mandatory
parent 9db810a4
...@@ -131,18 +131,10 @@ ...@@ -131,18 +131,10 @@
<div id="openassessment_rubric_feedback_wrapper" class="wrapper-comp-settings"> <div id="openassessment_rubric_feedback_wrapper" class="wrapper-comp-settings">
<div id="openassessment_rubric_feedback_header_open"> <div id="openassessment_rubric_feedback_header">
<span> <span>
{% trans "Rubric Feedback" %} {% trans "Rubric Feedback" %}
</span> </span>
<div class="openassessment_rubric_remove_button" id="openassessment_rubric_feedback_remove">
<h2>{% trans "Remove" %}</h2>
</div>
<div id="openassessment_rubric_feedback_header_closed">
<h2>
{% trans "Add Rubric Feedback" %}
</h2>
</div>
</div> </div>
<ul class="list-input settings-list"> <ul class="list-input settings-list">
<li class="field comp-setting-entry"> <li class="field comp-setting-entry">
...@@ -152,7 +144,7 @@ ...@@ -152,7 +144,7 @@
</div> </div>
</li> </li>
<p class="setting-help"> <p class="setting-help">
{% trans "If you would like your students to be able to provide feedback on the rubric, add a prompt to ask them for it." %} {% trans "Directions shown to students when they give feedback." %}
</p> </p>
</ul> </ul>
</div> </div>
...@@ -171,14 +163,14 @@ ...@@ -171,14 +163,14 @@
<li class="openassessment_date_editor field comp-setting-entry"> <li class="openassessment_date_editor field comp-setting-entry">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label for="openassessment_submission_start_editor" class="setting-label">{% trans "Response Submission Start Date"%} </label> <label for="openassessment_submission_start_editor" class="setting-label">{% trans "Response Submission Start Date"%} </label>
<input type="datetime-local" class="input setting-input" id="openassessment_submission_start_editor"> <input type="text" class="input setting-input" id="openassessment_submission_start_editor">
</div> </div>
<p class="setting-help">{% trans "The date at which submissions will first be accepted." %}</p> <p class="setting-help">{% trans "The date at which submissions will first be accepted." %}</p>
</li> </li>
<li class="openassessment_date_editor field comp-setting-entry"> <li class="openassessment_date_editor field comp-setting-entry">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label for="openassessment_submission_due_editor" class="setting-label">{% trans "Response Submission Due Date" %}</label> <label for="openassessment_submission_due_editor" class="setting-label">{% trans "Response Submission Due Date" %}</label>
<input type="datetime-local" class="input setting-input" id="openassessment_submission_due_editor"> <input type="text" class="input setting-input" id="openassessment_submission_due_editor">
</div> </div>
<p class="setting-help">{% trans "The date at which submissions will stop being accepted." %}</p> <p class="setting-help">{% trans "The date at which submissions will stop being accepted." %}</p>
</li> </li>
...@@ -247,14 +239,14 @@ ...@@ -247,14 +239,14 @@
<li class="field comp-setting-entry"> <li class="field comp-setting-entry">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label for="peer_assessment_start_date" class="setting-label">{% trans "Start Date" %}</label> <label for="peer_assessment_start_date" class="setting-label">{% trans "Start Date" %}</label>
<input id="peer_assessment_start_date" type="datetime-local" class="input setting-input"> <input id="peer_assessment_start_date" type="text" class="input setting-input">
</div> </div>
<p class="setting-help">{% trans "If desired, specify a start date for the peer assessment period. If no date is specified, peer assessment can begin when submissions begin."%}</p> <p class="setting-help">{% trans "If desired, specify a start date for the peer assessment period. If no date is specified, peer assessment can begin when submissions begin."%}</p>
</li> </li>
<li class="field comp-setting-entry"> <li class="field comp-setting-entry">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label for="peer_assessment_due_date" class="setting-label">{% trans "Due Date" %}</label> <label for="peer_assessment_due_date" class="setting-label">{% trans "Due Date" %}</label>
<input id="peer_assessment_due_date" type="datetime-local" class="input setting-input"> <input id="peer_assessment_due_date" type="text" class="input setting-input">
</div> </div>
<p class="setting-help">{% trans "If desired, specify a due date for the peer assessment period. If no date is specified, peer assessment can run as long as the problem is open."%}</p> <p class="setting-help">{% trans "If desired, specify a due date for the peer assessment period. If no date is specified, peer assessment can run as long as the problem is open."%}</p>
</li> </li>
...@@ -280,14 +272,14 @@ ...@@ -280,14 +272,14 @@
<li class="field comp-setting-entry"> <li class="field comp-setting-entry">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label for="self_assessment_start_date" class="setting-label">{% trans "Start Date" %}</label> <label for="self_assessment_start_date" class="setting-label">{% trans "Start Date" %}</label>
<input id="self_assessment_start_date" type="datetime-local" class="input setting-input"> <input id="self_assessment_start_date" type="text" class="input setting-input">
</div> </div>
<p class="setting-help">{% trans "If desired, specify a start date for the self assessment period. If no date is specified, self assessment can begin when submissions begin."%}</p> <p class="setting-help">{% trans "If desired, specify a start date for the self assessment period. If no date is specified, self assessment can begin when submissions begin."%}</p>
</li> </li>
<li class="field comp-setting-entry"> <li class="field comp-setting-entry">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label for="self_assessment_due_date" class="setting-label">{% trans "Due Date" %}</label> <label for="self_assessment_due_date" class="setting-label">{% trans "Due Date" %}</label>
<input id="self_assessment_due_date" type="datetime-local" class="input setting-input"> <input id="self_assessment_due_date" type="text" class="input setting-input">
</div> </div>
<p class="setting-help">{% trans "If desired, specify a due date for the self assessment period. If no date is specified, self assessment can run as long as the problem is open."%}</p> <p class="setting-help">{% trans "If desired, specify a due date for the self assessment period. If no date is specified, self assessment can run as long as the problem is open."%}</p>
</li> </li>
......
...@@ -25,11 +25,11 @@ from openassessment.xblock.studio_mixin import StudioMixin ...@@ -25,11 +25,11 @@ from openassessment.xblock.studio_mixin import StudioMixin
from openassessment.xblock.xml import parse_from_xml, serialize_content_to_xml from openassessment.xblock.xml import parse_from_xml, serialize_content_to_xml
from openassessment.xblock.staff_info_mixin import StaffInfoMixin from openassessment.xblock.staff_info_mixin import StaffInfoMixin
from openassessment.xblock.workflow_mixin import WorkflowMixin from openassessment.xblock.workflow_mixin import WorkflowMixin
from openassessment.workflow import api as workflow_api
from openassessment.workflow.errors import AssessmentWorkflowError from openassessment.workflow.errors import AssessmentWorkflowError
from openassessment.xblock.student_training_mixin import StudentTrainingMixin from openassessment.xblock.student_training_mixin import StudentTrainingMixin
from openassessment.xblock.validation import validator from openassessment.xblock.validation import validator
from openassessment.xblock.resolve_dates import resolve_dates, DISTANT_PAST, DISTANT_FUTURE from openassessment.xblock.resolve_dates import resolve_dates, DISTANT_PAST, DISTANT_FUTURE
from openassessment.xblock.data_conversion import create_rubric_dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -345,60 +345,26 @@ class OpenAssessmentBlock( ...@@ -345,60 +345,26 @@ class OpenAssessmentBlock(
Inherited by XBlock core. Inherited by XBlock core.
""" """
block = runtime.construct_xblock_from_class(cls, keys)
config = parse_from_xml(node) config = parse_from_xml(node)
rubric = { block = runtime.construct_xblock_from_class(cls, keys)
"prompt": config["prompt"],
"feedbackprompt": config["rubric_feedback_prompt"],
"criteria": config["rubric_criteria"],
}
xblock_validator = validator(block, strict_post_release=False) xblock_validator = validator(block, strict_post_release=False)
xblock_validator( xblock_validator(
rubric, create_rubric_dict(config['prompt'], config['rubric_criteria']),
{ 'due': config['submission_due'], 'start': config['submission_start']},
config['rubric_assessments']
)
block.update(
config['rubric_criteria'],
config['rubric_feedback_prompt'],
config['rubric_assessments'], config['rubric_assessments'],
config['submission_due'], submission_start=config['submission_start'],
config['submission_start'], submission_due=config['submission_due']
config['title'],
config['prompt']
) )
return block
def update(self, criteria, feedback_prompt, assessments, submission_due, block.rubric_criteria = config['rubric_criteria']
submission_start, title, prompt): block.rubric_feedback_prompt = config['rubric_feedback_prompt']
""" block.rubric_assessments = config['rubric_assessments']
Given a dictionary of properties, update the XBlock block.submission_start = config['submission_start']
block.submission_due = config['submission_due']
block.title = config['title']
block.prompt = config['prompt']
Args: return block
criteria (list): A list of rubric criteria for this XBlock.
feedback_prompt (str):
assessments (list): A list of assessment module configurations for
this XBlock.
submission_due (str): ISO formatted submission due date.
submission_start (str): ISO formatted submission start date.
title (str): The title of this XBlock
prompt (str): The prompt for this XBlock.
Returns:
None
"""
# If we've gotten this far, then we've successfully parsed the XML
# and validated the contents. At long last, we can safely update the XBlock.
self.title = title
self.prompt = prompt
self.rubric_criteria = criteria
self.rubric_assessments = assessments
self.rubric_feedback_prompt = feedback_prompt
self.submission_start = submission_start
self.submission_due = submission_due
@property @property
def valid_assessments(self): def valid_assessments(self):
......
"""
Schema for validating and sanitizing data received from the JavaScript client.
"""
import dateutil
from pytz import utc
from voluptuous import Schema, Required, All, Any, Range, In, Invalid
from openassessment.xblock.xml import parse_examples_xml_str, UpdateFromXmlError
def utf8_validator(value):
"""Validate and sanitize unicode strings.
If we're given a bytestring, assume that the encoding is UTF-8
Args:
value: The value to validate
Returns:
unicode
Raises:
Invalid
"""
try:
if isinstance(value, str):
return value.decode('utf-8')
else:
return unicode(value)
except (ValueError, TypeError):
raise Invalid(u"Could not load unicode from value \"{val}\"".format(val=value))
def datetime_validator(value):
"""Validate and sanitize a datetime string in ISO format.
Args:
value: The value to validate
Returns:
unicode: ISO-formatted datetime string
Raises:
Invalid
"""
try:
# The dateutil parser defaults empty values to the current day,
# which is NOT what we want.
if value is None or value == '':
raise Invalid(u"Datetime value cannot be \"{val}\"".format(val=value))
# Parse the date and interpret it as UTC
value = dateutil.parser.parse(value).replace(tzinfo=utc)
return unicode(value.isoformat())
except (ValueError, TypeError):
raise Invalid(u"Could not parse datetime from value \"{val}\"".format(val=value))
def examples_xml_validator(value):
"""Parse and validate student training examples XML.
Args:
value: The value to parse.
Returns:
list of training examples, serialized as dictionaries.
Raises:
Invalid
"""
try:
return parse_examples_xml_str(value)
except UpdateFromXmlError:
raise Invalid(u"Could not parse examples from XML")
# Schema definition for an update from the Studio JavaScript editor.
EDITOR_UPDATE_SCHEMA = Schema({
Required('prompt'): utf8_validator,
Required('title'): utf8_validator,
Required('feedback_prompt'): utf8_validator,
Required('submission_start'): Any(datetime_validator, None),
Required('submission_due'): Any(datetime_validator, None),
Required('assessments'): [
Schema({
Required('name'): All(
utf8_validator,
In([
u'peer-assessment',
u'self-assessment',
u'example-based-assessment',
u'student-training'
])
),
Required('start', default=None): Any(datetime_validator, None),
Required('due', default=None): Any(datetime_validator, None),
'must_grade': All(int, Range(min=0)),
'must_be_graded_by': All(int, Range(min=0)),
'examples': All(utf8_validator, examples_xml_validator)
})
],
Required('feedbackprompt', default=u""): utf8_validator,
Required('criteria'): [
Schema({
Required('order_num'): All(int, Range(min=0)),
Required('name'): utf8_validator,
Required('prompt'): utf8_validator,
Required('feedback'): All(
utf8_validator,
In([
'disabled',
'optional',
'required',
])
),
Required('options'): [
Schema({
Required('order_num'): All(int, Range(min=0)),
Required('name'): utf8_validator,
Required('explanation'): utf8_validator,
Required('points'): All(int, Range(min=0)),
})
]
})
]
})
\ No newline at end of file
...@@ -31,6 +31,7 @@ describe("OpenAssessment.Server", function() { ...@@ -31,6 +31,7 @@ describe("OpenAssessment.Server", function() {
}; };
var PROMPT = "Hello this is the prompt yes."; var PROMPT = "Hello this is the prompt yes.";
var FEEDBACK_PROMPT = "Prompt for feedback";
var RUBRIC = '<rubric>'+ var RUBRIC = '<rubric>'+
'<criterion>'+ '<criterion>'+
...@@ -51,6 +52,14 @@ describe("OpenAssessment.Server", function() { ...@@ -51,6 +52,14 @@ describe("OpenAssessment.Server", function() {
'</criterion>'+ '</criterion>'+
'</rubric>'; '</rubric>';
var CRITERIA = [
'criteria',
'objects',
'would',
'be',
'here'
];
var ASSESSMENTS = [ var ASSESSMENTS = [
{ {
"name": "peer-assessment", "name": "peer-assessment",
...@@ -226,14 +235,25 @@ describe("OpenAssessment.Server", function() { ...@@ -226,14 +235,25 @@ describe("OpenAssessment.Server", function() {
it("updates the XBlock's Context definition", function() { it("updates the XBlock's Context definition", function() {
stubAjax(true, { success: true }); stubAjax(true, { success: true });
server.updateEditorContext( server.updateEditorContext({
PROMPT, RUBRIC, TITLE, SUBMISSION_START, SUBMISSION_DUE, ASSESSMENTS prompt: PROMPT,
); feedbackPrompt: FEEDBACK_PROMPT,
title: TITLE,
submissionStart: SUBMISSION_START,
submissionDue: SUBMISSION_DUE,
criteria: CRITERIA,
assessments: ASSESSMENTS
});
expect($.ajax).toHaveBeenCalledWith({ expect($.ajax).toHaveBeenCalledWith({
type: "POST", url: '/update_editor_context', type: "POST", url: '/update_editor_context',
data: JSON.stringify({ data: JSON.stringify({
prompt: PROMPT, rubric: RUBRIC, title: TITLE, submission_start: SUBMISSION_START, prompt: PROMPT,
submission_due: SUBMISSION_DUE, assessments: ASSESSMENTS feedback_prompt: FEEDBACK_PROMPT,
title: TITLE,
submission_start: SUBMISSION_START,
submission_due: SUBMISSION_DUE,
criteria: CRITERIA,
assessments: ASSESSMENTS
}) })
}); });
}); });
......
...@@ -68,7 +68,6 @@ OpenAssessment.StudioView = function(runtime, element, server) { ...@@ -68,7 +68,6 @@ OpenAssessment.StudioView = function(runtime, element, server) {
this.numberOfOptions = []; this.numberOfOptions = [];
this.rubricCriteriaSelectors = []; this.rubricCriteriaSelectors = [];
this.rubricFeedbackPrompt = $('#openassessment_rubric_feedback', liveElement); this.rubricFeedbackPrompt = $('#openassessment_rubric_feedback', liveElement);
this.hasRubricFeedbackPrompt = true;
$('#openassessment_criterion_list', liveElement).empty(); $('#openassessment_criterion_list', liveElement).empty();
this.addNewCriterionToRubric(); this.addNewCriterionToRubric();
...@@ -96,25 +95,6 @@ OpenAssessment.StudioView = function(runtime, element, server) { ...@@ -96,25 +95,6 @@ OpenAssessment.StudioView = function(runtime, element, server) {
view.addNewCriterionToRubric(liveElement); view.addNewCriterionToRubric(liveElement);
}); });
// Adds a listener which removes rubric feedback
$("#openassessment_rubric_feedback_remove", liveElement). click( function(eventData){
$("#openassessment_rubric_feedback_header_open", liveElement).fadeOut();
$("#openassessment_rubric_feedback_input_wrapper", liveElement).fadeOut();
$("#openassessment_rubric_feedback_header_closed", liveElement).fadeIn();
view.hasRubricFeedbackPrompt = false;
});
// Adds a listener which adds rubric feedback if not already displayed.
$("#openassessment_rubric_feedback_header_closed", liveElement). click( function(eventData){
$("#openassessment_rubric_feedback_header_closed", liveElement).fadeOut();
$("#openassessment_rubric_feedback_header_open", liveElement).fadeIn();
$("#openassessment_rubric_feedback_input_wrapper", liveElement).fadeIn();
view.hasRubricFeedbackPrompt = true;
});
// Initially Hides the rubric "add rubric feedback" div
$("#openassessment_rubric_feedback_header_closed", liveElement).hide();
}; };
OpenAssessment.StudioView.prototype = { OpenAssessment.StudioView.prototype = {
...@@ -339,7 +319,6 @@ OpenAssessment.StudioView.prototype = { ...@@ -339,7 +319,6 @@ OpenAssessment.StudioView.prototype = {
// Hides the criterion header used for adding // Hides the criterion header used for adding
$(".openassessment_criterion_feedback_header_closed", liveElement).hide(); $(".openassessment_criterion_feedback_header_closed", liveElement).hide();
}, },
/** /**
...@@ -444,7 +423,7 @@ OpenAssessment.StudioView.prototype = { ...@@ -444,7 +423,7 @@ OpenAssessment.StudioView.prototype = {
criterionID (string): The criterion ID that we are deleting from criterionID (string): The criterion ID that we are deleting from
optionToRemove (string): The option ID that we are "deleting" optionToRemove (string): The option ID that we are "deleting"
*/ */
removeOptionFromCriterion: function(liveElement, criterionID, optionToRemove){ removeOptionFromCriterion: function(liveElement, criterionID, optionToRemove) {
var view = this; var view = this;
var numberOfOptions = view.numberOfOptions[criterionID]; var numberOfOptions = view.numberOfOptions[criterionID];
var optionSelectors = view.rubricCriteriaSelectors[criterionID].options; var optionSelectors = view.rubricCriteriaSelectors[criterionID].options;
...@@ -473,12 +452,6 @@ OpenAssessment.StudioView.prototype = { ...@@ -473,12 +452,6 @@ OpenAssessment.StudioView.prototype = {
// to save so it can show the "Saving..." notification // to save so it can show the "Saving..." notification
this.runtime.notify('save', {state: 'start'}); this.runtime.notify('save', {state: 'start'});
// Send the updated XML to the server
var prompt = this.settingsFieldSelectors.promptBox.prop('value');
var title = this.settingsFieldSelectors.titleField.prop('value');
var subStart = this.settingsFieldSelectors.submissionStartField.prop('value');
var subDue = this.settingsFieldSelectors.submissionDueField.prop('value');
// Grabs values from all of our fields, and stores them in a format which can be easily validated. // Grabs values from all of our fields, and stores them in a format which can be easily validated.
var rubricCriteria = []; var rubricCriteria = [];
...@@ -497,77 +470,64 @@ OpenAssessment.StudioView.prototype = { ...@@ -497,77 +470,64 @@ OpenAssessment.StudioView.prototype = {
var optionSelectors = optionSelectorList[j]; var optionSelectors = optionSelectorList[j];
optionValueList = optionValueList.concat([{ optionValueList = optionValueList.concat([{
order_num: j-1, order_num: j-1,
points: optionSelectors.points.prop('value'), points: this._getInt(optionSelectors.points),
name: optionSelectors.name.prop('value'), name: optionSelectors.name.val(),
explanation: optionSelectors.explanation.prop('value') explanation: optionSelectors.explanation.val()
}]); }]);
} }
criterionValueDict.options = optionValueList; criterionValueDict.options = optionValueList;
rubricCriteria = rubricCriteria.concat([criterionValueDict]); rubricCriteria = rubricCriteria.concat([criterionValueDict]);
} }
var rubric = { 'criteria': rubricCriteria };
if (this.hasRubricFeedbackPrompt){
rubric.feedbackprompt = this.rubricFeedbackPrompt.prop('value');
}
var assessments = []; var assessments = [];
if (this.settingsFieldSelectors.hasTraining.prop('checked')){ if (this.settingsFieldSelectors.hasTraining.prop('checked')){
assessments[assessments.length] = { assessments.push({
"name": "student-training", name: "student-training",
"examples": this.studentTrainingExamplesCodeBox.getValue() examples: this.studentTrainingExamplesCodeBox.getValue()
}; });
} }
if (this.settingsFieldSelectors.hasPeer.prop('checked')) { if (this.settingsFieldSelectors.hasPeer.prop('checked')) {
var assessment = { assessments.push({
"name": "peer-assessment", name: "peer-assessment",
"must_grade": parseInt(this.settingsFieldSelectors.peerMustGrade.prop('value')), must_grade: this._getInt(this.settingsFieldSelectors.peerMustGrade),
"must_be_graded_by": parseInt(this.settingsFieldSelectors.peerGradedBy.prop('value')) must_be_graded_by: this._getInt(this.settingsFieldSelectors.peerGradedBy),
}; start: this._getDateTime(this.settingsFieldSelectors.peerStart),
var startStr = this.settingsFieldSelectors.peerStart.prop('value'); due: this._getDateTime(this.settingsFieldSelectors.peerDue)
var dueStr = this.settingsFieldSelectors.peerDue.prop('value'); });
if (startStr){
assessment = $.extend(assessment, {"start": startStr});
}
if (dueStr){
assessment = $.extend(assessment, {"due": dueStr});
}
assessments[assessments.length] = assessment;
} }
if (this.settingsFieldSelectors.hasSelf.prop('checked')) { if (this.settingsFieldSelectors.hasSelf.prop('checked')) {
var assessment = { assessments.push({
"name": "self-assessment" name: "self-assessment",
}; start: this._getDateTime(this.settingsFieldSelectors.selfStart),
var startStr = this.settingsFieldSelectors.selfStart.prop('value'); due: this._getDateTime(this.settingsFieldSelectors.selfDue)
var dueStr = this.settingsFieldSelectors.selfDue.prop('value'); });
if (startStr){
assessment = $.extend(assessment, {"start": startStr});
}
if (dueStr){
assessment = $.extend(assessment, {"due": dueStr});
}
assessments[assessments.length] = assessment;
} }
if (this.settingsFieldSelectors.hasAI.prop('checked')) { if (this.settingsFieldSelectors.hasAI.prop('checked')) {
assessments[assessments.length] = { assessments.push({
"name": "example-based-assessment", name: "example-based-assessment",
"examples": this.aiTrainingExamplesCodeBox.getValue() examples: this.aiTrainingExamplesCodeBox.getValue()
}; });
} }
var view = this; var view = this;
this.server.updateEditorContext(prompt, rubric, title, subStart, subDue, assessments).done(function () { this.server.updateEditorContext({
// Notify the client-side runtime that we finished saving title: this.settingsFieldSelectors.titleField.val(),
// so it can hide the "Saving..." notification. prompt: this.settingsFieldSelectors.promptBox.val(),
view.runtime.notify('save', {state: 'end'}); feedbackPrompt: this.rubricFeedbackPrompt.val(),
submissionStart: this._getDateTime(this.settingsFieldSelectors.submissionStartField),
// Reload the XML definition in the editor submissionDue: this._getDateTime(this.settingsFieldSelectors.submissionDueField),
view.load(); criteria: rubricCriteria,
assessments: assessments
}).done(
function () {
// Notify the client-side runtime that we finished saving
// so it can hide the "Saving..." notification.
// Then reload the view.
view.runtime.notify('save', {state: 'end'});
}).fail(function (msg) { }).fail(function (msg) {
view.showError(msg); view.showError(msg);
}); });
...@@ -589,7 +549,55 @@ OpenAssessment.StudioView.prototype = { ...@@ -589,7 +549,55 @@ OpenAssessment.StudioView.prototype = {
**/ **/
showError: function (errorMsg) { showError: function (errorMsg) {
this.runtime.notify('error', {msg: errorMsg}); this.runtime.notify('error', {msg: errorMsg});
},
/**
Retrieve a value from a datetime input.
Args:
selector: The JQuery selector for the datetime input.
Returns:
ISO-formatted datetime string or null
**/
_getDateTime: function(selector) {
var dateStr = selector.val();
// By convention, empty date strings are null,
// meaning choose the default date based on
// other dates set in the problem configuration.
if (dateStr === "") {
return null;
}
// Attempt to parse the date string
// TO DO: currently invalid dates also are set as null,
// which is probably NOT what the user wants!
// We should add proper validation here.
var timestamp = Date.parse(dateStr);
if (isNaN(timestamp)) {
return null;
}
// Send the datetime in ISO format
// This will also convert the timezone to UTC
return new Date(timestamp).toISOString();
},
/**
Retrieve an integer value from an input.
Args:
selector: The JQuery selector for the input.
Returns:
int
**/
_getInt: function(selector) {
return parseInt(selector.val(), 10);
} }
}; };
......
...@@ -427,26 +427,30 @@ OpenAssessment.Server.prototype = { ...@@ -427,26 +427,30 @@ OpenAssessment.Server.prototype = {
/** /**
Update the XBlock's XML definition on the server. Update the XBlock's XML definition on the server.
Return Kwargs:
title (string): The title of the problem.
prompt (string): The question prompt.
feedbackPrompt (string): The directions to the student for giving overall feedback on a submission.
submissionStart (ISO-formatted datetime string or null): The start date of the submission.
submissionDue (ISO-formatted datetime string or null): The date the submission is due.
criteria (list of object literals): The rubric criteria.
assessments (list of object literals): The assessments the student will be evaluated on.
Returns:
A JQuery promise, which resolves with no arguments A JQuery promise, which resolves with no arguments
and fails with an error message. and fails with an error message.
Example usage:
server.updateXml(xml).done(
function() {}
).fail(
function(err) { console.log(err); }
);
**/ **/
updateEditorContext: function(prompt, rubric, title, sub_start, sub_due, assessments) { updateEditorContext: function(kwargs) {
var url = this.url('update_editor_context'); var url = this.url('update_editor_context');
var payload = JSON.stringify({ var payload = JSON.stringify({
'prompt': prompt, prompt: kwargs.prompt,
'rubric': rubric, feedback_prompt: kwargs.feedbackPrompt,
'title': title, title: kwargs.title,
'submission_start': sub_start, submission_start: kwargs.submissionStart,
'submission_due': sub_due, submission_due: kwargs.submissionDue,
'assessments': assessments criteria: kwargs.criteria,
assessments: kwargs.assessments
}); });
return $.Deferred(function(defer) { return $.Deferred(function(defer) {
$.ajax({ $.ajax({
......
...@@ -120,7 +120,10 @@ class StudentTrainingMixin(object): ...@@ -120,7 +120,10 @@ class StudentTrainingMixin(object):
examples examples
) )
context['training_essay'] = example['answer'] context['training_essay'] = example['answer']
context['training_rubric'] = example['rubric'] context['training_rubric'] = {
'criteria': example['rubric']['criteria'],
'points_possible': example['rubric']['points_possible']
}
template = 'openassessmentblock/student_training/student_training.html' template = 'openassessmentblock/student_training/student_training.html'
return template, context return template, context
......
{
"no-dates": {
"assessments_list": [
{
"name": "peer-assessment",
"start": "",
"due": "",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "",
"start": ""
}
],
"results": [
{
"name": "peer-assessment",
"start": null,
"due": null,
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": null,
"start": null
}
]
},
"student-training": {
"assessments_list": [
{
"name": "student-training",
"start": "",
"due": "",
"examples": "<example><answer>ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Test criterion\" option=\"Yes\" /><select criterion=\"Another test criterion\" option=\"No\" /></example><example><answer>äṅöẗḧëṛ ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Another test criterion\" option=\"Yes\" /><select criterion=\"Test criterion\" option=\"No\" /></example>"
},
{
"name": "peer-assessment",
"start": "",
"due": "",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "",
"start": ""
}
],
"results": [
{
"name": "student-training",
"due": null,
"start": null,
"examples": [
{
"answer": "ẗëṡẗ äṅṡẅëṛ",
"options_selected": [
{
"criterion": "Test criterion",
"option": "Yes"
},
{
"criterion": "Another test criterion",
"option": "No"
}
]
},
{
"answer": "äṅöẗḧëṛ ẗëṡẗ äṅṡẅëṛ",
"options_selected": [
{
"criterion": "Another test criterion",
"option": "Yes"
},
{
"criterion": "Test criterion",
"option": "No"
}
]
}
]
},
{
"name": "peer-assessment",
"start": null,
"due": null,
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": null,
"start": null
}
]
},
"date-parsing": {
"assessments_list": [
{
"name": "student-training",
"start": "2014-10-10T01:00:01",
"due": "",
"examples": "<example><answer>ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Test criterion\" option=\"Yes\" /><select criterion=\"Another test criterion\" option=\"No\" /></example><example><answer>äṅöẗḧëṛ ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Another test criterion\" option=\"Yes\" /><select criterion=\"Test criterion\" option=\"No\" /></example>"
},
{
"name": "peer-assessment",
"start": "",
"due": "2015-01-01T00:00:00",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "2015-01-01T00:00:00",
"start": ""
}
],
"results": [
{
"name": "student-training",
"due": null,
"start": "2014-10-10T01:00:01",
"examples": [
{
"answer": "ẗëṡẗ äṅṡẅëṛ",
"options_selected": [
{
"criterion": "Test criterion",
"option": "Yes"
},
{
"criterion": "Another test criterion",
"option": "No"
}
]
},
{
"answer": "äṅöẗḧëṛ ẗëṡẗ äṅṡẅëṛ",
"options_selected": [
{
"criterion": "Another test criterion",
"option": "Yes"
},
{
"criterion": "Test criterion",
"option": "No"
}
]
}
]
},
{
"name": "peer-assessment",
"start": null,
"due": "2015-01-01T00:00:00",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "2015-01-01T00:00:00",
"start": null
}
]
}
}
\ No newline at end of file
{
"date-parsing-due": {
"assessments_list": [
{
"name": "student-training",
"start": "2014-10-10T01:00:01",
"due": "",
"examples": "<examples><example><answer>ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Test criterion\" option=\"Yes\" /><select criterion=\"Another test criterion\" option=\"No\" /></example><example><answer>äṅöẗḧëṛ ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Another test criterion\" option=\"Yes\" /><select criterion=\"Test criterion\" option=\"No\" /></example></examples>"
},
{
"name": "peer-assessment",
"start": "",
"due": "2015-01-01T00:00:HI",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "2015-014-01",
"start": ""
}
]
},
"date-parsing-start": {
"assessments_list": [
{
"name": "peer-assessment",
"start": "2014-13-13T00:00:00",
"due": "",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "",
"start": ""
}
]
},
"no-answers-in-examples": {
"assessments_list": [
{
"name": "student-training",
"start": "",
"due": "",
"examples": "<example><select criterion=\"Test criterion\" option=\"Yes\" /><select criterion=\"Another test criterion\" option=\"No\" /></example><example><answer>äṅöẗḧëṛ ẗëṡẗ äṅṡẅëṛ</answer><select criterion=\"Another test criterion\" option=\"Yes\" /><select criterion=\"Test criterion\" option=\"No\" /></example>"
},
{
"name": "peer-assessment",
"start": "",
"due": "",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "",
"start": ""
}
]
},
"must_grade": {
"assessments_list": [
{
"name": "peer-assessment",
"start": "",
"due": "",
"must_grade": "Not a number fool!",
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"due": "",
"start": ""
}
]
},
"must_be_graded_by": {
"assessments_list": [
{
"name": "peer-assessment",
"start": "",
"due": "",
"must_grade": 3,
"must_be_graded_by": "Not a number fool!"
},
{
"name": "self-assessment",
"due": "",
"start": ""
}
]
}
}
\ No newline at end of file
...@@ -7,9 +7,6 @@ ...@@ -7,9 +7,6 @@
"training_num_available": 2, "training_num_available": 2,
"training_essay": "This is my answer.", "training_essay": "This is my answer.",
"training_rubric": { "training_rubric": {
"id": 2,
"content_hash": "de2bb2b7e2c6e3df014e53b8c65f37d511cc4344",
"structure_hash": "a513b20d93487d6d80e31e1d974bf22519332567",
"criteria": [ "criteria": [
{ {
"order_num": 0, "order_num": 0,
......
{ {
"simple": { "simple": {
"rubric": { "criteria": [
"prompt": "Test Prompt", {
"criteria": [ "order_num": 0,
{ "name": "Test criterion",
"order_num": 0, "prompt": "Test criterion prompt",
"name": "Test criterion", "options": [
"prompt": "Test criterion prompt", {
"options": [ "order_num": 0,
{ "points": 0,
"order_num": 0, "name": "No",
"points": 0, "explanation": "No explanation"
"name": "No", },
"explanation": "No explanation" {
}, "order_num": 1,
{ "points": 2,
"order_num": 1, "name": "Yes",
"points": 2, "explanation": "Yes explanation"
"name": "Yes", }
"explanation": "Yes explanation" ],
} "feedback": "required"
], }
"feedback": "required" ],
}
]
},
"prompt": "My new prompt.", "prompt": "My new prompt.",
"submission_due": "4014-02-27T09:46:28", "feedback_prompt": "Feedback prompt",
"submission_start": "4014-02-10T09:46:28", "submission_due": "4014-02-27T09:46",
"submission_start": "4014-02-10T09:46",
"title": "My new title.", "title": "My new title.",
"assessments": [ "assessments": [
{ {
"name": "peer-assessment", "name": "peer-assessment",
"must_grade": 5, "must_grade": 5,
"must_be_graded_by": 3, "must_be_graded_by": 3,
"start": "", "start": null,
"due": "4014-03-10T00:00:00" "due": "4014-03-10T00:00"
}, },
{ {
"name": "self-assessment", "name": "self-assessment",
"start": "", "start": null,
"due": "" "due": null
} }
], ]
"expected-assessment": "peer-assessment",
"expected-criterion-prompt": "Test criterion prompt"
}, },
"unicode": { "unicode": {
"rubric": { "criteria": [
"prompt": "Ṫëṡẗ ṗṛöṁṗẗ", {
"criteria": [ "order_num": 0,
{ "name": "Ṫëṡẗ ċṛïẗëïṛöṅ",
"order_num": 0, "prompt": "Téśt ćŕítéíŕőń ṕŕőḿṕt",
"name": "Ṫëṡẗ ċṛïẗëïṛöṅ", "options": [
"prompt": "Téśt ćŕítéíŕőń ṕŕőḿṕt", {
"options": [ "order_num": 0,
{ "points": 0,
"order_num": 0, "name": "Ṅö",
"points": 0, "explanation": "Ńő éxṕĺáńátíőń"
"name": "Ṅö", },
"explanation": "Ńő éxṕĺáńátíőń" {
}, "order_num": 1,
{ "points": 2,
"order_num": 1, "name": "sǝʎ",
"points": 2, "explanation": "Чэѕ эхрlаиатіои"
"name": "sǝʎ", }
"explanation": "Чэѕ эхрlаиатіои" ],
} "feedback": "required"
], }
"feedback": "required" ],
}
]
},
"prompt": "Ṁÿ ṅëẅ ṗṛöṁṗẗ.", "prompt": "Ṁÿ ṅëẅ ṗṛöṁṗẗ.",
"submission_due": "4014-02-27T09:46:28", "feedback_prompt": "ḟëëḋḅäċḳ ṗṛöṁṗẗ",
"submission_start": "4014-02-10T09:46:28", "submission_due": "4014-02-27T09:46",
"submission_start": "4014-02-10T09:46",
"title": "ɯʎ uǝʍ ʇıʇןǝ", "title": "ɯʎ uǝʍ ʇıʇןǝ",
"assessments": [ "assessments": [
{ {
"name": "peer-assessment", "name": "peer-assessment",
"must_grade": 5, "must_grade": 5,
"must_be_graded_by": 3, "must_be_graded_by": 3,
"start": "", "start": null,
"due": "4014-03-10T00:00:00" "due": "4014-03-10T00:00"
}, },
{ {
"name": "self-assessment", "name": "self-assessment",
"start": "", "start": null,
"due": "" "due": null
} }
], ]
"expected-assessment": "peer-assessment",
"expected-criterion-prompt": "Ṫëṡẗ ċṛïẗëṛïöṅ ṗṛöṁṗẗ"
} }
} }
...@@ -5,6 +5,7 @@ Tests for the student training step in the Open Assessment XBlock. ...@@ -5,6 +5,7 @@ Tests for the student training step in the Open Assessment XBlock.
import datetime import datetime
import ddt import ddt
import json import json
import pprint
from mock import patch from mock import patch
import pytz import pytz
from django.db import DatabaseError from django.db import DatabaseError
...@@ -196,7 +197,11 @@ class StudentTrainingAssessTest(XBlockHandlerTestCase): ...@@ -196,7 +197,11 @@ class StudentTrainingAssessTest(XBlockHandlerTestCase):
iso_date = context['training_due'].isoformat() iso_date = context['training_due'].isoformat()
self.assertEqual(iso_date, expected_context[key]) self.assertEqual(iso_date, expected_context[key])
else: else:
self.assertEqual(context[key], expected_context[key]) msg = u"Expected \n {expected} \n but found \n {actual}".format(
actual=pprint.pformat(context[key]),
expected=pprint.pformat(expected_context[key])
)
self.assertEqual(context[key], expected_context[key], msg=msg)
# Verify that we render without error # Verify that we render without error
resp = self.request(xblock, 'render_student_training', json.dumps({})) resp = self.request(xblock, 'render_student_training', json.dumps({}))
......
...@@ -48,10 +48,11 @@ class StudioViewTest(XBlockHandlerTestCase): ...@@ -48,10 +48,11 @@ class StudioViewTest(XBlockHandlerTestCase):
@file_data('data/invalid_update_xblock.json') @file_data('data/invalid_update_xblock.json')
@scenario('data/basic_scenario.xml') @scenario('data/basic_scenario.xml')
def test_update_context_invalid_request_data(self, xblock, data): def test_update_context_invalid_request_data(self, xblock, data):
expected_error = data.pop('expected_error')
xblock.published_date = None xblock.published_date = None
resp = self.request(xblock, 'update_editor_context', json.dumps(data), response_format='json') resp = self.request(xblock, 'update_editor_context', json.dumps(data), response_format='json')
self.assertFalse(resp['success']) self.assertFalse(resp['success'])
self.assertIn(data['expected_error'], resp['msg'].lower()) self.assertIn(expected_error, resp['msg'].lower())
@file_data('data/invalid_rubric.json') @file_data('data/invalid_rubric.json')
@scenario('data/basic_scenario.xml') @scenario('data/basic_scenario.xml')
...@@ -67,7 +68,7 @@ class StudioViewTest(XBlockHandlerTestCase): ...@@ -67,7 +68,7 @@ class StudioViewTest(XBlockHandlerTestCase):
# Verify the response fails # Verify the response fails
resp = self.request(xblock, 'update_editor_context', request, response_format='json') resp = self.request(xblock, 'update_editor_context', request, response_format='json')
self.assertFalse(resp['success']) self.assertFalse(resp['success'])
self.assertIn("the following keys were missing", resp['msg'].lower()) self.assertIn("error updating xblock configuration", resp['msg'].lower())
# Check that the XBlock fields were NOT updated # Check that the XBlock fields were NOT updated
# We don't need to be exhaustive here, because we have other unit tests # We don't need to be exhaustive here, because we have other unit tests
......
...@@ -235,11 +235,6 @@ class ValidationIntegrationTest(TestCase): ...@@ -235,11 +235,6 @@ class ValidationIntegrationTest(TestCase):
] ]
} }
SUBMISSION = {
"start": None,
"due": None
}
EXAMPLES = [ EXAMPLES = [
{ {
"answer": "ẗëṡẗ äṅṡẅëṛ", "answer": "ẗëṡẗ äṅṡẅëṛ",
...@@ -293,7 +288,7 @@ class ValidationIntegrationTest(TestCase): ...@@ -293,7 +288,7 @@ class ValidationIntegrationTest(TestCase):
self.validator = validator(self.oa_block) self.validator = validator(self.oa_block)
def test_validates_successfully(self): def test_validates_successfully(self):
is_valid, msg = self.validator(self.RUBRIC, self.SUBMISSION, self.ASSESSMENTS) is_valid, msg = self.validator(self.RUBRIC, self.ASSESSMENTS)
self.assertTrue(is_valid, msg=msg) self.assertTrue(is_valid, msg=msg)
self.assertEqual(msg, "") self.assertEqual(msg, "")
...@@ -303,7 +298,7 @@ class ValidationIntegrationTest(TestCase): ...@@ -303,7 +298,7 @@ class ValidationIntegrationTest(TestCase):
mutated_assessments[0]['examples'][0]['options_selected'][0]['criterion'] = 'Invalid criterion!' mutated_assessments[0]['examples'][0]['options_selected'][0]['criterion'] = 'Invalid criterion!'
# Expect a validation error # Expect a validation error
is_valid, msg = self.validator(self.RUBRIC, self.SUBMISSION, mutated_assessments) is_valid, msg = self.validator(self.RUBRIC, mutated_assessments)
self.assertFalse(is_valid) self.assertFalse(is_valid)
self.assertEqual(msg, u'Example 1 has an extra option for "Invalid criterion!"; Example 1 is missing an option for "vocabulary"') self.assertEqual(msg, u'Example 1 has an extra option for "Invalid criterion!"; Example 1 is missing an option for "vocabulary"')
...@@ -313,7 +308,7 @@ class ValidationIntegrationTest(TestCase): ...@@ -313,7 +308,7 @@ class ValidationIntegrationTest(TestCase):
mutated_assessments[0]['examples'][0]['options_selected'][0]['option'] = 'Invalid option!' mutated_assessments[0]['examples'][0]['options_selected'][0]['option'] = 'Invalid option!'
# Expect a validation error # Expect a validation error
is_valid, msg = self.validator(self.RUBRIC, self.SUBMISSION, mutated_assessments) is_valid, msg = self.validator(self.RUBRIC, mutated_assessments)
self.assertFalse(is_valid) self.assertFalse(is_valid)
self.assertEqual(msg, u'Example 1 has an invalid option for "vocabulary": "Invalid option!"') self.assertEqual(msg, u'Example 1 has an invalid option for "vocabulary": "Invalid option!"')
...@@ -327,12 +322,12 @@ class ValidationIntegrationTest(TestCase): ...@@ -327,12 +322,12 @@ class ValidationIntegrationTest(TestCase):
option['points'] = 1 option['points'] = 1
# Expect a validation error # Expect a validation error
is_valid, msg = self.validator(mutated_rubric, self.SUBMISSION, self.ASSESSMENTS) is_valid, msg = self.validator(mutated_rubric, self.ASSESSMENTS)
self.assertFalse(is_valid) self.assertFalse(is_valid)
self.assertEqual(msg, u'Example-based assessments cannot have duplicate point values.') self.assertEqual(msg, u'Example-based assessments cannot have duplicate point values.')
# But it should be okay if we don't have example-based assessment # But it should be okay if we don't have example-based assessment
no_example_based = copy.deepcopy(self.ASSESSMENTS)[1:] no_example_based = copy.deepcopy(self.ASSESSMENTS)[1:]
is_valid, msg = self.validator(mutated_rubric, self.SUBMISSION, no_example_based) is_valid, msg = self.validator(mutated_rubric, no_example_based)
self.assertTrue(is_valid) self.assertTrue(is_valid)
self.assertEqual(msg, u'') self.assertEqual(msg, u'')
...@@ -11,7 +11,6 @@ import dateutil.parser ...@@ -11,7 +11,6 @@ import dateutil.parser
from django.test import TestCase from django.test import TestCase
import ddt import ddt
from openassessment.xblock.openassessmentblock import OpenAssessmentBlock from openassessment.xblock.openassessmentblock import OpenAssessmentBlock
from openassessment.xblock.studio_mixin import parse_assessment_dictionaries
from openassessment.xblock.xml import ( from openassessment.xblock.xml import (
serialize_content, parse_from_xml_str, parse_rubric_xml_str, serialize_content, parse_from_xml_str, parse_rubric_xml_str,
parse_examples_xml_str, parse_assessments_xml_str, parse_examples_xml_str, parse_assessments_xml_str,
...@@ -359,25 +358,6 @@ class TestParseAssessmentsFromXml(TestCase): ...@@ -359,25 +358,6 @@ class TestParseAssessmentsFromXml(TestCase):
self.assertEqual(assessments, data['assessments']) self.assertEqual(assessments, data['assessments'])
@ddt.ddt
class TestParseAssessmentsFromDictionaries(TestCase):
@ddt.file_data('data/parse_assessment_dicts.json')
def test_parse_assessments_dictionary(self, data):
config = parse_assessment_dictionaries(data['assessments_list'])
if len(config) == 0:
# Prevents this test from passing benignly if parse_assessment_dictionaries returns []
self.assertTrue(False)
for config_assessment, correct_assessment in zip(config, data['results']):
self.assertEqual(config_assessment, correct_assessment)
@ddt.file_data('data/parse_assessment_dicts_error.json')
def test_parse_assessments_dictionary_error(self, data):
with self.assertRaises(UpdateFromXmlError):
parse_assessment_dictionaries(data['assessments_list'])
@ddt.ddt @ddt.ddt
class TestUpdateFromXml(TestCase): class TestUpdateFromXml(TestCase):
......
...@@ -299,7 +299,7 @@ def validator(oa_block, strict_post_release=True): ...@@ -299,7 +299,7 @@ def validator(oa_block, strict_post_release=True):
callable, of a form that can be passed to `update_from_xml`. callable, of a form that can be passed to `update_from_xml`.
""" """
def _inner(rubric_dict, submission_dict, assessments): def _inner(rubric_dict, assessments, submission_start=None, submission_due=None):
is_released = strict_post_release and oa_block.is_released() is_released = strict_post_release and oa_block.is_released()
...@@ -325,7 +325,7 @@ def validator(oa_block, strict_post_release=True): ...@@ -325,7 +325,7 @@ def validator(oa_block, strict_post_release=True):
return (False, msg) return (False, msg)
# Dates # Dates
submission_dates = [(submission_dict['start'], submission_dict['due'])] submission_dates = [(submission_start, submission_due)]
assessment_dates = [(asmnt.get('start'), asmnt.get('due')) for asmnt in assessments] assessment_dates = [(asmnt.get('start'), asmnt.get('due')) for asmnt in assessments]
success, msg = validate_dates(oa_block.start, oa_block.due, submission_dates + assessment_dates) success, msg = validate_dates(oa_block.start, oa_block.due, submission_dates + assessment_dates)
if not success: if not success:
......
...@@ -304,9 +304,6 @@ def parse_rubric_xml(rubric_root): ...@@ -304,9 +304,6 @@ def parse_rubric_xml(rubric_root):
Args: Args:
rubric_root (lxml.etree.Element): The root of the <rubric> node in the tree. rubric_root (lxml.etree.Element): The root of the <rubric> node in the tree.
validator (callable): Function that accepts a rubric dict and returns
a boolean indicating whether the rubric is semantically valid
and an error message string.
Returns: Returns:
dict, a serialized representation of a rubric, as defined by the peer grading serializers. dict, a serialized representation of a rubric, as defined by the peer grading serializers.
......
...@@ -22,6 +22,7 @@ loremipsum==1.0.2 ...@@ -22,6 +22,7 @@ loremipsum==1.0.2
python-dateutil==2.1 python-dateutil==2.1
pytz==2012h pytz==2012h
South==0.7.6 South==0.7.6
voluptuous==0.8.5
# AI grading # AI grading
git+https://github.com/edx/ease.git@f9f47fb6b5c7c8b6c3360efa72eb56561e1a03b0#egg=ease git+https://github.com/edx/ease.git@f9f47fb6b5c7c8b6c3360efa72eb56561e1a03b0#egg=ease
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment