diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 7ebb2648ec..f1402ed840 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -58,7 +58,7 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\
CourseSettingsEncoder
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
-from lxml import etree
+from cms.djangoapps.models.settings.course_metadata import CourseMetadata
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
@@ -1109,6 +1109,8 @@ def get_course_settings(request, org, course, name):
return render_to_response('settings.html', {
'active_tab': 'settings',
'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)
})
@@ -1133,6 +1135,9 @@ def course_settings_updates(request, org, course, name, section):
manager = CourseDetails
elif section == 'grading':
manager = CourseGradingModel
+ elif section == 'advanced':
+ # not implemented b/c it assumes prefetched and then everything thru course_edit_metadata
+ return
else: return
if request.method == 'GET':
@@ -1178,6 +1183,56 @@ def course_grader_updates(request, org, course, name, grader_index=None):
mimetype="application/json")
+@login_required
+@ensure_csrf_cookie
+def course_edit_metadata(request, org, course, name):
+ """
+ Send models and views as well as html for editing the course editable metadata to the client.
+
+ org, course, name: Attributes of the Location for the item to edit
+ """
+ location = ['i4x', org, course, 'course', name]
+
+ # check that logged in user has permissions to this item
+ if not has_access(request.user, location):
+ raise PermissionDenied()
+
+ editable = CourseMetadata.fetch(location)
+
+ # for now defer to settings general until we split the divs out into separate pages
+ return get_course_settings(request, org, course, name)
+
+## NB: expect_json failed on ["key", "key2"] and json payload
+@login_required
+@ensure_csrf_cookie
+def course_metadata_rest_access(request, org, course, name):
+ """
+ restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh,
+ the payload is either a key or a list of keys to delete.
+
+ org, course: Attributes of the Location for the item to edit
+ """
+ location = ['i4x', org, course, 'course', name]
+
+ # check that logged in user has permissions to this item
+ if not has_access(request.user, location):
+ raise PermissionDenied()
+
+ # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
+ if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
+ real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
+ else:
+ real_method = request.method
+
+ if request.method == 'GET':
+ return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
+ elif real_method == 'DELETE':
+ return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json")
+ elif request.method == 'POST':
+ # 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
@ensure_csrf_cookie
def asset_index(request, org, course, name):
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
new file mode 100644
index 0000000000..bbe2d8a66d
--- /dev/null
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -0,0 +1,68 @@
+from xmodule.modulestore import Location
+from contentstore.utils import get_modulestore
+from xmodule.x_module import XModuleDescriptor
+
+
+class CourseMetadata(object):
+ '''
+ 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.
+ '''
+ # __new_advanced_key__ is used by client not server; so, could argue against it being here
+ FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__']
+
+ @classmethod
+ def fetch(cls, course_location):
+ """
+ Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model.
+ """
+ if not isinstance(course_location, Location):
+ course_location = Location(course_location)
+
+ course = {}
+
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ for k, v in descriptor.metadata.iteritems():
+ if k not in cls.FILTERED_LIST:
+ course[k] = v
+
+ return course
+
+ @classmethod
+ def update_from_json(cls, course_location, jsondict):
+ """
+ Decode the json into CourseMetadata and save any changed attrs to the db
+ """
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ dirty = False
+
+ for k, v in jsondict.iteritems():
+ # should it be an error if one of the filtered list items is in the payload?
+ if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v):
+ dirty = True
+ descriptor.metadata[k] = v
+
+ if dirty:
+ get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+
+ # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
+ # it persisted correctly
+ return cls.fetch(course_location)
+
+ @classmethod
+ def delete_key(cls, course_location, payload):
+ '''
+ Remove the given metadata key(s) from the course. payload can be a single key or [key..]
+ '''
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ for key in payload['deleteKeys']:
+ if key in descriptor.metadata:
+ del descriptor.metadata[key]
+
+ get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+
+ return cls.fetch(course_location)
+
\ No newline at end of file
diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html
new file mode 100644
index 0000000000..b76cd72a4a
--- /dev/null
+++ b/cms/static/client_templates/advanced_entry.html
@@ -0,0 +1,18 @@
+
+
+
+
Policy Key:
+
+
+ Keys are case sensitive and cannot contain spaces or start with a number
+
+
+
+
+ Delete
+
\ No newline at end of file
diff --git a/cms/static/js/models/settings/advanced.js b/cms/static/js/models/settings/advanced.js
new file mode 100644
index 0000000000..2beb662240
--- /dev/null
+++ b/cms/static/js/models/settings/advanced.js
@@ -0,0 +1,213 @@
+if (!CMS.Models['Settings']) CMS.Models.Settings = {};
+
+CMS.Models.Settings.Advanced = Backbone.Model.extend({
+ 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() {
+ console.log('in initialize');
+ },
+ validate: function(attrs) {
+ 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(_.sortBy(_.keys(this.model.attributes), _.identity),
+ function(key) {
+ listEle$.append(self.template({ key : key, value : self.model.get(key)}));
+ 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.clear({silent : true});
+ this.model.fetch({
+ success : function() { self.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});
+ }
+});
diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js
index cce4e0207d..13957aac51 100644
--- a/cms/static/js/models/settings/course_grading_policy.js
+++ b/cms/static/js/models/settings/course_grading_policy.js
@@ -1,55 +1,55 @@
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
- defaults : {
- course_location : null,
- graders : null, // CourseGraderCollection
- grade_cutoffs : null, // CourseGradeCutoff model
+ defaults : {
+ course_location : null,
+ graders : null, // CourseGraderCollection
+ grade_cutoffs : null, // CourseGradeCutoff model
grace_period : null // either null or { hours: n, minutes: m, ...}
- },
- parse: function(attributes) {
- if (attributes['course_location']) {
- attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true});
- }
- if (attributes['graders']) {
- var graderCollection;
- if (this.has('graders')) {
- graderCollection = this.get('graders');
- graderCollection.reset(attributes.graders);
- }
- else {
- graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders);
- graderCollection.course_location = attributes['course_location'] || this.get('course_location');
- }
- attributes.graders = graderCollection;
- }
- return attributes;
- },
- url : function() {
- var location = this.get('course_location');
- return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading';
- },
- gracePeriodToDate : function() {
- var newDate = new Date();
- if (this.has('grace_period') && this.get('grace_period')['hours'])
- newDate.setHours(this.get('grace_period')['hours']);
- else newDate.setHours(0);
- if (this.has('grace_period') && this.get('grace_period')['minutes'])
- newDate.setMinutes(this.get('grace_period')['minutes']);
- else newDate.setMinutes(0);
- if (this.has('grace_period') && this.get('grace_period')['seconds'])
- newDate.setSeconds(this.get('grace_period')['seconds']);
- else newDate.setSeconds(0);
-
- return newDate;
- },
- dateToGracePeriod : function(date) {
- return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
- }
+ },
+ parse: function(attributes) {
+ if (attributes['course_location']) {
+ attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true});
+ }
+ if (attributes['graders']) {
+ var graderCollection;
+ if (this.has('graders')) {
+ graderCollection = this.get('graders');
+ graderCollection.reset(attributes.graders);
+ }
+ else {
+ graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders);
+ graderCollection.course_location = attributes['course_location'] || this.get('course_location');
+ }
+ attributes.graders = graderCollection;
+ }
+ return attributes;
+ },
+ url : function() {
+ var location = this.get('course_location');
+ return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading';
+ },
+ gracePeriodToDate : function() {
+ var newDate = new Date();
+ if (this.has('grace_period') && this.get('grace_period')['hours'])
+ newDate.setHours(this.get('grace_period')['hours']);
+ else newDate.setHours(0);
+ if (this.has('grace_period') && this.get('grace_period')['minutes'])
+ newDate.setMinutes(this.get('grace_period')['minutes']);
+ else newDate.setMinutes(0);
+ if (this.has('grace_period') && this.get('grace_period')['seconds'])
+ newDate.setSeconds(this.get('grace_period')['seconds']);
+ else newDate.setSeconds(0);
+
+ return newDate;
+ },
+ dateToGracePeriod : function(date) {
+ return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
+ }
});
CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
- defaults: {
+ defaults: {
"type" : "", // must be unique w/in collection (ie. w/in course)
"min_count" : 1,
"drop_count" : 0,
@@ -57,71 +57,71 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
"weight" : 0 // int 0..100
},
parse : function(attrs) {
- if (attrs['weight']) {
- if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
- }
- if (attrs['min_count']) {
- if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
- }
- if (attrs['drop_count']) {
- if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
- }
- return attrs;
+ if (attrs['weight']) {
+ if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
+ }
+ if (attrs['min_count']) {
+ if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
+ }
+ if (attrs['drop_count']) {
+ if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
+ }
+ return attrs;
},
validate : function(attrs) {
- var errors = {};
- if (attrs['type']) {
- if (_.isEmpty(attrs['type'])) {
- errors.type = "The assignment type must have a name.";
- }
- else {
- // 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);
- if (existing) {
- errors.type = "There's already another assignment type with this name.";
- }
- }
- }
- if (attrs['weight']) {
- if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
- errors.weight = "Please enter an integer between 0 and 100.";
- }
- else {
- attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
- if (this.collection && attrs.weight > 0) {
- // 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
- // or figure out a wholistic way to balance the vals across the whole
-// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
-// errors.weight = "The weights cannot add to more than 100.";
- }
- }}
- if (attrs['min_count']) {
- if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
- errors.min_count = "Please enter an integer.";
- }
- else attrs.min_count = parseInt(attrs.min_count);
- }
- if (attrs['drop_count']) {
- if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
- errors.drop_count = "Please enter an integer.";
- }
- else attrs.drop_count = parseInt(attrs.drop_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.";
- }
- if (!_.isEmpty(errors)) return errors;
+ var errors = {};
+ if (attrs['type']) {
+ if (_.isEmpty(attrs['type'])) {
+ errors.type = "The assignment type must have a name.";
+ }
+ else {
+ // 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);
+ if (existing) {
+ errors.type = "There's already another assignment type with this name.";
+ }
+ }
+ }
+ if (attrs['weight']) {
+ if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
+ errors.weight = "Please enter an integer between 0 and 100.";
+ }
+ else {
+ attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
+ if (this.collection && attrs.weight > 0) {
+ // 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
+ // or figure out a wholistic way to balance the vals across the whole
+// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
+// errors.weight = "The weights cannot add to more than 100.";
+ }
+ }}
+ if (attrs['min_count']) {
+ if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
+ errors.min_count = "Please enter an integer.";
+ }
+ else attrs.min_count = parseInt(attrs.min_count);
+ }
+ if (attrs['drop_count']) {
+ if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
+ errors.drop_count = "Please enter an integer.";
+ }
+ else attrs.drop_count = parseInt(attrs.drop_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.";
+ }
+ if (!_.isEmpty(errors)) return errors;
}
});
CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({
- model : CMS.Models.Settings.CourseGrader,
- course_location : null, // must be set to a Location object
- url : function() {
- return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/';
- },
- sumWeights : function() {
- return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
- }
+ model : CMS.Models.Settings.CourseGrader,
+ course_location : null, // must be set to a Location object
+ url : function() {
+ return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/';
+ },
+ sumWeights : function() {
+ return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
+ }
});
\ No newline at end of file
diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js
index 89ecc98cc4..6108d96346 100644
--- a/cms/static/js/template_loader.js
+++ b/cms/static/js/template_loader.js
@@ -5,7 +5,7 @@
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
- templateVersion: "0.0.12",
+ templateVersion: "0.0.13",
templates: {},
loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) {
diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js
index 826b385dff..15762eaeac 100644
--- a/cms/static/js/views/settings/main_settings_view.js
+++ b/cms/static/js/views/settings/main_settings_view.js
@@ -1,216 +1,231 @@
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
-// TODO move to common place
+//TODO move to common place
CMS.Views.ValidatingView = Backbone.View.extend({
- // 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
- // either have your init call this one or copy the contents
- initialize : function() {
- this.model.on('error', this.handleValidationError, this);
- this.selectorToField = _.invert(this.fieldToSelectorMap);
- },
-
- errorTemplate : _.template('<%= message %> '),
+ // 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
+ // either have your init call this one or copy the contents
+ initialize : function() {
+ this.model.on('error', this.handleValidationError, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ },
- events : {
- "blur input" : "clearValidationErrors",
- "blur textarea" : "clearValidationErrors"
- },
- fieldToSelectorMap : {
- // Your subclass must populate this w/ all of the model keys and dom selectors
- // which may be the subjects of validation errors
- },
- _cacheValidationErrors : [],
- handleValidationError : function(model, error) {
- // error is object w/ fields and error strings
- for (var field in error) {
- var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
- this._cacheValidationErrors.push(ele);
- if ($(ele).is('div')) {
- // put error on the contained inputs
- $(ele).find('input, textarea').addClass('error');
- }
- else $(ele).addClass('error');
- $(ele).parent().append(this.errorTemplate({message : error[field]}));
- }
- },
-
- clearValidationErrors : function() {
- // error is object w/ fields and error strings
- while (this._cacheValidationErrors.length > 0) {
- var ele = this._cacheValidationErrors.pop();
- if ($(ele).is('div')) {
- // put error on the contained inputs
- $(ele).find('input, textarea').removeClass('error');
- }
- else $(ele).removeClass('error');
- $(ele).nextAll('.message-error').remove();
- }
- },
-
- saveIfChanged : function(event) {
- // returns true if the value changed and was thus sent to server
- var field = this.selectorToField[event.currentTarget.id];
- var currentVal = this.model.get(field);
- var newVal = $(event.currentTarget).val();
- if (currentVal != newVal) {
- this.clearValidationErrors();
- this.model.save(field, newVal, { error : CMS.ServerError});
- return true;
- }
- else return false;
- }
+ errorTemplate : _.template('<%= message %> '),
+
+ events : {
+ "change input" : "clearValidationErrors",
+ "change textarea" : "clearValidationErrors"
+ },
+ fieldToSelectorMap : {
+ // Your subclass must populate this w/ all of the model keys and dom selectors
+ // which may be the subjects of validation errors
+ },
+ _cacheValidationErrors : [],
+ handleValidationError : function(model, error) {
+ console.log('validation', model, error);
+ // error is object w/ fields and error strings
+ for (var field in error) {
+ var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
+ this._cacheValidationErrors.push(ele);
+ if ($(ele).is('div')) {
+ // put error on the contained inputs
+ $(ele).find('input, textarea').addClass('error');
+ }
+ else $(ele).addClass('error');
+ $(ele).parent().append(this.errorTemplate({message : error[field]}));
+ }
+ },
+
+ clearValidationErrors : function() {
+ // error is object w/ fields and error strings
+ while (this._cacheValidationErrors.length > 0) {
+ var ele = this._cacheValidationErrors.pop();
+ if ($(ele).is('div')) {
+ // put error on the contained inputs
+ $(ele).find('input, textarea').removeClass('error');
+ }
+ else $(ele).removeClass('error');
+ $(ele).nextAll('.message-error').remove();
+ }
+ },
+
+ saveIfChanged : function(event) {
+ // returns true if the value changed and was thus sent to server
+ var field = this.selectorToField[event.currentTarget.id];
+ var currentVal = this.model.get(field);
+ var newVal = $(event.currentTarget).val();
+ if (currentVal != newVal) {
+ this.clearValidationErrors();
+ this.model.save(field, newVal, { error : CMS.ServerError});
+ return true;
+ }
+ else return false;
+ }
});
CMS.Views.Settings.Main = Backbone.View.extend({
- // Model class is CMS.Models.Settings.CourseSettings
- // allow navigation between the tabs
- events: {
- 'click .settings-page-menu a': "showSettingsTab",
- 'mouseover #timezone' : "updateTime"
- },
-
- currentTab: null,
- subviews: {}, // indexed by tab name
+ // Model class is CMS.Models.Settings.CourseSettings
+ // allow navigation between the tabs
+ events: {
+ 'click .settings-page-menu a': "showSettingsTab",
+ 'mouseover #timezone' : "updateTime"
+ },
- initialize: function() {
- // load templates
- this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section');
- // create the initial subview
- this.subviews[this.currentTab] = this.createSubview();
-
- // fill in fields
- 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-number").val(this.model.get('courseLocation').get('course'));
- this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
- this.$el.find(":input, textarea").focus(function() {
- $("label[for='" + this.id + "']").addClass("is-focused");
- }).blur(function() {
- $("label").removeClass("is-focused");
- });
- this.render();
- },
-
- render: function() {
-
- // create any necessary subviews and put them onto the page
- if (!this.model.has(this.currentTab)) {
- // TODO disable screen until fetch completes?
- var cachethis = this;
- this.model.retrieve(this.currentTab, function() {
- cachethis.subviews[cachethis.currentTab] = cachethis.createSubview();
- cachethis.subviews[cachethis.currentTab].render();
- });
- }
- else this.subviews[this.currentTab].render();
-
- var dateIntrospect = new Date();
- this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
-
- return this;
- },
-
- createSubview: function() {
- switch (this.currentTab) {
- case 'details':
- return new CMS.Views.Settings.Details({
- el: this.$el.find('.settings-' + this.currentTab),
- model: this.model.get(this.currentTab)
- });
- case 'faculty':
- break;
- case 'grading':
- return new CMS.Views.Settings.Grading({
- el: this.$el.find('.settings-' + this.currentTab),
- model: this.model.get(this.currentTab)
- });
- case 'problems':
- break;
- case 'discussions':
- break;
- }
- },
-
- updateTime : function(e) {
- 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).attr('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();
- }
+ currentTab: null,
+ subviews: {}, // indexed by tab name
+
+ initialize: function() {
+ // load templates
+ this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section');
+ // create the initial subview
+ this.subviews[this.currentTab] = this.createSubview();
+
+ // fill in fields
+ 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-number").val(this.model.get('courseLocation').get('course'));
+ this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
+ this.$el.find(":input, textarea").focus(function() {
+ $("label[for='" + this.id + "']").addClass("is-focused");
+ }).blur(function() {
+ $("label").removeClass("is-focused");
+ });
+ this.render();
+ },
+
+ render: function() {
+
+ // create any necessary subviews and put them onto the page
+ if (!this.model.has(this.currentTab)) {
+ // TODO disable screen until fetch completes?
+ var cachethis = this;
+ this.model.retrieve(this.currentTab, function() {
+ cachethis.subviews[cachethis.currentTab] = cachethis.createSubview();
+ cachethis.subviews[cachethis.currentTab].render();
+ });
+ }
+ else {
+ // Advanced (at least) model gets created at bootstrap but the view does not
+ if (!this.subviews[this.currentTab]) {
+ this.subviews[this.currentTab] = this.createSubview();
+ }
+ this.subviews[this.currentTab].render();
+ }
+
+ var dateIntrospect = new Date();
+ this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
+
+ return this;
+ },
+
+ createSubview: function() {
+ switch (this.currentTab) {
+ case 'details':
+ return new CMS.Views.Settings.Details({
+ el: this.$el.find('.settings-' + this.currentTab),
+ model: this.model.get(this.currentTab)
+ });
+ break;
+ case 'faculty':
+ break;
+ case 'grading':
+ return new CMS.Views.Settings.Grading({
+ el: this.$el.find('.settings-' + this.currentTab),
+ model: this.model.get(this.currentTab)
+ });
+ break;
+ case 'advanced':
+ return new CMS.Views.Settings.Advanced({
+ el: this.$el.find('.settings-' + this.currentTab),
+ model: this.model.get(this.currentTab)
+ });
+ break;
+ case 'problems':
+ break;
+ case 'discussions':
+ break;
+ }
+ },
+
+ updateTime : function(e) {
+ 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({
- // Model class is CMS.Models.Settings.CourseDetails
- events : {
- "blur input" : "updateModel",
- "blur textarea" : "updateModel",
- 'click .remove-course-syllabus' : "removeSyllabus",
- 'click .new-course-syllabus' : 'assetSyllabus',
- 'click .remove-course-introduction-video' : "removeVideo",
- 'focus #course-overview' : "codeMirrorize"
- },
- initialize : function() {
- // TODO move the html frag to a loaded asset
- this.fileAnchorTemplate = _.template(' 📄 <%= filename %> ');
- this.model.on('error', this.handleValidationError, this);
- this.selectorToField = _.invert(this.fieldToSelectorMap);
- },
-
- render: function() {
- this.setupDatePicker('start_date');
- this.setupDatePicker('end_date');
- this.setupDatePicker('enrollment_start');
- this.setupDatePicker('enrollment_end');
-
- if (this.model.has('syllabus')) {
- this.$el.find(this.fieldToSelectorMap['syllabus']).html(
- this.fileAnchorTemplate({
- fullpath : this.model.get('syllabus'),
- filename: 'syllabus'}));
- this.$el.find('.remove-course-syllabus').show();
- }
- else {
- this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html("");
- this.$el.find('.remove-course-syllabus').hide();
- }
-
- this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
- this.codeMirrorize(null, $('#course-overview')[0]);
-
- this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample());
- if (this.model.has('intro_video')) {
- this.$el.find('.remove-course-introduction-video').show();
- this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video'));
- }
- else this.$el.find('.remove-course-introduction-video').hide();
-
- this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
-
- return this;
- },
- fieldToSelectorMap : {
- 'start_date' : "course-start",
- 'end_date' : 'course-end',
- 'enrollment_start' : 'enrollment-start',
- 'enrollment_end' : 'enrollment-end',
- 'syllabus' : '.current-course-syllabus .doc-filename',
- 'overview' : 'course-overview',
- 'intro_video' : 'course-introduction-video',
- 'effort' : "course-effort"
- },
+ // Model class is CMS.Models.Settings.CourseDetails
+ events : {
+ "change input" : "updateModel",
+ "change textarea" : "updateModel",
+ 'click .remove-course-syllabus' : "removeSyllabus",
+ 'click .new-course-syllabus' : 'assetSyllabus',
+ 'click .remove-course-introduction-video' : "removeVideo",
+ 'focus #course-overview' : "codeMirrorize"
+ },
+ initialize : function() {
+ // TODO move the html frag to a loaded asset
+ this.fileAnchorTemplate = _.template(' 📄 <%= filename %> ');
+ this.model.on('error', this.handleValidationError, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ },
+
+ render: function() {
+ this.setupDatePicker('start_date');
+ this.setupDatePicker('end_date');
+ this.setupDatePicker('enrollment_start');
+ this.setupDatePicker('enrollment_end');
+
+ if (this.model.has('syllabus')) {
+ this.$el.find(this.fieldToSelectorMap['syllabus']).html(
+ this.fileAnchorTemplate({
+ fullpath : this.model.get('syllabus'),
+ filename: 'syllabus'}));
+ this.$el.find('.remove-course-syllabus').show();
+ }
+ else {
+ this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html("");
+ this.$el.find('.remove-course-syllabus').hide();
+ }
+
+ this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
+ this.codeMirrorize(null, $('#course-overview')[0]);
+
+ this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample());
+ if (this.model.has('intro_video')) {
+ this.$el.find('.remove-course-introduction-video').show();
+ this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video'));
+ }
+ else this.$el.find('.remove-course-introduction-video').hide();
+
+ this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
+
+ return this;
+ },
+ fieldToSelectorMap : {
+ 'start_date' : "course-start",
+ 'end_date' : 'course-end',
+ 'enrollment_start' : 'enrollment-start',
+ 'enrollment_end' : 'enrollment-end',
+ 'syllabus' : '.current-course-syllabus .doc-filename',
+ 'overview' : 'course-overview',
+ 'intro_video' : 'course-introduction-video',
+ 'effort' : "course-effort"
+ },
setupDatePicker: function (fieldName) {
var cacheModel = this.model;
@@ -245,58 +260,58 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
datefield.datepicker('setDate', this.model.get(fieldName));
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
},
-
- updateModel: function(event) {
- switch (event.currentTarget.id) {
- case 'course-start-date': // handled via onSelect method
- case 'course-end-date':
- case 'course-enrollment-start-date':
- case 'course-enrollment-end-date':
- break;
- case 'course-overview':
- // handled via code mirror
- break;
+ updateModel: function(event) {
+ switch (event.currentTarget.id) {
+ case 'course-start-date': // handled via onSelect method
+ case 'course-end-date':
+ case 'course-enrollment-start-date':
+ case 'course-enrollment-end-date':
+ break;
- case 'course-effort':
- this.saveIfChanged(event);
- break;
- case 'course-introduction-video':
- this.clearValidationErrors();
- var previewsource = this.model.save_videosource($(event.currentTarget).val());
- this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource);
- if (this.model.has('intro_video')) {
- this.$el.find('.remove-course-introduction-video').show();
- }
- else {
- this.$el.find('.remove-course-introduction-video').hide();
- }
- break;
-
- default:
- break;
- }
-
- },
-
- removeSyllabus: function() {
- if (this.model.has('syllabus')) this.model.save({'syllabus': null},
- { error : CMS.ServerError});
- },
-
- assetSyllabus : function() {
- // TODO implement
- },
-
- removeVideo: function() {
- if (this.model.has('intro_video')) {
- this.model.save_videosource(null);
- this.$el.find(".current-course-introduction-video iframe").attr("src", "");
- this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val("");
- this.$el.find('.remove-course-introduction-video').hide();
- }
- },
- codeMirrors : {},
+ case 'course-overview':
+ // handled via code mirror
+ break;
+
+ case 'course-effort':
+ this.saveIfChanged(event);
+ break;
+ case 'course-introduction-video':
+ this.clearValidationErrors();
+ var previewsource = this.model.save_videosource($(event.currentTarget).val());
+ this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource);
+ if (this.model.has('intro_video')) {
+ this.$el.find('.remove-course-introduction-video').show();
+ }
+ else {
+ this.$el.find('.remove-course-introduction-video').hide();
+ }
+ break;
+
+ default:
+ break;
+ }
+
+ },
+
+ removeSyllabus: function() {
+ if (this.model.has('syllabus')) this.model.save({'syllabus': null},
+ { error : CMS.ServerError});
+ },
+
+ assetSyllabus : function() {
+ // TODO implement
+ },
+
+ removeVideo: function() {
+ if (this.model.has('intro_video')) {
+ this.model.save_videosource(null);
+ this.$el.find(".current-course-introduction-video iframe").attr("src", "");
+ this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val("");
+ this.$el.find('.remove-course-introduction-video').hide();
+ }
+ },
+ codeMirrors : {},
codeMirrorize: function (e, forcedTarget) {
var thisTarget;
if (forcedTarget) {
@@ -316,42 +331,42 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
cachethis.clearValidationErrors();
var newVal = mirror.getValue();
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({
- // Model class is CMS.Models.Settings.CourseGradingPolicy
- events : {
- "blur input" : "updateModel",
- "blur textarea" : "updateModel",
- "blur span[contenteditable=true]" : "updateDesignation",
- "click .settings-extra header" : "showSettingsExtras",
- "click .new-grade-button" : "addNewGrade",
- "click .remove-button" : "removeGrade",
- "click .add-grading-data" : "addAssignmentType"
- },
- initialize : function() {
- // load template for grading view
- var self = this;
+ // Model class is CMS.Models.Settings.CourseGradingPolicy
+ events : {
+ "change input" : "updateModel",
+ "change textarea" : "updateModel",
+ "change span[contenteditable=true]" : "updateDesignation",
+ "click .settings-extra header" : "showSettingsExtras",
+ "click .new-grade-button" : "addNewGrade",
+ "click .remove-button" : "removeGrade",
+ "click .add-grading-data" : "addAssignmentType"
+ },
+ initialize : function() {
+ // load template for grading view
+ var self = this;
this.gradeCutoffTemplate = _.template('' +
- '<%= descriptor %>' +
- ' ' +
- '<% if (removable) {%>remove <% ;} %>' +
- ' ');
+ '<%= descriptor %>' +
+ ' ' +
+ '<% if (removable) {%>remove <% ;} %>' +
+ '');
// Instrument grading scale
// convert cutoffs to inversely ordered list
var modelCutoffs = this.model.get('grade_cutoffs');
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,
- function (gradeEle) { return -gradeEle['cutoff']; });
+ function (gradeEle) { return -gradeEle['cutoff']; });
// Instrument grace period
this.$el.find('#course-grading-graceperiod').timepicker();
@@ -359,330 +374,330 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// 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.
window.templateLoader.loadRemoteTemplate("course_grade_policy",
- "/static/client_templates/course_grade_policy.html",
- function (raw_template) {
- self.template = _.template(raw_template);
- self.render();
+ "/static/client_templates/course_grade_policy.html",
+ function (raw_template) {
+ self.template = _.template(raw_template);
+ self.render();
}
);
- this.model.on('error', this.handleValidationError, this);
- this.model.get('graders').on('remove', this.render, this);
- this.model.get('graders').on('reset', this.render, this);
- this.model.get('graders').on('add', this.render, this);
- this.selectorToField = _.invert(this.fieldToSelectorMap);
- },
-
- render: function() {
- // prevent bootstrap race condition by event dispatch
- if (!this.template) return;
-
- // Create and render the grading type subs
- var self = this;
- var gradelist = this.$el.find('.course-grading-assignment-list');
- // Undo the double invocation error. At some point, fix the double invocation
- $(gradelist).empty();
- var gradeCollection = this.model.get('graders');
- gradeCollection.each(function(gradeModel) {
- $(gradelist).append(self.template({model : gradeModel }));
- var newEle = gradelist.children().last();
- var newView = new CMS.Views.Settings.GraderView({el: newEle,
- model : gradeModel, collection : gradeCollection });
- });
-
- // render the grade cutoffs
- this.renderCutoffBar();
-
- var graceEle = this.$el.find('#course-grading-graceperiod');
- graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
- 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
- graceEle.off('change', this.setGracePeriod);
- graceEle.on('change', this, this.setGracePeriod);
-
- return this;
- },
- addAssignmentType : function(e) {
- e.preventDefault();
- this.model.get('graders').push({});
- },
- fieldToSelectorMap : {
- 'grace_period' : 'course-grading-graceperiod'
- },
- setGracePeriod : function(event) {
- event.data.clearValidationErrors();
- 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,
- { error : CMS.ServerError});
- },
- updateModel : function(event) {
- if (!this.selectorToField[event.currentTarget.id]) return;
+ this.model.on('error', this.handleValidationError, this);
+ this.model.get('graders').on('remove', this.render, this);
+ this.model.get('graders').on('reset', this.render, this);
+ this.model.get('graders').on('add', this.render, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ },
- switch (this.selectorToField[event.currentTarget.id]) {
- case 'grace_period': // handled above
- break;
+ render: function() {
+ // prevent bootstrap race condition by event dispatch
+ if (!this.template) return;
- default:
- this.saveIfChanged(event);
- break;
- }
- },
-
- // Grade sliders attributes and methods
- // 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
- // 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"
-
- // A does not have a drag bar (cannot change its upper limit)
- // Need to insert new bars in right place.
- GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators
- descendingCutoffs : [], // array of { designation : , cutoff : }
- gradeBarWidth : null, // cache of value since it won't change (more certain)
-
- renderCutoffBar: function() {
- var gradeBar =this.$el.find('.grade-bar');
- this.gradeBarWidth = gradeBar.width();
- var gradelist = gradeBar.children('.grades');
- // HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x
- gradelist.empty();
- var nextWidth = 100; // first width is 100%
+ // Create and render the grading type subs
+ var self = this;
+ var gradelist = this.$el.find('.course-grading-assignment-list');
+ // Undo the double invocation error. At some point, fix the double invocation
+ $(gradelist).empty();
+ var gradeCollection = this.model.get('graders');
+ gradeCollection.each(function(gradeModel) {
+ $(gradelist).append(self.template({model : gradeModel }));
+ var newEle = gradelist.children().last();
+ var newView = new CMS.Views.Settings.GraderView({el: newEle,
+ model : gradeModel, collection : gradeCollection });
+ });
+
+ // render the grade cutoffs
+ this.renderCutoffBar();
+
+ var graceEle = this.$el.find('#course-grading-graceperiod');
+ graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
+ 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
+ graceEle.off('change', this.setGracePeriod);
+ graceEle.on('change', this, this.setGracePeriod);
+
+ return this;
+ },
+ addAssignmentType : function(e) {
+ e.preventDefault();
+ this.model.get('graders').push({});
+ },
+ fieldToSelectorMap : {
+ 'grace_period' : 'course-grading-graceperiod'
+ },
+ setGracePeriod : function(event) {
+ event.data.clearValidationErrors();
+ 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,
+ { error : CMS.ServerError});
+ },
+ updateModel : function(event) {
+ if (!this.selectorToField[event.currentTarget.id]) return;
+
+ switch (this.selectorToField[event.currentTarget.id]) {
+ case 'grace_period': // handled above
+ break;
+
+ default:
+ this.saveIfChanged(event);
+ break;
+ }
+ },
+
+ // Grade sliders attributes and methods
+ // 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
+ // 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"
+
+ // A does not have a drag bar (cannot change its upper limit)
+ // Need to insert new bars in right place.
+ GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators
+ descendingCutoffs : [], // array of { designation : , cutoff : }
+ gradeBarWidth : null, // cache of value since it won't change (more certain)
+
+ renderCutoffBar: function() {
+ var gradeBar =this.$el.find('.grade-bar');
+ this.gradeBarWidth = gradeBar.width();
+ var gradelist = gradeBar.children('.grades');
+ // HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x
+ gradelist.empty();
+ var nextWidth = 100; // first width is 100%
// Can probably be simplified to one variable now.
var removable = false;
var draggable = false; // first and last are not removable, first is not draggable
- _.each(this.descendingCutoffs,
- function(cutoff, index) {
- var newBar = this.gradeCutoffTemplate({
- descriptor : cutoff['designation'] ,
- width : nextWidth,
- removable : removable });
- gradelist.append(newBar);
- if (draggable) {
- newBar = gradelist.children().last(); // get the dom object not the unparsed string
- newBar.resizable({
- handles: "e",
- containment : "parent",
- start : this.startMoveClosure(),
- resize : this.moveBarClosure(),
- stop : this.stopDragClosure()
- });
- }
- // prepare for next
- nextWidth = cutoff['cutoff'];
- removable = true; // first is not removable, all others are
- draggable = true;
- },
- this);
- // add fail which is not in data
- var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(),
- width : nextWidth, removable : false});
- $(failBar).find("span[contenteditable=true]").attr("contenteditable", false);
- gradelist.append(failBar);
- gradelist.children().last().resizable({
- handles: "e",
- containment : "parent",
- start : this.startMoveClosure(),
- resize : this.moveBarClosure(),
- stop : this.stopDragClosure()
- });
-
- this.renderGradeRanges();
- },
-
- showSettingsExtras : function(event) {
- $(event.currentTarget).toggleClass('active');
- $(event.currentTarget).siblings.toggleClass('is-shown');
- },
-
+ _.each(this.descendingCutoffs,
+ function(cutoff, index) {
+ var newBar = this.gradeCutoffTemplate({
+ descriptor : cutoff['designation'] ,
+ width : nextWidth,
+ removable : removable });
+ gradelist.append(newBar);
+ if (draggable) {
+ newBar = gradelist.children().last(); // get the dom object not the unparsed string
+ newBar.resizable({
+ handles: "e",
+ containment : "parent",
+ start : this.startMoveClosure(),
+ resize : this.moveBarClosure(),
+ stop : this.stopDragClosure()
+ });
+ }
+ // prepare for next
+ nextWidth = cutoff['cutoff'];
+ removable = true; // first is not removable, all others are
+ draggable = true;
+ },
+ this);
+ // add fail which is not in data
+ var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(),
+ width : nextWidth, removable : false});
+ $(failBar).find("span[contenteditable=true]").attr("contenteditable", false);
+ gradelist.append(failBar);
+ gradelist.children().last().resizable({
+ handles: "e",
+ containment : "parent",
+ start : this.startMoveClosure(),
+ resize : this.moveBarClosure(),
+ stop : this.stopDragClosure()
+ });
- startMoveClosure : function() {
- // set min/max widths
- var cachethis = this;
- var widthPerPoint = cachethis.gradeBarWidth / 100;
- return function(event, ui) {
- var barIndex = ui.element.index();
- // 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);
- // 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);
- ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint});
- };
- },
+ this.renderGradeRanges();
+ },
- moveBarClosure : function() {
- // 0th ele doesn't have a bar; so, will never invoke this
- var cachethis = this;
- return function(event, ui) {
- var barIndex = ui.element.index();
- // 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);
- // 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 percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max);
- cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage);
- cachethis.renderGradeRanges();
- };
- },
-
- renderGradeRanges: function() {
- // the labels showing the range e.g., 71-80
- var cutoffs = this.descendingCutoffs;
- this.$el.find('.range').each(function(i) {
- var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0);
- var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100);
- $(this).text(min + '-' + max);
- });
- },
-
- stopDragClosure: function() {
- var cachethis = this;
- return function(event, ui) {
- // for some reason the resize is setting height to 0
- cachethis.saveCutoffs();
- };
- },
-
- saveCutoffs: function() {
- this.model.save('grade_cutoffs',
- _.reduce(this.descendingCutoffs,
- function(object, cutoff) {
- object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
- return object;
- },
- {}),
- { error : CMS.ServerError});
- },
-
- addNewGrade: function(e) {
- e.preventDefault();
- var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades
- if(gradeLength > 3) {
- // TODO shouldn't we disable the button
- return;
- }
- var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff'];
- // 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 targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2);
- this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth});
- this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth);
-
- var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength],
- width : targetWidth, removable : true });
- var gradeDom = this.$el.find('.grades');
- gradeDom.children().last().before($newGradeBar);
- var newEle = gradeDom.children()[gradeLength];
- $(newEle).resizable({
- handles: "e",
- containment : "parent",
- start : this.startMoveClosure(),
- resize : this.moveBarClosure(),
- stop : this.stopDragClosure()
- });
-
- // Munge existing grade labels?
- // If going from Pass/Fail to 3 levels, change to Pass to A
- if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') {
- this.descendingCutoffs[0]['designation'] = this.GRADES[0];
- this.setTopGradeLabel();
- }
- this.setFailLabel();
-
- this.renderGradeRanges();
- this.saveCutoffs();
- },
-
- removeGrade: function(e) {
- e.preventDefault();
- var domElement = $(e.currentTarget).closest('li');
- var index = domElement.index();
- // copy the boundary up to the next higher grade then remove
- this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff'];
- this.descendingCutoffs.splice(index, 1);
- domElement.remove();
-
- if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) {
- this.descendingCutoffs[0]['designation'] = 'Pass';
- this.setTopGradeLabel();
- }
- this.setFailLabel();
- this.renderGradeRanges();
- this.saveCutoffs();
- },
-
- updateDesignation: function(e) {
- var index = $(e.currentTarget).closest('li').index();
- this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html();
- this.saveCutoffs();
- },
+ showSettingsExtras : function(event) {
+ $(event.currentTarget).toggleClass('active');
+ $(event.currentTarget).siblings.toggleClass('is-shown');
+ },
- failLabel: function() {
- if (this.descendingCutoffs.length === 1) return 'Fail';
- else return 'F';
- },
- setFailLabel: function() {
- this.$el.find('.grades .letter-grade').last().html(this.failLabel());
- },
- setTopGradeLabel: function() {
- this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']);
- }
+
+ startMoveClosure : function() {
+ // set min/max widths
+ var cachethis = this;
+ var widthPerPoint = cachethis.gradeBarWidth / 100;
+ return function(event, ui) {
+ var barIndex = ui.element.index();
+ // 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);
+ // 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);
+ ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint});
+ };
+ },
+
+ moveBarClosure : function() {
+ // 0th ele doesn't have a bar; so, will never invoke this
+ var cachethis = this;
+ return function(event, ui) {
+ var barIndex = ui.element.index();
+ // 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);
+ // 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 percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max);
+ cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage);
+ cachethis.renderGradeRanges();
+ };
+ },
+
+ renderGradeRanges: function() {
+ // the labels showing the range e.g., 71-80
+ var cutoffs = this.descendingCutoffs;
+ this.$el.find('.range').each(function(i) {
+ var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0);
+ var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100);
+ $(this).text(min + '-' + max);
+ });
+ },
+
+ stopDragClosure: function() {
+ var cachethis = this;
+ return function(event, ui) {
+ // for some reason the resize is setting height to 0
+ cachethis.saveCutoffs();
+ };
+ },
+
+ saveCutoffs: function() {
+ this.model.save('grade_cutoffs',
+ _.reduce(this.descendingCutoffs,
+ function(object, cutoff) {
+ object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
+ return object;
+ },
+ {}),
+ { error : CMS.ServerError});
+ },
+
+ addNewGrade: function(e) {
+ e.preventDefault();
+ var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades
+ if(gradeLength > 3) {
+ // TODO shouldn't we disable the button
+ return;
+ }
+ var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff'];
+ // 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 targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2);
+ this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth});
+ this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth);
+
+ var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength],
+ width : targetWidth, removable : true });
+ var gradeDom = this.$el.find('.grades');
+ gradeDom.children().last().before($newGradeBar);
+ var newEle = gradeDom.children()[gradeLength];
+ $(newEle).resizable({
+ handles: "e",
+ containment : "parent",
+ start : this.startMoveClosure(),
+ resize : this.moveBarClosure(),
+ stop : this.stopDragClosure()
+ });
+
+ // Munge existing grade labels?
+ // If going from Pass/Fail to 3 levels, change to Pass to A
+ if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') {
+ this.descendingCutoffs[0]['designation'] = this.GRADES[0];
+ this.setTopGradeLabel();
+ }
+ this.setFailLabel();
+
+ this.renderGradeRanges();
+ this.saveCutoffs();
+ },
+
+ removeGrade: function(e) {
+ e.preventDefault();
+ var domElement = $(e.currentTarget).closest('li');
+ var index = domElement.index();
+ // copy the boundary up to the next higher grade then remove
+ this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff'];
+ this.descendingCutoffs.splice(index, 1);
+ domElement.remove();
+
+ if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) {
+ this.descendingCutoffs[0]['designation'] = 'Pass';
+ this.setTopGradeLabel();
+ }
+ this.setFailLabel();
+ this.renderGradeRanges();
+ this.saveCutoffs();
+ },
+
+ updateDesignation: function(e) {
+ var index = $(e.currentTarget).closest('li').index();
+ this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html();
+ this.saveCutoffs();
+ },
+
+ failLabel: function() {
+ if (this.descendingCutoffs.length === 1) return 'Fail';
+ else return 'F';
+ },
+ setFailLabel: function() {
+ this.$el.find('.grades .letter-grade').last().html(this.failLabel());
+ },
+ setTopGradeLabel: function() {
+ this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']);
+ }
});
CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
- // Model class is CMS.Models.Settings.CourseGrader
- events : {
- "blur input" : "updateModel",
- "blur textarea" : "updateModel",
- "click .remove-grading-data" : "deleteModel"
- },
- initialize : function() {
- this.model.on('error', this.handleValidationError, this);
- this.selectorToField = _.invert(this.fieldToSelectorMap);
- this.render();
- },
-
- render: function() {
- return this;
- },
- fieldToSelectorMap : {
- 'type' : 'course-grading-assignment-name',
- 'short_label' : 'course-grading-assignment-shortname',
- 'min_count' : 'course-grading-assignment-totalassignments',
- 'drop_count' : 'course-grading-assignment-droppable',
- 'weight' : 'course-grading-assignment-gradeweight'
- },
- updateModel : function(event) {
- // 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
- // give 2 assignments the same name.]
- if (!this.model.collection) {
- this.model.collection = this.collection;
- }
-
- switch (event.currentTarget.id) {
- case 'course-grading-assignment-totalassignments':
- this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
- this.saveIfChanged(event);
- break;
- case 'course-grading-assignment-name':
- var oldName = this.model.get('type');
- if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
- // overload the error display logic
- this._cacheValidationErrors.push(event.currentTarget);
- $(event.currentTarget).parent().append(
- this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
- '" subsections to "' + this.model.get('type') + '".'}));
- }
- break;
- default:
- this.saveIfChanged(event);
- break;
- }
- },
- deleteModel : function(e) {
- this.model.destroy(
- { error : CMS.ServerError});
- e.preventDefault();
- }
-
+ // Model class is CMS.Models.Settings.CourseGrader
+ events : {
+ "change input" : "updateModel",
+ "change textarea" : "updateModel",
+ "click .remove-grading-data" : "deleteModel"
+ },
+ initialize : function() {
+ this.model.on('error', this.handleValidationError, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ this.render();
+ },
+
+ render: function() {
+ return this;
+ },
+ fieldToSelectorMap : {
+ 'type' : 'course-grading-assignment-name',
+ 'short_label' : 'course-grading-assignment-shortname',
+ 'min_count' : 'course-grading-assignment-totalassignments',
+ 'drop_count' : 'course-grading-assignment-droppable',
+ 'weight' : 'course-grading-assignment-gradeweight'
+ },
+ updateModel : function(event) {
+ // 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
+ // give 2 assignments the same name.]
+ if (!this.model.collection) {
+ this.model.collection = this.collection;
+ }
+
+ switch (event.currentTarget.id) {
+ case 'course-grading-assignment-totalassignments':
+ this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
+ this.saveIfChanged(event);
+ break;
+ case 'course-grading-assignment-name':
+ var oldName = this.model.get('type');
+ if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
+ // overload the error display logic
+ this._cacheValidationErrors.push(event.currentTarget);
+ $(event.currentTarget).parent().append(
+ this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
+ '" subsections to "' + this.model.get('type') + '".'}));
+ }
+ break;
+ default:
+ this.saveIfChanged(event);
+ break;
+ }
+ },
+ deleteModel : function(e) {
+ this.model.destroy(
+ { error : CMS.ServerError});
+ e.preventDefault();
+ }
+
});
\ No newline at end of file
diff --git a/cms/static/sass/_settings.scss b/cms/static/sass/_settings.scss
index b11378145b..51c9489254 100644
--- a/cms/static/sass/_settings.scss
+++ b/cms/static/sass/_settings.scss
@@ -63,6 +63,15 @@
color: $blue;
}
+ .instructions {
+ font-size: 14px;
+ margin: 0 0 20px 0;
+
+ strong {
+ font-weight: 600;
+ }
+ }
+
> section {
margin-bottom: 100px;
@include clearfix;
@@ -189,8 +198,8 @@
// form layouts
.row {
- margin-bottom: 30px;
- padding-bottom: 30px;
+ margin-bottom: 15px;
+ padding-bottom: 15px;
border-bottom: 1px solid $lightGrey;
&:last-child {
@@ -538,8 +547,99 @@
margin-left: 10px;
}
}
+ }
+ .settings-advanced {
+ .course-advanced-policy-list {
+
+ .row {
+ @include clearfix();
+
+ }
+
+ .key, .value {
+ margin: 0;
+ border: none;
+ padding: 0;
+
+ // existing fields
+ &.existing {
+
+ input, textarea {
+ color: $mediumGrey;
+ }
+ }
+ }
+
+ .key {
+ float: left;
+ width: 30%;
+ margin-right: 5px;
+
+ .field {
+
+ input {
+ width: 100%;
+ }
+
+ .tip {
+ @include transition (opacity 0.5s ease-in-out 0s);
+ opacity: 0;
+ }
+
+ input:focus {
+
+ & + .tip {
+ opacity: 1.0;
+ }
+ }
+ }
+ }
+
+ .value {
+ float: right;
+ width: 65%;
+
+ .field {
+
+ textarea {
+ width: 100%;
+ height: 100px;
+ }
+ }
+ }
+
+ .message-error {
+ float: left;
+ margin: 0 0 10px 0;
+ }
+ }
+
+ .actions {
+ @include clearfix();
+ margin-top: 15px;
+ border-top: 1px solid $lightGrey;
+ padding-top: 15px;
+
+ .save-button {
+ float: left;
+ @include blue-button;
+ margin-right: 10px;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+
+ .cancel-button {
+ float: left;
+ @include white-button;
+ margin-top: 4px;
+ }
+
+ .new-button {
+ float: right;
+ }
+ }
}
// states
@@ -569,10 +669,14 @@
// }
// }
- input.error, textarea.error {
+ input.error, textarea.error, .error input, .error textarea {
border-color: $red;
}
+ .error label {
+ color: $red;
+ }
+
.message-error {
display: block;
margin-top: 5px;
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
index 8cd4246da9..b2ac3aa772 100644
--- a/cms/templates/settings.html
+++ b/cms/templates/settings.html
@@ -1,4 +1,5 @@
<%inherit file="base.html" />
+<%! from django.core.urlresolvers import reverse %>
<%block name="bodyclass">settings%block>
<%block name="title">Settings%block>
@@ -15,21 +16,28 @@ from contentstore import utils
+
-
+
+
## codemirror