Grading mostly working
This commit is contained in:
@@ -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=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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
238
cms/djangoapps/models/settings/course_grading.py
Normal file
238
cms/djangoapps/models/settings/course_grading.py
Normal file
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
<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';
|
||||
},
|
||||
|
||||
115
cms/static/js/models/settings/course_grading_policy.js
Normal file
115
cms/static/js/models/settings/course_grading_policy.js
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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('<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">📄</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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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>
|
||||
@@ -516,19 +399,6 @@ from contentstore import utils
|
||||
<span class="detail">Deadlines and Requirements</span>
|
||||
</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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user