Commit 39024a7f by Don Mitchell

Grading mostly working

parent 50d7e616
......@@ -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, '<li><a href="#" class="is-shown" data-section="details">Course Details</a></li>', 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)
......
......@@ -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=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(manager.update_from_json(request.POST), cls=CourseDetailsEncoder),
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course',name]), request.POST)),
mimetype="application/json")
......
......@@ -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()
......
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
<li class="input input-existing multi course-grading-assignment-list-item">
<div class="row row-col2">
<label for="course-grading-assignment-name">Assignment Type Name:</label>
<div class="field">
<div class="input course-grading-assignment-name">
<input type="text" class="long"
id="course-grading-assignment-name" value="<%= model.get('type') %>">
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-shortname">Abbreviation:</label>
<div class="field">
<div class="input course-grading-shortname">
<input type="text" class="short"
id="course-grading-assignment-shortname"
value="<%= model.get('short_label') %>">
<span class="tip tip-inline">e.g. HW, Midterm, Final</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-gradeweight">Weight of Total
Grade:</label>
<div class="field">
<div class="input course-grading-gradeweight">
<input type="text" class="short"
id="course-grading-assignment-gradeweight"
value = "<%= model.get('weight') %>">
<span class="tip tip-inline">e.g. 25%</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-totalassignments">Total
Number:</label>
<div class="field">
<div class="input course-grading-totalassignments">
<input type="text" class="short"
id="course-grading-assignment-totalassignments"
value = "<%= model.get('min_count') %>">
<span class="tip tip-inline">total exercises assigned</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-droppable">Number of
Droppable:</label>
<div class="field">
<div class="input course-grading-droppable">
<input type="text" class="short"
id="course-grading-assignment-droppable"
value = "<%= model.get('drop_count') %>">
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
</div>
</div> <a href="#" class="remove-item remove-grading-data"><span
class="delete-icon"></span> Delete Assignment Type</a>
</li>
......@@ -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';
},
......
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
......@@ -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
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('<span class="message-error"><%= message %></span>'),
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('<a href="<%= fullpath %>"> <i class="ss-icon ss-standard">&#x1F4C4;</i><%= filename %></a>');
this.errorTemplate = _.template('<span class="message-error"><%= message %></span>');
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('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' +
'<%= descriptor %>' +
'</span><span class="range"></span>' +
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
'</li>');
// 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
......@@ -716,6 +716,10 @@
}
}
}
.grade-specific-bar {
height: 50px;
}
.grades {
position: relative;
......
......@@ -18,6 +18,7 @@ from contentstore import utils
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_settings.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
<script type="text/javascript">
......@@ -36,115 +37,6 @@ from contentstore import utils
editor.render();
});
// TODO move most of this into view handlers
var $body;
var $gradeBar;
var $draggingBar;
var barOrigin;
var barWidth;
var gradeThresholds;
var GRADES = ['A', 'B', 'C', 'D', 'F'];
(function() {
$body = $('body');
$gradeBar = $('.grade-bar');
// gradeThresholds = [100, 50];
setThresholds();
$('.settings-extra header').bind('click', showSettingsExtras);
$body.on('mousedown', '.drag-bar', startDragBar);
$('.new-grade-button').bind('click', addNewGrade);
$body.on('click', '.remove-button', removeGrade);
renderGradeRanges();
})();
function setThresholds() {
gradeThresholds = [];
$('.grades li').each(function(i) {
gradeThresholds.push(parseInt($(this)[0].style.width));
});
}
function addNewGrade(e) {
e.preventDefault();
var $grades = $('.grades');
var gradeLength = $('li', $grades).length;
if(gradeLength > 4) {
return;
}
var $newGradeBar = $('<li><span class="letter-grade" contenteditable>' + GRADES[gradeLength - 1] + '</span><span class="range"></span><a href="#" class="drag-bar"></a><a href="#" class="remove-button">remove</a></li>');
var failBarWidth = parseFloat($('.bar-fail', $grades)[0].style.width);
var lastBarWidth = parseFloat($('li', $grades).eq(gradeLength - 2)[0].style.width);
var targetWidth = failBarWidth + ((lastBarWidth - failBarWidth) / 2);
$newGradeBar.css('width', targetWidth + '%');
$('.bar-fail', $grades).before($newGradeBar);
setGrades();
setThresholds();
renderGradeRanges();
}
function removeGrade(e) {
e.preventDefault();
var index = $(this).closest('li').index();
gradeThresholds.splice(index, 1);
$(this).closest('li').remove();
setGrades();
setThresholds();
renderGradeRanges();
}
function setGrades() {
var $gradeBars = $('.grades li');
var barCount = $gradeBars.length;
if(barCount <= 2) {
$gradeBars.eq(0).find('.letter-grade').html('Pass');
$('.bar-fail').find('.letter-grade').html('Fail');
} else {
$gradeBars.each(function(i) {
$('.letter-grade', this).html(GRADES[i]);
});
$('.bar-fail').find('.letter-grade').html('F');
}
}
function showSettingsExtras(e) {
e.preventDefault();
$(this).toggleClass('active');
$(this).siblings.toggleClass('is-shown');
}
function startDragBar(e) {
e.preventDefault();
barOrigin = $gradeBar.offset().left;
barWidth = $gradeBar.width();
$draggingBar = $(e.target).closest('li').addClass('is-dragging');
$body.bind('mousemove', moveBar);
$body.bind('mouseup', stopDragBar);
}
function moveBar(e) {
var barIndex = $draggingBar.index();
var min = gradeThresholds[barIndex + 1] || 0;
var max = gradeThresholds[barIndex - 1] || 100;
var percentage = Math.min(Math.max((e.pageX - barOrigin) / barWidth * 100, min), max);
$draggingBar.css('width', percentage + '%');
gradeThresholds[$draggingBar.index()] = Math.round(percentage);
renderGradeRanges();
}
function stopDragBar(e) {
$draggingBar.removeClass('is-dragging');
$body.unbind('mousemove', moveBar);
$body.unbind('mouseup', stopDragBar);
}
function renderGradeRanges() {
$('.range').each(function(i) {
var min = gradeThresholds[i + 1] + 1 || 0;
var max = gradeThresholds[i];
$(this).text(min + '-' + max);
});
}
</script>
</%block>
......@@ -158,7 +50,7 @@ from contentstore import utils
<nav class="settings-page-menu">
<ul>
<li><a href="#" class="is-shown" data-section="details">Course Details</a></li>
<li><a href="#" data-section="faculty">Faculty</a></li>
<!-- <li><a href="#" data-section="faculty">Faculty</a></li> -->
<li><a href="#" data-section="grading">Grading</a></li>
<li><a href="#" data-section="problems">Problems</a></li>
<li><a href="#" data-section="discussions">Discussions</a></li>
......@@ -493,15 +385,6 @@ from contentstore import utils
<li class="increment-100">100</li>
</ol>
<ol class="grades">
<li class="bar-a" style="width: 100%;">
<span class="letter-grade" contenteditable>Pass</span>
<span class="range"></span>
</li>
<li class="bar-fail" style="width: 50%;">
<span class="letter-grade" contenteditable>Fail</span>
<span class="range"></span>
<a href="#" class="drag-bar"></a>
</li>
</ol>
</div>
</div>
......@@ -517,19 +400,6 @@ from contentstore import utils
</header>
<div class="row row-col2">
<label for="course-grading-deadline">General Assignment Deadline:</label>
<div class="field datepicker">
<div class="input">
<input type="text" class="time" id="course-grading-deadline" value="" placeholder="HH:MM">
<span class="tip tip-stacked">Boston, MA Local Time (UTC/GMT -5 hours).<br />
<a href="http://www.worldtimeserver.com/convert_time_in_UTC.aspx">Convert to your time zone</a>
</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-graceperiod">Grace Period on Deadline:</label>
<div class="field">
......@@ -549,101 +419,6 @@ from contentstore import utils
<div class="row">
<div class="field enum">
<ul class="input-list course-grading-assignment-list">
<li class="input input-existing multi course-grading-assignment-list-item">
<div class="row row-col2">
<label for="ourse-grading-assignment-1-name">Assignment Type Name:</label>
<div class="field">
<div class="input course-grading-assignment-name">
<input type="text" class="long" id="course-grading-assignment-1-name">
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-1-gradeweight">Weight of Total Grade:</label>
<div class="field">
<div class="input course-grading-gradeweight">
<input type="text" class="short" id="course-grading-assignment-1-gradeweight">
<span class="tip tip-inline">e.g. 25%</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-1-totalassignments">Total Number:</label>
<div class="field">
<div class="input course-grading-totalassignments">
<input type="text" class="short" id="course-grading-assignment-1-totalassignments">
<span class="tip tip-inline">total exercises assigned</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-1-droppable">Number of Droppable:</label>
<div class="field">
<div class="input course-grading-droppable">
<input type="text" class="short" id="course-grading-assignment-1-droppable">
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
</div>
</div>
<a href="#" class="remove-item remove-grading-data"><span class="delete-icon"></span> Delete Assignment Type</a>
</li>
<li class="input input-existing multi course-grading-assignment-list-item">
<div class="row row-col2">
<label for="course-grading-assignment-2-name">Assignment Type Name:</label>
<div class="field">
<div class="input course-grading-assignment-name">
<input type="text" class="long" id="course-grading-assignment-2-name">
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-2-gradeweight">Weight of Total Grade:</label>
<div class="field">
<div class="input course-grading-gradeweight">
<input type="text" class="short" id="course-grading-assignment-2-gradeweight">
<span class="tip tip-inline">e.g. 25%</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-2-totalassignments">Total Number:</label>
<div class="field">
<div class="input course-grading-totalassignments">
<input type="text" class="short" id="course-grading-assignment-2-totalassignments">
<span class="tip tip-inline">total exercises assigned</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-2-droppable">Number of Droppable:</label>
<div class="field">
<div class="input course-grading-droppable">
<input type="text" class="short" id="course-grading-assignment-2-droppable">
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
</div>
</div>
<a href="#" class="remove-item remove-grading-data"><span class="delete-icon"></span> Delete Assignment Type</a>
</li>
</ul>
<a href="#" class="new-item new-course-grading-item add-grading-data">
......
......@@ -38,6 +38,7 @@ urlpatterns = ('',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$', 'contentstore.views.course_info_updates', name='course_info'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings/(?P<name>[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/grades/(?P<name>[^/]+)/(?P<grader_index>.*)$', 'contentstore.views.course_grader_updates', name='course_settings'),
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages',
name='static_pages'),
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),
......
......@@ -6,6 +6,7 @@ def time_to_date(time_obj):
"""
Convert a time.time_struct to a true universal time (can pass to js Date constructor)
"""
# TODO change to using the isoformat() function on datetime. js date can parse those
return calendar.timegm(time_obj) * 1000
def jsdate_to_time(field):
......
......@@ -10,6 +10,7 @@ import json
import logging
import requests
import time
import copy
log = logging.getLogger(__name__)
......@@ -99,19 +100,11 @@ class CourseDescriptor(SequenceDescriptor):
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
def set_grading_policy(self, course_policy):
if course_policy is None:
course_policy = {}
def defaut_grading_policy(self):
"""
The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is
missing, it reverts to the default.
Return a dict which is a copy of the default grading policy
"""
default_policy_string = """
{
"GRADER" : [
default = {"GRADER" : [
{
"type" : "Homework",
"min_count" : 12,
......@@ -127,33 +120,41 @@ class CourseDescriptor(SequenceDescriptor):
"weight" : 0.15
},
{
"type" : "Midterm",
"name" : "Midterm Exam",
"type" : "Midterm Exam",
"short_label" : "Midterm",
"min_count" : 1,
"drop_count" : 0,
"weight" : 0.3
},
{
"type" : "Final",
"name" : "Final Exam",
"type" : "Final Exam",
"short_label" : "Final",
"min_count" : 1,
"drop_count" : 0,
"weight" : 0.4
}
],
"GRADE_CUTOFFS" : {
"A" : 0.87,
"B" : 0.7,
"C" : 0.6
}
}
"Pass" : 0.5
}}
return copy.deepcopy(default)
def set_grading_policy(self, course_policy):
"""
The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is
missing, it reverts to the default.
"""
if course_policy is None:
course_policy = {}
# Load the global settings as a dictionary
grading_policy = json.loads(default_policy_string)
grading_policy = self.defaut_grading_policy()
# Override any global settings with the course settings
grading_policy.update(course_policy)
# Here is where we should parse any configurations, so that we can fail early
grading_policy['RAW_GRADER'] = grading_policy['GRADER'] # used for cms access
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
self._grading_policy = grading_policy
......@@ -272,10 +273,26 @@ class CourseDescriptor(SequenceDescriptor):
@property
def grader(self):
return self._grading_policy['GRADER']
@property
def raw_grader(self):
return self._grading_policy['RAW_GRADER']
@raw_grader.setter
def raw_grader(self, value):
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
self._grading_policy['RAW_GRADER'] = value
self.definition['data'].setdefault('grading_policy',{})['GRADER'] = value
@property
def grade_cutoffs(self):
return self._grading_policy['GRADE_CUTOFFS']
@grade_cutoffs.setter
def grade_cutoffs(self, value):
self._grading_policy['GRADE_CUTOFFS'] = value
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
@property
def tabs(self):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment