diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 4495683a01..ae7c6a2375 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -7,7 +7,7 @@ from django.test.client import Client from django.core.urlresolvers import reverse from xmodule.modulestore import Location from cms.djangoapps.models.settings.course_details import CourseDetails,\ - CourseDetailsEncoder + CourseSettingsEncoder import json from common.djangoapps.util import converters @@ -87,7 +87,7 @@ class CourseDetailsTestCase(TestCase): def test_encoder(self): details = CourseDetails.fetch(self.course_location) - jsondetails = json.dumps(details, cls=CourseDetailsEncoder) + jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=") # Note, start_date is being initialized someplace. I'm not sure why b/c the default will make no sense. @@ -164,23 +164,22 @@ class CourseDetailsViewTest(TestCase): def alter_field(self, url, details, field, val): details[field] = val - jsondetails = json.dumps(details, cls=CourseDetailsEncoder) + jsondetails = json.dumps(details, cls=CourseSettingsEncoder) resp = self.client.post(url, jsondetails) - self.assertDictEqual(json.loads(resp), details, field + val) + self.assertDictEqual(json.loads(resp.content), details.__dict__, field + val) def test_update_and_fetch(self): details = CourseDetails.fetch(self.course_location) - details_loc = self.course_location.dict().copy() - details_loc['section'] = 'details' - resp = self.client.get(reverse('contentstore.views.get_course_settings', kwargs=self.course_location.dict())) + resp = self.client.get(reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, + 'name' : self.course_location.name })) self.assertContains(resp, '
  • Course Details
  • ', status_code=200, html=True) # resp s/b json from here on - url = reverse('contentstore.views.course_settings_updates', kwargs=details_loc) + url = reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, + 'name' : self.course_location.name, 'section' : 'details' }) resp = self.client.get(url) - jsondetails = json.dumps(details, cls=CourseDetailsEncoder) - self.assertDictEqual(resp, jsondetails, "virgin get") + self.assertDictEqual(json.loads(resp.content), details.__dict__, "virgin get") self.alter_field(url, details, 'start_date', time.time() * 1000) self.alter_field(url, details, 'start_date', time.time() * 1000 + 60 * 60 * 24) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 5b91b77554..220697bcab 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -46,7 +46,8 @@ import time from contentstore import course_info_model from contentstore.utils import get_modulestore from cms.djangoapps.models.settings.course_details import CourseDetails,\ - CourseDetailsEncoder + CourseSettingsEncoder +from cms.djangoapps.models.settings.course_grading import CourseGradingModel # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' @@ -955,7 +956,7 @@ def get_course_settings(request, org, course, name): return render_to_response('settings.html', { 'active_tab': 'settings-tab', 'context_course': course_module, - 'course_details' : json.dumps(course_details, cls=CourseDetailsEncoder) + 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) }) @expect_json @@ -963,7 +964,7 @@ def get_course_settings(request, org, course, name): @ensure_csrf_cookie def course_settings_updates(request, org, course, name, section): """ - restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely + restful CRUD operations on course settings. This differs from get_course_settings by communicating purely through json (not rendering any html) and handles section level operations rather than whole page. org, course: Attributes of the Location for the item to edit @@ -971,14 +972,42 @@ def course_settings_updates(request, org, course, name, section): """ if section == 'details': manager = CourseDetails + elif section == 'grading': + manager = CourseGradingModel else: return if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( - return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseDetailsEncoder), + return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseSettingsEncoder), mimetype="application/json") elif request.method == 'POST': # post or put, doesn't matter. - return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseDetailsEncoder), + return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), + mimetype="application/json") + +@expect_json +@login_required +@ensure_csrf_cookie +def course_grader_updates(request, org, course, name, grader_index=None): + """ + restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely + through json (not rendering any html) and handles section level operations rather than whole page. + + org, course: Attributes of the Location for the item to edit + """ + 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 real_method == 'GET': + # Cannot just do a get w/o knowing the course name :-( + return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course',name]), grader_index)), + mimetype="application/json") + elif real_method == "DELETE": + # ??? Shoudl this return anything? Perhaps success fail? + CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course',name]), grader_index) + elif request.method == 'POST': # post or put, doesn't matter. + return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course',name]), request.POST)), mimetype="application/json") diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 7f89589c60..90f49a7b32 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -6,6 +6,7 @@ from json.encoder import JSONEncoder import time from contentstore.utils import get_modulestore from util.converters import jsdate_to_time, time_to_date +from cms.djangoapps.models.settings import course_grading class CourseDetails: def __init__(self, location): @@ -131,10 +132,11 @@ class CourseDetails: # 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 CourseDetails.fetch(course_location) - -class CourseDetailsEncoder(json.JSONEncoder): + +# TODO move to a more general util? Is there a better way to do the isinstance model check? +class CourseSettingsEncoder(json.JSONEncoder): def default(self, obj): - if isinstance(obj, CourseDetails): + if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel): return obj.__dict__ elif isinstance(obj, Location): return obj.dict() diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py new file mode 100644 index 0000000000..69e84c9c7c --- /dev/null +++ b/cms/djangoapps/models/settings/course_grading.py @@ -0,0 +1,238 @@ +from xmodule.modulestore import Location +from contentstore.utils import get_modulestore +import datetime +import re +from common.djangoapps.util import converters +import time + + +class CourseGradingModel: + """ + Basically a DAO and Model combo for CRUD operations pertaining to grading policy. + """ + def __init__(self, course_descriptor): + self.course_location = course_descriptor.location + self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100] + self.grade_cutoffs = course_descriptor.grade_cutoffs + self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor) + + @classmethod + def fetch(cls, course_location): + """ + Fetch the course details for the given course from persistence and return a CourseDetails model. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + + model = cls(descriptor) + return model + + @staticmethod + def fetch_grader(course_location, index): + """ + Fetch the course's nth grader + Returns an empty dict if there's no such grader. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + # # ??? it would be good if these had the course_location in them so that they stand alone sufficiently + # # but that would require not using CourseDescriptor's field directly. Opinions? + + # FIXME how do I tell it to ignore index? Is there another iteration mech I should use? + if len(descriptor.raw_grader) > index: + return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) + + # return empty model + else: + return { + "id" : index, + "type" : "", + "min_count" : 0, + "drop_count" : 0, + "short_label" : None, + "weight" : 0 + } + + @staticmethod + def fetch_cutoffs(course_location): + """ + Fetch the course's grade cutoffs. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + return descriptor.grade_cutoffs + + @staticmethod + def fetch_grace_period(course_location): + """ + Fetch the course's default grace period. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + return {'grace_period' : CourseGradingModel.convert_set_grace_period(descriptor) } + + @staticmethod + def update_from_json(jsondict): + """ + Decode the json into CourseGradingModel and save any changes. Returns the modified model. + Probably not the usual path for updates as it's too coarse grained. + """ + course_location = jsondict['course_location'] + descriptor = get_modulestore(course_location).get_item(course_location) + + graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']] + + descriptor.raw_grader = graders_parsed + descriptor.grade_cutoffs = jsondict['grade_cutoffs'] + + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period']) + + return CourseGradingModel.fetch(course_location) + + + @staticmethod + def update_grader_from_json(course_location, grader): + """ + Create or update the grader of the given type (string key) for the given course. Returns the modified + grader which is a full model on the client but not on the server (just a dict) + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + # # ??? it would be good if these had the course_location in them so that they stand alone sufficiently + # # but that would require not using CourseDescriptor's field directly. Opinions? + + # parse removes the id; so, grab it before parse + index = grader.get('id', None) + grader = CourseGradingModel.parse_grader(grader) + + if index < len(descriptor.raw_grader): + descriptor.raw_grader[index] = grader + else: + descriptor.raw_grader.append(grader) + + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + + return grader + + @staticmethod + def update_cutoffs_from_json(course_location, cutoffs): + """ + Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra + db fetch). + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + descriptor.grade_cutoffs = cutoffs + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + + return cutoffs + + + @staticmethod + def update_grace_period_from_json(course_location, graceperiodjson): + """ + Update the course's default grace period. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + if not isinstance(graceperiodjson, dict): + graceperiodjson = {'grace_period' : graceperiodjson} + + grace_time = converters.jsdate_to_time(graceperiodjson['grace_period']) + # NOTE: this does not handle > 24 hours + grace_rep = time.strftime("%H hours %M minutes %S seconds", grace_time) + + descriptor = get_modulestore(course_location).get_item(course_location) + descriptor.metadata['graceperiod'] = grace_rep + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + return graceperiodjson + + + @staticmethod + def delete_grader(course_location, index): + """ + Delete the grader of the given type from the given course. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + if index < len(descriptor.raw_grader): + del descriptor.raw_grader[index] + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + + # NOTE cannot delete cutoffs. May be useful to reset + @staticmethod + def delete_cutoffs(course_location, cutoffs): + """ + Resets the cutoffs to the defaults + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS'] + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + + return descriptor.grade_cutoffs + + @staticmethod + def delete_grace_period(course_location): + """ + Delete the course's default grace period. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + descriptor = get_modulestore(course_location).get_item(course_location) + del descriptor.metadata['graceperiod'] + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + @staticmethod + def convert_set_grace_period(descriptor): + # 5 hours 59 minutes 59 seconds => converted to iso format + rawgrace = descriptor.metadata.get('graceperiod', None) + if rawgrace: + parsedgrace = {str(key): val for (val, key) in re.findall('\s*(\d*)\s*(\w*)', rawgrace)} + gracedate = datetime.datetime.today() + gracedate = gracedate.replace(minute = int(parsedgrace.get('minutes',0)), hour = int(parsedgrace.get('hours',0))) + return gracedate.isoformat() + 'Z' + else: return None + + @staticmethod + def parse_grader(json_grader): + # manual to clear out kruft + result = { + "type" : json_grader["type"], + "min_count" : json_grader.get('min_count', 0), + "drop_count" : json_grader.get('drop_count', 0), + "short_label" : json_grader.get('short_label', None), + "weight" : json_grader.get('weight', 0) / 100.0 + } + + return result + + @staticmethod + def jsonize_grader(i, grader): + grader['id'] = i + if grader['weight']: + grader['weight'] *= 100 + if not 'short_label' in grader: + grader['short_label'] = "" + + return grader \ No newline at end of file diff --git a/cms/static/coffee/src/client_templates/course_grade_policy.html b/cms/static/coffee/src/client_templates/course_grade_policy.html new file mode 100644 index 0000000000..97b0c20eb8 --- /dev/null +++ b/cms/static/coffee/src/client_templates/course_grade_policy.html @@ -0,0 +1,69 @@ +
  • +
    + + +
    +
    + + e.g. Homework, Labs, Midterm Exams, Final Exam +
    +
    +
    + +
    + + +
    +
    + + e.g. HW, Midterm, Final +
    +
    +
    + +
    + + +
    +
    + + e.g. 25% +
    +
    +
    + +
    + + +
    +
    + + total exercises assigned +
    +
    +
    + +
    + + +
    +
    + + total exercises that won't be graded +
    +
    +
    Delete Assignment Type +
  • diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 91e211be95..222d2fd11e 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -67,7 +67,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ // NOTE don't return empty errors as that will be interpreted as an error state }, - urlRoot: function() { + url: function() { var location = this.get('location'); return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/details'; }, diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js new file mode 100644 index 0000000000..75cf68cb02 --- /dev/null +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -0,0 +1,115 @@ +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 + grace_period : null // either null or seconds of grace period + }, + parse: function(attributes) { + if (attributes['course_location']) { + attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); + } + if (attributes['grace_period']) { + attributes.grace_period = new Date(attributes.grace_period); + } + 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'; + } +}); + +CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ + defaults: { + "type" : "", // must be unique w/in collection (ie. w/in course) + "min_count" : 0, + "drop_count" : 0, + "short_label" : "", // what to use in place of type if space is an issue + "weight" : 0 // int 0..100 + }, + initialize: function() { + if (!this.collection) + console.log("damn"); + }, + 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; + }, + 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 (!parseInt(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) { + // if get() doesn't get the value before the call, use previous() + 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 (!parseInt(attrs.min_count)) { + errors.min_count = "Please enter an integer."; + } + else attrs.min_count = parseInt(attrs.min_count); + } + if (attrs['drop_count']) { + if (!parseInt(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); + } +}); \ No newline at end of file diff --git a/cms/static/js/models/settings/course_settings.js b/cms/static/js/models/settings/course_settings.js index de4468c00b..9d09e4bdc5 100644 --- a/cms/static/js/models/settings/course_settings.js +++ b/cms/static/js/models/settings/course_settings.js @@ -13,15 +13,31 @@ CMS.Models.Settings.CourseSettings = Backbone.Model.extend({ retrieve: function(submodel, callback) { if (this.get(submodel)) callback(); - else switch (submodel) { - case 'details': - this.set('details', new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')})).fetch({ - success : callback - }); - break; + else { + var cachethis = this; + switch (submodel) { + case 'details': + var details = new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')}); + details.fetch( { + success : function(model) { + cachethis.set('details', model); + callback(model); + } + }); + break; + case 'grading': + var grading = new CMS.Models.Settings.CourseGradingPolicy({course_location: this.get('courseLocation')}); + grading.fetch( { + success : function(model) { + cachethis.set('grading', model); + callback(model); + } + }); + break; - default: - break; + default: + break; + } } } }) \ No newline at end of file diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index f959a97d90..91f82383b5 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -1,5 +1,56 @@ if (!CMS.Views['Settings']) CMS.Views.Settings = new Object(); +// 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 %>'), + + 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 : null, + handleValidationError : function(model, error) { + this._cacheValidationErrors = error; + // error is object w/ fields and error strings + for (var field in error) { + var ele = this.$el.find(this.fieldToSelectorMap[field]); + 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() { + if (this._cacheValidationErrors == null) return; + // error is object w/ fields and error strings + for (var field in this._cacheValidationErrors) { + var ele = this.$el.find(this.fieldToSelectorMap[field]); + 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(); + } + this._cacheValidationErrors = null; + } +}) + CMS.Views.Settings.Main = Backbone.View.extend({ // Model class is CMS.Models.Settings.CourseSettings // allow navigation between the tabs @@ -34,9 +85,10 @@ CMS.Views.Settings.Main = Backbone.View.extend({ // 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() { - this.subviews[this.currentTab] = this.createSubview(); - this.subviews[this.currentTab].render(); + cachethis.subviews[cachethis.currentTab] = cachethis.createSubview(); + cachethis.subviews[cachethis.currentTab].render(); }); } else this.subviews[this.currentTab].render(); @@ -55,6 +107,10 @@ CMS.Views.Settings.Main = Backbone.View.extend({ 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 'problems': break; @@ -75,7 +131,7 @@ CMS.Views.Settings.Main = Backbone.View.extend({ }); -CMS.Views.Settings.Details = Backbone.View.extend({ +CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ // Model class is CMS.Models.Settings.CourseDetails events : { "blur input" : "updateModel", @@ -87,8 +143,8 @@ CMS.Views.Settings.Details = Backbone.View.extend({ initialize : function() { // TODO move the html frag to a loaded asset this.fileAnchorTemplate = _.template(' 📄<%= filename %>'); - this.errorTemplate = _.template('<%= message %>'); this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); }, render: function() { @@ -133,36 +189,6 @@ CMS.Views.Settings.Details = Backbone.View.extend({ 'effort' : "#course-effort" }, - _cacheValidationErrors : null, - handleValidationError : function(model, error) { - this._cacheValidationErrors = error; - // error is object w/ fields and error strings - for (var field in error) { - var ele = this.$el.find(this.fieldToSelectorMap[field]); - 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() { - if (this._cacheValidationErrors == null) return; - // error is object w/ fields and error strings - for (var field in this._cacheValidationErrors) { - var ele = this.$el.find(this.fieldToSelectorMap[field]); - 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(); - } - this._cacheValidationErrors = null; - }, - setupDatePicker : function(fieldName) { var cacheModel = this.model; var div = this.$el.find(this.fieldToSelectorMap[fieldName]); @@ -185,8 +211,6 @@ CMS.Views.Settings.Details = Backbone.View.extend({ }, updateModel: function(event) { - this.clearValidationErrors(); - switch (event.currentTarget.id) { case 'course-start-date': // handled via onSelect method case 'course-end-date': @@ -228,4 +252,326 @@ CMS.Views.Settings.Details = Backbone.View.extend({ } } +}); + +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" + }, + initialize : function() { + // load template for grading view + var self = this; + this.gradeCutoffTemplate = _.template('
  • ' + + '<%= descriptor %>' + + '' + + '<% if (removable) {%>remove<% ;} %>' + + '
  • '); + + // Instrument grading scale + // convert cutoffs to inversely ordered list + var modelCutoffs = this.model.get('grade_cutoffs'); + for (cutoff in modelCutoffs) { + this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)}); + } + this.descendingCutoffs = _.sortBy(this.descendingCutoffs, + function (gradeEle) { return -gradeEle['cutoff']; }); + + // Instrument grace period + this.$el.find('#course-grading-graceperiod').timepicker(); + + // 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_info_update", + // TODO Where should the template reside? how to use the static.url to create the path? + "/static/coffee/src/client_templates/course_grade_policy.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); + } + ); + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, + + render: function() { + // 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(); + this.model.get('graders').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}); + }); + + // render the grade cutoffs + this.renderCutoffBar(); + + var graceEle = this.$el.find('#course-grading-graceperiod'); + graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime + graceEle.timepicker('setTime', (this.model.has('grace_period') ? this.model.get('grace_period') : new Date(0))); + + return this; + }, + fieldToSelectorMap : { + 'grace_period' : 'course-grading-graceperiod' + }, + updateModel : function(event) { + switch (this.selectorToField[event.currentTarget.id]) { + case null: + break; + + case 'grace_period': + this.model.save('grace_period', $(event.currentTarget).timepicker('getTime')); + break; + + default: + this.model.save(this.selectorToField[event.currentTarget.id], $(event.currentTarget).val()); + 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% + var draggable = removable = 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", + // TODO perhaps add a start which sets minWidth to next element's edge + 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'); + }, + + + 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) { + ui.element.height("50px"); + 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 + ui.element.height("50px"); + cachethis.saveCutoffs(); + } + }, + + saveCutoffs: function() { + this.model.save('grade_cutoffs', + _.reduce(this.descendingCutoffs, + function(object, cutoff) { + object[cutoff['designation']] = cutoff['cutoff'] / 100.0; + return object; + }, + new Object())); + }, + + addNewGrade: function(e) { + 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) { + 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" + }, + 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) { + if (!this.model.collection) + console.log("Huh?"); + + switch (event.currentTarget.id) { + case 'course-grading-assignment-totalassignments': + this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val()); + // no break b/c want to use the default save + default: + this.model.save(this.selectorToField[event.currentTarget.id], $(event.currentTarget).val()); + break; + + } + } + }); \ No newline at end of file diff --git a/cms/static/sass/_settings.scss b/cms/static/sass/_settings.scss index ab72bc464c..3f1106584a 100644 --- a/cms/static/sass/_settings.scss +++ b/cms/static/sass/_settings.scss @@ -716,6 +716,10 @@ } } } + + .grade-specific-bar { + height: 50px; + } .grades { position: relative; diff --git a/cms/templates/settings.html b/cms/templates/settings.html index d57eb9d87c..833e0310c6 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -18,6 +18,7 @@ from contentstore import utils + @@ -158,7 +50,7 @@ from contentstore import utils