Merge pull request #1125 from MITx/feature/dhm/cms-settings

Feature/dhm/cms settings
This commit is contained in:
chrisndodge
2012-12-13 06:52:49 -08:00
39 changed files with 3940 additions and 106 deletions

View File

View File

@@ -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 = "<a href='foo'>bar</a>"
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, '<li><a href="#" class="is-shown" data-section="details">Course Details</a></li>', status_code=200, html=True)
# resp s/b json from here on
url = reverse('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', "<a href='foo'>bar</a>")
# 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)

View File

@@ -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):
'''

View File

@@ -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

View File

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,69 @@
<li class="input input-existing multi course-grading-assignment-list-item">
<div class="row row-col2">
<label for="course-grading-assignment-name">Assignment Type Name:</label>
<div class="field">
<div class="input course-grading-assignment-name">
<input type="text" class="long"
id="course-grading-assignment-name" value="<%= model.get('type') %>">
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-shortname">Abbreviation:</label>
<div class="field">
<div class="input course-grading-shortname">
<input type="text" class="short"
id="course-grading-assignment-shortname"
value="<%= model.get('short_label') %>">
<span class="tip tip-inline">e.g. HW, Midterm, Final</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-gradeweight">Weight of Total
Grade:</label>
<div class="field">
<div class="input course-grading-gradeweight">
<input type="text" class="short"
id="course-grading-assignment-gradeweight"
value = "<%= model.get('weight') %>">
<span class="tip tip-inline">e.g. 25%</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-totalassignments">Total
Number:</label>
<div class="field">
<div class="input course-grading-totalassignments">
<input type="text" class="short"
id="course-grading-assignment-totalassignments"
value = "<%= model.get('min_count') %>">
<span class="tip tip-inline">total exercises assigned</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-grading-assignment-droppable">Number of
Droppable:</label>
<div class="field">
<div class="input course-grading-droppable">
<input type="text" class="short"
id="course-grading-assignment-droppable"
value = "<%= model.get('drop_count') %>">
<span class="tip tip-inline">total exercises that won't be graded</span>
</div>
</div>
</div> <a href="#" class="remove-item remove-grading-data"><span
class="delete-icon"></span> Delete Assignment Type</a>
</li>

View File

@@ -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
});

View File

@@ -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<videos.length; i++) {
// doesn't call parseFloat or Number b/c they stop on first non parsable and return what they have
if (!isFinite(videos[i].speed)) vid_errors.push(videos[i].speed + " is not a valid speed.");
// can't use get from client to test if video exists b/c of CORS (crossbrowser get not allowed)
// GET "http://gdata.youtube.com/feeds/api/videos/" + videokey
}
if (!_.isEmpty(vid_errors)) {
errors.intro_video = vid_errors.join('/n');
}
}
if (!_.isEmpty(errors)) return errors;
// NOTE don't return empty errors as that will be interpreted as an error state
},
url: function() {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/details';
},
_videoprefix : /\s*<video\s*youtube="/g,
// the below is lax to enable validation
_videospeedparse : /[^:]*/g, // /\d+\.?\d*(?=:)/g,
_videokeyparse : /([^,\/]+)/g,
_videonosuffix : /[^\"]+/g,
_getNextMatch : function (regex, string, cursor) {
regex.lastIndex = cursor;
var result = regex.exec(string);
if (_.isArray(result)) return result[0];
else return result;
},
// the whole string for editing (put in edit box)
getVideoSource: function() {
if (this.get('intro_video')) {
var cursor = 0;
var videostring = this.get('intro_video');
this._getNextMatch(this._videoprefix, videostring, cursor);
cursor = this._videoprefix.lastIndex;
return this._getNextMatch(this._videonosuffix, videostring, cursor);
}
else return "";
},
// the source closest to 1.0 speed
videosourceSample: function() {
if (this.get('intro_video')) {
var cursor = 0;
var videostring = this.get('intro_video');
this._getNextMatch(this._videoprefix, videostring, cursor);
cursor = this._videoprefix.lastIndex;
// parse from [speed:id,/s?]* to find 1.0 or take first
var parsedspeed = this._getNextMatch(this._videospeedparse, videostring, cursor);
var bestkey;
if (parsedspeed) {
cursor = this._videospeedparse.lastIndex + 1;
var bestspeed = Number(parsedspeed);
bestkey = this._getNextMatch(this._videokeyparse, videostring, cursor);
cursor = this._videokeyparse.lastIndex + 1;
while (cursor < videostring.length && bestspeed != 1.0) {
parsedspeed = this._getNextMatch(this._videospeedparse, videostring, cursor);
cursor = this._videospeedparse.lastIndex + 1;
if (Math.abs(Number(parsedspeed) - 1.0) < Math.abs(bestspeed - 1.0)) {
bestspeed = Number(parsedspeed);
bestkey = this._getNextMatch(this._videokeyparse, videostring, cursor);
}
else this._getNextMatch(this._videokeyparse, videostring, cursor);
cursor = this._videokeyparse.lastIndex + 1;
}
}
else {
bestkey = this._getNextMatch(this._videokeyparse, videostring, cursor);
}
if (bestkey) {
// WTF? for some reason bestkey is an array [key, key] (same one repeated)
if (_.isArray(bestkey)) bestkey = bestkey[0];
return "http://www.youtube.com/embed/" + bestkey;
}
else return "";
}
},
parse_videosource: function(videostring) {
// used to validate before set so cannot get from model attr. Returns [{ speed: fff, key: sss }]
var cursor = 0;
this._getNextMatch(this._videoprefix, videostring, cursor);
cursor = this._videoprefix.lastIndex;
videostring = this._getNextMatch(this._videonosuffix, videostring, cursor);
cursor = 0;
// parsed to "fff:kkk,fff:kkk"
var result = new Array();
while (cursor < videostring.length) {
var speed = this._getNextMatch(this._videospeedparse, videostring, cursor);
if (speed) cursor = this._videospeedparse.lastIndex + 1;
else return result;
var key = this._getNextMatch(this._videokeyparse, videostring, cursor);
cursor = this._videokeyparse.lastIndex + 1;
// See the WTF above
if (_.isArray(key)) key = key[0];
result.push({speed: speed, key: key});
}
return result;
},
save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> 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', '<video youtube="' + newsource + '"/>');
return this.videosourceSample();
}
});

View File

@@ -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);
}
});

View File

@@ -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;
}
}
}
})

View File

@@ -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]) {

View File

@@ -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('<span class="message-error"><%= message %></span>'),
events : {
"blur input" : "clearValidationErrors",
"blur textarea" : "clearValidationErrors"
},
fieldToSelectorMap : {
// Your subclass must populate this w/ all of the model keys and dom selectors
// which may be the subjects of validation errors
},
_cacheValidationErrors : [],
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('<a href="<%= fullpath %>"> <i class="ss-icon ss-standard">&#x1F4C4;</i><%= filename %></a>');
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('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable>' +
'<%= descriptor %>' +
'</span><span class="range"></span>' +
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
'</li>');
// Instrument grading scale
// convert cutoffs to inversely ordered list
var modelCutoffs = this.model.get('grade_cutoffs');
for (cutoff in modelCutoffs) {
this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)});
}
this.descendingCutoffs = _.sortBy(this.descendingCutoffs,
function (gradeEle) { return -gradeEle['cutoff']; });
// Instrument grace period
this.$el.find('#course-grading-graceperiod').timepicker();
// instantiates an editor template for each update in the collection
// Because this calls render, put it after everything which render may depend upon to prevent race condition.
window.templateLoader.loadRemoteTemplate("course_info_update",
"/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();
}
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -89,7 +89,6 @@
.new-course-save {
@include blue-button;
// padding: ;
}
.new-course-cancel {

View File

@@ -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;
}
}
}
}
}
}
}

View File

@@ -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;

View File

@@ -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';

730
cms/templates/settings.html Normal file
View File

@@ -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">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
<script src="${static.url('js/vendor/timepicker/jquery.timepicker.js')}"></script>
<script src="${static.url('js/vendor/timepicker/datepair.js')}"></script>
<script src="${static.url('js/vendor/date.js')}"></script>
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_details.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_settings.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
<script type="text/javascript">
$(document).ready(function(){
var settingsModel = new CMS.Models.Settings.CourseSettings({
courseLocation: new CMS.Models.Location('${context_course.location}',{parse:true}),
details: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true})
});
var editor = new CMS.Views.Settings.Main({
el: $('.main-wrapper'),
model : settingsModel
});
editor.render();
});
</script>
</%block>
<%block name="content">
<!-- -->
<div class="main-wrapper">
<div class="inner-wrapper">
<h1>Settings</h1>
<article class="settings-overview">
<div class="sidebar">
<nav class="settings-page-menu">
<ul>
<li><a href="#" class="is-shown" data-section="details">Course Details</a></li>
<!-- <li><a href="#" data-section="faculty">Faculty</a></li> -->
<li><a href="#" data-section="grading">Grading</a></li>
<!-- <li><a href="#" data-section="problems">Problems</a></li> -->
<!-- <li><a href="#" data-section="discussions">Discussions</a></li> -->
</ul>
</nav>
</div>
<div class="settings-page-section main-column">
<section class="settings-details is-shown">
<h2 class="title">Course Details</h2>
<section class="settings-details-basic">
<header>
<h3>Basic Information</h3>
<span class="detail">The nuts and bolts of your course</span>
</header>
<div class="row row-col2">
<label for="course-name">Course Name:</label>
<div class="field">
<div class="input">
<input type="text" class="long" id="course-name" value="[Course Name]" disabled="disabled">
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course URL</a>, and cannot be changed</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-organization">Organization:</label>
<div class="field">
<div class="input">
<input type="text" class="long" id="course-organization" value="[Course Organization]" disabled="disabled">
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course URL</a>, and cannot be changed</span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-number">Course Number:</label>
<div class="field">
<div class="input">
<input type="text" class="short" id="course-number" value="[Course No.]" disabled="disabled">
<span class="tip tip-inline">e.g. 101x</span>
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course URL</a>, and cannot be changed</span>
</div>
</div>
</div>
</section><!-- .settings-details-basic -->
<hr class="divide" />
<section class="settings-details-schedule">
<header>
<h3>Course Schedule</h3>
<span class="detail">Important steps and segments of your course</span>
</header>
<div class="row row-col2">
<h4 class="label">Course Dates:</h4>
<div class="field">
<div class="input multi multi-inline" id="course-start">
<div class="group">
<label for="course-start-date">Start Date</label>
<input type="text" class="start-date date start datepicker" id="course-start-date" placeholder="MM/DD/YYYY" autocomplete="off">
<span class="tip tip-stacked">First day the course begins</span>
</div>
<div class="group">
<label for="course-start-time">Start Time</label>
<input type="text" class="time start timepicker" id="course-start-time" value="" placeholder="HH:MM" autocomplete="off">
<span class="tip tip-stacked" id="timezone"></span>
</div>
</div>
</div>
<div class="field field-additional">
<div class="input multi multi-inline" id="course-end">
<div class="group">
<label for="course-end-date">End Date</label>
<input type="text" class="end-date date end" id="course-end-date" placeholder="MM/DD/YYYY" autocomplete="off">
<span class="tip tip-stacked">Last day the course is active</span>
</div>
<div class="group">
<label for="course-end-time">End Time</label>
<input type="text" class="time end" id="course-end-time" value="" placeholder="HH:MM" autocomplete="off">
<span class="tip tip-stacked" id="timezone"></span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<h4 class="label">Enrollment Dates:</h4>
<div class="field">
<div class="input multi multi-inline" id="enrollment-start">
<div class="group">
<label for="course-enrollment-start-date">Start Date</label>
<input type="text" class="start-date date start" id="course-enrollment-start-date" placeholder="MM/DD/YYYY" autocomplete="off">
<span class="tip tip-stacked">First day students can enroll</span>
</div>
<div class="group">
<label for="course-enrollment-start-time">Start Time</label>
<input type="text" class="time start" id="course-enrollment-start-time" value="" placeholder="HH:MM" autocomplete="off">
<span class="tip tip-stacked" id="timezone"></span>
</div>
</div>
</div>
<div class="field field-additional">
<div class="input multi multi-inline" id="enrollment-end">
<div class="group">
<label for="course-enrollment-end-date">End Date</label>
<input type="text" class="end-date date end" id="course-enrollment-end-date" placeholder="MM/DD/YYYY" autocomplete="off">
<span class="tip tip-stacked">Last day students can enroll</span>
</div>
<div class="group">
<label for="course-enrollment-end-time">End Time</label>
<input type="text" class="time end" id="course-enrollment-end-time" value="" placeholder="HH:MM" autocomplete="off">
<span class="tip tip-stacked" id="timezone"></span>
</div>
</div>
</div>
</div>
<!-- <div class="row row-col2">
<label for="course-syllabus">Course Syllabus</label>
<div class="field">
<div class="input input-existing">
<div class="current current-course-syllabus">
<span class="doc-filename"></span>
<a href="#" class="remove-item remove-course-syllabus remove-doc-data" id="course-syllabus"><span class="delete-icon"></span> Delete Syllabus</a>
</div>
</div>
<div class="input">
<a href="#" class="new-item new-course-syllabus add-syllabus-data" id="course-syllabus">
<span class="upload-icon"></span>Upload Syllabus
</a>
<span class="tip tip-inline">PDF formatting preferred</span>
</div>
</div>
</div> -->
</section><!-- .settings-details-schedule -->
<hr class="divide" />
<section class="setting-details-marketing">
<header>
<h3>Introducing Your Course</h3>
<span class="detail">Information for perspective students</span>
</header>
<div class="row row-col2">
<label for="course-overview">Course Overview:</label>
<div class="field">
<div class="input">
<textarea class="long tall edit-box tinymce" id="course-overview"></textarea>
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course summary page</a></span>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-introduction-video">Introduction Video:</label>
<div class="field">
<div class="input input-existing">
<div class="current current-course-introduction-video">
<iframe width="380" height="215" src="" frameborder="0" allowfullscreen></iframe>
<a href="#" class="remove-item remove-course-introduction-video remove-video-data"><span class="delete-icon"></span> Delete Video</a>
</div>
</div>
<div class="input">
<input type="text" class="new-item new-course-introduction-video add-video-data" id="course-introduction-video" value="" placeholder="speed:id,speed:id" autocomplete="off">
<span class="tip tip-inline">Video restrictions go here</span>
</div>
</div>
</div>
</section><!-- .settings-details-marketing -->
<hr class="divide" />
<section class="settings-details-requirements">
<header>
<h3>Requirements</h3>
<span class="detail">Expectations of the students taking this course</span>
</header>
<div class="row row-col2">
<label for="course-effort">Hours of Effort per Week:</label>
<div class="field">
<div class="input">
<input type="text" class="short time" id="course-effort" placeholder="HH:MM">
<span class="tip tip-stacked">Time students should spend on all course work</span>
</div>
</div>
</div>
</section>
</section><!-- .settings-details -->
<section class="settings-faculty">
<h2 class="title">Faculty</h2>
<section class="settings-faculty-members">
<header>
<h3>Faculty Members</h3>
<span class="detail">Individuals instructing and help with this course</span>
</header>
<div class="row">
<div class="field enum">
<ul class="input-list course-faculty-list">
<li class="input input-existing multi course-faculty-list-item">
<div class="row row-col2">
<label for="course-faculty-1-firstname">Faculty First Name:</label>
<div class="field">
<input type="text" class="long" id="course-faculty-1-firstname">
</div>
</div>
<div class="row row-col2">
<label for="course-faculty-1-lastname">Faculty Last Name:</label>
<div class="field">
<input type="text" class="long" id="course-faculty-1-lastname">
</div>
</div>
<div class="row row-col2">
<label for="course-faculty-1-photo">Faculty Photo</label>
<div class="field">
<div class="input input-existing">
<div class="current current-faculty-1-photo">
<img src="http://placehold.it/400x300&text=Faculty+Photo" alt="Faculty Photo" />
<a href="#" class="remove-item remove-faculty-photo remove-video-data"><span class="delete-icon"></span> Delete Faculty Photo</a>
</div>
</div>
</div>
</div>
<div class="row">
<label for="course-faculty-1-bio">Faculty Bio:</label>
<div class="field">
<textarea class="long tall edit-box tinymce" id="course-faculty-1-bio"></textarea>
<span class="tip tip-stacked">A brief description of your education, experience, and expertise</span>
</div>
</div>
<a href="#" class="remove-item remove-faculty-data"><span class="delete-icon"></span> Delete Faculty Member</a>
</li>
<li class="input multi course-faculty-list-item">
<div class="row row-col2">
<label for="course-faculty-2-firstname">Faculty First Name:</label>
<div class="field">
<input type="text" class="long" id="course-faculty-2-firstname">
</div>
</div>
<div class="row row-col2">
<label for="course-faculty-2-lastname">Faculty Last Name:</label>
<div class="field">
<input type="text" class="long" id="course-faculty-2-lastname">
</div>
</div>
<div class="row row-col2">
<label for="course-faculty-2-photo">Faculty Photo</label>
<div class="field">
<div class="input">
<a href="#" class="new-item new-faculty-photo add-faculty-photo-data" id="course-faculty-2-photo">
<span class="upload-icon"></span>Upload Faculty Photo
</a>
<span class="tip tip-inline">Max size: 30KB</span>
</div>
</div>
</div>
<div class="row">
<label for="course-faculty-2-bio">Faculty Bio:</label>
<div class="field">
<div clas="input">
<textarea class="long tall edit-box tinymce" id="course-faculty-2-bio"></textarea>
<span class="tip tip-stacked">A brief description of your education, experience, and expertise</span>
</div>
</div>
</div>
</li>
</ul>
<a href="#" class="new-item new-course-faculty-item add-faculty-data">
<span class="plus-icon"></span>New Faculty Member
</a>
</div>
</div>
</section>
</section><!-- .settings-staff -->
<section class="settings-grading">
<h2 class="title">Grading</h2>
<section class="settings-grading-range">
<header>
<h3>Overall Grade Range</h3>
<span class="detail">Course grade ranges and their values</span>
</header>
<div class="row">
<div class="grade-controls course-grading-range well">
<a href="#" class="new-grade-button"><span class="plus-icon"></span></a>
<div class="grade-slider">
<div class="grade-bar">
<ol class="increments">
<li class="increment-0">0</li>
<li class="increment-10">10</li>
<li class="increment-20">20</li>
<li class="increment-30">30</li>
<li class="increment-40">40</li>
<li class="increment-50">50</li>
<li class="increment-60">60</li>
<li class="increment-70">70</li>
<li class="increment-80">80</li>
<li class="increment-90">90</li>
<li class="increment-100">100</li>
</ol>
<ol class="grades">
</ol>
</div>
</div>
</div>
</div>
</section>
<section class="settings-grading-general">
<header>
<h3>General Grading</h3>
<span class="detail">Deadlines and Requirements</span>
</header>
<div class="row row-col2">
<label for="course-grading-graceperiod">Grace Period on Deadline:</label>
<div class="field">
<div class="input">
<input type="text" class="short time" id="course-grading-graceperiod" value="0:00" placeholder="e.g. 10 minutes">
<span class="tip tip-stacked">leeway on due dates</span>
</div>
</div>
</div>
</section>
<section class="setting-grading-assignment-types">
<header>
<h3>Assignment Types</h3>
</header>
<div class="row">
<div class="field enum">
<ul class="input-list course-grading-assignment-list">
</ul>
<a href="#" class="new-item new-course-grading-item add-grading-data">
<span class="plus-icon"></span>New Assignment Type
</a>
</div>
</div>
</section>
</section><!-- .settings-grading -->
<section class="settings-problems">
<h2 class="title">Problems</h2>
<section class="settings-problems-general">
<header>
<h3>General Settings</h3>
<span class="detail">Course-wide settings for all problems</span>
</header>
<div class="row row-col2">
<h4 class="label">Problem Randomization:</h4>
<div class="field">
<div class="input input-radio">
<input checked="checked" type="radio" name="course-problems-general-randomization" id="course-problems-general-randomization-always" value="Always">
<div class="copy">
<label for="course-problems-general-randomization-always">Always</label>
<span class="tip tip-stacked"><strong>randomize all</strong> problems</span>
</div>
</div>
<div class="input input-radio">
<input type="radio" name="course-problems-general-randomization" id="course-problems-general-randomization-never" value="Never">
<div class="copy">
<label for="course-problems-general-randomization-never">Never</label>
<span class="tip tip-stacked"><strong>do not randomize</strong> problems</span>
</div>
</div>
<div class="input input-radio">
<input type="radio" name="course-problems-general-randomization" id="course-problems-general-randomization-perstudent" value="Per Student">
<div class="copy">
<label for="course-problems-general-randomization-perstudent">Per Student</label>
<span class="tip tip-stacked">randomize problems <strong>per student</strong></span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<h4 class="label">Show Answers:</h4>
<div class="field">
<div class="input input-radio">
<input checked="checked" type="radio" name="course-problems-general-showanswer" id="course-problems-general-showanswer-always" value="Always">
<div class="copy">
<label for="course-problems-general-showanswer-always">Always</label>
<span class="tip tip-stacked">Answers will be shown after the number of attempts has been met</span>
</div>
</div>
<div class="input input-radio">
<input type="radio" name="course-problems-general-showanswer" id="course-problems-general-showanswer-never" value="Never">
<div class="copy">
<label for="course-problems-general-showanswer-never">Never</label>
<span class="tip tip-stacked">Answers will never be shown, regardless of attempts</span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<label for="pcourse-roblems-general-attempts">Number of Attempts <br /> Allowed on Problems: </label>
<div class="field">
<div class="input">
<input type="text" class="short" id="course-problems-general-attempts" placeholder="0 or higher" value="0">
<span class="tip tip-stacked">Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0"</span>
</div>
</div>
</div>
</section><!-- .settings-problems-general -->
<section class="settings-problems-assignment-1 settings-extras">
<header>
<h3>[Assignment Type Name]</h3>
</header>
<div class="row row-col2">
<h4 class="label">Problem Randomization:</h4>
<div class="field">
<div class="input input-radio">
<input checked="checked" type="radio" name="course-problems-assignment-1-randomization" id="course-problems-assignment-1-randomization-always" value="Always">
<div class="copy">
<label for="course-problems-assignment-1-randomization-always">Always</label>
<span class="tip tip-stacked"><strong>randomize all</strong> problems</span>
</div>
</div>
<div class="input input-radio">
<input type="radio" name="course-problems-assignment-1-randomization" id="course-problems-assignment-1-randomization-never" value="Never">
<div class="copy">
<label for="course-problems-assignment-1-randomization-never">Never</label>
<span class="tip tip-stacked"><strong>do not randomize</strong> problems</span>
</div>
</div>
<div class="input input-radio">
<input type="radio" name="course-problems-assignment-1-randomization" id="course-problems-assignment-1-randomization-perstudent" value="Per Student">
<div class="copy">
<label for="course-problems-assignment-1-randomization-perstudent">Per Student</label>
<span class="tip tip-stacked">randomize problems <strong>per student</strong></span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<h4 class="label">Show Answers:</h4>
<div class="field">
<div class="input input-radio">
<input checked="checked" type="radio" name="course-problems-assignment-1-showanswer" id="course-problems-assignment-1-showanswer-always" value="Always">
<div class="copy">
<label for="course-problems-assignment-1-showanswer-always">Always</label>
<span class="tip tip-stacked">Answers will be shown after the number of attempts has been met</span>
</div>
</div>
<div class="input input-radio">
<input type="radio" name="course-problems-assignment-1-showanswer" id="course-problems-assignment-1-showanswer-never" value="Never">
<div class="copy">
<label for="pcourse-roblems-assignment-1-showanswer-never">Never</label>
<span class="tip tip-stacked">Answers will never be shown, regardless of attempts</span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<label for="course-problems-assignment-1-attempts">Number of Attempts <br /> Allowed on Problems: </label>
<div class="field">
<div class="input">
<input type="text" class="short" id="course-problems-assignment-1-attempts" placeholder="0 or higher" value="0">
<span class="tip tip-stacked">Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0"</span>
</div>
</div>
</div>
</section><!-- .settings-problems-assignment-1 -->
</section><!-- .settings-problems -->
<section class="settings-discussions">
<h2 class="title">Discussions</h2>
<section class="settings-discussions-general">
<header>
<h3>General Settings</h3>
<span class="detail">Course-wide settings for online discussion</span>
</header>
<div class="row row-col2">
<h4 class="label">Anonymous Discussions:</h4>
<div class="field">
<div class="input input-radio">
<input type="radio" name="course-discussions-anonymous" id="course-discussions-anonymous-allow" value="Allow">
<div class="copy">
<label for="course-discussions-anonymous-allow">Allow</label>
<span class="tip tip-stacked">Students and faculty <strong>will be able to post anonymously</strong></span>
</div>
</div>
<div class="input input-radio">
<input checked="checked" type="radio" name="course-discussions-anonymous" id="course-discussions-anonymous-dontallow" value="Do Not Allow">
<div class="copy">
<label for="course-discussions-anonymous-dontallow">Do not allow</label>
<span class="tip tip-stacked"><strong>Posting anonymously is not allowed</strong>. Any previous anonymous posts <strong>will be reverted to non-anonymous</strong></span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<h4 class="label">Anonymous Discussions:</h4>
<div class="field">
<div class="input input-radio">
<input checked="checked" type="radio" name="course-discussions-anonymous" id="course-discussions-anonymous-allow" value="Allow">
<div class="copy">
<label for="course-discussions-anonymous-allow">Allow</label>
<span class="tip tip-stacked">Students and faculty <strong>will be able to post anonymously</strong></span>
</div>
</div>
<div class="input input-radio">
<input disabled="disabled" type="radio" name="course-discussions-anonymous" id="course-discussions-anonymous-dontallow" value="Do Not Allow">
<div class="copy">
<label for="course-discussions-anonymous-dontallow">Do not allow</label>
<span class="tip tip-stacked">This option is disabled since there are previous discussions that are anonymous.</span>
</div>
</div>
</div>
</div>
<div class="row row-col2">
<h4 class="label">Discussion Categories</h4>
<div class="field enum">
<ul class="input-list course-discussions-categories-list sortable">
<li class="input input-existing input-default course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-1-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-1-name" placeholder="" value="General" disabled="disabled">
</div>
<a href="#" class="drag-handle"></a>
</li>
<li class="input input-existing input-default course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-2-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-2-name" placeholder="" value="Feedback" disabled="disabled">
</div>
<a href="#" class="drag-handle"></a>
</li>
<li class="input input-existing input-default course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-3-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-3-name" placeholder="" value="Troubleshooting" disabled="disabled">
</div>
<a href="#" class="drag-handle"></a>
</li>
<li class="input input-existing course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-4-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-4-name" placeholder="" value="Study Groups">
<a href="#" class="remove-item remove-course-discussions-categories-data"><span class="delete-icon"></span> Delete Category</a>
</div>
<a href="#" class="drag-handle"></a>
</li>
<li class="input input-existing course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-5-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-5-name" placeholder="" value="Lectures">
</div>
<a href="#" class="remove-item remove-course-discussions-categories-data"><span class="delete-icon"></span> Delete Category</a>
<a href="#" class="drag-handle"></a>
</li>
<li class="input input-existing course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-6-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-6-name" placeholder="" value="Labs">
</div>
<a href="#" class="remove-item remove-course-discussions-categories-data"><span class="delete-icon"></span> Delete Category</a>
<a href="#" class="drag-handle"></a>
</li>
<li class="input input-existing course-discussions-categories-list-item sortable-item">
<div class="group">
<label for="course-discussions-categories-6-name">Category Name: </label>
<input type="text" class="course-discussions-categories-name" id="course-discussions-categories-6-name" placeholder="" value="">
</div>
<a href="#" class="remove-item remove-course-discussions-categories-data"><span class="delete-icon"></span> Delete Category</a>
<a href="#" class="drag-handle"></a>
</li>
</ul>
<a href="#" class="new-item new-course-discussions-categories-item add-categories-data">
<span class="plus-icon"></span>New Discussion Category
</a>
</div>
</div>
</section><!-- .settings-discussions-general -->
</section><!-- .settings-discussions -->
</div>
</article>
</div>
</div>
</%block>

View File

@@ -14,6 +14,7 @@
<li><a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}" id='pages-tab'>Tabs</a></li>
<li><a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='assets-tab'>Assets</a></li>
<li><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}" id='users-tab'>Users</a></li>
<li><a href="${reverse('course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='settings-tab'>Settings</a></li>
<li><a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='import-tab'>Import</a></li>
</ul>
% endif

View File

@@ -35,9 +35,10 @@ urlpatterns = ('',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$',
'contentstore.views.remove_user', name='remove_user'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$', '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<org>[^/]+)/(?P<course>[^/]+)/course_info/updates$', 'contentstore.views.course_info_updates', name='course_info'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course_info/updates/(?P<provided_id>.*)$', 'contentstore.views.course_info_updates', name='course_info'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings/(?P<name>[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/grades/(?P<name>[^/]+)/(?P<grader_index>.*)$', 'contentstore.views.course_grader_updates', name='course_settings'),
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages',
name='static_pages'),
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),

View File

View File

View File

@@ -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 = ""

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):
"""

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
roots/2012_Fall.xml

View File

@@ -0,0 +1 @@
<course org="edX" course="graded" url_name="2012_Fall"/>

View File

@@ -1 +0,0 @@
roots/2012_Fall.xml

View File

@@ -0,0 +1 @@
<course org="edX" course="sa_test" url_name="2012_Fall"/>

View File

@@ -1 +0,0 @@
roots/2012_Fall.xml

View File

@@ -0,0 +1 @@
<course org="edX" course="test_start_date" url_name="2012_Fall"/>

View File

@@ -1 +0,0 @@
roots/2012_Fall.xml

View File

@@ -0,0 +1 @@
<course org="edX" course="toy" url_name="2012_Fall"/>

View File

@@ -1 +0,0 @@
../../../common/static/images/edge-logo-small.png

Before

Width:  |  Height:  |  Size: 49 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 B

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1 +0,0 @@
../../../../common/static/sass/_mixins.scss

View File

@@ -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;
}

View File

@@ -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": "Harvards 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",