diff --git a/cms/djangoapps/__init__.py b/cms/djangoapps/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
new file mode 100644
index 0000000000..67ff10bc50
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -0,0 +1,213 @@
+from django.test.testcases import TestCase
+import datetime
+import time
+from django.contrib.auth.models import User
+import xmodule
+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,\
+ CourseSettingsEncoder
+import json
+from common.djangoapps.util import converters
+import calendar
+
+# YYYY-MM-DDThh:mm:ss.s+/-HH:MM
+class ConvertersTestCase(TestCase):
+ def struct_to_datetime(self, struct_time):
+ return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour, struct_time.tm_min, struct_time.tm_sec)
+
+ def compare_dates(self, date1, date2, expected_delta):
+ dt1 = self.struct_to_datetime(date1)
+ dt2 = self.struct_to_datetime(date2)
+ self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + str(date2) + "!=" + str(expected_delta))
+
+ def test_iso_to_struct(self):
+ self.compare_dates(converters.jsdate_to_time("2013-01-01"), converters.jsdate_to_time("2012-12-31"), datetime.timedelta(days=1))
+ self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), converters.jsdate_to_time("2012-12-31T23"), datetime.timedelta(hours=1))
+ self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), converters.jsdate_to_time("2012-12-31T23:59"), datetime.timedelta(minutes=1))
+ self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), converters.jsdate_to_time("2012-12-31T23:59:59"), datetime.timedelta(seconds=1))
+
+class CourseDetailsTestCase(TestCase):
+ def setUp(self):
+ uname = 'testuser'
+ email = 'test+courses@edx.org'
+ password = 'foo'
+
+ # Create the use so we can log them in.
+ self.user = User.objects.create_user(uname, email, password)
+
+ # Note that we do not actually need to do anything
+ # for registration if we directly mark them active.
+ self.user.is_active = True
+ # Staff has access to view all courses
+ self.user.is_staff = True
+ self.user.save()
+
+ # Flush and initialize the module store
+ # It needs the templates because it creates new records
+ # by cloning from the template.
+ # Note that if your test module gets in some weird state
+ # (though it shouldn't), do this manually
+ # from the bash shell to drop it:
+ # $ mongo test_xmodule --eval "db.dropDatabase()"
+ xmodule.modulestore.django._MODULESTORES = {}
+ xmodule.modulestore.django.modulestore().collection.drop()
+ xmodule.templates.update_templates()
+
+ self.client = Client()
+ self.client.login(username=uname, password=password)
+
+ self.course_data = {
+ 'template': 'i4x://edx/templates/course/Empty',
+ 'org': 'MITx',
+ 'number': '999',
+ 'display_name': 'Robot Super Course',
+ }
+ self.course_location = Location('i4x', 'MITx', '999', 'course', 'Robot_Super_Course')
+ self.create_course()
+
+ def tearDown(self):
+ xmodule.modulestore.django._MODULESTORES = {}
+ xmodule.modulestore.django.modulestore().collection.drop()
+
+ def create_course(self):
+ """Create new course"""
+ self.client.post(reverse('create_new_course'), self.course_data)
+
+ def test_virgin_fetch(self):
+ details = CourseDetails.fetch(self.course_location)
+ self.assertEqual(details.course_location, self.course_location, "Location not copied into")
+ self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
+ self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
+ self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
+ self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
+ self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview)
+ self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
+ self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
+
+ def test_encoder(self):
+ details = CourseDetails.fetch(self.course_location)
+ 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.
+ self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
+ self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
+ self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
+ self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
+ self.assertEqual(jsondetails['overview'], "", "overview somehow initialized")
+ self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
+ self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
+
+ def test_update_and_fetch(self):
+ ## NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
+ jsondetails = CourseDetails.fetch(self.course_location)
+ jsondetails.syllabus = "bar"
+ self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
+ jsondetails.syllabus, "After set syllabus")
+ jsondetails.overview = "Overview"
+ self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).overview,
+ jsondetails.overview, "After set overview")
+ jsondetails.intro_video = "intro_video"
+ self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
+ jsondetails.intro_video, "After set intro_video")
+ jsondetails.effort = "effort"
+ self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).effort,
+ jsondetails.effort, "After set effort")
+
+class CourseDetailsViewTest(TestCase):
+ def setUp(self):
+ uname = 'testuser'
+ email = 'test+courses@edx.org'
+ password = 'foo'
+
+ # Create the use so we can log them in.
+ self.user = User.objects.create_user(uname, email, password)
+
+ # Note that we do not actually need to do anything
+ # for registration if we directly mark them active.
+ self.user.is_active = True
+ # Staff has access to view all courses
+ self.user.is_staff = True
+ self.user.save()
+
+ # Flush and initialize the module store
+ # It needs the templates because it creates new records
+ # by cloning from the template.
+ # Note that if your test module gets in some weird state
+ # (though it shouldn't), do this manually
+ # from the bash shell to drop it:
+ # $ mongo test_xmodule --eval "db.dropDatabase()"
+ xmodule.modulestore.django._MODULESTORES = {}
+ xmodule.modulestore.django.modulestore().collection.drop()
+ xmodule.templates.update_templates()
+
+ self.client = Client()
+ self.client.login(username=uname, password=password)
+
+ self.course_data = {
+ 'template': 'i4x://edx/templates/course/Empty',
+ 'org': 'MITx',
+ 'number': '999',
+ 'display_name': 'Robot Super Course',
+ }
+ self.course_location = Location('i4x', 'MITx', '999', 'course', 'Robot_Super_Course')
+ self.create_course()
+
+ def tearDown(self):
+ xmodule.modulestore.django._MODULESTORES = {}
+ xmodule.modulestore.django.modulestore().collection.drop()
+
+ def create_course(self):
+ """Create new course"""
+ self.client.post(reverse('create_new_course'), self.course_data)
+
+ def alter_field(self, url, details, field, val):
+ setattr(details, field, val)
+# jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
+ resp = self.client.post(url, details)
+ self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + val)
+
+ def test_update_and_fetch(self):
+ details = CourseDetails.fetch(self.course_location)
+
+ resp = self.client.get(reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
+ 'name' : self.course_location.name }))
+ self.assertContains(resp, '
Course Details', status_code=200, html=True)
+
+ # resp s/b json from here on
+ url = reverse('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)
+ self.compare_details_with_encoding(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)
+# self.alter_field(url, details, 'end_date', time.time() * 1000 + 60 * 60 * 24 * 100)
+# self.alter_field(url, details, 'enrollment_start', time.time() * 1000)
+#
+# self.alter_field(url, details, 'enrollment_end', time.time() * 1000 + 60 * 60 * 24 * 8)
+# self.alter_field(url, details, 'syllabus', "bar")
+# self.alter_field(url, details, 'overview', "Overview")
+# self.alter_field(url, details, 'intro_video', "intro_video")
+# self.alter_field(url, details, 'effort', "effort")
+
+ def compare_details_with_encoding(self, encoded, details, context):
+ self.compare_date_fields(details, encoded, context, 'start_date')
+ self.compare_date_fields(details, encoded, context, 'end_date')
+ self.compare_date_fields(details, encoded, context, 'enrollment_start')
+ self.compare_date_fields(details, encoded, context, 'enrollment_end')
+ self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
+ self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
+ self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
+
+ def compare_date_fields(self, details, encoded, context, field):
+ if details[field] is not None:
+ if field in encoded and encoded[field] is not None:
+ self.assertEqual(encoded[field] / 1000, calendar.timegm(details[field]), "dates not == at " + context)
+ else:
+ self.fail(field + " missing from encoded but in details at " + context)
+ elif field in encoded and encoded[field] is not None:
+ self.fail(field + " included in encoding but missing from details at " + context)
+
\ No newline at end of file
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 508236a1e9..62c46cc9d4 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -3,6 +3,19 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
+DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
+
+def get_modulestore(location):
+ """
+ Returns the correct modulestore to use for modifying the specified location
+ """
+ if not isinstance(location, Location):
+ location = Location(location)
+
+ if location.category in DIRECT_ONLY_CATEGORIES:
+ return modulestore('direct')
+ else:
+ return modulestore()
def get_course_location_for_item(location):
'''
diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 938dbc8285..d2f19802af 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -50,28 +50,22 @@ from contentstore.course_info_model import get_course_updates,\
from cache_toolbox.core import del_cached_content
from xmodule.timeparse import stringify_time
from contentstore.module_info_model import get_module_info, set_module_info
+from cms.djangoapps.models.settings.course_details import CourseDetails,\
+ CourseSettingsEncoder
+from cms.djangoapps.models.settings.course_grading import CourseGradingModel
+from cms.djangoapps.contentstore.utils import get_modulestore
+
+# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
-DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
-
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
-def _modulestore(location):
- """
- Returns the correct modulestore to use for modifying the specified location
- """
- if location.category in DIRECT_ONLY_CATEGORIES:
- return modulestore('direct')
- else:
- return modulestore()
-
-
# ==== Public views ==================================================
@ensure_csrf_cookie
@@ -499,7 +493,7 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
module,
"xmodule_display.html",
)
-
+
module.get_html = replace_static_urls(
module.get_html,
module.metadata.get('data_dir', module.location.course),
@@ -548,7 +542,7 @@ def delete_item(request):
item = modulestore().get_item(item_location)
- store = _modulestore(item_loc)
+ store = get_modulestore(item_loc)
# @TODO: this probably leaves draft items dangling. My preferance would be for the semantic to be
@@ -579,7 +573,7 @@ def save_item(request):
if not has_access(request.user, item_location):
raise PermissionDenied()
- store = _modulestore(Location(item_location));
+ store = get_modulestore(Location(item_location));
if request.POST.get('data') is not None:
data = request.POST['data']
@@ -669,8 +663,6 @@ def unpublish_unit(request):
return HttpResponse()
-
-
@login_required
@expect_json
def clone_item(request):
@@ -682,10 +674,10 @@ def clone_item(request):
if not has_access(request.user, parent_location):
raise PermissionDenied()
- parent = _modulestore(template).get_item(parent_location)
+ parent = get_modulestore(template).get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
- new_item = _modulestore(template).clone_item(template, dest_location)
+ new_item = get_modulestore(template).clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
@@ -694,10 +686,10 @@ def clone_item(request):
if display_name is not None:
new_item.metadata['display_name'] = display_name
- _modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
+ get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
if new_item.location.category not in DETACHED_CATEGORIES:
- _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
+ get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
@@ -979,11 +971,86 @@ def module_info(request, module_location):
raise PermissionDenied()
if real_method == 'GET':
- return HttpResponse(json.dumps(get_module_info(_modulestore(location), location)), mimetype="application/json")
+ return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location)), mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
- return HttpResponse(json.dumps(set_module_info(_modulestore(location), location, request.POST)), mimetype="application/json")
+ return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
else:
- raise Http400
+ return HttpResponseBadRequest
+
+@login_required
+@ensure_csrf_cookie
+def get_course_settings(request, org, course, name):
+ """
+ Send models and views as well as html for editing the course settings to the client.
+
+ org, course, name: Attributes of the Location for the item to edit
+ """
+ location = ['i4x', org, course, 'course', name]
+
+ # check that logged in user has permissions to this item
+ if not has_access(request.user, location):
+ raise PermissionDenied()
+
+ course_module = modulestore().get_item(location)
+ course_details = CourseDetails.fetch(location)
+
+ return render_to_response('settings.html', {
+ 'active_tab': 'settings-tab',
+ 'context_course': course_module,
+ 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder)
+ })
+
+@expect_json
+@login_required
+@ensure_csrf_cookie
+def course_settings_updates(request, org, course, name, section):
+ """
+ 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
+ section: one of details, faculty, grading, problems, discussions
+ """
+ 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=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)
+ return HttpResponse()
+ 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")
@login_required
diff --git a/cms/djangoapps/models/__init__.py b/cms/djangoapps/models/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cms/djangoapps/models/settings/__init__.py b/cms/djangoapps/models/settings/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
new file mode 100644
index 0000000000..90f49a7b32
--- /dev/null
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -0,0 +1,146 @@
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore import Location
+from xmodule.modulestore.exceptions import ItemNotFoundError
+import json
+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):
+ self.course_location = location # a Location obj
+ self.start_date = None # 'start'
+ self.end_date = None # 'end'
+ self.enrollment_start = None
+ self.enrollment_end = None
+ self.syllabus = None # a pdf file asset
+ self.overview = "" # html to render as the overview
+ self.intro_video = None # a video pointer
+ self.effort = None # int hours/week
+
+ @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)
+
+ course = cls(course_location)
+
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ course.start_date = descriptor.start
+ course.end_date = descriptor.end
+ course.enrollment_start = descriptor.enrollment_start
+ course.enrollment_end = descriptor.enrollment_end
+
+ temploc = course_location._replace(category='about', name='syllabus')
+ try:
+ course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data']
+ except ItemNotFoundError:
+ pass
+
+ temploc = temploc._replace(name='overview')
+ try:
+ course.overview = get_modulestore(temploc).get_item(temploc).definition['data']
+ except ItemNotFoundError:
+ pass
+
+ temploc = temploc._replace(name='effort')
+ try:
+ course.effort = get_modulestore(temploc).get_item(temploc).definition['data']
+ except ItemNotFoundError:
+ pass
+
+ temploc = temploc._replace(name='video')
+ try:
+ course.intro_video = get_modulestore(temploc).get_item(temploc).definition['data']
+ except ItemNotFoundError:
+ pass
+
+ return course
+
+ @classmethod
+ def update_from_json(cls, jsondict):
+ """
+ Decode the json into CourseDetails and save any changed attrs to the db
+ """
+ ## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
+ course_location = jsondict['course_location']
+ ## Will probably want to cache the inflight courses because every blur generates an update
+ descriptor = get_modulestore(course_location).get_item(course_location)
+
+ dirty = False
+
+ ## ??? Will this comparison work?
+ if 'start_date' in jsondict:
+ converted = jsdate_to_time(jsondict['start_date'])
+ else:
+ converted = None
+ if converted != descriptor.start:
+ dirty = True
+ descriptor.start = converted
+
+ if 'end_date' in jsondict:
+ converted = jsdate_to_time(jsondict['end_date'])
+ else:
+ converted = None
+
+ if converted != descriptor.end:
+ dirty = True
+ descriptor.end = converted
+
+ if 'enrollment_start' in jsondict:
+ converted = jsdate_to_time(jsondict['enrollment_start'])
+ else:
+ converted = None
+
+ if converted != descriptor.enrollment_start:
+ dirty = True
+ descriptor.enrollment_start = converted
+
+ if 'enrollment_end' in jsondict:
+ converted = jsdate_to_time(jsondict['enrollment_end'])
+ else:
+ converted = None
+
+ if converted != descriptor.enrollment_end:
+ dirty = True
+ descriptor.enrollment_end = converted
+
+ if dirty:
+ get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+
+ # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
+ # to make faster, could compare against db or could have client send over a list of which fields changed.
+ temploc = Location(course_location)._replace(category='about', name='syllabus')
+ get_modulestore(temploc).update_item(temploc, jsondict['syllabus'])
+
+ temploc = temploc._replace(name='overview')
+ get_modulestore(temploc).update_item(temploc, jsondict['overview'])
+
+ temploc = temploc._replace(name='effort')
+ get_modulestore(temploc).update_item(temploc, jsondict['effort'])
+
+ temploc = temploc._replace(name='video')
+ get_modulestore(temploc).update_item(temploc, jsondict['intro_video'])
+
+
+ # 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)
+
+# 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) or isinstance(obj, course_grading.CourseGradingModel):
+ return obj.__dict__
+ elif isinstance(obj, Location):
+ return obj.dict()
+ elif isinstance(obj, time.struct_time):
+ return time_to_date(obj)
+ else:
+ return JSONEncoder.default(self, obj)
diff --git a/cms/djangoapps/models/settings/course_faculty.py b/cms/djangoapps/models/settings/course_faculty.py
new file mode 100644
index 0000000000..c1812614ec
--- /dev/null
+++ b/cms/djangoapps/models/settings/course_faculty.py
@@ -0,0 +1,22 @@
+from xmodule.modulestore import Location
+class CourseFaculty:
+ def __init__(self, location):
+ if not isinstance(location, Location):
+ location = Location(location)
+ # course_location is used so that updates know where to get the relevant data
+ self.course_location = location
+ self.first_name = ""
+ self.last_name = ""
+ self.photo = None
+ self.bio = ""
+
+
+ @classmethod
+ def fetch(cls, course_location):
+ """
+ Fetch a list of faculty for the course
+ """
+ if not isinstance(course_location, Location):
+ course_location = Location(course_location)
+
+ # Must always have at least one faculty member (possibly empty)
\ No newline at end of file
diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py
new file mode 100644
index 0000000000..fe10e651e3
--- /dev/null
+++ b/cms/djangoapps/models/settings/course_grading.py
@@ -0,0 +1,235 @@
+from xmodule.modulestore import Location
+from contentstore.utils import get_modulestore
+import datetime
+import re
+from 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?
+
+ index = int(index)
+ 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 = int(grader.get('id', len(descriptor.raw_grader)))
+ 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 CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
+
+ @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. Incoming dict is {hours: h, minutes: m} possibly as a
+ grace_period entry in an enclosing dict.
+ """
+ if not isinstance(course_location, Location):
+ course_location = Location(course_location)
+
+ if 'grace_period' in graceperiodjson:
+ graceperiodjson = graceperiodjson['grace_period']
+
+ grace_rep = " ".join(["%s %s" % (value, key) for (key, value) in graceperiodjson.iteritems()])
+
+ descriptor = get_modulestore(course_location).get_item(course_location)
+ descriptor.metadata['graceperiod'] = grace_rep
+ get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+
+ @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)
+ index = int(index)
+ if index < len(descriptor.raw_grader):
+ del descriptor.raw_grader[index]
+ # force propagation to defintion
+ descriptor.raw_grader = descriptor.raw_grader
+ 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)}
+ return parsedgrace
+ else: return None
+
+ @staticmethod
+ def parse_grader(json_grader):
+ # manual to clear out kruft
+ result = {
+ "type" : json_grader["type"],
+ "min_count" : int(json_grader.get('min_count', 0)),
+ "drop_count" : int(json_grader.get('drop_count', 0)),
+ "short_label" : json_grader.get('short_label', None),
+ "weight" : float(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
diff --git a/cms/static/client_templates/course_grade_policy.html b/cms/static/client_templates/course_grade_policy.html
new file mode 100644
index 0000000000..97b0c20eb8
--- /dev/null
+++ b/cms/static/client_templates/course_grade_policy.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+ e.g. Homework, Labs, Midterm Exams, Final Exam
+
+
+
+
+
+
+
+
+
+
+ e.g. HW, Midterm, Final
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ total exercises assigned
+
+
+
+
+
+
+
+
+
+
+ total exercises that won't be graded
+
+
+
Delete Assignment Type
+
diff --git a/cms/static/js/models/course_relative.js b/cms/static/js/models/course_relative.js
new file mode 100644
index 0000000000..c33339ff48
--- /dev/null
+++ b/cms/static/js/models/course_relative.js
@@ -0,0 +1,59 @@
+CMS.Models.Location = Backbone.Model.extend({
+ defaults: {
+ tag: "",
+ org: "",
+ course: "",
+ category: "",
+ name: ""
+ },
+ toUrl: function(overrides) {
+ return
+ (overrides['tag'] ? overrides['tag'] : this.get('tag')) + "://" +
+ (overrides['org'] ? overrides['org'] : this.get('org')) + "/" +
+ (overrides['course'] ? overrides['course'] : this.get('course')) + "/" +
+ (overrides['category'] ? overrides['category'] : this.get('category')) + "/" +
+ (overrides['name'] ? overrides['name'] : this.get('name')) + "/";
+ },
+ _tagPattern : /[^:]+/g,
+ _fieldPattern : new RegExp('[^/]+','g'),
+
+ parse: function(payload) {
+ if (_.isArray(payload)) {
+ return {
+ tag: payload[0],
+ org: payload[1],
+ course: payload[2],
+ category: payload[3],
+ name: payload[4]
+ }
+ }
+ else if (_.isString(payload)) {
+ var foundTag = this._tagPattern.exec(payload);
+ if (foundTag) {
+ this._fieldPattern.lastIndex = this._tagPattern.lastIndex + 1; // skip over the colon
+ return {
+ tag: foundTag[0],
+ org: this._fieldPattern.exec(payload)[0],
+ course: this._fieldPattern.exec(payload)[0],
+ category: this._fieldPattern.exec(payload)[0],
+ name: this._fieldPattern.exec(payload)[0]
+ }
+ }
+ else return null;
+ }
+ else {
+ return payload;
+ }
+ }
+});
+
+CMS.Models.CourseRelative = Backbone.Model.extend({
+ defaults: {
+ course_location : null, // must never be null, but here to doc the field
+ idx : null // the index making it unique in the containing collection (no implied sort)
+ }
+});
+
+CMS.Models.CourseRelativeCollection = Backbone.Collection.extend({
+ model : CMS.Models.CourseRelative
+});
\ No newline at end of file
diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js
new file mode 100644
index 0000000000..222d2fd11e
--- /dev/null
+++ b/cms/static/js/models/settings/course_details.js
@@ -0,0 +1,166 @@
+if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
+
+CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
+ defaults: {
+ location : null, // the course's Location model, required
+ start_date: null, // maps to 'start'
+ end_date: null, // maps to 'end'
+ enrollment_start: null,
+ enrollment_end: null,
+ syllabus: null,
+ overview: "",
+ intro_video: null,
+ effort: null // an int or null
+ },
+
+ // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
+ parse: function(attributes) {
+ if (attributes['course_location']) {
+ attributes.location = new CMS.Models.Location(attributes.course_location, {parse:true});
+ }
+ if (attributes['start_date']) {
+ attributes.start_date = new Date(attributes.start_date);
+ }
+ if (attributes['end_date']) {
+ attributes.end_date = new Date(attributes.end_date);
+ }
+ if (attributes['enrollment_start']) {
+ attributes.enrollment_start = new Date(attributes.enrollment_start);
+ }
+ if (attributes['enrollment_end']) {
+ attributes.enrollment_end = new Date(attributes.enrollment_end);
+ }
+ return attributes;
+ },
+
+ validate: function(newattrs) {
+ // Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs
+ // A bit funny in that the video key validation is asynchronous; so, it won't stop the validation.
+ var errors = {};
+ if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) {
+ errors.end_date = "The course end date cannot be before the course start date.";
+ }
+ if (newattrs.start_date && newattrs.enrollment_start && newattrs.start_date < newattrs.enrollment_start) {
+ errors.enrollment_start = "The course start date cannot be before the enrollment start date.";
+ }
+ if (newattrs.enrollment_start && newattrs.enrollment_end && newattrs.enrollment_start >= newattrs.enrollment_end) {
+ errors.enrollment_end = "The enrollment start date cannot be after the enrollment end date.";
+ }
+ if (newattrs.end_date && newattrs.enrollment_end && newattrs.end_date < newattrs.enrollment_end) {
+ errors.enrollment_end = "The enrollment end date cannot be after the course end date.";
+ }
+ if (newattrs.intro_video && newattrs.intro_video != this.get('intro_video')) {
+ var videos = this.parse_videosource(newattrs.intro_video);
+ var vid_errors = new Array();
+ var cachethis = this;
+ for (var i=0; i or just the "speed:key, *" string
+ // returns the videosource for the preview which iss the key whose speed is closest to 1
+ if (newsource == null) this.save({'intro_video': null});
+ // TODO remove all whitespace w/in string
+ else if (this._getNextMatch(this._videoprefix, newsource, 0)) this.save('intro_video', newsource);
+ else this.save('intro_video', '');
+
+ return this.videosourceSample();
+ }
+});
diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js
new file mode 100644
index 0000000000..3c5f18b1cf
--- /dev/null
+++ b/cms/static/js/models/settings/course_grading_policy.js
@@ -0,0 +1,131 @@
+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 { hours: n, minutes: m, ...}
+ },
+ parse: function(attributes) {
+ if (attributes['course_location']) {
+ attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true});
+ }
+ if (attributes['graders']) {
+ var graderCollection;
+ if (this.has('graders')) {
+ graderCollection = this.get('graders');
+ graderCollection.reset(attributes.graders);
+ }
+ else {
+ graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders);
+ graderCollection.course_location = attributes['course_location'] || this.get('course_location');
+ }
+ attributes.graders = graderCollection;
+ }
+ return attributes;
+ },
+ url : function() {
+ var location = this.get('course_location');
+ return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading';
+ },
+ gracePeriodToDate : function() {
+ var newDate = new Date();
+ if (this.has('grace_period') && this.get('grace_period')['hours'])
+ newDate.setHours(this.get('grace_period')['hours']);
+ else newDate.setHours(0);
+ if (this.has('grace_period') && this.get('grace_period')['minutes'])
+ newDate.setMinutes(this.get('grace_period')['minutes']);
+ else newDate.setMinutes(0);
+ if (this.has('grace_period') && this.get('grace_period')['seconds'])
+ newDate.setSeconds(this.get('grace_period')['seconds']);
+ else newDate.setSeconds(0);
+
+ return newDate;
+ },
+ dateToGracePeriod : function(date) {
+ return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() };
+ }
+});
+
+CMS.Models.Settings.CourseGrader = Backbone.Model.extend({
+ defaults: {
+ "type" : "", // must be unique w/in collection (ie. w/in course)
+ "min_count" : 1,
+ "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 (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
+ errors.weight = "Please enter an integer between 0 and 100.";
+ }
+ else {
+ attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
+ if (this.collection && attrs.weight > 0) {
+ // FIXME b/c saves don't update the models if validation fails, we should
+ // either revert the field value to the one in the model and make them make room
+ // or figure out a wholistic way to balance the vals across the whole
+// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
+// errors.weight = "The weights cannot add to more than 100.";
+ }
+ }}
+ if (attrs['min_count']) {
+ if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
+ errors.min_count = "Please enter an integer.";
+ }
+ else attrs.min_count = parseInt(attrs.min_count);
+ }
+ if (attrs['drop_count']) {
+ if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
+ errors.drop_count = "Please enter an integer.";
+ }
+ else attrs.drop_count = parseInt(attrs.drop_count);
+ }
+ if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) {
+ errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned.";
+ }
+ if (!_.isEmpty(errors)) return errors;
+ }
+});
+
+CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({
+ model : CMS.Models.Settings.CourseGrader,
+ course_location : null, // must be set to a Location object
+ url : function() {
+ return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/';
+ },
+ sumWeights : function() {
+ return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
+ }
+});
\ No newline at end of file
diff --git a/cms/static/js/models/settings/course_settings.js b/cms/static/js/models/settings/course_settings.js
new file mode 100644
index 0000000000..9d09e4bdc5
--- /dev/null
+++ b/cms/static/js/models/settings/course_settings.js
@@ -0,0 +1,43 @@
+if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
+CMS.Models.Settings.CourseSettings = Backbone.Model.extend({
+ // a container for the models representing the n possible tabbed states
+ defaults: {
+ courseLocation: null,
+ // NOTE: keep these sync'd w/ the data-section names in settings-page-menu
+ details: null,
+ faculty: null,
+ grading: null,
+ problems: null,
+ discussions: null
+ },
+
+ retrieve: function(submodel, callback) {
+ if (this.get(submodel)) callback();
+ 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;
+ }
+ }
+ }
+})
\ No newline at end of file
diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js
index 03104566ec..3748ac39b4 100644
--- a/cms/static/js/template_loader.js
+++ b/cms/static/js/template_loader.js
@@ -5,7 +5,7 @@
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
- templateVersion: "0.0.8",
+ templateVersion: "0.0.11",
templates: {},
loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) {
diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js
new file mode 100644
index 0000000000..db347bae82
--- /dev/null
+++ b/cms/static/js/views/settings/main_settings_view.js
@@ -0,0 +1,621 @@
+if (!CMS.Views['Settings']) CMS.Views.Settings = new Object();
+
+// TODO move to common place
+CMS.Views.ValidatingView = Backbone.View.extend({
+ // Intended as an abstract class which catches validation errors on the model and
+ // decorates the fields. Needs wiring per class, but this initialization shows how
+ // either have your init call this one or copy the contents
+ initialize : function() {
+ this.model.on('error', this.handleValidationError, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ },
+
+ errorTemplate : _.template('<%= message %>'),
+
+ events : {
+ "blur input" : "clearValidationErrors",
+ "blur textarea" : "clearValidationErrors"
+ },
+ fieldToSelectorMap : {
+ // Your subclass must populate this w/ all of the model keys and dom selectors
+ // which may be the subjects of validation errors
+ },
+ _cacheValidationErrors : [],
+ handleValidationError : function(model, error) {
+ // error is object w/ fields and error strings
+ for (var field in error) {
+ var ele = this.$el.find('#' + this.fieldToSelectorMap[field]);
+ this._cacheValidationErrors.push(ele);
+ if ($(ele).is('div')) {
+ // put error on the contained inputs
+ $(ele).find('input, textarea').addClass('error');
+ }
+ else $(ele).addClass('error');
+ $(ele).parent().append(this.errorTemplate({message : error[field]}));
+ }
+ },
+
+ clearValidationErrors : function() {
+ // error is object w/ fields and error strings
+ while (this._cacheValidationErrors.length > 0) {
+ var ele = this._cacheValidationErrors.pop();
+ if ($(ele).is('div')) {
+ // put error on the contained inputs
+ $(ele).find('input, textarea').removeClass('error');
+ }
+ else $(ele).removeClass('error');
+ $(ele).nextAll('.message-error').remove();
+ }
+ }
+})
+
+CMS.Views.Settings.Main = Backbone.View.extend({
+ // Model class is CMS.Models.Settings.CourseSettings
+ // allow navigation between the tabs
+ events: {
+ 'click .settings-page-menu a': "showSettingsTab",
+ 'mouseover #timezone' : "updateTime"
+ },
+
+ currentTab: null,
+ subviews: {}, // indexed by tab name
+
+ initialize: function() {
+ // load templates
+ this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section');
+ // create the initial subview
+ this.subviews[this.currentTab] = this.createSubview();
+
+ // fill in fields
+ this.$el.find("#course-name").val(this.model.get('courseLocation').get('name'));
+ this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org'));
+ this.$el.find("#course-number").val(this.model.get('courseLocation').get('course'));
+ this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
+ this.$el.find(":input, textarea").focus(function() {
+ $("label[for='" + this.id + "']").addClass("is-focused");
+ }).blur(function() {
+ $("label").removeClass("is-focused");
+ });
+ this.render();
+ },
+
+ render: function() {
+
+ // create any necessary subviews and put them onto the page
+ if (!this.model.has(this.currentTab)) {
+ // TODO disable screen until fetch completes?
+ var cachethis = this;
+ this.model.retrieve(this.currentTab, function() {
+ cachethis.subviews[cachethis.currentTab] = cachethis.createSubview();
+ cachethis.subviews[cachethis.currentTab].render();
+ });
+ }
+ else this.subviews[this.currentTab].render();
+
+ var dateIntrospect = new Date();
+ this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")");
+
+ return this;
+ },
+
+ createSubview: function() {
+ switch (this.currentTab) {
+ case 'details':
+ return new CMS.Views.Settings.Details({
+ el: this.$el.find('.settings-' + this.currentTab),
+ model: this.model.get(this.currentTab)
+ });
+ break;
+ case 'faculty':
+ break;
+ case 'grading':
+ return new CMS.Views.Settings.Grading({
+ el: this.$el.find('.settings-' + this.currentTab),
+ model: this.model.get(this.currentTab)
+ });
+ break;
+ case 'problems':
+ break;
+ case 'discussions':
+ break;
+ }
+ },
+
+ updateTime : function(e) {
+ var now = new Date();
+ var hours = now.getHours();
+ var minutes = now.getMinutes();
+ $(e.currentTarget).attr('title', (hours % 12 == 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "")
+ + now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)");
+ },
+
+ showSettingsTab: function(e) {
+ this.currentTab = $(e.target).attr('data-section');
+ $('.settings-page-section > section').hide();
+ $('.settings-' + this.currentTab).show();
+ $('.settings-page-menu .is-shown').removeClass('is-shown');
+ $(e.target).addClass('is-shown');
+ // fetch model for the tab if not loaded already
+ this.render();
+ }
+
+});
+
+CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
+ // Model class is CMS.Models.Settings.CourseDetails
+ events : {
+ "blur input" : "updateModel",
+ "blur textarea" : "updateModel",
+ 'click .remove-course-syllabus' : "removeSyllabus",
+ 'click .new-course-syllabus' : 'assetSyllabus',
+ 'click .remove-course-introduction-video' : "removeVideo",
+ 'focus #course-overview' : "codeMirrorize"
+ },
+ initialize : function() {
+ // TODO move the html frag to a loaded asset
+ this.fileAnchorTemplate = _.template(' 📄<%= filename %>');
+ this.model.on('error', this.handleValidationError, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ },
+
+ render: function() {
+ this.setupDatePicker('start_date')
+ this.setupDatePicker('end_date')
+ this.setupDatePicker('enrollment_start')
+ this.setupDatePicker('enrollment_end')
+
+ if (this.model.has('syllabus')) {
+ this.$el.find(this.fieldToSelectorMap['syllabus']).html(
+ this.fileAnchorTemplate({
+ fullpath : this.model.get('syllabus'),
+ filename: 'syllabus'}));
+ this.$el.find('.remove-course-syllabus').show();
+ }
+ else {
+ this.$el.find(this.fieldToSelectorMap['syllabus']).html("");
+ this.$el.find('.remove-course-syllabus').hide();
+ }
+
+ this.$el.find(this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
+
+ this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample());
+ if (this.model.has('intro_video')) {
+ this.$el.find('.remove-course-introduction-video').show();
+ this.$el.find(this.fieldToSelectorMap['intro_video']).val(this.model.getVideoSource());
+ }
+ else this.$el.find('.remove-course-introduction-video').hide();
+
+ this.$el.find(this.fieldToSelectorMap['effort']).val(this.model.get('effort'));
+
+ return this;
+ },
+ fieldToSelectorMap : {
+ 'start_date' : "#course-start",
+ 'end_date' : '#course-end',
+ 'enrollment_start' : '#enrollment-start',
+ 'enrollment_end' : '#enrollment-end',
+ 'syllabus' : '.current-course-syllabus .doc-filename',
+ 'overview' : '#course-overview',
+ 'intro_video' : '#course-introduction-video',
+ 'effort' : "#course-effort"
+ },
+
+ setupDatePicker : function(fieldName) {
+ var cacheModel = this.model;
+ var div = this.$el.find(this.fieldToSelectorMap[fieldName]);
+ var datefield = $(div).find(".date");
+ var timefield = $(div).find(".time");
+ var cachethis = this;
+ var savefield = function() {
+ cachethis.clearValidationErrors();
+ cacheModel.save(fieldName, new Date(datefield.datepicker('getDate').getTime()
+ + timefield.timepicker("getSecondsFromMidnight") * 1000));
+ };
+
+ // instrument as date and time pickers
+ timefield.timepicker();
+
+ // FIXME being called 2x on each change. Was trapping datepicker onSelect b4 but change to datepair broke that
+ datefield.datepicker({ onSelect : savefield });
+ timefield.on('changeTime', savefield);
+
+ datefield.datepicker('setDate', this.model.get(fieldName));
+ if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
+ },
+
+ updateModel: function(event) {
+ switch (event.currentTarget.id) {
+ case 'course-start-date': // handled via onSelect method
+ case 'course-end-date':
+ case 'course-enrollment-start-date':
+ case 'course-enrollment-end-date':
+ break;
+
+ case 'course-overview':
+ this.clearValidationErrors();
+ this.model.save('overview', $(event.currentTarget).val());
+ break;
+
+ case 'course-effort':
+ this.clearValidationErrors();
+ this.model.save('effort', $(event.currentTarget).val());
+ break;
+ case 'course-introduction-video':
+ this.clearValidationErrors();
+ var previewsource = this.model.save_videosource($(event.currentTarget).val());
+ this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource);
+ break
+
+ default:
+ break;
+ }
+
+ },
+
+ removeSyllabus: function() {
+ if (this.model.has('syllabus')) this.model.save({'syllabus': null});
+ },
+
+ assetSyllabus : function() {
+ // TODO implement
+ },
+
+ removeVideo: function() {
+ if (this.model.has('intro_video')) {
+ this.model.save_videosource(null);
+ this.$el.find(".current-course-introduction-video iframe").attr("src", "");
+ this.$el.find(this.fieldToSelectorMap['intro_video']).val("");
+ }
+ },
+ codeMirrors : {},
+ codeMirrorize : function(e) {
+ if (!this.codeMirrors[e.currentTarget.id]) {
+ var cachethis = this;
+ var field = this.selectorToField['#' + e.currentTarget.id];
+ this.codeMirrors[e.currentTarget.id] = CodeMirror.fromTextArea(e.currentTarget, {
+ mode: "text/html", lineNumbers: true, lineWrapping: true,
+ onBlur : function(mirror) {
+ mirror.save();
+ cachethis.clearValidationErrors();
+ cachethis.model.save(field, mirror.getValue());
+ }
+ });
+ }
+ }
+
+});
+
+CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
+ // Model class is CMS.Models.Settings.CourseGradingPolicy
+ events : {
+ "blur input" : "updateModel",
+ "blur textarea" : "updateModel",
+ "blur span[contenteditable=true]" : "updateDesignation",
+ "click .settings-extra header" : "showSettingsExtras",
+ "click .new-grade-button" : "addNewGrade",
+ "click .remove-button" : "removeGrade",
+ "click .add-grading-data" : "addAssignmentType"
+ },
+ initialize : function() {
+ // load template for grading view
+ var self = this;
+ this.gradeCutoffTemplate = _.template('' +
+ '<%= descriptor %>' +
+ '' +
+ '<% if (removable) {%>remove<% ;} %>' +
+ '');
+
+ // Instrument grading scale
+ // convert cutoffs to inversely ordered list
+ var modelCutoffs = this.model.get('grade_cutoffs');
+ for (cutoff in modelCutoffs) {
+ this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)});
+ }
+ this.descendingCutoffs = _.sortBy(this.descendingCutoffs,
+ function (gradeEle) { return -gradeEle['cutoff']; });
+
+ // Instrument grace period
+ this.$el.find('#course-grading-graceperiod').timepicker();
+
+ // instantiates an editor template for each update in the collection
+ // Because this calls render, put it after everything which render may depend upon to prevent race condition.
+ window.templateLoader.loadRemoteTemplate("course_info_update",
+ "/static/client_templates/course_grade_policy.html",
+ function (raw_template) {
+ self.template = _.template(raw_template);
+ self.render();
+ }
+ );
+ this.model.on('error', this.handleValidationError, this);
+ this.model.get('graders').on('remove', this.render, this);
+ this.model.get('graders').on('add', this.render, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ },
+
+ render: function() {
+ // prevent bootstrap race condition by event dispatch
+ if (!this.template) return;
+
+ // Create and render the grading type subs
+ var self = this;
+ var gradelist = this.$el.find('.course-grading-assignment-list');
+ // Undo the double invocation error. At some point, fix the double invocation
+ $(gradelist).empty();
+ 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
+ if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
+
+ return this;
+ },
+ addAssignmentType : function() {
+ this.model.get('graders').push({});
+ },
+ fieldToSelectorMap : {
+ 'grace_period' : 'course-grading-graceperiod'
+ },
+ updateModel : function(event) {
+ if (!this.selectorToField[event.currentTarget.id]) return;
+
+ switch (this.selectorToField[event.currentTarget.id]) {
+ case 'grace_period':
+ this.clearValidationErrors();
+ this.model.save('grace_period', this.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime')));
+ break;
+
+ default:
+ this.clearValidationErrors();
+ 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",
+ 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",
+ "click .remove-grading-data" : "deleteModel"
+ },
+ initialize : function() {
+ this.model.on('error', this.handleValidationError, this);
+ this.selectorToField = _.invert(this.fieldToSelectorMap);
+ this.render();
+ },
+
+ render: function() {
+ return this;
+ },
+ fieldToSelectorMap : {
+ 'type' : 'course-grading-assignment-name',
+ 'short_label' : 'course-grading-assignment-shortname',
+ 'min_count' : 'course-grading-assignment-totalassignments',
+ 'drop_count' : 'course-grading-assignment-droppable',
+ 'weight' : 'course-grading-assignment-gradeweight'
+ },
+ updateModel : function(event) {
+ 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.clearValidationErrors();
+ this.model.save(this.selectorToField[event.currentTarget.id], $(event.currentTarget).val());
+ break;
+
+ }
+ },
+ deleteModel : function() {
+ this.model.destroy();
+ }
+
+});
\ No newline at end of file
diff --git a/cms/static/sass/_cms_mixins.scss b/cms/static/sass/_cms_mixins.scss
index 3a8f24b5a9..b4db2f096e 100644
--- a/cms/static/sass/_cms_mixins.scss
+++ b/cms/static/sass/_cms_mixins.scss
@@ -55,7 +55,7 @@
background-color: #dfe5eb;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: #778192;
-
+
&:hover {
background-color: #f2f6f9;
color: #778192;
diff --git a/cms/static/sass/_courseware.scss b/cms/static/sass/_courseware.scss
index 23024f74e4..37c9ca9036 100644
--- a/cms/static/sass/_courseware.scss
+++ b/cms/static/sass/_courseware.scss
@@ -146,13 +146,13 @@ input.courseware-unit-search-input {
.save-button {
@include blue-button;
- padding: 10px 20px;
+ padding: 7px 20px 7px;
margin-right: 5px;
}
.cancel-button {
@include white-button;
- padding: 10px 20px;
+ padding: 7px 20px 7px;
}
}
@@ -208,7 +208,7 @@ input.courseware-unit-search-input {
.new-section-name-cancel,
.new-subsection-name-cancel {
@include white-button;
- padding: 6px 20px 8px;
+ padding: 2px 20px 5px;
color: #8891a1 !important;
}
diff --git a/cms/static/sass/_dashboard.scss b/cms/static/sass/_dashboard.scss
index 8821f3736c..8763927bdb 100644
--- a/cms/static/sass/_dashboard.scss
+++ b/cms/static/sass/_dashboard.scss
@@ -89,7 +89,6 @@
.new-course-save {
@include blue-button;
- // padding: ;
}
.new-course-cancel {
diff --git a/cms/static/sass/_settings.scss b/cms/static/sass/_settings.scss
new file mode 100644
index 0000000000..3f1106584a
--- /dev/null
+++ b/cms/static/sass/_settings.scss
@@ -0,0 +1,812 @@
+.settings {
+ .settings-overview {
+ @extend .window;
+ @include clearfix;
+ display: table;
+ width: 100%;
+
+ // layout
+ .sidebar {
+ display: table-cell;
+ float: none;
+ width: 20%;
+ padding: 30px 0 30px 20px;
+ @include border-radius(3px 0 0 3px);
+ background: $lightGrey;
+ }
+
+ .main-column {
+ display: table-cell;
+ float: none;
+ width: 80%;
+ padding: 30px 40px 30px 60px;
+ }
+
+ .settings-page-menu {
+ a {
+ display: block;
+ padding-left: 20px;
+ line-height: 52px;
+
+ &.is-shown {
+ background: #fff;
+ @include border-radius(5px 0 0 5px);
+ }
+ }
+ }
+
+ .settings-page-section {
+ > .alert {
+ display: none;
+
+ &.is-shown {
+ display: block;
+ }
+ }
+
+ > section {
+ display: none;
+ margin-bottom: 40px;
+
+ &.is-shown {
+ display: block;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ > .title {
+ margin-bottom: 30px;
+ font-size: 28px;
+ font-weight: 300;
+ color: $blue;
+ }
+
+ > section {
+ margin-bottom: 100px;
+ @include clearfix;
+
+ header {
+ @include clearfix;
+ border-bottom: 1px solid $mediumGrey;
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+
+ h3 {
+ color: $darkGrey;
+ float: left;
+
+ margin: 0 40px 0 0;
+ text-transform: uppercase;
+ }
+
+ .detail {
+ float: right;
+ margin-top: 3px;
+ color: $mediumGrey;
+ font-size: 13px;
+ }
+ }
+
+ &:last-child {
+ padding-bottom: 0;
+ border-bottom: none;
+ }
+ }
+ }
+ }
+
+ // form basics
+ label, .label {
+ padding: 0;
+ border: none;
+ background: none;
+ font-size: 15px;
+ font-weight: 400;
+
+ &.check-label {
+ display: inline;
+ margin-left: 10px;
+ }
+
+ &.ranges {
+ margin-bottom: 20px;
+ }
+ }
+
+ input, textarea {
+ @include transition(all 1s ease-in-out);
+ @include box-sizing(border-box);
+ font-size: 15px;
+
+ &.long {
+ width: 100%;
+ min-width: 400px;
+ }
+
+ &.tall {
+ height: 200px;
+ }
+
+ &.short {
+ min-width: 100px;
+ width: 25%;
+ }
+
+ &.date {
+ display: block !important;
+ }
+
+ &.time {
+ display: block !important;
+ width: 75px !important;
+ min-width: 75px !important;
+ }
+
+ &:focus {
+ @include linear-gradient(tint($blue, 80%), tint($blue, 90%));
+ border-color: $blue;
+ outline: 0;
+ }
+
+ &:disabled {
+ border-color: $mediumGrey;
+ color: $mediumGrey;
+ background: #fff;
+ }
+ }
+
+ input[type="checkbox"], input[type="radio"] {
+
+ }
+
+ input:disabled + .copy > label, input:disabled + .label {
+ color: $mediumGrey;
+ }
+
+
+ .input-default input, .input-default textarea {
+ color: $mediumGrey;
+ background: $lightGrey;
+ }
+
+ ::-webkit-input-placeholder {
+ color: $mediumGrey;
+ font-size: 13px;
+ }
+ :-moz-placeholder {
+ color: $mediumGrey;
+ font-size: 13px;
+ }
+
+ .tip {
+ color: $mediumGrey;
+ font-size: 13px;
+ }
+
+
+ // form layouts
+ .row {
+ margin-bottom: 30px;
+ padding-bottom: 30px;
+ border-bottom: 1px solid $lightGrey;
+
+ &:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+ }
+
+ // structural labels, not semantic labels per se
+ > label, .label {
+ display: inline-block;
+ vertical-align: top;
+ }
+
+ // tips
+ .tip-inline {
+ display: inline-block;
+ margin-left: 10px;
+ }
+
+ .tip-stacked {
+ display: block;
+ margin-top: 10px;
+ }
+
+ // structural field, not semantic fields per se
+ .field {
+ display: inline-block;
+ width: 100%;
+
+ > input, > textarea, .input {
+ display: inline-block;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ .group {
+ input, textarea {
+ margin-bottom: 5px;
+ }
+
+ .label, label {
+ font-size: 13px;
+ }
+ }
+
+ // multi-field
+ &.multi {
+ display: block;
+ background: tint($lightGrey, 50%);
+ padding: 20px 15px;
+ @include border-radius(4px);
+ @include box-sizing(border-box);
+
+ .group {
+ margin-bottom: 10px;
+ max-width: 175px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ input, .input, textarea {
+
+ }
+
+ .tip-stacked {
+ margin-top: 0;
+ }
+ }
+ }
+
+ // multi stacked
+ &.multi-stacked {
+
+ .group {
+ input, .input, textarea {
+ min-width: 370px;
+ width: 370px;
+ }
+ }
+ }
+
+ // multi-field inline
+ &.multi-inline {
+ @include clearfix;
+
+ .group {
+ float: left;
+ margin-right: 20px;
+
+ &:nth-child(2) {
+ margin-right: 0;
+ }
+
+ .input, input, textarea {
+ width: 100%;
+ }
+ }
+
+ .remove-item {
+ float: right;
+ }
+ }
+ }
+
+ // input-list
+ .input-list {
+
+ .input {
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px dotted $lightGrey;
+
+ &:last-child {
+ border: 0;
+ }
+
+ .row {
+ }
+ }
+ }
+
+ //radio buttons and checkboxes
+ .input-radio {
+ @include clearfix();
+
+ input {
+ display: block;
+ float: left;
+ margin-right: 10px;
+ }
+
+ .copy {
+ position: relative;
+ top: -5px;
+ float: left;
+ width: 350px;
+ }
+
+ label {
+ display: block;
+ margin-bottom: 0;
+ }
+
+ .tip {
+ display: block;
+ margin-top: 0;
+ }
+
+ .message-error {
+
+ }
+ }
+
+ .input-checkbox {
+
+ }
+
+ // enumerated inputs
+ &.enum {
+ }
+ }
+
+ // layout - aligned label/field pairs
+ &.row-col2 {
+
+ > label, .label {
+ width: 200px;
+ }
+
+ .field {
+ width: 400px ! important;
+ }
+
+ &.multi-inline {
+ @include clearfix();
+
+ .group {
+ width: 170px;
+ }
+ }
+ }
+
+ .field-additional {
+ margin-left: 204px;
+ }
+ }
+
+ // editing controls - adding
+ .new-item, .replace-item {
+ clear: both;
+ display: block;
+ margin-top: 10px;
+ padding-bottom: 10px;
+ @include grey-button;
+ @include box-sizing(border-box);
+ }
+
+
+ // editing controls - removing
+ .remove-item {
+ clear: both;
+ display: block;
+ margin-top: 10px;
+ opacity: 0.75;
+ font-size: 13px;
+ text-align: right;
+ @include transition(opacity 0.25s ease-in-out);
+
+
+ &:hover {
+ color: $blue;
+ opacity: 0.99;
+ }
+ }
+
+ // editing controls - preview
+ .input-existing {
+ display: block !important;
+
+ .current {
+ width: 100%;
+ margin: 10px 0;
+ padding: 10px;
+ @include box-sizing(border-box);
+ @include border-radius(5px);
+ font-size: 14px;
+ background: tint($lightGrey, 50%);
+ @include clearfix();
+
+ .doc-filename {
+ display: inline-block;
+ width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .remove-doc-data {
+ display: inline-block;
+ margin-top: 0;
+ width: 150px;
+ }
+ }
+ }
+
+ // specific sections
+ .settings-details {
+
+ }
+
+ .settings-faculty {
+
+ .settings-faculty-members {
+
+ > header {
+ display: none;
+ }
+
+ .field .multi {
+ display: block;
+ margin-bottom: 40px;
+ padding: 20px;
+ background: tint($lightGrey, 50%);
+ @include border-radius(4px);
+ @include box-sizing(border-box);
+ }
+
+ .course-faculty-list-item {
+
+ .row {
+
+ &:nth-child(4) {
+ padding-bottom: 0;
+ border-bottom: none;
+ }
+ }
+
+ .remove-faculty-photo {
+ display: inline-block;
+ }
+ }
+
+ #course-faculty-bio-input {
+ margin-bottom: 0;
+ }
+
+ .new-course-faculty-item {
+ }
+
+ .current-faculty-photo {
+ padding: 0;
+
+ img {
+ display: block;
+ @include box-shadow(0 1px 3px rgba(0,0,0,0.1));
+ padding: 10px;
+ border: 2px solid $mediumGrey;
+ background: #fff;
+ }
+ }
+ }
+ }
+
+ .settings-grading {
+
+ .course-grading-assignment-list-item {
+
+ .row:nth-child(4) {
+ border: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+ }
+ }
+
+ .input-list {
+ .row {
+
+ .input {
+ &:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ }
+ }
+ }
+ }
+ }
+
+ .settings-handouts {
+
+ }
+
+ .settings-problems {
+
+ > section {
+
+ &.is-shown {
+ display: block;
+ }
+ }
+ }
+
+ .settings-discussions {
+
+ .course-discussions-categories-list-item {
+
+ label {
+ display: none;
+ }
+
+ .group {
+ display: inline-block;
+ }
+
+ .remove-item {
+ display: inline-block !important;
+ margin-left: 10px;
+ }
+ }
+
+
+ }
+
+ // states
+ label.is-focused {
+ color: $blue;
+ @include transition(color 1s ease-in-out);
+ }
+
+ // extras/abbreviations
+ // .settings-extras {
+
+ // > header {
+ // cursor: pointer;
+
+ // &.active {
+
+ // }
+ // }
+
+ // > div {
+ // display: none;
+ // @include transition(display 0.25s ease-in-out);
+
+ // &.is-shown {
+ // display: block;
+ // }
+ // }
+ // }
+
+ input.error, textarea.error {
+ border-color: $red;
+ }
+
+ .message-error {
+ display: block;
+ margin-top: 5px;
+ color: $red;
+ font-size: 13px;
+ }
+
+ // misc
+ .divide {
+ display: none;
+ }
+
+ i.ss-icon {
+ position: relative;
+ top: 1px;
+ margin-right: 5px;
+ }
+
+ .well {
+ padding: 20px;
+ background: $lightGrey;
+ border: 1px solid $mediumGrey;
+ @include border-radius(4px);
+ @include box-shadow(0 1px 1px rgba(0,0,0,0.05) inset)
+ }
+ }
+
+
+
+ h3 {
+ margin-bottom: 30px;
+ font-size: 15px;
+ font-weight: 700;
+ color: $blue;
+ }
+
+ .grade-controls {
+ @include clearfix;
+ width: 642px;
+ }
+
+ .new-grade-button {
+ position: relative;
+ float: left;
+ display: block;
+ width: 29px;
+ height: 29px;
+ margin: 10px 20px 0 0;
+ border-radius: 20px;
+ border: 1px solid $darkGrey;
+ @include linear-gradient(top, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0));
+ background-color: #d1dae3;
+ @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
+ color: #6d788b;
+
+ .plus-icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-left: -6px;
+ margin-top: -6px;
+ }
+ }
+
+ .grade-slider {
+ float: left;
+ width: 580px;
+ margin-bottom: 10px;
+
+ .grade-bar {
+ position: relative;
+ width: 100%;
+ height: 50px;
+ background: $lightGrey;
+
+ .increments {
+ position: relative;
+
+ li {
+ position: absolute;
+ top: 52px;
+ width: 30px;
+ margin-left: -15px;
+ font-size: 9px;
+ text-align: center;
+
+ &.increment-0 {
+ left: 0;
+ }
+
+ &.increment-10 {
+ left: 10%;
+ }
+
+ &.increment-20 {
+ left: 20%;
+ }
+
+ &.increment-30 {
+ left: 30%;
+ }
+
+ &.increment-40 {
+ left: 40%;
+ }
+
+ &.increment-50 {
+ left: 50%;
+ }
+
+ &.increment-60 {
+ left: 60%;
+ }
+
+ &.increment-70 {
+ left: 70%;
+ }
+
+ &.increment-80 {
+ left: 80%;
+ }
+
+ &.increment-90 {
+ left: 90%;
+ }
+
+ &.increment-100 {
+ left: 100%;
+ }
+ }
+ }
+
+ .grade-specific-bar {
+ height: 50px;
+ }
+
+ .grades {
+ position: relative;
+
+ li {
+ position: absolute;
+ top: 0;
+ height: 50px;
+ text-align: right;
+ @include border-radius(2px);
+
+ &:hover,
+ &.is-dragging {
+ .remove-button {
+ display: block;
+ }
+ }
+
+ &.is-dragging {
+
+
+ }
+
+ .remove-button {
+ display: none;
+ position: absolute;
+ top: -17px;
+ right: 1px;
+ height: 17px;
+ font-size: 10px;
+ }
+
+ &:nth-child(1) {
+ background: #4fe696;
+ }
+
+ &:nth-child(2) {
+ background: #ffdf7e;
+ }
+
+ &:nth-child(3) {
+ background: #ffb657;
+ }
+
+ &:nth-child(4) {
+ background: #ef54a1;
+ }
+
+ &:nth-child(5),
+ &.bar-fail {
+ background: #fb336c;
+ }
+
+ .letter-grade {
+ display: block;
+ margin: 10px 15px 0 0;
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 14px;
+ }
+
+ .range {
+ display: block;
+ margin-right: 15px;
+ font-size: 10px;
+ line-height: 12px;
+ }
+
+ .drag-bar {
+ position: absolute;
+ top: 0;
+ right: -1px;
+ height: 50px;
+ width: 2px;
+ background-color: #fff;
+ @include box-shadow(-1px 0 3px rgba(0,0,0,0.1));
+
+ cursor: ew-resize;
+ @include transition(none);
+
+ &:hover {
+ width: 6px;
+ right: -2px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss
index fec65e4e11..8666dc192c 100644
--- a/cms/static/sass/_variables.scss
+++ b/cms/static/sass/_variables.scss
@@ -13,8 +13,11 @@ $body-line-height: golden-ratio(.875em, 1);
$pink: rgb(182,37,104);
$error-red: rgb(253, 87, 87);
+$offBlack: #3c3c3c;
$blue: #5597dd;
$orange: #edbd3c;
+$red: #b20610;
+$green: #108614;
$lightGrey: #edf1f5;
$mediumGrey: #ced2db;
$darkGrey: #8891a1;
diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss
index 038db536b1..73812125a8 100644
--- a/cms/static/sass/base-style.scss
+++ b/cms/static/sass/base-style.scss
@@ -18,13 +18,13 @@
@import "static-pages";
@import "users";
@import "import";
+@import "settings";
@import "course-info";
@import "landing";
@import "graphics";
@import "modal";
@import "alerts";
@import "login";
-@import "lms";
@import 'jquery-ui-calendar';
@import 'content-types';
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
new file mode 100644
index 0000000000..559df38cf4
--- /dev/null
+++ b/cms/templates/settings.html
@@ -0,0 +1,730 @@
+<%inherit file="base.html" />
+<%block name="bodyclass">settings%block>
+<%block name="title">Settings%block>
+
+<%namespace name='static' file='static_content.html'/>
+<%!
+from contentstore import utils
+%>
+
+
+<%block name="jsextra">
+
+
+
+
+
+
+
+
+
+
+
+
+
+%block>
+
+<%block name="content">
+
+
+
+
Settings
+
+
+
+
+
+ Course Details
+
+
+
+ Basic Information
+ The nuts and bolts of your course
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Course Schedule
+ Important steps and segments of your course
+
+
+
+
Course Dates:
+
+
+
+
+
+
+
+
Enrollment Dates:
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Introducing Your Course
+ Information for perspective students
+
+
+
+
+
+
+
+
+
+
+
+ Video restrictions go here
+
+
+
+
+
+
+
+
+
+ Requirements
+ Expectations of the students taking this course
+
+
+
+
+
+
+
+ Time students should spend on all course work
+
+
+
+
+
+
+
+ Faculty
+
+
+
+ Faculty Members
+ Individuals instructing and help with this course
+
+
+
+
+
+
+
+
+ Grading
+
+
+
+ Overall Grade Range
+ Course grade ranges and their values
+
+
+
+
+
+
+
+
+
+ - 0
+ - 10
+ - 20
+ - 30
+ - 40
+ - 50
+ - 60
+ - 70
+ - 80
+ - 90
+ - 100
+
+
+
+
+
+
+
+
+
+
+
+
+ General Grading
+ Deadlines and Requirements
+
+
+
+
+
+
+
+
+ leeway on due dates
+
+
+
+
+
+
+
+
+
+ Problems
+
+
+
+ General Settings
+ Course-wide settings for all problems
+
+
+
+
Problem Randomization:
+
+
+
+
+
+
+
+
+
+
+
+
+ Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0"
+
+
+
+
+
+
+
+
+
+ Discussions
+
+
+
+ General Settings
+ Course-wide settings for online discussion
+
+
+
+
Anonymous Discussions:
+
+
+
+
+
+
Anonymous Discussions:
+
+
+
+
+
+
Discussion Categories
+
+
+
+
+
+
+
+
+
+%block>
diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html
index 73ce3f0604..f65becb9c7 100644
--- a/cms/templates/widgets/header.html
+++ b/cms/templates/widgets/header.html
@@ -14,6 +14,7 @@
Tabs
Assets
Users
+ Settings
Import
% endif
diff --git a/cms/urls.py b/cms/urls.py
index 5df3215d12..29eae044b5 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -35,9 +35,10 @@ urlpatterns = ('',
url(r'^(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)/remove_user$',
'contentstore.views.remove_user', name='remove_user'),
url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', 'contentstore.views.course_info', name='course_info'),
- # ??? Is the following necessary or will the one below work w/ id=None if not sent?
- # url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates$', 'contentstore.views.course_info_updates', name='course_info'),
url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', 'contentstore.views.course_info_updates', name='course_info'),
+ url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'),
+ url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)/section/(?P[^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'),
+ url(r'^(?P[^/]+)/(?P[^/]+)/grades/(?P[^/]+)/(?P.*)$', 'contentstore.views.course_grader_updates', name='course_settings'),
url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages',
name='static_pages'),
url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),
diff --git a/common/djangoapps/__init__.py b/common/djangoapps/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/models/__init__.py b/common/djangoapps/models/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/models/course_relative.py b/common/djangoapps/models/course_relative.py
new file mode 100644
index 0000000000..4dfb83d183
--- /dev/null
+++ b/common/djangoapps/models/course_relative.py
@@ -0,0 +1,25 @@
+class CourseRelativeMember:
+ def __init__(self, location, idx):
+ self.course_location = location # a Location obj
+ self.idx = idx # which milestone this represents. Hopefully persisted # so we don't have race conditions
+
+### ??? If 2+ courses use the same textbook or other asset, should they point to the same db record?
+class linked_asset(CourseRelativeMember):
+ """
+ Something uploaded to our asset lib which has a name/label and location. Here it's tracked by course and index, but
+ we could replace the label/url w/ a pointer to a real asset and keep the join info here.
+ """
+ def __init__(self, location, idx):
+ CourseRelativeMember.__init__(self, location, idx)
+ self.label = ""
+ self.url = None
+
+class summary_detail_pair(CourseRelativeMember):
+ """
+ A short text with an arbitrary html descriptor used for paired label - details elements.
+ """
+ def __init__(self, location, idx):
+ CourseRelativeMember.__init__(self, location, idx)
+ self.summary = ""
+ self.detail = ""
+
\ No newline at end of file
diff --git a/common/djangoapps/util/converters.py b/common/djangoapps/util/converters.py
new file mode 100644
index 0000000000..e9bf5f84bf
--- /dev/null
+++ b/common/djangoapps/util/converters.py
@@ -0,0 +1,22 @@
+import time, datetime
+import re
+import calendar
+
+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):
+ """
+ Convert a universal time (iso format) or msec since epoch to a time obj
+ """
+ if field is None:
+ return field
+ elif isinstance(field, unicode) or isinstance(field, str): # iso format but ignores time zone assuming it's Z
+ d=datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable
+ return d.utctimetuple()
+ elif isinstance(field, int) or isinstance(field, float):
+ return time.gmtime(field / 1000)
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index e4d2961723..d18273f762 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -1,18 +1,17 @@
-from fs.errors import ResourceNotFoundError
-import logging
-import json
+from cStringIO import StringIO
from lxml import etree
from path import path # NOTE (THK): Only used for detecting presence of syllabus
-import requests
-import time
-from cStringIO import StringIO
-
-from xmodule.util.decorators import lazyproperty
+from xmodule.graders import grader_from_conf
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor, SequenceModule
-from xmodule.xml_module import XmlDescriptor
from xmodule.timeparse import parse_time, stringify_time
-from xmodule.graders import grader_from_conf
+from xmodule.util.decorators import lazyproperty
+import json
+import logging
+import requests
+import time
+import copy
+
log = logging.getLogger(__name__)
@@ -92,10 +91,6 @@ class CourseDescriptor(SequenceDescriptor):
log.critical(msg)
system.error_tracker(msg)
- self.enrollment_start = self._try_parse_time("enrollment_start")
- self.enrollment_end = self._try_parse_time("enrollment_end")
- self.end = self._try_parse_time("end")
-
# NOTE: relies on the modulestore to call set_grading_policy() right after
# init. (Modulestore is in charge of figuring out where to load the policy from)
@@ -105,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,
@@ -133,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
@@ -251,13 +246,53 @@ class CourseDescriptor(SequenceDescriptor):
def has_started(self):
return time.gmtime() > self.start
+ @property
+ def end(self):
+ return self._try_parse_time("end")
+ @end.setter
+ def end(self, value):
+ if isinstance(value, time.struct_time):
+ self.metadata['end'] = stringify_time(value)
+ @property
+ def enrollment_start(self):
+ return self._try_parse_time("enrollment_start")
+
+ @enrollment_start.setter
+ def enrollment_start(self, value):
+ if isinstance(value, time.struct_time):
+ self.metadata['enrollment_start'] = stringify_time(value)
+ @property
+ def enrollment_end(self):
+ return self._try_parse_time("enrollment_end")
+
+ @enrollment_end.setter
+ def enrollment_end(self, value):
+ if isinstance(value, time.struct_time):
+ self.metadata['enrollment_end'] = stringify_time(value)
+
@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):
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index b61bada2c2..690f78fd53 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -10,10 +10,11 @@ from collections import namedtuple
from pkg_resources import resource_listdir, resource_string, resource_isdir
from xmodule.modulestore import Location
-from xmodule.timeparse import parse_time
+from xmodule.timeparse import parse_time, stringify_time
from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
from xmodule.modulestore.exceptions import ItemNotFoundError
+import time
log = logging.getLogger('mitx.' + __name__)
@@ -494,6 +495,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
return None
return self._try_parse_time('start')
+ @start.setter
+ def start(self, value):
+ if isinstance(value, time.struct_time):
+ self.metadata['start'] = stringify_time(value)
+
@property
def own_metadata(self):
"""
diff --git a/common/static/js/vendor/underscore-min.js b/common/static/js/vendor/underscore-min.js
index 5a0cb3b008..7ed6e5284f 100644
--- a/common/static/js/vendor/underscore-min.js
+++ b/common/static/js/vendor/underscore-min.js
@@ -1,32 +1 @@
-// Underscore.js 1.3.3
-// (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc.
-// Underscore is freely distributable under the MIT license.
-// Portions of Underscore are inspired or borrowed from Prototype,
-// Oliver Steele's Functional, and John Resig's Micro-Templating.
-// For all details and documentation:
-// http://documentcloud.github.com/underscore
-(function(){function r(a,c,d){if(a===c)return 0!==a||1/a==1/c;if(null==a||null==c)return a===c;a._chain&&(a=a._wrapped);c._chain&&(c=c._wrapped);if(a.isEqual&&b.isFunction(a.isEqual))return a.isEqual(c);if(c.isEqual&&b.isFunction(c.isEqual))return c.isEqual(a);var e=l.call(a);if(e!=l.call(c))return!1;switch(e){case "[object String]":return a==""+c;case "[object Number]":return a!=+a?c!=+c:0==a?1/a==1/c:a==+c;case "[object Date]":case "[object Boolean]":return+a==+c;case "[object RegExp]":return a.source==
-c.source&&a.global==c.global&&a.multiline==c.multiline&&a.ignoreCase==c.ignoreCase}if("object"!=typeof a||"object"!=typeof c)return!1;for(var f=d.length;f--;)if(d[f]==a)return!0;d.push(a);var f=0,g=!0;if("[object Array]"==e){if(f=a.length,g=f==c.length)for(;f--&&(g=f in a==f in c&&r(a[f],c[f],d)););}else{if("constructor"in a!="constructor"in c||a.constructor!=c.constructor)return!1;for(var h in a)if(b.has(a,h)&&(f++,!(g=b.has(c,h)&&r(a[h],c[h],d))))break;if(g){for(h in c)if(b.has(c,h)&&!f--)break;
-g=!f}}d.pop();return g}var s=this,I=s._,o={},k=Array.prototype,p=Object.prototype,i=k.slice,J=k.unshift,l=p.toString,K=p.hasOwnProperty,y=k.forEach,z=k.map,A=k.reduce,B=k.reduceRight,C=k.filter,D=k.every,E=k.some,q=k.indexOf,F=k.lastIndexOf,p=Array.isArray,L=Object.keys,t=Function.prototype.bind,b=function(a){return new m(a)};"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(exports=module.exports=b),exports._=b):s._=b;b.VERSION="1.3.3";var j=b.each=b.forEach=function(a,
-c,d){if(a!=null)if(y&&a.forEach===y)a.forEach(c,d);else if(a.length===+a.length)for(var e=0,f=a.length;e2;a==null&&(a=[]);if(A&&
-a.reduce===A){e&&(c=b.bind(c,e));return f?a.reduce(c,d):a.reduce(c)}j(a,function(a,b,i){if(f)d=c.call(e,d,a,b,i);else{d=a;f=true}});if(!f)throw new TypeError("Reduce of empty array with no initial value");return d};b.reduceRight=b.foldr=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(B&&a.reduceRight===B){e&&(c=b.bind(c,e));return f?a.reduceRight(c,d):a.reduceRight(c)}var g=b.toArray(a).reverse();e&&!f&&(c=b.bind(c,e));return f?b.reduce(g,c,d,e):b.reduce(g,c)};b.find=b.detect=function(a,
-c,b){var e;G(a,function(a,g,h){if(c.call(b,a,g,h)){e=a;return true}});return e};b.filter=b.select=function(a,c,b){var e=[];if(a==null)return e;if(C&&a.filter===C)return a.filter(c,b);j(a,function(a,g,h){c.call(b,a,g,h)&&(e[e.length]=a)});return e};b.reject=function(a,c,b){var e=[];if(a==null)return e;j(a,function(a,g,h){c.call(b,a,g,h)||(e[e.length]=a)});return e};b.every=b.all=function(a,c,b){var e=true;if(a==null)return e;if(D&&a.every===D)return a.every(c,b);j(a,function(a,g,h){if(!(e=e&&c.call(b,
-a,g,h)))return o});return!!e};var G=b.some=b.any=function(a,c,d){c||(c=b.identity);var e=false;if(a==null)return e;if(E&&a.some===E)return a.some(c,d);j(a,function(a,b,h){if(e||(e=c.call(d,a,b,h)))return o});return!!e};b.include=b.contains=function(a,c){var b=false;if(a==null)return b;if(q&&a.indexOf===q)return a.indexOf(c)!=-1;return b=G(a,function(a){return a===c})};b.invoke=function(a,c){var d=i.call(arguments,2);return b.map(a,function(a){return(b.isFunction(c)?c||a:a[c]).apply(a,d)})};b.pluck=
-function(a,c){return b.map(a,function(a){return a[c]})};b.max=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.max.apply(Math,a);if(!c&&b.isEmpty(a))return-Infinity;var e={computed:-Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b>=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;bd?1:0}),"value")};b.groupBy=function(a,c){var d={},e=b.isFunction(c)?c:function(a){return a[c]};
-j(a,function(a,b){var c=e(a,b);(d[c]||(d[c]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e>1;d(a[g])=0})})};b.difference=function(a){var c=b.flatten(i.call(arguments,1),true);return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a=
-i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return a<=0?b():function(){if(--a<1)return b.apply(this,arguments)}};b.keys=L||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var c=[],d;for(d in a)b.has(a,d)&&(c[c.length]=d);return c};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&&
-c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]=b[d]});return a};b.pick=function(a){var c={};j(b.flatten(i.call(arguments,1)),function(b){b in a&&(c[b]=a[b])});return c};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]=b[d])});return a};b.clone=function(a){return!b.isObject(a)?a:b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return r(a,b,[])};b.isEmpty=
-function(a){if(a==null)return true;if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(b.has(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=p||function(a){return l.call(a)=="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return l.call(a)=="[object Arguments]"};b.isArguments(arguments)||(b.isArguments=function(a){return!(!a||!b.has(a,"callee"))});b.isFunction=function(a){return l.call(a)=="[object Function]"};
-b.isString=function(a){return l.call(a)=="[object String]"};b.isNumber=function(a){return l.call(a)=="[object Number]"};b.isFinite=function(a){return b.isNumber(a)&&isFinite(a)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||l.call(a)=="[object Boolean]"};b.isDate=function(a){return l.call(a)=="[object Date]"};b.isRegExp=function(a){return l.call(a)=="[object RegExp]"};b.isNull=function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.has=function(a,
-b){return K.call(a,b)};b.noConflict=function(){s._=I;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")};b.result=function(a,c){if(a==null)return null;var d=a[c];return b.isFunction(d)?d.call(a):d};b.mixin=function(a){j(b.functions(a),function(c){M(c,b[c]=a[c])})};var N=0;b.uniqueId=
-function(a){var b=N++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var u=/.^/,n={"\\":"\\","'":"'",r:"\r",n:"\n",t:"\t",u2028:"\u2028",u2029:"\u2029"},v;for(v in n)n[n[v]]=v;var O=/\\|'|\r|\n|\t|\u2028|\u2029/g,P=/\\(\\|'|r|n|t|u2028|u2029)/g,w=function(a){return a.replace(P,function(a,b){return n[b]})};b.template=function(a,c,d){d=b.defaults(d||{},b.templateSettings);a="__p+='"+a.replace(O,function(a){return"\\"+n[a]}).replace(d.escape||
-u,function(a,b){return"'+\n_.escape("+w(b)+")+\n'"}).replace(d.interpolate||u,function(a,b){return"'+\n("+w(b)+")+\n'"}).replace(d.evaluate||u,function(a,b){return"';\n"+w(b)+"\n;__p+='"})+"';\n";d.variable||(a="with(obj||{}){\n"+a+"}\n");var a="var __p='';var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n"+a+"return __p;\n",e=new Function(d.variable||"obj","_",a);if(c)return e(c,b);c=function(a){return e.call(this,a,b)};c.source="function("+(d.variable||"obj")+"){\n"+a+"}";return c};
-b.chain=function(a){return b(a).chain()};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var x=function(a,c){return c?b(a).chain():a},M=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);J.call(a,this._wrapped);return x(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),function(a){var b=k[a];m.prototype[a]=function(){var d=this._wrapped;b.apply(d,arguments);var e=d.length;(a=="shift"||a=="splice")&&e===0&&delete d[0];return x(d,
-this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return x(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}}).call(this);
+(function(){var n=this,t=n._,r={},e=Array.prototype,u=Object.prototype,i=Function.prototype,a=e.push,o=e.slice,c=e.concat,l=u.toString,f=u.hasOwnProperty,s=e.forEach,p=e.map,v=e.reduce,h=e.reduceRight,g=e.filter,d=e.every,m=e.some,y=e.indexOf,b=e.lastIndexOf,x=Array.isArray,_=Object.keys,j=i.bind,w=function(n){return n instanceof w?n:this instanceof w?(this._wrapped=n,void 0):new w(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=w),exports._=w):n._=w,w.VERSION="1.4.3";var A=w.each=w.forEach=function(n,t,e){if(null!=n)if(s&&n.forEach===s)n.forEach(t,e);else if(n.length===+n.length){for(var u=0,i=n.length;i>u;u++)if(t.call(e,n[u],u,n)===r)return}else for(var a in n)if(w.has(n,a)&&t.call(e,n[a],a,n)===r)return};w.map=w.collect=function(n,t,r){var e=[];return null==n?e:p&&n.map===p?n.map(t,r):(A(n,function(n,u,i){e[e.length]=t.call(r,n,u,i)}),e)};var O="Reduce of empty array with no initial value";w.reduce=w.foldl=w.inject=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),v&&n.reduce===v)return e&&(t=w.bind(t,e)),u?n.reduce(t,r):n.reduce(t);if(A(n,function(n,i,a){u?r=t.call(e,r,n,i,a):(r=n,u=!0)}),!u)throw new TypeError(O);return r},w.reduceRight=w.foldr=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),h&&n.reduceRight===h)return e&&(t=w.bind(t,e)),u?n.reduceRight(t,r):n.reduceRight(t);var i=n.length;if(i!==+i){var a=w.keys(n);i=a.length}if(A(n,function(o,c,l){c=a?a[--i]:--i,u?r=t.call(e,r,n[c],c,l):(r=n[c],u=!0)}),!u)throw new TypeError(O);return r},w.find=w.detect=function(n,t,r){var e;return E(n,function(n,u,i){return t.call(r,n,u,i)?(e=n,!0):void 0}),e},w.filter=w.select=function(n,t,r){var e=[];return null==n?e:g&&n.filter===g?n.filter(t,r):(A(n,function(n,u,i){t.call(r,n,u,i)&&(e[e.length]=n)}),e)},w.reject=function(n,t,r){return w.filter(n,function(n,e,u){return!t.call(r,n,e,u)},r)},w.every=w.all=function(n,t,e){t||(t=w.identity);var u=!0;return null==n?u:d&&n.every===d?n.every(t,e):(A(n,function(n,i,a){return(u=u&&t.call(e,n,i,a))?void 0:r}),!!u)};var E=w.some=w.any=function(n,t,e){t||(t=w.identity);var u=!1;return null==n?u:m&&n.some===m?n.some(t,e):(A(n,function(n,i,a){return u||(u=t.call(e,n,i,a))?r:void 0}),!!u)};w.contains=w.include=function(n,t){return null==n?!1:y&&n.indexOf===y?-1!=n.indexOf(t):E(n,function(n){return n===t})},w.invoke=function(n,t){var r=o.call(arguments,2);return w.map(n,function(n){return(w.isFunction(t)?t:n[t]).apply(n,r)})},w.pluck=function(n,t){return w.map(n,function(n){return n[t]})},w.where=function(n,t){return w.isEmpty(t)?[]:w.filter(n,function(n){for(var r in t)if(t[r]!==n[r])return!1;return!0})},w.max=function(n,t,r){if(!t&&w.isArray(n)&&n[0]===+n[0]&&65535>n.length)return Math.max.apply(Math,n);if(!t&&w.isEmpty(n))return-1/0;var e={computed:-1/0,value:-1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;a>=e.computed&&(e={value:n,computed:a})}),e.value},w.min=function(n,t,r){if(!t&&w.isArray(n)&&n[0]===+n[0]&&65535>n.length)return Math.min.apply(Math,n);if(!t&&w.isEmpty(n))return 1/0;var e={computed:1/0,value:1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;e.computed>a&&(e={value:n,computed:a})}),e.value},w.shuffle=function(n){var t,r=0,e=[];return A(n,function(n){t=w.random(r++),e[r-1]=e[t],e[t]=n}),e};var F=function(n){return w.isFunction(n)?n:function(t){return t[n]}};w.sortBy=function(n,t,r){var e=F(t);return w.pluck(w.map(n,function(n,t,u){return{value:n,index:t,criteria:e.call(r,n,t,u)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||void 0===r)return 1;if(e>r||void 0===e)return-1}return n.indexi;){var o=i+a>>>1;u>r.call(e,n[o])?i=o+1:a=o}return i},w.toArray=function(n){return n?w.isArray(n)?o.call(n):n.length===+n.length?w.map(n,w.identity):w.values(n):[]},w.size=function(n){return null==n?0:n.length===+n.length?n.length:w.keys(n).length},w.first=w.head=w.take=function(n,t,r){return null==n?void 0:null==t||r?n[0]:o.call(n,0,t)},w.initial=function(n,t,r){return o.call(n,0,n.length-(null==t||r?1:t))},w.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:o.call(n,Math.max(n.length-t,0))},w.rest=w.tail=w.drop=function(n,t,r){return o.call(n,null==t||r?1:t)},w.compact=function(n){return w.filter(n,w.identity)};var R=function(n,t,r){return A(n,function(n){w.isArray(n)?t?a.apply(r,n):R(n,t,r):r.push(n)}),r};w.flatten=function(n,t){return R(n,t,[])},w.without=function(n){return w.difference(n,o.call(arguments,1))},w.uniq=w.unique=function(n,t,r,e){w.isFunction(t)&&(e=r,r=t,t=!1);var u=r?w.map(n,r,e):n,i=[],a=[];return A(u,function(r,e){(t?e&&a[a.length-1]===r:w.contains(a,r))||(a.push(r),i.push(n[e]))}),i},w.union=function(){return w.uniq(c.apply(e,arguments))},w.intersection=function(n){var t=o.call(arguments,1);return w.filter(w.uniq(n),function(n){return w.every(t,function(t){return w.indexOf(t,n)>=0})})},w.difference=function(n){var t=c.apply(e,o.call(arguments,1));return w.filter(n,function(n){return!w.contains(t,n)})},w.zip=function(){for(var n=o.call(arguments),t=w.max(w.pluck(n,"length")),r=Array(t),e=0;t>e;e++)r[e]=w.pluck(n,""+e);return r},w.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},w.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=w.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}if(y&&n.indexOf===y)return n.indexOf(t,r);for(;u>e;e++)if(n[e]===t)return e;return-1},w.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=null!=r;if(b&&n.lastIndexOf===b)return e?n.lastIndexOf(t,r):n.lastIndexOf(t);for(var u=e?r:n.length;u--;)if(n[u]===t)return u;return-1},w.range=function(n,t,r){1>=arguments.length&&(t=n||0,n=0),r=arguments[2]||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=0,i=Array(e);e>u;)i[u++]=n,n+=r;return i};var I=function(){};w.bind=function(n,t){var r,e;if(n.bind===j&&j)return j.apply(n,o.call(arguments,1));if(!w.isFunction(n))throw new TypeError;return r=o.call(arguments,2),e=function(){if(!(this instanceof e))return n.apply(t,r.concat(o.call(arguments)));I.prototype=n.prototype;var u=new I;I.prototype=null;var i=n.apply(u,r.concat(o.call(arguments)));return Object(i)===i?i:u}},w.bindAll=function(n){var t=o.call(arguments,1);return 0==t.length&&(t=w.functions(n)),A(t,function(t){n[t]=w.bind(n[t],n)}),n},w.memoize=function(n,t){var r={};return t||(t=w.identity),function(){var e=t.apply(this,arguments);return w.has(r,e)?r[e]:r[e]=n.apply(this,arguments)}},w.delay=function(n,t){var r=o.call(arguments,2);return setTimeout(function(){return n.apply(null,r)},t)},w.defer=function(n){return w.delay.apply(w,[n,1].concat(o.call(arguments,1)))},w.throttle=function(n,t){var r,e,u,i,a=0,o=function(){a=new Date,u=null,i=n.apply(r,e)};return function(){var c=new Date,l=t-(c-a);return r=this,e=arguments,0>=l?(clearTimeout(u),u=null,a=c,i=n.apply(r,e)):u||(u=setTimeout(o,l)),i}},w.debounce=function(n,t,r){var e,u;return function(){var i=this,a=arguments,o=function(){e=null,r||(u=n.apply(i,a))},c=r&&!e;return clearTimeout(e),e=setTimeout(o,t),c&&(u=n.apply(i,a)),u}},w.once=function(n){var t,r=!1;return function(){return r?t:(r=!0,t=n.apply(this,arguments),n=null,t)}},w.wrap=function(n,t){return function(){var r=[n];return a.apply(r,arguments),t.apply(this,r)}},w.compose=function(){var n=arguments;return function(){for(var t=arguments,r=n.length-1;r>=0;r--)t=[n[r].apply(this,t)];return t[0]}},w.after=function(n,t){return 0>=n?t():function(){return 1>--n?t.apply(this,arguments):void 0}},w.keys=_||function(n){if(n!==Object(n))throw new TypeError("Invalid object");var t=[];for(var r in n)w.has(n,r)&&(t[t.length]=r);return t},w.values=function(n){var t=[];for(var r in n)w.has(n,r)&&t.push(n[r]);return t},w.pairs=function(n){var t=[];for(var r in n)w.has(n,r)&&t.push([r,n[r]]);return t},w.invert=function(n){var t={};for(var r in n)w.has(n,r)&&(t[n[r]]=r);return t},w.functions=w.methods=function(n){var t=[];for(var r in n)w.isFunction(n[r])&&t.push(r);return t.sort()},w.extend=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]=t[r]}),n},w.pick=function(n){var t={},r=c.apply(e,o.call(arguments,1));return A(r,function(r){r in n&&(t[r]=n[r])}),t},w.omit=function(n){var t={},r=c.apply(e,o.call(arguments,1));for(var u in n)w.contains(r,u)||(t[u]=n[u]);return t},w.defaults=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)null==n[r]&&(n[r]=t[r])}),n},w.clone=function(n){return w.isObject(n)?w.isArray(n)?n.slice():w.extend({},n):n},w.tap=function(n,t){return t(n),n};var S=function(n,t,r,e){if(n===t)return 0!==n||1/n==1/t;if(null==n||null==t)return n===t;n instanceof w&&(n=n._wrapped),t instanceof w&&(t=t._wrapped);var u=l.call(n);if(u!=l.call(t))return!1;switch(u){case"[object String]":return n==t+"";case"[object Number]":return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case"[object Date]":case"[object Boolean]":return+n==+t;case"[object RegExp]":return n.source==t.source&&n.global==t.global&&n.multiline==t.multiline&&n.ignoreCase==t.ignoreCase}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]==n)return e[i]==t;r.push(n),e.push(t);var a=0,o=!0;if("[object Array]"==u){if(a=n.length,o=a==t.length)for(;a--&&(o=S(n[a],t[a],r,e)););}else{var c=n.constructor,f=t.constructor;if(c!==f&&!(w.isFunction(c)&&c instanceof c&&w.isFunction(f)&&f instanceof f))return!1;for(var s in n)if(w.has(n,s)&&(a++,!(o=w.has(t,s)&&S(n[s],t[s],r,e))))break;if(o){for(s in t)if(w.has(t,s)&&!a--)break;o=!a}}return r.pop(),e.pop(),o};w.isEqual=function(n,t){return S(n,t,[],[])},w.isEmpty=function(n){if(null==n)return!0;if(w.isArray(n)||w.isString(n))return 0===n.length;for(var t in n)if(w.has(n,t))return!1;return!0},w.isElement=function(n){return!(!n||1!==n.nodeType)},w.isArray=x||function(n){return"[object Array]"==l.call(n)},w.isObject=function(n){return n===Object(n)},A(["Arguments","Function","String","Number","Date","RegExp"],function(n){w["is"+n]=function(t){return l.call(t)=="[object "+n+"]"}}),w.isArguments(arguments)||(w.isArguments=function(n){return!(!n||!w.has(n,"callee"))}),w.isFunction=function(n){return"function"==typeof n},w.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},w.isNaN=function(n){return w.isNumber(n)&&n!=+n},w.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"==l.call(n)},w.isNull=function(n){return null===n},w.isUndefined=function(n){return void 0===n},w.has=function(n,t){return f.call(n,t)},w.noConflict=function(){return n._=t,this},w.identity=function(n){return n},w.times=function(n,t,r){for(var e=Array(n),u=0;n>u;u++)e[u]=t.call(r,u);return e},w.random=function(n,t){return null==t&&(t=n,n=0),n+(0|Math.random()*(t-n+1))};var T={escape:{"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"}};T.unescape=w.invert(T.escape);var M={escape:RegExp("["+w.keys(T.escape).join("")+"]","g"),unescape:RegExp("("+w.keys(T.unescape).join("|")+")","g")};w.each(["escape","unescape"],function(n){w[n]=function(t){return null==t?"":(""+t).replace(M[n],function(t){return T[n][t]})}}),w.result=function(n,t){if(null==n)return null;var r=n[t];return w.isFunction(r)?r.call(n):r},w.mixin=function(n){A(w.functions(n),function(t){var r=w[t]=n[t];w.prototype[t]=function(){var n=[this._wrapped];return a.apply(n,arguments),z.call(this,r.apply(w,n))}})};var N=0;w.uniqueId=function(n){var t=""+ ++N;return n?n+t:t},w.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var q=/(.)^/,B={"'":"'","\\":"\\","\r":"r","\n":"n"," ":"t","\u2028":"u2028","\u2029":"u2029"},D=/\\|'|\r|\n|\t|\u2028|\u2029/g;w.template=function(n,t,r){r=w.defaults({},r,w.templateSettings);var e=RegExp([(r.escape||q).source,(r.interpolate||q).source,(r.evaluate||q).source].join("|")+"|$","g"),u=0,i="__p+='";n.replace(e,function(t,r,e,a,o){return i+=n.slice(u,o).replace(D,function(n){return"\\"+B[n]}),r&&(i+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'"),e&&(i+="'+\n((__t=("+e+"))==null?'':__t)+\n'"),a&&(i+="';\n"+a+"\n__p+='"),u=o+t.length,t}),i+="';\n",r.variable||(i="with(obj||{}){\n"+i+"}\n"),i="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+i+"return __p;\n";try{var a=Function(r.variable||"obj","_",i)}catch(o){throw o.source=i,o}if(t)return a(t,w);var c=function(n){return a.call(this,n,w)};return c.source="function("+(r.variable||"obj")+"){\n"+i+"}",c},w.chain=function(n){return w(n).chain()};var z=function(n){return this._chain?w(n).chain():n};w.mixin(w),A(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=e[n];w.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!=n&&"splice"!=n||0!==r.length||delete r[0],z.call(this,r)}}),A(["concat","join","slice"],function(n){var t=e[n];w.prototype[n]=function(){return z.call(this,t.apply(this._wrapped,arguments))}}),w.extend(w.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}})}).call(this);
\ No newline at end of file
diff --git a/common/test/data/graded/course.xml b/common/test/data/graded/course.xml
deleted file mode 120000
index 49041310f6..0000000000
--- a/common/test/data/graded/course.xml
+++ /dev/null
@@ -1 +0,0 @@
-roots/2012_Fall.xml
\ No newline at end of file
diff --git a/common/test/data/graded/course.xml b/common/test/data/graded/course.xml
new file mode 100644
index 0000000000..b046a28d3b
--- /dev/null
+++ b/common/test/data/graded/course.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/common/test/data/self_assessment/course.xml b/common/test/data/self_assessment/course.xml
deleted file mode 120000
index 49041310f6..0000000000
--- a/common/test/data/self_assessment/course.xml
+++ /dev/null
@@ -1 +0,0 @@
-roots/2012_Fall.xml
\ No newline at end of file
diff --git a/common/test/data/self_assessment/course.xml b/common/test/data/self_assessment/course.xml
new file mode 100644
index 0000000000..ea7d5c420d
--- /dev/null
+++ b/common/test/data/self_assessment/course.xml
@@ -0,0 +1 @@
+
diff --git a/common/test/data/test_start_date/course.xml b/common/test/data/test_start_date/course.xml
deleted file mode 120000
index 49041310f6..0000000000
--- a/common/test/data/test_start_date/course.xml
+++ /dev/null
@@ -1 +0,0 @@
-roots/2012_Fall.xml
\ No newline at end of file
diff --git a/common/test/data/test_start_date/course.xml b/common/test/data/test_start_date/course.xml
new file mode 100644
index 0000000000..30dd5e0180
--- /dev/null
+++ b/common/test/data/test_start_date/course.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/common/test/data/toy/course.xml b/common/test/data/toy/course.xml
deleted file mode 120000
index 49041310f6..0000000000
--- a/common/test/data/toy/course.xml
+++ /dev/null
@@ -1 +0,0 @@
-roots/2012_Fall.xml
\ No newline at end of file
diff --git a/common/test/data/toy/course.xml b/common/test/data/toy/course.xml
new file mode 100644
index 0000000000..b71528809b
--- /dev/null
+++ b/common/test/data/toy/course.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/lms/static/images/edge-on-edx-logo.png b/lms/static/images/edge-on-edx-logo.png
deleted file mode 120000
index af5121d3f4..0000000000
--- a/lms/static/images/edge-on-edx-logo.png
+++ /dev/null
@@ -1 +0,0 @@
-../../../common/static/images/edge-logo-small.png
\ No newline at end of file
diff --git a/lms/static/images/edge-on-edx-logo.png b/lms/static/images/edge-on-edx-logo.png
new file mode 100644
index 0000000000..41bd7127f5
Binary files /dev/null and b/lms/static/images/edge-on-edx-logo.png differ
diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss
deleted file mode 120000
index 61597bb5eb..0000000000
--- a/lms/static/sass/base/_mixins.scss
+++ /dev/null
@@ -1 +0,0 @@
-../../../../common/static/sass/_mixins.scss
\ No newline at end of file
diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss
new file mode 100644
index 0000000000..58a92d1ee6
--- /dev/null
+++ b/lms/static/sass/base/_mixins.scss
@@ -0,0 +1,24 @@
+@function em($pxval, $base: 16) {
+ @return #{$pxval / $base}em;
+}
+
+// Line-height
+@function lh($amount: 1) {
+ @return $body-line-height * $amount;
+}
+
+@mixin hide-text(){
+ text-indent: -9999px;
+ overflow: hidden;
+ display: block;
+}
+
+@mixin vertically-and-horizontally-centered ( $height, $width ) {
+ left: 50%;
+ margin-left: -$width / 2;
+ //margin-top: -$height / 2;
+ min-height: $height;
+ min-width: $width;
+ position: absolute;
+ top: 150px;
+}
diff --git a/lms/templates/press.json b/lms/templates/press.json
index 24e4028bc7..b165037544 100644
--- a/lms/templates/press.json
+++ b/lms/templates/press.json
@@ -423,6 +423,429 @@
"publication": "Daily News and Analysis India",
"publish_date": "October 1, 2012"
},
+ {
+ "title": "The Year of the MOOC",
+ "url": "http://www.nytimes.com/2012/11/04/education/edlife/massive-open-online-courses-are-multiplying-at-a-rapid-pace.html",
+ "author": "Laura Pappano",
+ "image": "nyt_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "The New York Times",
+ "publish_date": "November 2, 2012"
+ },
+ {
+ "title": "The Most Important Education Technology in 200 Years",
+ "url": "http://www.technologyreview.com/news/506351/the-most-important-education-technology-in-200-years/",
+ "author": "Antonio Regalado",
+ "image": "techreview_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Technology Review",
+ "publish_date": "November 2, 2012"
+ },
+ {
+ "title": "Classroom in the Cloud",
+ "url": "http://harvardmagazine.com/2012/11/classroom-in-the-cloud",
+ "author": null,
+ "image": "harvardmagazine_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Harvard Magazine",
+ "publish_date": "November-December 2012"
+ },
+ {
+ "title": "How do you stop online students cheating?",
+ "url": "http://www.bbc.co.uk/news/business-19661899",
+ "author": "Sean Coughlan",
+ "image": "bbc_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "BBC",
+ "publish_date": "October 31, 2012"
+ },
+ {
+ "title": "VMware to provide software for HarvardX CS50x",
+ "url": "http://tech.mit.edu/V132/N48/edxvmware.html",
+ "author": "Stan Gill",
+ "image": "thetech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Tech",
+ "publish_date": "October 26, 2012"
+ },
+ {
+ "title": "EdX platform integrates into classes",
+ "url": "http://tech.mit.edu/V132/N48/801edx.html",
+ "author": "Leon Lin",
+ "image": "thetech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Tech",
+ "publish_date": "October 26, 2012"
+ },
+ {
+ "title": "VMware Offers Free Software to edX Learners",
+ "url": "http://campustechnology.com/articles/2012/10/25/vmware-offers-free-virtualization-software-for-edx-computer-science-students.aspx",
+ "author": "Joshua Bolkan",
+ "image": "campustech_logo_178x138.jpg",
+ "deck": "VMware Offers Free Virtualization Software for EdX Computer Science Students",
+ "publication": "Campus Technology",
+ "publish_date": "October 25, 2012"
+ },
+ {
+ "title": "Lone Star moots charges to make Moocs add up",
+ "url": "http://www.timeshighereducation.co.uk/story.asp?sectioncode=26&storycode=421577&c=1",
+ "author": "David Matthews",
+ "image": "timeshighered_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Times Higher Education",
+ "publish_date": "October 25, 2012"
+ },
+ {
+ "title": "Free, high-quality and with mass appeal",
+ "url": "http://www.ft.com/intl/cms/s/2/73030f44-d4dd-11e1-9444-00144feabdc0.html#axzz2A9qvk48A",
+ "author": "Rebecca Knight",
+ "image": "ft_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Financial Times",
+ "publish_date": "October 22, 2012"
+ },
+ {
+ "title": "Getting the most out of an online education",
+ "url": "http://www.reuters.com/article/2012/10/19/us-education-courses-online-idUSBRE89I17120121019",
+ "author": "Kathleen Kingsbury",
+ "image": "reuters_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Reuters",
+ "publish_date": "October 19, 2012"
+ },
+ {
+ "title": "EdX announces partnership with Cengage",
+ "url": "http://tech.mit.edu/V132/N46/cengage.html",
+ "author": "Leon Lin",
+ "image": "thetech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Tech",
+ "publish_date": "October 19, 2012"
+ },
+ {
+ "title": "U Texas System Joins EdX",
+ "url": "http://campustechnology.com/articles/2012/10/18/u-texas-system-joins-edx.aspx",
+ "author": "Joshua Bolkan",
+ "image": "campustech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Campus Technology",
+ "publish_date": "October 18, 2012"
+ },
+ {
+ "title": "San Jose State University Runs Blended Learning Course Using edX ",
+ "url": "http://chronicle.com/blogs/wiredcampus/san-jose-state-u-says-replacing-live-lectures-with-videos-increased-test-scores/40470",
+ "author": "Alisha Azevedo",
+ "image": "chroniclehighered_logo_178x138.jpeg",
+ "deck": "San Jose State U. Says Replacing Live Lectures With Videos Increased Test Scores",
+ "publication": "Chronicle of Higher Education",
+ "publish_date": "October 17, 2012"
+ },
+ {
+ "title": "Online university to charge tuition fees",
+ "url": "http://www.bbc.co.uk/news/education-19964787",
+ "author": "Sean Coughlan",
+ "image": "bbc_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "BBC",
+ "publish_date": "October 17, 2012"
+ },
+ {
+ "title": "HarvardX marks the spot",
+ "url": "http://news.harvard.edu/gazette/story/2012/10/harvardx-marks-the-spot/",
+ "author": "Tania delLuzuriaga",
+ "image": "harvardgazette_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Harvard Gazette",
+ "publish_date": "October 17, 2012"
+ },
+ {
+ "title": "Harvard EdX Enrolls Near 100000 Students for Free Online Classes",
+ "url": "http://www.collegeclasses.com/harvard-edx-enrolls-near-100000-students-for-free-online-classes/",
+ "author": "Keith Koong",
+ "image": "college_classes_logo_178x138.jpg",
+ "deck": null,
+ "publication": "CollegeClasses.com",
+ "publish_date": "October 17, 2012"
+ },
+ {
+ "title": "Cengage Learning to Provide Book Content and Pedagogy through edX's Not-for-Profit Interactive Study Via the Web",
+ "url": "http://www.outsellinc.com/our_industry/headlines/1087978",
+ "author": null,
+ "image": "outsell_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Outsell.com",
+ "publish_date": "October 17, 2012"
+ },
+ {
+ "title": "University of Texas System Embraces MOOCs",
+ "url": "http://www.usnewsuniversitydirectory.com/articles/university-of-texas-system-embraces-moocs_12713.aspx#.UIBLVq7bNzo",
+ "author": "Chris Hassan",
+ "image": "usnews_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "US News",
+ "publish_date": "October 17, 2012"
+ },
+ {
+ "title": "Texas MOOCs for Credit?",
+ "url": "http://www.insidehighered.com/news/2012/10/16/u-texas-aims-use-moocs-reduce-costs-increase-completion",
+ "author": "Steve Kolowich",
+ "image": "insidehighered_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Insider Higher Ed",
+ "publish_date": "October 16, 2012"
+ },
+ {
+ "title": "University of Texas Joins Harvard-Founded edX",
+ "url": "http://www.thecrimson.com/article/2012/10/16/University-of-Texas-edX/",
+ "author": "Kevin J. Wu",
+ "image": "harvardcrimson_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "The Crimson",
+ "publish_date": "October 16, 2012"
+ },
+ {
+ "title": "Entire UT System to join edX",
+ "url": "http://tech.mit.edu/V132/N45/edx.html",
+ "author": "Ethan A. Solomon",
+ "image": "thetech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Tech",
+ "publish_date": "October 16, 2012"
+ },
+ {
+ "title": "First University System Joins edX Platform",
+ "url": "http://www.govtech.com/education/First-University-System-Joins-edX-platform.html",
+ "author": "Tanya Roscoria",
+ "image": "govtech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "GovTech.com",
+ "publish_date": "October 16, 2012"
+ },
+ {
+ "title": "University of Texas Joining Harvard, MIT Online Venture",
+ "url": "http://www.bloomberg.com/news/2012-10-15/university-of-texas-joining-harvard-mit-online-venture.html",
+ "author": "David Mildenberg",
+ "image": "bloomberg_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Bloomberg",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "University of Texas Joining Harvard, MIT Online Venture",
+ "url": "http://www.businessweek.com/news/2012-10-15/university-of-texas-joining-harvard-mit-online-venture",
+ "author": "David Mildenberg",
+ "image": "busweek_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Business Week",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "Univ. of Texas joins online course program edX",
+ "url": "http://news.yahoo.com/univ-texas-joins-online-course-program-edx-172202035--finance.html",
+ "author": "Chris Tomlinson",
+ "image": "ap_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Associated Press",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "U. of Texas Plans to Join edX",
+ "url": "http://www.insidehighered.com/quicktakes/2012/10/15/u-texas-plans-join-edx",
+ "author": null,
+ "image": "insidehighered_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Inside Higher Ed",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "U. of Texas System Is Latest to Sign Up With edX for Online Courses",
+ "url": "http://chronicle.com/blogs/wiredcampus/u-of-texas-system-is-latest-to-sign-up-with-edx-for-online-courses/40440",
+ "author": "Alisha Azevedo",
+ "image": "chroniclehighered_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Chronicle of Higher Education",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "First University System Joins edX",
+ "url": "http://www.centerdigitaled.com/news/First-University-System-Joins-edX.html",
+ "author": "Tanya Roscoria",
+ "image": "center_digeducation_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Center for Digital Education",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "University of Texas Joins Harvard, MIT in edX Online Learning Venture",
+ "url": "http://harvardmagazine.com/2012/10/university-of-texas-joins-harvard-mit-edx",
+ "author": null,
+ "image": "harvardmagazine_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Harvard Magazine",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "University of Texas joins edX",
+ "url": "http://www.masshightech.com/stories/2012/10/15/daily13-University-of-Texas-joins-edX.html",
+ "author": "Don Seiffert",
+ "image": "masshightech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "MassHighTech",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "UT System to Forge Partnership with EdX",
+ "url": "http://www.texastribune.org/texas-education/higher-education/ut-system-announce-partnership-edx/",
+ "author": "Reeve Hamilton",
+ "image": "texastribune_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Texas Tribune",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "UT System puts $5 million into online learning initiative",
+ "url": "http://www.statesman.com/news/news/local/ut-system-puts-5-million-into-online-learning-init/nSdd5/",
+ "author": "Ralph K.M. Haurwitz",
+ "image": "austin_statesman_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Austin Statesman",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "Harvard’s Online Classes Sound Pretty Popular",
+ "url": "http://blogs.bostonmagazine.com/boston_daily/2012/10/15/harvards-online-classes-sound-pretty-popular/",
+ "author": "Eric Randall",
+ "image": "bostonmag_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Boston Magazine",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "Harvard Debuts Free Online Courses",
+ "url": "http://www.ibtimes.com/harvard-debuts-free-online-courses-846629",
+ "author": "Eli Epstein",
+ "image": "ibtimes_logo_178x138.jpg",
+ "deck": null,
+ "publication": "International Business Times",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "UT System Joins Online Learning Effort",
+ "url": "http://www.texastechpulse.com/ut_system_joins_online_learning_effort/s-0045632.html",
+ "author": null,
+ "image": "texaspulse_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Texas Tech Pulse",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "University of Texas Joins edX",
+ "url": "http://www.onlinecolleges.net/2012/10/15/university-of-texas-joins-edx/",
+ "author": "Alex Wukman",
+ "image": "online_colleges_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Online Colleges.net",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "100,000 sign up for first Harvard online courses",
+ "url": "http://www.masslive.com/news/index.ssf/2012/10/100000_sign_up_for_first_harva.html",
+ "author": null,
+ "image": "ap_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Associated Press",
+ "publish_date": "October 15, 2012"
+ },
+ {
+ "title": "In the new Listener, on sale from 14.10.12",
+ "url": "http://www.listener.co.nz/commentary/the-internaut/in-the-new-listener-on-sale-from-14-10-12/",
+ "author": null,
+ "image": "nz_listener_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Listener",
+ "publish_date": "October 14, 2012"
+ },
+ {
+ "title": "HarvardX Classes to Begin Tomorrow",
+ "url": "http://www.thecrimson.com/article/2012/10/14/harvardx-classes-start-tomorrow/",
+ "author": "Hana N. Rouse",
+ "image": "harvardcrimson_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "The Crimson",
+ "publish_date": "October 14, 2012"
+ },
+ {
+ "title": "Online Harvard University courses draw well",
+ "url": "http://bostonglobe.com/metro/2012/10/14/harvard-launching-free-online-courses-sign-for-first-two-classes/zBDuHY0zqD4OESrXWfEgML/story.html",
+ "author": "Brock Parker",
+ "image": "bostonglobe_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Boston Globe",
+ "publish_date": "October 14, 2012"
+ },
+ {
+ "title": "Harvard ready to launch its first free online courses Monday",
+ "url": "http://www.boston.com/yourtown/news/cambridge/2012/10/harvard_ready_to_launch_its_fi.html",
+ "author": "Brock Parker",
+ "image": "bostonglobe_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Boston Globe",
+ "publish_date": "October 12, 2012"
+ },
+ {
+ "title": "edX: Harvard's New Domain",
+ "url": "http://www.thecrimson.com/article/2012/10/4/edx-scrutiny-online-learning/ ",
+ "author": "Delphine Rodrik and Kevin Su",
+ "image": "harvardcrimson_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "The Crimson",
+ "publish_date": "October 4, 2012"
+ },
+ {
+ "title": "New Experiments in the edX Higher Ed Petri Dish",
+ "url": "http://www.nonprofitquarterly.org/policysocial-context/21116-new-experiments-in-the-edx-higher-ed-petri-dish.html",
+ "author": "Michelle Shumate",
+ "image": "npq_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Non-Profit Quarterly",
+ "publish_date": "October 4, 2012"
+ },
+ {
+ "title": "What Campuses Can Learn From Online Teaching",
+ "url": "http://online.wsj.com/article/SB10000872396390444620104578012262106378182.html?mod=googlenews_wsj",
+ "author": "Rafael Reif",
+ "image": "wsj_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Wall Street Journal",
+ "publish_date": "October 2, 2012"
+ },
+ {
+ "title": "MongoDB courses to be offered via edX",
+ "url": "http://tech.mit.edu/V132/N42/edxmongodb.html",
+ "author": "Jake H. Gunter",
+ "image": "thetech_logo_178x138.jpg",
+ "deck": null,
+ "publication": "The Tech",
+ "publish_date": "October 2, 2012"
+ },
+ {
+ "title": "5 Ways That edX Could Change Education",
+ "url": "http://chronicle.com/article/5-Ways-That-edX-Could-Change/134672/",
+ "author": "Marc Parry",
+ "image": "chroniclehighered_logo_178x138.jpeg",
+ "deck": null,
+ "publication": "Chronicle of Higher Education",
+ "publish_date": "October 1, 2012"
+ },
+ {
+ "title": "MIT profs wait to teach you, for free",
+ "url": "http://www.dnaindia.com/mumbai/report_mit-profs-wait-to-teach-you-for-free_1747273",
+ "author": "Kanchan Srivastava",
+ "image": "dailynews_india_logo_178x138.jpg",
+ "deck": null,
+ "publication": "Daily News and Analysis India",
+ "publish_date": "October 1, 2012"
+ },
{
"title": "EdX offers free higher education online",
"url": "http://www.youtube.com/watch?v=yr5Ep7RN4Bs",