Commit 8f16d639 by Don Mitchell

CRUD on policy fields w/ some validation

parent 07f87d47
...@@ -1109,6 +1109,8 @@ def get_course_settings(request, org, course, name): ...@@ -1109,6 +1109,8 @@ def get_course_settings(request, org, course, name):
return render_to_response('settings.html', { return render_to_response('settings.html', {
'active_tab': 'settings', 'active_tab': 'settings',
'context_course': course_module, 'context_course': course_module,
'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST),
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder)
}) })
...@@ -1133,6 +1135,9 @@ def course_settings_updates(request, org, course, name, section): ...@@ -1133,6 +1135,9 @@ def course_settings_updates(request, org, course, name, section):
manager = CourseDetails manager = CourseDetails
elif section == 'grading': elif section == 'grading':
manager = CourseGradingModel manager = CourseGradingModel
elif section == 'advanced':
# not implemented b/c it assumes prefetched and then everything thru course_edit_metadata
return
else: return else: return
if request.method == 'GET': if request.method == 'GET':
...@@ -1194,14 +1199,10 @@ def course_edit_metadata(request, org, course, name): ...@@ -1194,14 +1199,10 @@ def course_edit_metadata(request, org, course, name):
editable = CourseMetadata.fetch(location) editable = CourseMetadata.fetch(location)
return render_to_response('course_info.html', { # for now defer to settings general until we split the divs out into separate pages
'active_tab': 'settings', return get_course_settings(request, org, course, name)
'editable_metadata': editable,
'url_base' : "/" + org + "/" + course + "/",
'blacklist_keys' : CourseMetadata.FILTERED_LIST
})
@expect_json ## NB: expect_json failed on ["key", "key2"] and json payload
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_metadata_rest_access(request, org, course, name): def course_metadata_rest_access(request, org, course, name):
...@@ -1225,10 +1226,11 @@ def course_metadata_rest_access(request, org, course, name): ...@@ -1225,10 +1226,11 @@ def course_metadata_rest_access(request, org, course, name):
if request.method == 'GET': if request.method == 'GET':
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json") return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE elif real_method == 'DELETE':
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, request.POST)), mimetype="application/json") return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json")
elif request.method == 'POST': elif request.method == 'POST':
return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, request.POST)), mimetype="application/json") # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json")
@login_required @login_required
......
...@@ -7,8 +7,8 @@ class CourseMetadata(object): ...@@ -7,8 +7,8 @@ class CourseMetadata(object):
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
The objects have no predefined attrs but instead are obj encodings of the editable metadata. The objects have no predefined attrs but instead are obj encodings of the editable metadata.
''' '''
# __new_advanced_key__ is used by client not server; so, could argue against it being here
FILTERED_LIST = ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod'] FILTERED_LIST = ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__']
@classmethod @classmethod
def fetch(cls, course_location): def fetch(cls, course_location):
...@@ -57,14 +57,10 @@ class CourseMetadata(object): ...@@ -57,14 +57,10 @@ class CourseMetadata(object):
''' '''
descriptor = get_modulestore(course_location).get_item(course_location) descriptor = get_modulestore(course_location).get_item(course_location)
if isinstance(payload, list): for key in payload['deleteKeys']:
for key in payload: if key in descriptor.metadata:
if key in descriptor.metadata: del descriptor.metadata[key]
del descriptor.metadata[key]
else:
if payload in descriptor.metadata:
del descriptor.metadata[payload]
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
return cls.fetch(course_location) return cls.fetch(course_location)
<li class="input multi course-advanced-policy-list-item">
<div class="row">
<div class="key" id="<%= (_.isEmpty(key) ? '__new_advanced_key__' : key) %>">
<label for="course-advanced-policy-key">Policy Key:</label>
<div class="field">
<input type="text" class="short" id="course-advanced-policy-key" value="<%= key %>" />
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
</div>
</div>
<div class="value">
<label for="course-advanced-policy-value">Policy Value:</label>
<div class="field">
<textarea class="ace text" id="course-advanced-policy-value"><%= value %></textarea>
</div>
</div>
</div> <a href="#" class="delete-button standard remove-item advanced-policy-data">
<span class="delete-icon"></span>Delete</a>
</li>
\ No newline at end of file
...@@ -2,13 +2,211 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = {}; ...@@ -2,13 +2,211 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = {};
CMS.Models.Settings.Advanced = Backbone.Model.extend({ CMS.Models.Settings.Advanced = Backbone.Model.extend({
defaults: { defaults: {
// the properties are whatever the user types in
}, },
// which keys to send as the deleted keys on next save
deleteKeys : [],
blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based]
initialize: function() { initialize: function() {
console.log('in initialize'); console.log('in initialize');
var editor = ace.edit('course-advanced-policy-1-value'); },
editor.setTheme("ace/theme/monokai"); validate: function(attrs) {
editor.getSession().setMode("ace/mode/javascript"); var errors = {};
for (key in attrs) {
if (_.contains(this.blacklistKeys, key)) {
errors[key] = key + " is a reserved keyword or has another editor";
}
}
if (!_.isEmpty(errors)) return errors;
}
});
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.Advanced
events : {
'click .delete-button' : "deleteEntry",
'click .save-button' : "saveView",
'click .cancel-button' : "revertView",
'click .new-button' : "addEntry",
// update model on changes
'change #course-advanced-policy-key' : "updateKey",
'change #course-advanced-policy-value' : "updateValue"
// TODO enable/disable save (add disabled class) based on validation & dirty
// TODO enable/disable new button?
},
initialize : function() {
var self = this;
// instantiates an editor template for each update in the collection
window.templateLoader.loadRemoteTemplate("advanced_entry",
"/static/client_templates/advanced_entry.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
this.model.on('error', this.handleValidationError, this);
},
render: function() {
// catch potential outside call before template loaded
if (!this.template) return this;
var listEle$ = this.$el.find('.course-advanced-policy-list');
listEle$.empty();
// same object so manipulations to one keep the other up to date
this.fieldToSelectorMap = this.selectorToField = {};
// iterate through model and produce key : value editors for each property in model.get
var self = this;
_.each(this.model.attributes,
function(value, key) {
listEle$.append(self.template({ key : key, value : value}));
self.fieldToSelectorMap[key] = key;
});
// insert the empty one
this.addEntry();
// Should this be on an event rather than render?
// var editor = ace.edit('course-advanced-policy-1-value');
// editor.setTheme("ace/theme/monokai");
// editor.getSession().setMode("ace/mode/javascript");
return this;
},
deleteEntry : function(event) {
event.preventDefault();
// find out which entry
var li$ = $(event.currentTarget).closest('li');
// Not data b/c the validation view uses it for a selector
var key = $('.key', li$).attr('id');
delete this.fieldToSelectorMap[key];
if (key !== '__new_advanced_key__') {
this.model.deleteKeys.push(key);
delete this.model[key];
}
li$.remove();
},
saveView : function(event) {
// TODO one last verification scan:
// call validateKey on each to ensure proper format
// check for dupes
this.model.save({
success : function() { window.alert("Saved"); },
error : CMS.ServerError
});
// FIXME don't delete if the validation didn't succeed in the save call
// remove deleted attrs
if (!_.isEmpty(this.model.deleteKeys)) {
var self = this;
// hmmm, not sure how to do this via backbone since we're not destroying the model
$.ajax({
url : this.model.url,
// json to and fro
contentType : "application/json",
dataType : "json",
// delete
type : 'DELETE',
// data
data : JSON.stringify({ deleteKeys : this.model.deleteKeys})
})
.fail(function(hdr, status, error) { CMS.ServerError(self.model, "Deleting keys:" + status); })
.done(function(data, status, error) {
// clear deleteKeys on success
self.model.deleteKeys = [];
});
}
},
revertView : function(event) {
this.model.deleteKeys = [];
var self = this;
this.model.fetch({
success : this.render,
error : CMS.ServerError
});
},
addEntry : function() {
var listEle$ = this.$el.find('.course-advanced-policy-list');
listEle$.append(this.template({ key : "", value : ""}));
// disable the value entry until there's an acceptable key
listEle$.find('.course-advanced-policy-value').addClass('disabled');
this.fieldToSelectorMap['__new_advanced_key__'] = '__new_advanced_key__';
},
updateKey : function(event) {
// old key: either the key as in the model or __new_advanced_key__. That is, it doesn't change as the val changes until val is accepted
var oldKey = $(event.currentTarget).closest('.key').attr('id');
var newKey = $(event.currentTarget).val();
console.log('update ', oldKey, newKey); // REMOVE ME
if (oldKey !== newKey) {
// may erase other errors but difficult to just remove these
this.clearValidationErrors();
if (!this.validateKey(oldKey, newKey)) return;
if (this.model.has(newKey)) {
console.log('dupe key');
var error = {};
error[oldKey] = newKey + " has another entry";
error[newKey] = "Other entry for " + newKey;
this.model.trigger("error", this.model, error);
return false;
}
// explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success
// method which is uglier I think?)
var newEntryModel = {};
// set the new key's value to the old one's
newEntryModel[newKey] = (oldKey === '__new_advanced_key__' ? '' : this.model.get(oldKey));
var validation = this.model.validate(newEntryModel);
if (validation) {
console.log('reserved key');
this.model.trigger("error", this.model, validation);
// abandon update
return;
}
// Now safe to actually do the update
this.model.set(newEntryModel);
delete this.fieldToSelectorMap[oldKey];
if (oldKey !== '__new_advanced_key__') {
// mark the old key for deletion and delete from field maps
this.model.deleteKeys.push(oldKey);
this.model.unset(oldKey) ;
}
else {
// enable the value entry
this.$el.find('.course-advanced-policy-value').removeClass('disabled');
}
// update gui (sets all the ids etc)
$(event.currentTarget).closest('li').replaceWith(this.template({key : newKey, value : this.model.get(newKey) }));
this.fieldToSelectorMap[newKey] = newKey;
}
},
validateKey : function(oldKey, newKey) {
// model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here
// TODO ensure there's no spaces or illegal chars
if (_.isEmpty(newKey)) {
console.log('no key');
var error = {};
error[oldKey] = "Key cannot be an empty string";
this.model.trigger("error", this.model, error);
return false;
}
else return true;
},
updateValue : function(event) {
// much simpler than key munging. just update the value
var key = $(event.currentTarget).closest('.row').children('.key').attr('id');
var value = $(event.currentTarget).val();
console.log('updating ', key, value);
this.model.set(key, value, {validate:true});
} }
}); });
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object(); if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
defaults : { defaults : {
course_location : null, course_location : null,
graders : null, // CourseGraderCollection graders : null, // CourseGraderCollection
grade_cutoffs : null, // CourseGradeCutoff model grade_cutoffs : null, // CourseGradeCutoff model
grace_period : null // either null or { hours: n, minutes: m, ...} grace_period : null // either null or { hours: n, minutes: m, ...}
}, },
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) { if (attributes['course_location']) {
attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true});
} }
if (attributes['graders']) { if (attributes['graders']) {
var graderCollection; var graderCollection;
if (this.has('graders')) { if (this.has('graders')) {
graderCollection = this.get('graders'); graderCollection = this.get('graders');
graderCollection.reset(attributes.graders); graderCollection.reset(attributes.graders);
} }
else { else {
graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders); graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders);
graderCollection.course_location = attributes['course_location'] || this.get('course_location'); graderCollection.course_location = attributes['course_location'] || this.get('course_location');
} }
attributes.graders = graderCollection; attributes.graders = graderCollection;
} }
return attributes; return attributes;
}, },
url : function() { url : function() {
var location = this.get('course_location'); var location = this.get('course_location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading'; return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading';
}, },
gracePeriodToDate : function() { gracePeriodToDate : function() {
var newDate = new Date(); var newDate = new Date();
if (this.has('grace_period') && this.get('grace_period')['hours']) if (this.has('grace_period') && this.get('grace_period')['hours'])
newDate.setHours(this.get('grace_period')['hours']); newDate.setHours(this.get('grace_period')['hours']);
else newDate.setHours(0); else newDate.setHours(0);
if (this.has('grace_period') && this.get('grace_period')['minutes']) if (this.has('grace_period') && this.get('grace_period')['minutes'])
newDate.setMinutes(this.get('grace_period')['minutes']); newDate.setMinutes(this.get('grace_period')['minutes']);
else newDate.setMinutes(0); else newDate.setMinutes(0);
if (this.has('grace_period') && this.get('grace_period')['seconds']) if (this.has('grace_period') && this.get('grace_period')['seconds'])
newDate.setSeconds(this.get('grace_period')['seconds']); newDate.setSeconds(this.get('grace_period')['seconds']);
else newDate.setSeconds(0); else newDate.setSeconds(0);
return newDate; return newDate;
}, },
dateToGracePeriod : function(date) { dateToGracePeriod : function(date) {
return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() }; return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
} }
}); });
CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
defaults: { defaults: {
"type" : "", // must be unique w/in collection (ie. w/in course) "type" : "", // must be unique w/in collection (ie. w/in course)
"min_count" : 1, "min_count" : 1,
"drop_count" : 0, "drop_count" : 0,
...@@ -57,71 +57,71 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ ...@@ -57,71 +57,71 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
"weight" : 0 // int 0..100 "weight" : 0 // int 0..100
}, },
parse : function(attrs) { parse : function(attrs) {
if (attrs['weight']) { if (attrs['weight']) {
if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight); if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
} }
if (attrs['min_count']) { if (attrs['min_count']) {
if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count); if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
} }
if (attrs['drop_count']) { if (attrs['drop_count']) {
if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count); if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
} }
return attrs; return attrs;
}, },
validate : function(attrs) { validate : function(attrs) {
var errors = {}; var errors = {};
if (attrs['type']) { if (attrs['type']) {
if (_.isEmpty(attrs['type'])) { if (_.isEmpty(attrs['type'])) {
errors.type = "The assignment type must have a name."; errors.type = "The assignment type must have a name.";
} }
else { else {
// FIXME somehow this.collection is unbound sometimes. I can't track down when // FIXME somehow this.collection is unbound sometimes. I can't track down when
var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this); var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this);
if (existing) { if (existing) {
errors.type = "There's already another assignment type with this name."; errors.type = "There's already another assignment type with this name.";
} }
} }
} }
if (attrs['weight']) { if (attrs['weight']) {
if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) { if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
errors.weight = "Please enter an integer between 0 and 100."; errors.weight = "Please enter an integer between 0 and 100.";
} }
else { else {
attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
if (this.collection && attrs.weight > 0) { if (this.collection && attrs.weight > 0) {
// FIXME b/c saves don't update the models if validation fails, we should // FIXME b/c saves don't update the models if validation fails, we should
// either revert the field value to the one in the model and make them make room // either revert the field value to the one in the model and make them make room
// or figure out a wholistic way to balance the vals across the whole // or figure out a wholistic way to balance the vals across the whole
// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100) // if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
// errors.weight = "The weights cannot add to more than 100."; // errors.weight = "The weights cannot add to more than 100.";
} }
}} }}
if (attrs['min_count']) { if (attrs['min_count']) {
if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) { if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
errors.min_count = "Please enter an integer."; errors.min_count = "Please enter an integer.";
} }
else attrs.min_count = parseInt(attrs.min_count); else attrs.min_count = parseInt(attrs.min_count);
} }
if (attrs['drop_count']) { if (attrs['drop_count']) {
if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) { if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
errors.drop_count = "Please enter an integer."; errors.drop_count = "Please enter an integer.";
} }
else attrs.drop_count = parseInt(attrs.drop_count); else attrs.drop_count = parseInt(attrs.drop_count);
} }
if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) { if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) {
errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned."; errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned.";
} }
if (!_.isEmpty(errors)) return errors; if (!_.isEmpty(errors)) return errors;
} }
}); });
CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({ CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({
model : CMS.Models.Settings.CourseGrader, model : CMS.Models.Settings.CourseGrader,
course_location : null, // must be set to a Location object course_location : null, // must be set to a Location object
url : function() { url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/'; return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/';
}, },
sumWeights : function() { sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
} }
}); });
\ No newline at end of file
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
if (typeof window.templateLoader == 'function') return; if (typeof window.templateLoader == 'function') return;
var templateLoader = { var templateLoader = {
templateVersion: "0.0.12", templateVersion: "0.0.13",
templates: {}, templates: {},
loadRemoteTemplate: function(templateName, filename, callback) { loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) { if (!this.templates[templateName]) {
......
if (!CMS.Views['Settings']) CMS.Views.Settings = {}; if (!CMS.Views['Settings']) CMS.Views.Settings = {};
// TODO move to common place //TODO move to common place
CMS.Views.ValidatingView = Backbone.View.extend({ CMS.Views.ValidatingView = Backbone.View.extend({
// Intended as an abstract class which catches validation errors on the model and // Intended as an abstract class which catches validation errors on the model and
// decorates the fields. Needs wiring per class, but this initialization shows how // decorates the fields. Needs wiring per class, but this initialization shows how
// either have your init call this one or copy the contents // either have your init call this one or copy the contents
initialize : function() { initialize : function() {
this.model.on('error', this.handleValidationError, this); this.model.on('error', this.handleValidationError, this);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
}, },
errorTemplate : _.template('<span class="message-error"><%= message %></span>'), errorTemplate : _.template('<span class="message-error"><%= message %></span>'),
events : { events : {
"blur input" : "clearValidationErrors", "change input" : "clearValidationErrors",
"blur textarea" : "clearValidationErrors" "change textarea" : "clearValidationErrors"
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
// Your subclass must populate this w/ all of the model keys and dom selectors // Your subclass must populate this w/ all of the model keys and dom selectors
// which may be the subjects of validation errors // which may be the subjects of validation errors
}, },
_cacheValidationErrors : [], _cacheValidationErrors : [],
handleValidationError : function(model, error) { handleValidationError : function(model, error) {
// error is object w/ fields and error strings console.log('validation', model, error);
for (var field in error) { // error is object w/ fields and error strings
var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); for (var field in error) {
this._cacheValidationErrors.push(ele); var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
if ($(ele).is('div')) { this._cacheValidationErrors.push(ele);
// put error on the contained inputs if ($(ele).is('div')) {
$(ele).find('input, textarea').addClass('error'); // put error on the contained inputs
} $(ele).find('input, textarea').addClass('error');
else $(ele).addClass('error'); }
$(ele).parent().append(this.errorTemplate({message : error[field]})); else $(ele).addClass('error');
} $(ele).parent().append(this.errorTemplate({message : error[field]}));
}, }
},
clearValidationErrors : function() {
// error is object w/ fields and error strings clearValidationErrors : function() {
while (this._cacheValidationErrors.length > 0) { // error is object w/ fields and error strings
var ele = this._cacheValidationErrors.pop(); while (this._cacheValidationErrors.length > 0) {
if ($(ele).is('div')) { var ele = this._cacheValidationErrors.pop();
// put error on the contained inputs if ($(ele).is('div')) {
$(ele).find('input, textarea').removeClass('error'); // put error on the contained inputs
} $(ele).find('input, textarea').removeClass('error');
else $(ele).removeClass('error'); }
$(ele).nextAll('.message-error').remove(); else $(ele).removeClass('error');
} $(ele).nextAll('.message-error').remove();
}, }
},
saveIfChanged : function(event) {
// returns true if the value changed and was thus sent to server saveIfChanged : function(event) {
var field = this.selectorToField[event.currentTarget.id]; // returns true if the value changed and was thus sent to server
var currentVal = this.model.get(field); var field = this.selectorToField[event.currentTarget.id];
var newVal = $(event.currentTarget).val(); var currentVal = this.model.get(field);
if (currentVal != newVal) { var newVal = $(event.currentTarget).val();
this.clearValidationErrors(); if (currentVal != newVal) {
this.model.save(field, newVal, { error : CMS.ServerError}); this.clearValidationErrors();
return true; this.model.save(field, newVal, { error : CMS.ServerError});
} return true;
else return false; }
} else return false;
}
}); });
CMS.Views.Settings.Main = Backbone.View.extend({ CMS.Views.Settings.Main = Backbone.View.extend({
// Model class is CMS.Models.Settings.CourseSettings // Model class is CMS.Models.Settings.CourseSettings
// allow navigation between the tabs // allow navigation between the tabs
events: { events: {
'click .settings-page-menu a': "showSettingsTab", 'click .settings-page-menu a': "showSettingsTab",
'mouseover #timezone' : "updateTime" 'mouseover #timezone' : "updateTime"
}, },
currentTab: null, currentTab: null,
subviews: {}, // indexed by tab name subviews: {}, // indexed by tab name
initialize: function() { initialize: function() {
// load templates // load templates
this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section'); this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section');
// create the initial subview // create the initial subview
this.subviews[this.currentTab] = this.createSubview(); this.subviews[this.currentTab] = this.createSubview();
// fill in fields // fill in fields
this.$el.find("#course-name").val(this.model.get('courseLocation').get('name')); this.$el.find("#course-name").val(this.model.get('courseLocation').get('name'));
this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org')); this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org'));
this.$el.find("#course-number").val(this.model.get('courseLocation').get('course')); this.$el.find("#course-number").val(this.model.get('courseLocation').get('course'));
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
this.$el.find(":input, textarea").focus(function() { this.$el.find(":input, textarea").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused"); $("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() { }).blur(function() {
$("label").removeClass("is-focused"); $("label").removeClass("is-focused");
}); });
this.render(); this.render();
}, },
render: function() { render: function() {
// create any necessary subviews and put them onto the page // create any necessary subviews and put them onto the page
if (!this.model.has(this.currentTab)) { if (!this.model.has(this.currentTab)) {
// TODO disable screen until fetch completes? // TODO disable screen until fetch completes?
var cachethis = this; var cachethis = this;
this.model.retrieve(this.currentTab, function() { this.model.retrieve(this.currentTab, function() {
cachethis.subviews[cachethis.currentTab] = cachethis.createSubview(); cachethis.subviews[cachethis.currentTab] = cachethis.createSubview();
cachethis.subviews[cachethis.currentTab].render(); cachethis.subviews[cachethis.currentTab].render();
}); });
} }
else this.subviews[this.currentTab].render(); else {
// Advanced (at least) model gets created at bootstrap but the view does not
var dateIntrospect = new Date(); if (!this.subviews[this.currentTab]) {
this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); this.subviews[this.currentTab] = this.createSubview();
}
return this; this.subviews[this.currentTab].render();
}, }
createSubview: function() { var dateIntrospect = new Date();
switch (this.currentTab) { this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
case 'details':
return new CMS.Views.Settings.Details({ return this;
el: this.$el.find('.settings-' + this.currentTab), },
model: this.model.get(this.currentTab)
}); createSubview: function() {
case 'faculty': switch (this.currentTab) {
break; case 'details':
case 'grading': return new CMS.Views.Settings.Details({
return new CMS.Views.Settings.Grading({ el: this.$el.find('.settings-' + this.currentTab),
el: this.$el.find('.settings-' + this.currentTab), model: this.model.get(this.currentTab)
model: this.model.get(this.currentTab) });
}); break;
case 'problems': case 'faculty':
break; break;
case 'discussions': case 'grading':
break; return new CMS.Views.Settings.Grading({
} el: this.$el.find('.settings-' + this.currentTab),
}, model: this.model.get(this.currentTab)
});
updateTime : function(e) { break;
var now = new Date(); case 'advanced':
var hours = now.getHours(); return new CMS.Views.Settings.Advanced({
var minutes = now.getMinutes(); el: this.$el.find('.settings-' + this.currentTab),
$(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") + model: this.model.get(this.currentTab)
now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)"); });
}, break;
case 'problems':
showSettingsTab: function(e) { break;
this.currentTab = $(e.target).attr('data-section'); case 'discussions':
$('.settings-page-section > section').hide(); break;
$('.settings-' + this.currentTab).show(); }
$('.settings-page-menu .is-shown').removeClass('is-shown'); },
$(e.target).addClass('is-shown');
// fetch model for the tab if not loaded already updateTime : function(e) {
this.render(); var now = new Date();
} var hours = now.getHours();
var minutes = now.getMinutes();
$(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") +
now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)");
},
showSettingsTab: function(e) {
this.currentTab = $(e.target).data('section');
$('.settings-page-section > section').hide();
$('.settings-' + this.currentTab).show();
$('.settings-page-menu .is-shown').removeClass('is-shown');
$(e.target).addClass('is-shown');
// fetch model for the tab if not loaded already
this.render();
}
}); });
CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails // Model class is CMS.Models.Settings.CourseDetails
events : { events : {
"blur input" : "updateModel", "change input" : "updateModel",
"blur textarea" : "updateModel", "change textarea" : "updateModel",
'click .remove-course-syllabus' : "removeSyllabus", 'click .remove-course-syllabus' : "removeSyllabus",
'click .new-course-syllabus' : 'assetSyllabus', 'click .new-course-syllabus' : 'assetSyllabus',
'click .remove-course-introduction-video' : "removeVideo", 'click .remove-course-introduction-video' : "removeVideo",
'focus #course-overview' : "codeMirrorize" 'focus #course-overview' : "codeMirrorize"
}, },
initialize : function() { initialize : function() {
// TODO move the html frag to a loaded asset // TODO move the html frag to a loaded asset
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="ss-icon ss-standard">&#x1F4C4;</i><%= filename %></a>'); this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="ss-icon ss-standard">&#x1F4C4;</i><%= filename %></a>');
this.model.on('error', this.handleValidationError, this); this.model.on('error', this.handleValidationError, this);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
}, },
render: function() { render: function() {
this.setupDatePicker('start_date'); this.setupDatePicker('start_date');
this.setupDatePicker('end_date'); this.setupDatePicker('end_date');
this.setupDatePicker('enrollment_start'); this.setupDatePicker('enrollment_start');
this.setupDatePicker('enrollment_end'); this.setupDatePicker('enrollment_end');
if (this.model.has('syllabus')) { if (this.model.has('syllabus')) {
this.$el.find(this.fieldToSelectorMap['syllabus']).html( this.$el.find(this.fieldToSelectorMap['syllabus']).html(
this.fileAnchorTemplate({ this.fileAnchorTemplate({
fullpath : this.model.get('syllabus'), fullpath : this.model.get('syllabus'),
filename: 'syllabus'})); filename: 'syllabus'}));
this.$el.find('.remove-course-syllabus').show(); this.$el.find('.remove-course-syllabus').show();
} }
else { else {
this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html(""); this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html("");
this.$el.find('.remove-course-syllabus').hide(); this.$el.find('.remove-course-syllabus').hide();
} }
this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
this.codeMirrorize(null, $('#course-overview')[0]); this.codeMirrorize(null, $('#course-overview')[0]);
this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample());
if (this.model.has('intro_video')) { if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show(); this.$el.find('.remove-course-introduction-video').show();
this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video')); this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video'));
} }
else this.$el.find('.remove-course-introduction-video').hide(); else this.$el.find('.remove-course-introduction-video').hide();
this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
return this; return this;
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
'start_date' : "course-start", 'start_date' : "course-start",
'end_date' : 'course-end', 'end_date' : 'course-end',
'enrollment_start' : 'enrollment-start', 'enrollment_start' : 'enrollment-start',
'enrollment_end' : 'enrollment-end', 'enrollment_end' : 'enrollment-end',
'syllabus' : '.current-course-syllabus .doc-filename', 'syllabus' : '.current-course-syllabus .doc-filename',
'overview' : 'course-overview', 'overview' : 'course-overview',
'intro_video' : 'course-introduction-video', 'intro_video' : 'course-introduction-video',
'effort' : "course-effort" 'effort' : "course-effort"
}, },
setupDatePicker: function (fieldName) { setupDatePicker: function (fieldName) {
var cacheModel = this.model; var cacheModel = this.model;
...@@ -245,58 +260,58 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -245,58 +260,58 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
datefield.datepicker('setDate', this.model.get(fieldName)); datefield.datepicker('setDate', this.model.get(fieldName));
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName)); if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
}, },
updateModel: function(event) { updateModel: function(event) {
switch (event.currentTarget.id) { switch (event.currentTarget.id) {
case 'course-start-date': // handled via onSelect method case 'course-start-date': // handled via onSelect method
case 'course-end-date': case 'course-end-date':
case 'course-enrollment-start-date': case 'course-enrollment-start-date':
case 'course-enrollment-end-date': case 'course-enrollment-end-date':
break; break;
case 'course-overview': case 'course-overview':
// handled via code mirror // handled via code mirror
break; break;
case 'course-effort': case 'course-effort':
this.saveIfChanged(event); this.saveIfChanged(event);
break; break;
case 'course-introduction-video': case 'course-introduction-video':
this.clearValidationErrors(); this.clearValidationErrors();
var previewsource = this.model.save_videosource($(event.currentTarget).val()); var previewsource = this.model.save_videosource($(event.currentTarget).val());
this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource); this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource);
if (this.model.has('intro_video')) { if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show(); this.$el.find('.remove-course-introduction-video').show();
} }
else { else {
this.$el.find('.remove-course-introduction-video').hide(); this.$el.find('.remove-course-introduction-video').hide();
} }
break; break;
default: default:
break; break;
} }
}, },
removeSyllabus: function() { removeSyllabus: function() {
if (this.model.has('syllabus')) this.model.save({'syllabus': null}, if (this.model.has('syllabus')) this.model.save({'syllabus': null},
{ error : CMS.ServerError}); { error : CMS.ServerError});
}, },
assetSyllabus : function() { assetSyllabus : function() {
// TODO implement // TODO implement
}, },
removeVideo: function() { removeVideo: function() {
if (this.model.has('intro_video')) { if (this.model.has('intro_video')) {
this.model.save_videosource(null); this.model.save_videosource(null);
this.$el.find(".current-course-introduction-video iframe").attr("src", ""); this.$el.find(".current-course-introduction-video iframe").attr("src", "");
this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(""); this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val("");
this.$el.find('.remove-course-introduction-video').hide(); this.$el.find('.remove-course-introduction-video').hide();
} }
}, },
codeMirrors : {}, codeMirrors : {},
codeMirrorize: function (e, forcedTarget) { codeMirrorize: function (e, forcedTarget) {
var thisTarget; var thisTarget;
if (forcedTarget) { if (forcedTarget) {
...@@ -316,42 +331,42 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -316,42 +331,42 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
cachethis.clearValidationErrors(); cachethis.clearValidationErrors();
var newVal = mirror.getValue(); var newVal = mirror.getValue();
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal, if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal,
{ error: CMS.ServerError}); { error: CMS.ServerError});
} }
}); });
} }
} }
}); });
CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGradingPolicy // Model class is CMS.Models.Settings.CourseGradingPolicy
events : { events : {
"blur input" : "updateModel", "change input" : "updateModel",
"blur textarea" : "updateModel", "change textarea" : "updateModel",
"blur span[contenteditable=true]" : "updateDesignation", "change span[contenteditable=true]" : "updateDesignation",
"click .settings-extra header" : "showSettingsExtras", "click .settings-extra header" : "showSettingsExtras",
"click .new-grade-button" : "addNewGrade", "click .new-grade-button" : "addNewGrade",
"click .remove-button" : "removeGrade", "click .remove-button" : "removeGrade",
"click .add-grading-data" : "addAssignmentType" "click .add-grading-data" : "addAssignmentType"
}, },
initialize : function() { initialize : function() {
// load template for grading view // load template for grading view
var self = this; var self = this;
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' + this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' +
'<%= descriptor %>' + '<%= descriptor %>' +
'</span><span class="range"></span>' + '</span><span class="range"></span>' +
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' + '<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
'</li>'); '</li>');
// Instrument grading scale // Instrument grading scale
// convert cutoffs to inversely ordered list // convert cutoffs to inversely ordered list
var modelCutoffs = this.model.get('grade_cutoffs'); var modelCutoffs = this.model.get('grade_cutoffs');
for (var cutoff in modelCutoffs) { for (var cutoff in modelCutoffs) {
this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)}); this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)});
} }
this.descendingCutoffs = _.sortBy(this.descendingCutoffs, this.descendingCutoffs = _.sortBy(this.descendingCutoffs,
function (gradeEle) { return -gradeEle['cutoff']; }); function (gradeEle) { return -gradeEle['cutoff']; });
// Instrument grace period // Instrument grace period
this.$el.find('#course-grading-graceperiod').timepicker(); this.$el.find('#course-grading-graceperiod').timepicker();
...@@ -359,330 +374,330 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ ...@@ -359,330 +374,330 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// instantiates an editor template for each update in the collection // instantiates an editor template for each update in the collection
// Because this calls render, put it after everything which render may depend upon to prevent race condition. // Because this calls render, put it after everything which render may depend upon to prevent race condition.
window.templateLoader.loadRemoteTemplate("course_grade_policy", window.templateLoader.loadRemoteTemplate("course_grade_policy",
"/static/client_templates/course_grade_policy.html", "/static/client_templates/course_grade_policy.html",
function (raw_template) { function (raw_template) {
self.template = _.template(raw_template); self.template = _.template(raw_template);
self.render(); self.render();
} }
); );
this.model.on('error', this.handleValidationError, this); this.model.on('error', this.handleValidationError, this);
this.model.get('graders').on('remove', this.render, this); this.model.get('graders').on('remove', this.render, this);
this.model.get('graders').on('reset', this.render, this); this.model.get('graders').on('reset', this.render, this);
this.model.get('graders').on('add', this.render, this); this.model.get('graders').on('add', this.render, this);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
}, },
render: function() { render: function() {
// prevent bootstrap race condition by event dispatch // prevent bootstrap race condition by event dispatch
if (!this.template) return; if (!this.template) return;
// Create and render the grading type subs // Create and render the grading type subs
var self = this; var self = this;
var gradelist = this.$el.find('.course-grading-assignment-list'); var gradelist = this.$el.find('.course-grading-assignment-list');
// Undo the double invocation error. At some point, fix the double invocation // Undo the double invocation error. At some point, fix the double invocation
$(gradelist).empty(); $(gradelist).empty();
var gradeCollection = this.model.get('graders'); var gradeCollection = this.model.get('graders');
gradeCollection.each(function(gradeModel) { gradeCollection.each(function(gradeModel) {
$(gradelist).append(self.template({model : gradeModel })); $(gradelist).append(self.template({model : gradeModel }));
var newEle = gradelist.children().last(); var newEle = gradelist.children().last();
var newView = new CMS.Views.Settings.GraderView({el: newEle, var newView = new CMS.Views.Settings.GraderView({el: newEle,
model : gradeModel, collection : gradeCollection }); model : gradeModel, collection : gradeCollection });
}); });
// render the grade cutoffs // render the grade cutoffs
this.renderCutoffBar(); this.renderCutoffBar();
var graceEle = this.$el.find('#course-grading-graceperiod'); var graceEle = this.$el.find('#course-grading-graceperiod');
graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate()); if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
// remove any existing listeners to keep them from piling on b/c render gets called frequently // remove any existing listeners to keep them from piling on b/c render gets called frequently
graceEle.off('change', this.setGracePeriod); graceEle.off('change', this.setGracePeriod);
graceEle.on('change', this, this.setGracePeriod); graceEle.on('change', this, this.setGracePeriod);
return this; return this;
}, },
addAssignmentType : function(e) { addAssignmentType : function(e) {
e.preventDefault(); e.preventDefault();
this.model.get('graders').push({}); this.model.get('graders').push({});
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
'grace_period' : 'course-grading-graceperiod' 'grace_period' : 'course-grading-graceperiod'
}, },
setGracePeriod : function(event) { setGracePeriod : function(event) {
event.data.clearValidationErrors(); event.data.clearValidationErrors();
var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime')); var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal, if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal,
{ error : CMS.ServerError}); { error : CMS.ServerError});
}, },
updateModel : function(event) { updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return; if (!this.selectorToField[event.currentTarget.id]) return;
switch (this.selectorToField[event.currentTarget.id]) { switch (this.selectorToField[event.currentTarget.id]) {
case 'grace_period': // handled above case 'grace_period': // handled above
break; break;
default: default:
this.saveIfChanged(event); this.saveIfChanged(event);
break; break;
} }
}, },
// Grade sliders attributes and methods // Grade sliders attributes and methods
// Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ... // Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ...
// The actual cutoff for each grade is the width % of the next lower grade; so, the hack here // The actual cutoff for each grade is the width % of the next lower grade; so, the hack here
// is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade // is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade
// starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F" // starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F"
// A does not have a drag bar (cannot change its upper limit) // A does not have a drag bar (cannot change its upper limit)
// Need to insert new bars in right place. // Need to insert new bars in right place.
GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators
descendingCutoffs : [], // array of { designation : , cutoff : } descendingCutoffs : [], // array of { designation : , cutoff : }
gradeBarWidth : null, // cache of value since it won't change (more certain) gradeBarWidth : null, // cache of value since it won't change (more certain)
renderCutoffBar: function() { renderCutoffBar: function() {
var gradeBar =this.$el.find('.grade-bar'); var gradeBar =this.$el.find('.grade-bar');
this.gradeBarWidth = gradeBar.width(); this.gradeBarWidth = gradeBar.width();
var gradelist = gradeBar.children('.grades'); var gradelist = gradeBar.children('.grades');
// HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x // HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x
gradelist.empty(); gradelist.empty();
var nextWidth = 100; // first width is 100% var nextWidth = 100; // first width is 100%
// Can probably be simplified to one variable now. // Can probably be simplified to one variable now.
var removable = false; var removable = false;
var draggable = false; // first and last are not removable, first is not draggable var draggable = false; // first and last are not removable, first is not draggable
_.each(this.descendingCutoffs, _.each(this.descendingCutoffs,
function(cutoff, index) { function(cutoff, index) {
var newBar = this.gradeCutoffTemplate({ var newBar = this.gradeCutoffTemplate({
descriptor : cutoff['designation'] , descriptor : cutoff['designation'] ,
width : nextWidth, width : nextWidth,
removable : removable }); removable : removable });
gradelist.append(newBar); gradelist.append(newBar);
if (draggable) { if (draggable) {
newBar = gradelist.children().last(); // get the dom object not the unparsed string newBar = gradelist.children().last(); // get the dom object not the unparsed string
newBar.resizable({ newBar.resizable({
handles: "e", handles: "e",
containment : "parent", containment : "parent",
start : this.startMoveClosure(), start : this.startMoveClosure(),
resize : this.moveBarClosure(), resize : this.moveBarClosure(),
stop : this.stopDragClosure() stop : this.stopDragClosure()
}); });
} }
// prepare for next // prepare for next
nextWidth = cutoff['cutoff']; nextWidth = cutoff['cutoff'];
removable = true; // first is not removable, all others are removable = true; // first is not removable, all others are
draggable = true; draggable = true;
}, },
this); this);
// add fail which is not in data // add fail which is not in data
var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(), var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(),
width : nextWidth, removable : false}); width : nextWidth, removable : false});
$(failBar).find("span[contenteditable=true]").attr("contenteditable", false); $(failBar).find("span[contenteditable=true]").attr("contenteditable", false);
gradelist.append(failBar); gradelist.append(failBar);
gradelist.children().last().resizable({ gradelist.children().last().resizable({
handles: "e", handles: "e",
containment : "parent", containment : "parent",
start : this.startMoveClosure(), start : this.startMoveClosure(),
resize : this.moveBarClosure(), resize : this.moveBarClosure(),
stop : this.stopDragClosure() stop : this.stopDragClosure()
}); });
this.renderGradeRanges(); this.renderGradeRanges();
}, },
showSettingsExtras : function(event) { showSettingsExtras : function(event) {
$(event.currentTarget).toggleClass('active'); $(event.currentTarget).toggleClass('active');
$(event.currentTarget).siblings.toggleClass('is-shown'); $(event.currentTarget).siblings.toggleClass('is-shown');
}, },
startMoveClosure : function() { startMoveClosure : function() {
// set min/max widths // set min/max widths
var cachethis = this; var cachethis = this;
var widthPerPoint = cachethis.gradeBarWidth / 100; var widthPerPoint = cachethis.gradeBarWidth / 100;
return function(event, ui) { return function(event, ui) {
var barIndex = ui.element.index(); var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide) // min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3); var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it // minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97); var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97);
ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint}); ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint});
}; };
}, },
moveBarClosure : function() { moveBarClosure : function() {
// 0th ele doesn't have a bar; so, will never invoke this // 0th ele doesn't have a bar; so, will never invoke this
var cachethis = this; var cachethis = this;
return function(event, ui) { return function(event, ui) {
var barIndex = ui.element.index(); var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide) // min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3); var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it // minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100); var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100);
var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max); var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max);
cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage); cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage);
cachethis.renderGradeRanges(); cachethis.renderGradeRanges();
}; };
}, },
renderGradeRanges: function() { renderGradeRanges: function() {
// the labels showing the range e.g., 71-80 // the labels showing the range e.g., 71-80
var cutoffs = this.descendingCutoffs; var cutoffs = this.descendingCutoffs;
this.$el.find('.range').each(function(i) { this.$el.find('.range').each(function(i) {
var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0); var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0);
var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100); var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100);
$(this).text(min + '-' + max); $(this).text(min + '-' + max);
}); });
}, },
stopDragClosure: function() { stopDragClosure: function() {
var cachethis = this; var cachethis = this;
return function(event, ui) { return function(event, ui) {
// for some reason the resize is setting height to 0 // for some reason the resize is setting height to 0
cachethis.saveCutoffs(); cachethis.saveCutoffs();
}; };
}, },
saveCutoffs: function() { saveCutoffs: function() {
this.model.save('grade_cutoffs', this.model.save('grade_cutoffs',
_.reduce(this.descendingCutoffs, _.reduce(this.descendingCutoffs,
function(object, cutoff) { function(object, cutoff) {
object[cutoff['designation']] = cutoff['cutoff'] / 100.0; object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
return object; return object;
}, },
{}), {}),
{ error : CMS.ServerError}); { error : CMS.ServerError});
}, },
addNewGrade: function(e) { addNewGrade: function(e) {
e.preventDefault(); e.preventDefault();
var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades
if(gradeLength > 3) { if(gradeLength > 3) {
// TODO shouldn't we disable the button // TODO shouldn't we disable the button
return; return;
} }
var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff']; var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff'];
// going to split the grade above the insertion point in half leaving fail in same place // going to split the grade above the insertion point in half leaving fail in same place
var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100); var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100);
var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2); var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2);
this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth}); this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth});
this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth); this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth);
var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength], var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength],
width : targetWidth, removable : true }); width : targetWidth, removable : true });
var gradeDom = this.$el.find('.grades'); var gradeDom = this.$el.find('.grades');
gradeDom.children().last().before($newGradeBar); gradeDom.children().last().before($newGradeBar);
var newEle = gradeDom.children()[gradeLength]; var newEle = gradeDom.children()[gradeLength];
$(newEle).resizable({ $(newEle).resizable({
handles: "e", handles: "e",
containment : "parent", containment : "parent",
start : this.startMoveClosure(), start : this.startMoveClosure(),
resize : this.moveBarClosure(), resize : this.moveBarClosure(),
stop : this.stopDragClosure() stop : this.stopDragClosure()
}); });
// Munge existing grade labels? // Munge existing grade labels?
// If going from Pass/Fail to 3 levels, change to Pass to A // If going from Pass/Fail to 3 levels, change to Pass to A
if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') { if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') {
this.descendingCutoffs[0]['designation'] = this.GRADES[0]; this.descendingCutoffs[0]['designation'] = this.GRADES[0];
this.setTopGradeLabel(); this.setTopGradeLabel();
} }
this.setFailLabel(); this.setFailLabel();
this.renderGradeRanges(); this.renderGradeRanges();
this.saveCutoffs(); this.saveCutoffs();
}, },
removeGrade: function(e) { removeGrade: function(e) {
e.preventDefault(); e.preventDefault();
var domElement = $(e.currentTarget).closest('li'); var domElement = $(e.currentTarget).closest('li');
var index = domElement.index(); var index = domElement.index();
// copy the boundary up to the next higher grade then remove // copy the boundary up to the next higher grade then remove
this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff']; this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff'];
this.descendingCutoffs.splice(index, 1); this.descendingCutoffs.splice(index, 1);
domElement.remove(); domElement.remove();
if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) { if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) {
this.descendingCutoffs[0]['designation'] = 'Pass'; this.descendingCutoffs[0]['designation'] = 'Pass';
this.setTopGradeLabel(); this.setTopGradeLabel();
} }
this.setFailLabel(); this.setFailLabel();
this.renderGradeRanges(); this.renderGradeRanges();
this.saveCutoffs(); this.saveCutoffs();
}, },
updateDesignation: function(e) { updateDesignation: function(e) {
var index = $(e.currentTarget).closest('li').index(); var index = $(e.currentTarget).closest('li').index();
this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html(); this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html();
this.saveCutoffs(); this.saveCutoffs();
}, },
failLabel: function() { failLabel: function() {
if (this.descendingCutoffs.length === 1) return 'Fail'; if (this.descendingCutoffs.length === 1) return 'Fail';
else return 'F'; else return 'F';
}, },
setFailLabel: function() { setFailLabel: function() {
this.$el.find('.grades .letter-grade').last().html(this.failLabel()); this.$el.find('.grades .letter-grade').last().html(this.failLabel());
}, },
setTopGradeLabel: function() { setTopGradeLabel: function() {
this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']); this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']);
} }
}); });
CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGrader // Model class is CMS.Models.Settings.CourseGrader
events : { events : {
"blur input" : "updateModel", "change input" : "updateModel",
"blur textarea" : "updateModel", "change textarea" : "updateModel",
"click .remove-grading-data" : "deleteModel" "click .remove-grading-data" : "deleteModel"
}, },
initialize : function() { initialize : function() {
this.model.on('error', this.handleValidationError, this); this.model.on('error', this.handleValidationError, this);
this.selectorToField = _.invert(this.fieldToSelectorMap); this.selectorToField = _.invert(this.fieldToSelectorMap);
this.render(); this.render();
}, },
render: function() { render: function() {
return this; return this;
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
'type' : 'course-grading-assignment-name', 'type' : 'course-grading-assignment-name',
'short_label' : 'course-grading-assignment-shortname', 'short_label' : 'course-grading-assignment-shortname',
'min_count' : 'course-grading-assignment-totalassignments', 'min_count' : 'course-grading-assignment-totalassignments',
'drop_count' : 'course-grading-assignment-droppable', 'drop_count' : 'course-grading-assignment-droppable',
'weight' : 'course-grading-assignment-gradeweight' 'weight' : 'course-grading-assignment-gradeweight'
}, },
updateModel : function(event) { updateModel : function(event) {
// HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving // HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving
// this in out of paranoia. If this error ever happens, the user will get a warning that they cannot // this in out of paranoia. If this error ever happens, the user will get a warning that they cannot
// give 2 assignments the same name.] // give 2 assignments the same name.]
if (!this.model.collection) { if (!this.model.collection) {
this.model.collection = this.collection; this.model.collection = this.collection;
} }
switch (event.currentTarget.id) { switch (event.currentTarget.id) {
case 'course-grading-assignment-totalassignments': case 'course-grading-assignment-totalassignments':
this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val()); this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
this.saveIfChanged(event); this.saveIfChanged(event);
break; break;
case 'course-grading-assignment-name': case 'course-grading-assignment-name':
var oldName = this.model.get('type'); var oldName = this.model.get('type');
if (this.saveIfChanged(event) && !_.isEmpty(oldName)) { if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
// overload the error display logic // overload the error display logic
this._cacheValidationErrors.push(event.currentTarget); this._cacheValidationErrors.push(event.currentTarget);
$(event.currentTarget).parent().append( $(event.currentTarget).parent().append(
this.errorTemplate({message : 'For grading to work, you must change all "' + oldName + this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
'" subsections to "' + this.model.get('type') + '".'})); '" subsections to "' + this.model.get('type') + '".'}));
} }
break; break;
default: default:
this.saveIfChanged(event); this.saveIfChanged(event);
break; break;
} }
}, },
deleteModel : function(e) { deleteModel : function(e) {
this.model.destroy( this.model.destroy(
{ error : CMS.ServerError}); { error : CMS.ServerError});
e.preventDefault(); e.preventDefault();
} }
}); });
\ No newline at end of file
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %>
<%block name="bodyclass">settings</%block> <%block name="bodyclass">settings</%block>
<%block name="title">Settings</%block> <%block name="title">Settings</%block>
...@@ -15,24 +16,28 @@ from contentstore import utils ...@@ -15,24 +16,28 @@ from contentstore import utils
<script src="${static.url('js/vendor/date.js')}"></script> <script src="${static.url('js/vendor/date.js')}"></script>
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script> <script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script> <script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/advanced.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script> <script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_settings.js')}"></script> <script type="text/javascript" src="${static.url('js/models/settings/course_settings.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script> <script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script> <script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script> <script type="text/javascript" src="${static.url('js/models/settings/advanced.js')}"></script>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
var advancedModel = new CMS.Models.Settings.Advanced(${advanced_dict | n}, {parse:true});
advancedModel.blacklistKeys = ${advanced_blacklist | n};
advancedModel.url = "${reverse('course_advanced_settings', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
var settingsModel = new CMS.Models.Settings.CourseSettings({ var settingsModel = new CMS.Models.Settings.CourseSettings({
courseLocation: new CMS.Models.Location('${context_course.location}',{parse:true}), courseLocation: new CMS.Models.Location('${context_course.location}',{parse:true}),
details: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true}) details: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true}),
advanced: advancedModel
}); });
var advancedSettingsModel = new CMS.Models.Settings.Advanced({});
var editor = new CMS.Views.Settings.Main({ var editor = new CMS.Views.Settings.Main({
el: $('.main-wrapper'), el: $('.main-wrapper'),
model : settingsModel model : settingsModel
...@@ -743,90 +748,6 @@ from contentstore import utils ...@@ -743,90 +748,6 @@ from contentstore import utils
<!-- basic empty & initial empty field (if user had no values yet) --> <!-- basic empty & initial empty field (if user had no values yet) -->
<ul class="input-list course-advanced-policy-list"> <ul class="input-list course-advanced-policy-list">
<li class="input multi course-advanced-policy-list-item">
<div class="row">
<div class="key">
<label for="course-advanced-policy-1-key">Policy Key:</label>
<div class="field">
<input type="text" class="short" id="course-advanced-policy-1-key" value="" />
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
</div>
</div>
<div class="value">
<label for="course-advanced-policy-1-value">Policy Value:</label>
<div class="field">
<div class="ace text" id="course-advanced-policy-1-value">some existing text</div>
</div>
</div>
</div>
<a href="#" class="delete-button standard remove-item advanced-policy-data"><span class="delete-icon"></span>Delete</a>
</li>
<!-- error existing key pair example -->
<li class="input multi course-advanced-policy-list-item">
<div class="row">
<div class="key">
<label for="course-advanced-policy-2-key">Policy Key:</label>
<div class="field">
<input type="text" class="short" id="course-advanced-policy-2-key" value="" />
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
</div>
</div>
<div class="value">
<label for="course-advanced-policy-2-value">Policy Value:</label>
<div class="field">
<textarea class="ace text" id="course-advanced-policy-2-value"></textarea>
</div>
</div>
</div>
<span class="message-error">This policy key, $KEYNAME, already exists.</span>
<a href="#" class="delete-button standard remove-item advanced-policy-data"><span class="delete-icon"></span>Delete</a>
</li>
<!-- error on key left empty example -->
<li class="input multi course-advanced-policy-list-item">
<div class="row">
<div class="key error">
<label for="course-advanced-policy-3-key">Policy Key:</label>
<div class="field">
<input type="text" class="short" id="course-advanced-policy-3-key" value="" />
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
</div>
</div>
<div class="value error">
<label for="course-advanced-policy-3-value">Policy Value:</label>
<div class="field">
<textarea class="ace text" id="course-advanced-policy-3-value"></textarea>
</div>
</div>
</div>
<span class="message-error">You cannot leave the key value for this pair blank.</span>
<a href="#" class="delete-button standard remove-item advanced-policy-data"><span class="delete-icon"></span>Delete</a>
</li>
<!-- error with value formatting example -->
<li class="input multi course-advanced-policy-list-item">
<div class="row">
<div class="key error">
<label for="course-advanced-policy-4-key">Policy Key:</label>
<div class="field">
<input type="text" class="short" id="course-advanced-policy-4-key" value="" />
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
</div>
</div>
<div class="value error">
<label for="course-advanced-policy-4-value">Policy Value:</label>
<div class="field">
<textarea class="ace text" id="course-advanced-policy-4-value" value=""></textarea>
</div>
</div>
</div>
<span class="message-error">The JSON value for $KEYNAME is invalid.</span>
<a href="#" class="delete-button standard remove-item advanced-policy-data"><span class="delete-icon"></span>Delete</a>
</li>
</ul> </ul>
<!-- advanced policy actions --> <!-- advanced policy actions -->
...@@ -838,14 +759,6 @@ from contentstore import utils ...@@ -838,14 +759,6 @@ from contentstore import utils
</a> </a>
</div> </div>
<!-- advanced policy actions (with disabled save state) -->
<div class="actions actions-advanced-policies">
<a href="#" class="save-button disabled">Save</a>
<a href="#" class="cancel-button">Cancel</a>
<a href="#" class="new-button new-advanced-policy-item add-policy-data">
<span class="plus-icon white"></span>New Manual Policy
</a>
</div>
</div> </div>
</div> </div>
</section><!-- .settings-advanced-policies --> </section><!-- .settings-advanced-policies -->
......
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