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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
%block>
@@ -158,7 +50,7 @@ from contentstore import utils