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 @@ +
  • +
    +
    + +
    + + 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 name="title">Settings @@ -15,21 +16,28 @@ from contentstore import utils + - +