Commit 62523760 by Chris Dodge

Merge branch 'feature/cale/cms-master' of github.com:MITx/mitx into feature/cdodge/export

Conflicts:
	common/lib/xmodule/xmodule/contentstore/content.py
	common/lib/xmodule/xmodule/contentstore/mongo.py
parents 10b417c8 53b73ca3
......@@ -55,6 +55,39 @@ def create_new_course_group(creator, location, role):
return
'''
This is to be called only by either a command line code path or through a app which has already
asserted permissions
'''
def _delete_course_group(location):
# remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
user.groups.remove(instructors)
user.save()
staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME))
for user in staff.user_set.all():
user.groups.remove(staff)
user.save()
'''
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
'''
def _copy_course_group(source, dest):
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
user.groups.add(new_instructors_group)
user.save()
staff = Group.objects.get(name=get_course_groupname_for_role(source, STAFF_ROLE_NAME))
new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME))
for user in staff.user_set.all():
user.groups.add(new_staff_group)
user.save()
def add_user_to_course_group(caller, user, location, role):
# only admins can add/remove other users
......
......@@ -4,6 +4,7 @@ from xmodule.modulestore.django import modulestore
from lxml import etree
import re
from django.http import HttpResponseBadRequest
import logging
## TODO store as array of { date, content } and override course_info_module.definition_from_xml
## This should be in a class which inherits from XmlDescriptor
......@@ -23,27 +24,28 @@ def get_course_updates(location):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True))
course_html_parsed = etree.fromstring(course_updates.definition['data'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
course_upd_collection = []
if course_html_parsed.tag == 'ol':
# 0 is the oldest so that new ones get unique idx
for idx, update in enumerate(course_html_parsed.iter("li")):
# 0 is the newest
for idx, update in enumerate(course_html_parsed):
if (len(update) == 0):
continue
elif (len(update) == 1):
content = update.find("h2").tail
# could enforce that update[0].tag == 'h2'
content = update[0].tail
else:
content = etree.tostring(update[1])
content = "\n".join([etree.tostring(ele) for ele in update[1:]])
course_upd_collection.append({"id" : location_base + "/" + str(idx),
# make the id on the client be 1..len w/ 1 being the oldest and len being the newest
course_upd_collection.append({"id" : location_base + "/" + str(len(course_html_parsed) - idx),
"date" : update.findtext("h2"),
"content" : content})
# return newest to oldest
course_upd_collection.reverse()
return course_upd_collection
def update_course_updates(location, update, passed_id=None):
......@@ -59,43 +61,25 @@ def update_course_updates(location, update, passed_id=None):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True))
course_html_parsed = etree.fromstring(course_updates.definition['data'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
try:
new_html_parsed = etree.fromstring(update['content'], etree.XMLParser(remove_blank_text=True))
except etree.XMLSyntaxError:
new_html_parsed = None
# No try/catch b/c failure generates an error back to client
new_html_parsed = etree.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
if passed_id:
element = course_html_parsed.findall("li")[get_idx(passed_id)]
element[0].text = update['date']
if (len(element) == 1):
if new_html_parsed is not None:
element[0].tail = None
element.append(new_html_parsed)
else:
element[0].tail = update['content']
else:
if new_html_parsed is not None:
element[1] = new_html_parsed
else:
element.pop(1)
element[0].tail = update['content']
idx = get_idx(passed_id)
# idx is count from end of list
course_html_parsed[-idx] = new_html_parsed
else:
idx = len(course_html_parsed.findall("li"))
course_html_parsed.insert(0, new_html_parsed)
idx = len(course_html_parsed)
passed_id = course_updates.location.url() + "/" + str(idx)
element = etree.SubElement(course_html_parsed, "li")
date_element = etree.SubElement(element, "h2")
date_element.text = update['date']
if new_html_parsed is not None:
element.append(new_html_parsed)
else:
date_element.tail = update['content']
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
......@@ -121,15 +105,17 @@ def delete_course_update(location, update, passed_id):
# TODO use delete_blank_text parser throughout and cache as a static var in a class
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True))
course_html_parsed = etree.fromstring(course_updates.definition['data'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
element_to_delete = course_html_parsed.xpath('/ol/li[position()=' + str(get_idx(passed_id) + 1) + "]")
if element_to_delete:
course_html_parsed.remove(element_to_delete[0])
idx = get_idx(passed_id)
# idx is count from end of list
element_to_delete = course_html_parsed[-idx]
if element_to_delete is not None:
course_html_parsed.remove(element_to_delete)
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
......@@ -143,6 +129,6 @@ def get_idx(passed_id):
From the url w/ idx appended, get the idx.
"""
# TODO compile this regex into a class static and reuse for each call
idx_matcher = re.search(r'.*/(\d)+$', passed_id)
idx_matcher = re.search(r'.*/(\d+)$', passed_id)
if idx_matcher:
return int(idx_matcher.group(1))
\ No newline at end of file
###
### Script for cloning a course
###
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from auth.authz import _copy_course_group
#
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
#
class Command(BaseCommand):
help = \
'''Clone a MongoDB backed course to another location'''
def handle(self, *args, **options):
if len(args) != 2:
raise CommandError("clone requires two arguments: <source-location> <dest-location>")
source_location_str = args[0]
dest_location_str = args[1]
ms = modulestore('direct')
cs = contentstore()
print "Cloning course {0} to {1}".format(source_location_str, dest_location_str)
source_location = CourseDescriptor.id_to_location(source_location_str)
dest_location = CourseDescriptor.id_to_location(dest_location_str)
if clone_course(ms, cs, source_location, dest_location):
print "copying User permissions..."
_copy_course_group(source_location, dest_location)
###
### Script for cloning a course
###
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from prompt import query_yes_no
from auth.authz import _delete_course_group
#
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
#
class Command(BaseCommand):
help = \
'''Delete a MongoDB backed course'''
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("delete_course requires one argument: <location>")
loc_str = args[0]
ms = modulestore('direct')
cs = contentstore()
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc) == True:
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
_delete_course_group(loc)
import sys
def query_yes_no(question, default="yes"):
"""Ask a yes/no question via raw_input() and return their answer.
"question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits <Enter>.
It must be "yes" (the default), "no" or None (meaning
an answer is required of the user).
The "answer" return value is one of "yes" or "no".
"""
valid = {"yes":True, "y":True, "ye":True,
"no":False, "n":False}
if default == None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "
elif default == "no":
prompt = " [y/N] "
else:
raise ValueError("invalid default answer: '%s'" % default)
while True:
sys.stdout.write(question + prompt)
choice = raw_input().lower()
if default is not None and choice == '':
return valid[default]
elif choice in valid:
return valid[choice]
else:
sys.stdout.write("Please respond with 'yes' or 'no' "\
"(or 'y' or 'n').\n")
\ No newline at end of file
......@@ -40,11 +40,8 @@ def set_module_info(store, location, post_data):
module = store.clone_item(template_location, location)
isNew = True
logging.debug('post = {0}'.format(post_data))
if post_data.get('data') is not None:
data = post_data['data']
logging.debug('data = {0}'.format(data))
store.update_item(location, data)
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
......
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 util import converters
import calendar
from util.converters import jsdate_to_time
from django.utils.timezone import UTC
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore
import copy
# YYYY-MM-DDThh:mm:ss.s+/-HH:MM
class ConvertersTestCase(TestCase):
@staticmethod
def struct_to_datetime(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, tzinfo = UTC())
def compare_dates(self, date1, date2, expected_delta):
dt1 = ConvertersTestCase.struct_to_datetime(date1)
dt2 = ConvertersTestCase.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 CourseTestCase(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)
class CourseDetailsTestCase(CourseTestCase):
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>"
# encode - decode to convert date fields and other data which changes form
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(CourseTestCase):
def alter_field(self, url, details, field, val):
setattr(details, field, val)
# Need to partially serialize payload b/c the mock doesn't handle it correctly
payload = copy.copy(details.__dict__)
payload['course_location'] = details.course_location.url()
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
payload['enrollment_end'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_end)
resp = self.client.post(url, json.dumps(payload), "application/json")
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
@staticmethod
def convert_datetime_to_iso(datetime):
if datetime is not None:
return datetime.isoformat("T")
else:
return None
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")
utc = UTC()
self.alter_field(url, details, 'start_date', datetime.datetime(2012,11,12,1,30, tzinfo=utc))
self.alter_field(url, details, 'start_date', datetime.datetime(2012,11,1,13,30, tzinfo=utc))
self.alter_field(url, details, 'end_date', datetime.datetime(2013,2,12,1,30, tzinfo=utc))
self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012,10,12,1,30, tzinfo=utc))
self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012,11,15,1,30, tzinfo=utc))
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:
encoded_encoded = jsdate_to_time(encoded[field])
dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded)
if isinstance(details[field], datetime.datetime):
dt2 = details[field]
else:
details_encoded = jsdate_to_time(details[field])
dt2 = ConvertersTestCase.struct_to_datetime(details_encoded)
expected_delta = datetime.timedelta(0)
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " 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)
class CourseGradingTest(CourseTestCase):
def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location)
test_grader = CourseGradingModel(descriptor)
# ??? How much should this test bake in expectations about defaults and thus fail if defaults change?
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
def test_fetch_grader(self):
test_grader = CourseGradingModel.fetch(self.course_location.url())
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
test_grader = CourseGradingModel.fetch(self.course_location)
self.assertEqual(self.course_location, test_grader.course_location, "Course locations")
self.assertIsNotNone(test_grader.graders, "No graders")
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
for i, grader in enumerate(test_grader.graders):
subgrader = CourseGradingModel.fetch_grader(self.course_location, i)
self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
subgrader = CourseGradingModel.fetch_grader(self.course_location.list(), 0)
self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list")
def test_fetch_cutoffs(self):
test_grader = CourseGradingModel.fetch_cutoffs(self.course_location)
# ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think)
self.assertIsNotNone(test_grader, "No cutoffs via fetch")
test_grader = CourseGradingModel.fetch_cutoffs(self.course_location.url())
self.assertIsNotNone(test_grader, "No cutoffs via fetch with url")
def test_fetch_grace(self):
test_grader = CourseGradingModel.fetch_grace_period(self.course_location)
# almost a worthless test
self.assertIn('grace_period', test_grader, "No grace via fetch")
test_grader = CourseGradingModel.fetch_grace_period(self.course_location.url())
self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url")
def test_update_from_json(self):
test_grader = CourseGradingModel.fetch(self.course_location)
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2")
test_grader.grade_cutoffs['D'] = 0.3
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
test_grader.grace_period = {'hours' : '4'}
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
def test_update_grader_from_json(self):
test_grader = CourseGradingModel.fetch(self.course_location)
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2")
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
from django.test.testcases import TestCase
from cms.djangoapps.contentstore import utils
import mock
class LMSLinksTestCase(TestCase):
def about_page_test(self):
location = 'i4x','mitX','101','course', 'test'
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_about_page(location)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about")
def ls_link_test(self):
location = 'i4x','mitX','101','vertical', 'contacting_us'
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
link = utils.get_lms_link_for_item(location, False)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
link = utils.get_lms_link_for_item(location, True)
self.assertEquals(link, "//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
......@@ -13,6 +13,11 @@ from xmodule.modulestore.xml_importer import import_from_xml
import copy
from factories import *
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.course_module import CourseDescriptor
def parse_json(response):
"""Parse response, which is assumed to be json"""
......@@ -339,4 +344,45 @@ class ContentStoreTest(TestCase):
def test_edit_unit_full(self):
self.check_edit_unit('full')
def test_clone_course(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
resp = self.client.post(reverse('create_new_course'), self.course_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
ms = modulestore('direct')
cs = contentstore()
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course')
clone_course(ms, cs, source_location, dest_location)
# now loop through all the units in the course and verify that the clone can render them, which
# means the objects are at least present
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
clone_items = ms.get_items(Location(['i4x', 'MITx','999','vertical', None]))
self.assertGreater(len(clone_items), 0)
for descriptor in items:
new_loc = descriptor.location._replace(org = 'MITx', course='999')
print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url())
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
self.assertEqual(resp.status_code, 200)
def test_delete_course(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
cs = contentstore()
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
delete_course(ms, cs, location)
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertEqual(len(items), 0)
......@@ -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):
'''
......@@ -60,20 +73,38 @@ def get_course_for_item(location):
def get_lms_link_for_item(location, preview=False):
location = Location(location)
if settings.LMS_BASE is not None:
lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format(
preview='preview.' if preview else '',
lms_base=settings.LMS_BASE,
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
course_id=modulestore().get_containing_courses(location)[0].id,
location=location,
course_id=get_course_id(location),
location=Location(location)
)
else:
lms_link = None
return lms_link
def get_lms_link_for_about_page(location):
"""
Returns the url to the course about page from the location tuple.
"""
if settings.LMS_BASE is not None:
lms_link = "//{lms_base}/courses/{course_id}/about".format(
lms_base=settings.LMS_BASE,
course_id=get_course_id(location)
)
else:
lms_link = None
return lms_link
def get_course_id(location):
"""
Returns the course_id from a given the location tuple.
"""
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
return modulestore().get_containing_courses(Location(location))[0].id
class UnitState(object):
draft = 'draft'
......@@ -103,3 +134,12 @@ def compute_unit_state(unit):
def get_date_display(date):
return date.strftime("%d %B, %Y at %I:%M %p")
def update_item(location, value):
"""
If value is None, delete the db entry. Otherwise, update it using the correct modulestore.
"""
if value is None:
get_modulestore(location).delete_item(location)
else:
get_modulestore(location).update_item(location, value)
\ No newline at end of file
......@@ -50,28 +50,23 @@ 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
from lxml import etree
# 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
......@@ -173,6 +168,7 @@ def course_index(request, org, course, name):
'active_tab': 'courseware',
'context_course': course,
'sections': sections,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
......@@ -230,6 +226,8 @@ def edit_subsection(request, location):
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'lms_link': lms_link,
'preview_link': preview_link,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'parent_item': parent,
'policy_metadata' : policy_metadata,
'subsection_units' : subsection_units,
......@@ -324,7 +322,7 @@ def edit_unit(request, location):
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else 'Unset',
'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None,
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'unit_state': unit_state,
......@@ -345,6 +343,24 @@ def preview_component(request, location):
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
})
@expect_json
@login_required
@ensure_csrf_cookie
def assignment_type_update(request, org, course, category, name):
'''
CRUD operations on assignment types for sections and subsections and anything else gradable.
'''
location = Location(['i4x', org, course, category, name])
if not has_access(request.user, location):
raise HttpResponseForbidden()
if request.method == 'GET':
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
mimetype="application/json")
elif request.method == 'POST': # post or put, doesn't matter.
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
mimetype="application/json")
def user_author_string(user):
'''Get an author string for commits by this user. Format:
......@@ -499,7 +515,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 +564,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 +595,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 +685,6 @@ def unpublish_unit(request):
return HttpResponse()
@login_required
@expect_json
def clone_item(request):
......@@ -682,10 +696,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 +708,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()}))
......@@ -857,7 +871,8 @@ def remove_user(request, location):
def landing(request, org, course, coursename):
return render_to_response('temp-course-landing.html', {})
@login_required
@ensure_csrf_cookie
def static_pages(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename]
......@@ -877,14 +892,23 @@ def static_pages(request, org, course, coursename):
def edit_static(request, org, course, coursename):
return render_to_response('edit-static-page.html', {})
@login_required
@ensure_csrf_cookie
def edit_tabs(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename]
course_item = modulestore().get_item(location)
static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
static_tabs = modulestore('direct').get_items(static_tabs_loc)
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
if course_item.tabs is None or len(course_item.tabs) == 0:
initialize_course_tabs(course_item)
components = [
static_tab.location.url()
for static_tab
......@@ -945,6 +969,11 @@ def course_info_updates(request, org, course, provided_id=None):
# ??? No way to check for access permission afaik
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
......@@ -953,13 +982,13 @@ def course_info_updates(request, org, course, provided_id=None):
if request.method == 'GET':
return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json")
elif real_method == 'POST':
# new instance (unless django makes PUT a POST): updates are coming as POST. Not sure why.
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
elif real_method == 'PUT':
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE
return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json")
elif request.method == 'POST':
try:
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
except etree.XMLSyntaxError:
return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain")
@expect_json
......@@ -967,6 +996,10 @@ def course_info_updates(request, org, course, provided_id=None):
@ensure_csrf_cookie
def module_info(request, module_location):
location = Location(module_location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
......@@ -979,11 +1012,99 @@ 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:
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',
'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
"""
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()
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
"""
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()
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
raise Http400
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
......@@ -1085,22 +1206,25 @@ def create_new_course(request):
# set a default start date to now
new_course.metadata['start'] = stringify_time(time.gmtime())
initialize_course_tabs(new_course)
create_all_course_groups(request.user, new_course.location)
return HttpResponse(json.dumps({'id': new_course.location.url()}))
def initialize_course_tabs(course):
# set up the default tabs
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
# at least a list populated with the minimal times
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
new_course.tabs = [{"type": "courseware"},
course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
modulestore('direct').update_metadata(new_course.location.url(), new_course.own_metadata)
create_all_course_groups(request.user, new_course.location)
return HttpResponse(json.dumps({'id': new_course.location.url()}))
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
@ensure_csrf_cookie
@login_required
......
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
from cms.djangoapps.contentstore.utils import update_item
import re
import logging
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:
raw_video = get_modulestore(temploc).get_item(temploc).definition['data']
course.intro_video = CourseDetails.parse_video_tag(raw_video)
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')
update_item(temploc, jsondict['syllabus'])
temploc = temploc._replace(name='overview')
update_item(temploc, jsondict['overview'])
temploc = temploc._replace(name='effort')
update_item(temploc, jsondict['effort'])
temploc = temploc._replace(name='video')
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag)
# 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)
@staticmethod
def parse_video_tag(raw_video):
"""
Because the client really only wants the author to specify the youtube key, that's all we send to and get from the client.
The problem is that the db stores the html markup as well (which, of course, makes any sitewide changes to how we do videos
next to impossible.)
"""
if not raw_video:
return None
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher is None:
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher:
return keystring_matcher.group(0)
else:
logging.warn("ignoring the content because it doesn't not conform to expected pattern: " + raw_video)
return None
@staticmethod
def recompose_video_tag(video_key):
# TODO should this use a mako template? Of course, my hope is that this is a short-term workaround for the db not storing
# the right thing
result = None
if video_key:
result = '<iframe width="560" height="315" src="http://www.youtube.com/embed/' + \
video_key + '?autoplay=1&rel=0" frameborder="0" allowfullscreen=""></iframe>'
return result
# 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)
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
import re
from util import converters
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. It is also safe to call this method with a value of
None for graceperiodjson.
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
# Before a graceperiod has ever been created, it will be None (once it has been
# created, it cannot be set back to None).
if graceperiodjson is not None:
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 definition
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)
if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod']
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
@staticmethod
def get_section_grader_type(location):
if not isinstance(location, Location):
location = Location(location)
descriptor = get_modulestore(location).get_item(location)
return {
"graderType" : descriptor.metadata.get('format', u"Not Graded"),
"location" : location,
"id" : 99 # just an arbitrary value to
}
@staticmethod
def update_section_grader_type(location, jsondict):
if not isinstance(location, Location):
location = Location(location)
descriptor = get_modulestore(location).get_item(location)
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
descriptor.metadata['format'] = jsondict.get('graderType')
descriptor.metadata['graded'] = True
else:
if 'format' in descriptor.metadata: del descriptor.metadata['format']
if 'graded' in descriptor.metadata: del descriptor.metadata['graded']
get_modulestore(location).update_metadata(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
......@@ -59,6 +59,14 @@ MODULESTORE = {
}
}
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db' : 'xcontent',
}
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
......@@ -77,6 +85,8 @@ DATABASES = {
}
}
LMS_BASE = "localhost:8000"
CACHES = {
# This is the cache used for most things. Askbot will not work without a
# functioning cache -- it relies on caching to load its settings in places.
......
<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="delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
</li>
......@@ -3,6 +3,6 @@
"/static/js/vendor/jquery.min.js",
"/static/js/vendor/json2.js",
"/static/js/vendor/underscore-min.js",
"/static/js/vendor/backbone.js"
"/static/js/vendor/backbone-min.js"
]
}
......@@ -56,6 +56,7 @@ class CMS.Views.ModuleEdit extends Backbone.View
event.preventDefault()
data = @module.save()
data.metadata = @metadata()
$modalCover.hide()
@model.save(data).done( =>
# # showToastMessage("Your changes have been saved.", null, 3)
@module = null
......@@ -69,9 +70,11 @@ class CMS.Views.ModuleEdit extends Backbone.View
event.preventDefault()
@$el.removeClass('editing')
@$component_editor().slideUp(150)
$modalCover.hide()
clickEditButton: (event) ->
event.preventDefault()
@$el.addClass('editing')
$modalCover.show()
@$component_editor().slideDown(150)
@loadEdit()
......@@ -33,6 +33,10 @@ class CMS.Views.TabsEdit extends Backbone.View
)
$('.new-component-item').before(editor.$el)
editor.$el.addClass('new')
setTimeout(=>
editor.$el.removeClass('new')
, 500)
editor.cloneTemplate(
@model.get('id'),
......
......@@ -4,7 +4,6 @@ class CMS.Views.UnitEdit extends Backbone.View
'click .new-component .cancel-button': 'closeNewComponent'
'click .new-component-templates .new-component-template a': 'saveNewComponent'
'click .new-component-templates .cancel-button': 'closeNewComponent'
'click .new-component-button': 'showNewComponentForm'
'click .delete-draft': 'deleteDraft'
'click .create-draft': 'createDraft'
'click .publish-draft': 'publishDraft'
......@@ -54,30 +53,20 @@ class CMS.Views.UnitEdit extends Backbone.View
)
)
# New component creation
showNewComponentForm: (event) =>
event.preventDefault()
@$newComponentItem.addClass('adding')
$(event.target).fadeOut(150)
@$newComponentItem.css('height', @$newComponentTypePicker.outerHeight())
@$newComponentTypePicker.slideDown(250)
showComponentTemplates: (event) =>
event.preventDefault()
type = $(event.currentTarget).data('type')
@$newComponentTypePicker.fadeOut(250)
@$(".new-component-#{type}").fadeIn(250)
@$newComponentTypePicker.slideUp(250)
@$(".new-component-#{type}").slideDown(250)
closeNewComponent: (event) =>
event.preventDefault()
@$newComponentTypePicker.slideUp(250)
@$newComponentTypePicker.slideDown(250)
@$newComponentTemplatePickers.slideUp(250)
@$newComponentButton.fadeIn(250)
@$newComponentItem.removeClass('adding')
@$newComponentItem.find('.rendered-component').remove()
@$newComponentItem.css('height', @$newComponentButton.outerHeight())
saveNewComponent: (event) =>
event.preventDefault()
......
......@@ -2,8 +2,6 @@ var $body;
var $modal;
var $modalCover;
var $newComponentItem;
var $newComponentStep1;
var $newComponentStep2;
var $changedInput;
var $spinner;
......@@ -15,6 +13,10 @@ $(document).ready(function() {
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window, when we can access it from other
// scopes (namely the course-info tab)
window.$modalCover = $modalCover;
// Control whether template caching in local memory occurs (see template_loader.js). Caching screws up development but may
// be a good optimization in production (it works fairly well)
window.cachetemplates = false;
$body.append($modalCover);
$newComponentItem = $('.new-component-item');
......@@ -39,6 +41,8 @@ $(document).ready(function() {
$('.unit .item-actions .delete-button').bind('click', deleteUnit);
$('.new-unit-item').bind('click', createNewUnit);
$('.collapse-all-button').bind('click', collapseAll);
// autosave when a field is updated on the subsection page
$body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue);
$('.subsection-display-name-input, .unit-subtitle, .policy-list-name, .policy-list-value').each(function(i) {
......@@ -105,15 +109,12 @@ $(document).ready(function() {
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate);
// modal upload asset dialog. Bind it in the initializer otherwise multiple hanlders will get registered causing
// pretty wacky stuff to happen
$('.file-input').bind('change', startUpload);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
$body.on('click', '.section-published-date .edit-button', editSectionPublishDate);
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
$body.on('click', '.edit-subsection-publish-settings .cancel-button', hideModal)
$body.on('click', '.edit-subsection-publish-settings .cancel-button', hideModal);
$body.on('change', '.edit-subsection-publish-settings .start-date', function() {
if($('.edit-subsection-publish-settings').find('.start-time').val() == '') {
$('.edit-subsection-publish-settings').find('.start-time').val('12:00am');
......@@ -124,6 +125,11 @@ $(document).ready(function() {
});
});
function collapseAll(e) {
$('.branch').addClass('collapsed');
$('.expand-collapse-icon').removeClass('collapse').addClass('expand');
}
function editSectionPublishDate(e) {
e.preventDefault();
$modal = $('.edit-subsection-publish-settings').show();
......@@ -303,7 +309,7 @@ function checkForNewValue(e) {
this.saveTimer = setTimeout(function() {
$changedInput = $(e.target);
saveSubsection()
saveSubsection();
this.saveTimer = null;
}, 500);
}
......@@ -316,7 +322,7 @@ function autosaveInput(e) {
this.saveTimer = setTimeout(function() {
$changedInput = $(e.target);
saveSubsection()
saveSubsection();
this.saveTimer = null;
}, 500);
}
......@@ -338,23 +344,22 @@ function saveSubsection() {
// pull all 'normalized' metadata editable fields on page
var metadata_fields = $('input[data-metadata-name]');
metadata = {};
var metadata = {};
for(var i=0; i< metadata_fields.length;i++) {
el = metadata_fields[i];
var el = metadata_fields[i];
metadata[$(el).data("metadata-name")] = el.value;
}
// now add 'free-formed' metadata which are presented to the user as dual input fields (name/value)
$('ol.policy-list > li.policy-list-element').each( function(i, element) {
name = $(element).children('.policy-list-name').val();
val = $(element).children('.policy-list-value').val();
metadata[name] = val;
var name = $(element).children('.policy-list-name').val();
metadata[name] = $(element).children('.policy-list-value').val();
});
// now add any 'removed' policy metadata which is stored in a separate hidden div
// 'null' presented to the server means 'remove'
$("#policy-to-delete > li.policy-list-element").each(function(i, element) {
name = $(element).children('.policy-list-name').val();
var name = $(element).children('.policy-list-name').val();
if (name != "")
metadata[name] = null;
});
......@@ -390,7 +395,7 @@ function createNewUnit(e) {
$.post('/clone_item',
{'parent_location' : parent,
'template' : template,
'display_name': 'New Unit',
'display_name': 'New Unit'
},
function(data) {
// redirect to the edit page
......@@ -480,7 +485,7 @@ function displayFinishedUpload(xhr) {
var template = $('#new-asset-element').html();
var html = Mustache.to_html(template, resp);
$('table > tbody > tr:first').before(html);
$('table > tbody').prepend(html);
}
......@@ -493,6 +498,7 @@ function hideModal(e) {
if(e) {
e.preventDefault();
}
$('.file-input').unbind('change', startUpload);
$modal.hide();
$modalCover.hide();
}
......@@ -593,9 +599,11 @@ function hideToastMessage(e) {
function addNewSection(e, isTemplate) {
e.preventDefault();
$(e.target).addClass('disabled');
var $newSection = $($('#new-section-template').html());
var $cancelButton = $newSection.find('.new-section-name-cancel');
$('.new-courseware-section-button').after($newSection);
$('.courseware-overview').prepend($newSection);
$newSection.find('.new-section-name').focus().select();
$newSection.find('.section-name-form').bind('submit', saveNewSection);
$cancelButton.bind('click', cancelNewSection);
......@@ -632,11 +640,14 @@ function saveNewSection(e) {
function cancelNewSection(e) {
e.preventDefault();
$('.new-courseware-section-button').removeClass('disabled');
$(this).parents('section.new-section').remove();
}
function addNewCourse(e) {
e.preventDefault();
$(e.target).hide();
var $newCourse = $($('#new-course-template').html());
var $cancelButton = $newCourse.find('.new-course-cancel');
$('.new-course-button').after($newCourse);
......@@ -664,7 +675,7 @@ function saveNewCourse(e) {
'template' : template,
'org' : org,
'number' : number,
'display_name': display_name,
'display_name': display_name
},
function(data) {
if (data.id != undefined) {
......@@ -677,6 +688,7 @@ function saveNewCourse(e) {
function cancelNewCourse(e) {
e.preventDefault();
$('.new-course-button').show();
$(this).parents('section.new-course').remove();
}
......@@ -692,7 +704,7 @@ function addNewSubsection(e) {
var parent = $(this).parents("section.branch").data("id");
$saveButton.data('parent', parent)
$saveButton.data('parent', parent);
$saveButton.data('template', $(this).data('template'));
$newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection);
......@@ -757,7 +769,7 @@ function saveEditSectionName(e) {
$spinner.show();
if (display_name == '') {
alert("You must specify a name before saving.")
alert("You must specify a name before saving.");
return;
}
......@@ -794,13 +806,12 @@ function cancelSetSectionScheduleDate(e) {
function saveSetSectionScheduleDate(e) {
e.preventDefault();
input_date = $('.edit-subsection-publish-settings .start-date').val();
input_time = $('.edit-subsection-publish-settings .start-time').val();
var input_date = $('.edit-subsection-publish-settings .start-date').val();
var input_time = $('.edit-subsection-publish-settings .start-time').val();
start = getEdxTimeFromDateTimeVals(input_date, input_time);
var start = getEdxTimeFromDateTimeVals(input_date, input_time);
id = $modal.attr('data-id');
var $_this = $(this);
var id = $modal.attr('data-id');
// call into server to commit the new order
$.ajax({
......
CMS.Models.Location = Backbone.Model.extend({
defaults: {
tag: "",
org: "",
course: "",
category: "",
name: ""
},
toUrl: function(overrides) {
return
(overrides && overrides['tag'] ? overrides['tag'] : this.get('tag')) + "://" +
(overrides && overrides['org'] ? overrides['org'] : this.get('org')) + "/" +
(overrides && overrides['course'] ? overrides['course'] : this.get('course')) + "/" +
(overrides && overrides['category'] ? overrides['category'] : this.get('category')) + "/" +
(overrides && 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)) {
this._tagPattern.lastIndex = 0; // odd regex behavior requires this to be reset sometimes
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.getNextField(payload),
course: this.getNextField(payload),
category: this.getNextField(payload),
name: this.getNextField(payload)
}
}
else return null;
}
else {
return payload;
}
},
getNextField : function(payload) {
try {
return this._fieldPattern.exec(payload)[0];
}
catch (err) {
return "";
}
}
});
CMS.Models.CourseRelative = Backbone.Model.extend({
defaults: {
course_location : null, // must never be null, but here to doc the field
idx : null // the index making it unique in the containing collection (no implied sort)
}
});
CMS.Models.CourseRelativeCollection = Backbone.Collection.extend({
model : CMS.Models.CourseRelative
});
\ No newline at end of file
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')) {
if (this._videokey_illegal_chars.exec(newattrs.intro_video)) {
errors.intro_video = "Key should only contain letters, numbers, _, or -";
}
// TODO check if key points to a real video using google's youtube api
}
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';
},
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
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 (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null});
// TODO remove all whitespace w/in string
else {
if (this.get('intro_video') !== newsource) this.save('intro_video', newsource);
}
return this.videosourceSample();
},
videosourceSample : function() {
if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video');
else return "";
}
});
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
},
parse : function(attrs) {
if (attrs['weight']) {
if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
}
if (attrs['min_count']) {
if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
}
return attrs;
},
validate : function(attrs) {
var errors = {};
if (attrs['type']) {
if (_.isEmpty(attrs['type'])) {
errors.type = "The assignment type must have a name.";
}
else {
// FIXME somehow this.collection is unbound sometimes. I can't track down when
var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this);
if (existing) {
errors.type = "There's already another assignment type with this name.";
}
}
}
if (attrs['weight']) {
if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) {
errors.weight = "Please enter an integer between 0 and 100.";
}
else {
attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int
if (this.collection && attrs.weight > 0) {
// FIXME b/c saves don't update the models if validation fails, we should
// either revert the field value to the one in the model and make them make room
// or figure out a wholistic way to balance the vals across the whole
// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
// errors.weight = "The weights cannot add to more than 100.";
}
}}
if (attrs['min_count']) {
if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
errors.min_count = "Please enter an integer.";
}
else attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
errors.drop_count = "Please enter an integer.";
}
else attrs.drop_count = parseInt(attrs.drop_count);
}
if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) {
errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned.";
}
if (!_.isEmpty(errors)) return errors;
}
});
CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({
model : CMS.Models.Settings.CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/';
},
sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
}
});
\ No newline at end of file
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseSettings = Backbone.Model.extend({
// a container for the models representing the n possible tabbed states
defaults: {
courseLocation: null,
// NOTE: keep these sync'd w/ the data-section names in settings-page-menu
details: null,
faculty: null,
grading: null,
problems: null,
discussions: null
},
retrieve: function(submodel, callback) {
if (this.get(submodel)) callback();
else {
var cachethis = this;
switch (submodel) {
case 'details':
var details = new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')});
details.fetch( {
success : function(model) {
cachethis.set('details', model);
callback(model);
}
});
break;
case 'grading':
var grading = new CMS.Models.Settings.CourseGradingPolicy({course_location: this.get('courseLocation')});
grading.fetch( {
success : function(model) {
cachethis.set('grading', model);
callback(model);
}
});
break;
default:
break;
}
}
}
})
\ No newline at end of file
......@@ -5,7 +5,7 @@
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
templateVersion: "0.0.8",
templateVersion: "0.0.12",
templates: {},
loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) {
......@@ -35,7 +35,8 @@
localStorageAvailable: function() {
try {
return 'localStorage' in window && window['localStorage'] !== null;
// window.cachetemplates is global set in base.js to turn caching on/off
return window.cachetemplates && 'localStorage' in window && window['localStorage'] !== null;
} catch (e) {
return false;
}
......
......@@ -62,6 +62,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
},
onNew: function(event) {
event.preventDefault();
var self = this;
// create new obj, insert into collection, and render this one ele overriding the hidden attr
var newModel = new CMS.Models.CourseUpdate();
......@@ -69,6 +70,9 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
var $newForm = $(this.template({ updateModel : newModel }));
var updateEle = this.$el.find("#course-update-list");
$(updateEle).prepend($newForm);
var $textArea = $newForm.find(".new-update-content").first();
if (this.$codeMirror == null ) {
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
......@@ -77,9 +81,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
lineWrapping: true,
});
}
var updateEle = this.$el.find("#course-update-list");
$(updateEle).prepend($newForm);
$newForm.addClass('editing');
this.$currentPost = $newForm.closest('li');
......@@ -93,15 +95,19 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
},
onSave: function(event) {
event.preventDefault();
var targetModel = this.eventModel(event);
console.log(this.contentEntry(event).val());
targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() });
// push change to display, hide the editor, submit the change
targetModel.save({}, {error : function(model, xhr) {
// TODO use a standard component
window.alert(xhr.responseText);
}});
this.closeEditor(this);
targetModel.save();
},
onCancel: function(event) {
event.preventDefault();
// change editor contents back to model values and hide the editor
$(this.editor(event)).hide();
var targetModel = this.eventModel(event);
......@@ -109,6 +115,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
},
onEdit: function(event) {
event.preventDefault();
var self = this;
this.$currentPost = $(event.target).closest('li');
this.$currentPost.addClass('editing');
......@@ -131,6 +138,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
},
onDelete: function(event) {
event.preventDefault();
// TODO ask for confirmation
// remove the dom element and delete the model
var targetModel = this.eventModel(event);
......@@ -158,6 +166,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.$currentPost.find('form').hide();
window.$modalCover.unbind('click');
window.$modalCover.hide();
this.$codeMirror = null;
self.$currentPost.find('.CodeMirror').remove();
},
// Dereferencing from events to screen elements
......@@ -271,5 +281,7 @@ CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
this.$form.hide();
window.$modalCover.unbind('click');
window.$modalCover.hide();
self.$form.find('.CodeMirror').remove();
this.$codeMirror = null;
}
});
\ No newline at end of file
CMS.Models.AssignmentGrade = Backbone.Model.extend({
defaults : {
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral
location : null // A location object
},
initialize : function(attrs) {
if (attrs['assignmentUrl']) {
this.set('location', new CMS.Models.Location(attrs['assignmentUrl'], {parse: true}));
}
},
parse : function(attrs) {
if (attrs && attrs['location']) {
attrs.location = new CMS.Models.Location(attrs['location'], {parse: true});
}
},
urlRoot : function() {
if (this.has('location')) {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/'
+ location.get('name') + '/gradeas/';
}
else return "";
}
});
CMS.Views.OverviewAssignmentGrader = Backbone.View.extend({
// instantiate w/ { graders : CourseGraderCollection, el : <the gradable-status div> }
events : {
"click .menu-toggle" : "showGradeMenu",
"click .menu li" : "selectGradeType"
},
initialize : function() {
// call template w/ {assignmentType : formatname, graders : CourseGraderCollection instance }
this.template = _.template(
// TODO move to a template file
'<h4 class="status-label"><%= assignmentType %></h4>' +
'<a data-tooltip="Mark/unmark this subsection as graded" class="menu-toggle" href="#">' +
'<% if (!hideSymbol) {%><span class="ss-icon ss-standard">&#x2713;</span><%};%>' +
'</a>' +
'<ul class="menu">' +
'<% graders.each(function(option) { %>' +
'<li><a <% if (option.get("type") == assignmentType) {%>class="is-selected" <%}%> href="#"><%= option.get("type") %></a></li>' +
'<% }) %>' +
'<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' +
'</ul>');
this.assignmentGrade = new CMS.Models.AssignmentGrade({
assignmentUrl : this.$el.closest('.id-holder').data('id'),
graderType : this.$el.data('initial-status')});
// TODO throw exception if graders is null
this.graders = this.options['graders'];
var cachethis = this;
// defining here to get closure around this
this.removeMenu = function(e) {
e.preventDefault();
cachethis.$el.removeClass('is-active');
$(document).off('click', cachethis.removeMenu);
}
this.hideSymbol = this.options['hideSymbol'];
this.render();
},
render : function() {
this.$el.html(this.template({ assignmentType : this.assignmentGrade.get('graderType'), graders : this.graders,
hideSymbol : this.hideSymbol }));
if (this.assignmentGrade.has('graderType') && this.assignmentGrade.get('graderType') != "Not Graded") {
this.$el.addClass('is-set');
}
else {
this.$el.removeClass('is-set');
}
},
showGradeMenu : function(e) {
e.preventDefault();
// I sure hope this doesn't break anything but it's needed to keep the removeMenu from activating
e.stopPropagation();
// nasty global event trap :-(
$(document).on('click', this.removeMenu);
this.$el.addClass('is-active');
},
selectGradeType : function(e) {
e.preventDefault();
this.removeMenu(e);
// TODO I'm not happy with this string fetch via the html for what should be an id. I'd rather use the id attr
// of the CourseGradingPolicy model or null for Not Graded (NOTE, change template's if check for is-selected accordingly)
this.assignmentGrade.save('graderType', $(e.target).text());
this.render();
}
})
\ No newline at end of file
if (!CMS.Views['Settings']) CMS.Views.Settings = {};
// 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();
}
},
saveIfChanged : function(event) {
// returns true if the value changed and was thus sent to server
var field = this.selectorToField[event.currentTarget.id];
var currentVal = this.model.get(field);
var newVal = $(event.currentTarget).val();
if (currentVal != newVal) {
this.clearValidationErrors();
this.model.save(field, newVal);
return true;
}
else return false;
}
});
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)
});
case 'faculty':
break;
case 'grading':
return new CMS.Views.Settings.Grading({
el: this.$el.find('.settings-' + this.currentTab),
model: this.model.get(this.currentTab)
});
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.codeMirrorize(null, $('#course-overview')[0]);
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.get('intro_video'));
}
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();
var date = datefield.datepicker('getDate');
if (date) {
var time = timefield.timepicker("getSecondsFromMidnight");
if (!time) {
time = 0;
}
var newVal = new Date(date.getTime() + time * 1000);
if (cacheModel.get(fieldName) != newVal) cacheModel.save(fieldName, newVal);
}
};
// 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':
// handled via code mirror
break;
case 'course-effort':
this.saveIfChanged(event);
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);
if (this.model.has('intro_video')) {
this.$el.find('.remove-course-introduction-video').show();
}
else {
this.$el.find('.remove-course-introduction-video').hide();
}
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("");
this.$el.find('.remove-course-introduction-video').hide();
}
},
codeMirrors : {},
codeMirrorize : function(e, forcedTarget) {
if (forcedTarget) {
thisTarget = forcedTarget;
thisTarget.id = $(thisTarget).attr('id');
} else {
thisTarget = e.currentTarget;
}
if (!this.codeMirrors[thisTarget.id]) {
var cachethis = this;
var field = this.selectorToField[thisTarget.id];
this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, {
mode: "text/html", lineNumbers: true, lineWrapping: true,
onBlur : function(mirror) {
mirror.save();
cachethis.clearValidationErrors();
var newVal = mirror.getValue();
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal);
}
});
}
}
});
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 (var 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_grade_policy",
"/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('reset', 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();
var gradeCollection = this.model.get('graders');
gradeCollection.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, collection : gradeCollection });
});
// 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(e) {
e.preventDefault();
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();
var newVal = this.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
if (this.model.get('grace_period') != newVal) this.model.save('grace_period', newVal);
break;
default:
this.saveIfChanged(event);
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%
// Can probably be simplified to one variable now.
var removable = false;
var draggable = 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) {
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
cachethis.saveCutoffs();
};
},
saveCutoffs: function() {
this.model.save('grade_cutoffs',
_.reduce(this.descendingCutoffs,
function(object, cutoff) {
object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
return object;
},
{}));
},
addNewGrade: function(e) {
e.preventDefault();
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) {
e.preventDefault();
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) {
// HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving
// this in out of paranoia. If this error ever happens, the user will get a warning that they cannot
// give 2 assignments the same name.]
if (!this.model.collection) {
this.model.collection = this.collection;
}
switch (event.currentTarget.id) {
case 'course-grading-assignment-totalassignments':
this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
this.saveIfChanged(event);
break;
case 'course-grading-assignment-name':
var oldName = this.model.get('type');
if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
// overload the error display logic
this._cacheValidationErrors.push(event.currentTarget);
$(event.currentTarget).parent().append(
this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
'" subsections to "' + this.model.get('type') + '".'}));
};
break;
default:
this.saveIfChanged(event);
break;
}
},
deleteModel : function(e) {
this.model.destroy();
e.preventDefault();
}
});
\ No newline at end of file
......@@ -5,14 +5,6 @@
background-color: #fff;
}
.upload-button {
@include blue-button;
float: left;
margin-right: 20px;
padding: 8px 30px 10px;
font-size: 12px;
}
.asset-library {
@include clearfix;
......
......@@ -6,11 +6,15 @@
body {
min-width: 980px;
background: #f3f4f5;
font-family: 'Open Sans', sans-serif;
background: rgb(240, 241, 245);
font-size: 16px;
line-height: 1.6;
color: #3c3c3c;
color: $baseFontColor;
}
body,
input {
font-family: 'Open Sans', sans-serif;
}
a {
......@@ -26,7 +30,8 @@ a {
h1 {
float: left;
font-size: 28px;
margin: 36px 6px;
font-weight: 300;
margin: 24px 6px;
}
.waiting {
......@@ -34,8 +39,7 @@ h1 {
}
.page-actions {
float: right;
margin-top: 42px;
margin-bottom: 30px;
}
.main-wrapper {
......@@ -53,13 +57,6 @@ h1 {
}
}
.window {
background: #fff;
border: 1px solid $darkGrey;
border-radius: 3px;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1));
}
.sidebar {
float: right;
width: 28%;
......@@ -80,17 +77,18 @@ footer {
input[type="text"],
input[type="email"],
input[type="password"] {
input[type="password"],
textarea.text {
padding: 6px 8px 8px;
@include box-sizing(border-box);
border: 1px solid #b0b6c2;
border: 1px solid $mediumGrey;
border-radius: 2px;
@include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, .3));
background-color: #edf1f5;
@include linear-gradient($lightGrey, tint($lightGrey, 90%));
background-color: $lightGrey;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset);
font-family: 'Open Sans', sans-serif;
font-size: 11px;
color: #3c3c3c;
color: $baseFontColor;
outline: 0;
&::-webkit-input-placeholder,
......@@ -98,6 +96,11 @@ input[type="password"] {
&:-ms-input-placeholder {
color: #979faf;
}
&:focus {
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
outline: 0;
}
}
input.search {
......@@ -107,7 +110,7 @@ input.search {
border-radius: 20px;
background: url(../img/search-icon.png) no-repeat 8px 7px #edf1f5;
font-family: 'Open Sans', sans-serif;
color: #3c3c3c;
color: $baseFontColor;
outline: 0;
&::-webkit-input-placeholder {
......@@ -126,12 +129,18 @@ code {
font-family: Monaco, monospace;
}
.CodeMirror {
font-size: 13px;
border: 1px solid $darkGrey;
background: #fff;
}
.text-editor {
width: 100%;
min-height: 80px;
padding: 10px;
@include box-sizing(border-box);
border: 1px solid #b0b6c2;
border: 1px solid $mediumGrey;
@include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.3));
background-color: #edf1f5;
@include box-shadow(0 1px 2px rgba(0, 0, 0, 0.1) inset);
......@@ -173,27 +182,32 @@ code {
padding: 10px 0;
}
.details {
display: none;
margin-bottom: 30px;
font-size: 14px;
}
.window {
margin-bottom: 20px;
border: 1px solid $mediumGrey;
border-radius: 3px;
background: #fff;
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
.window-contents {
padding: 20px;
}
.details {
margin-bottom: 30px;
font-size: 14px;
}
h4 {
padding: 6px 14px;
border-bottom: 1px solid #cbd1db;
border-radius: 3px 3px 0 0;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0) 70%);
background-color: #edf1f5;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .7) inset);
border-bottom: 1px solid $mediumGrey;
border-radius: 2px 2px 0 0;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset);
font-size: 14px;
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
}
label {
......@@ -345,8 +359,9 @@ body.show-wip {
}
.new-button {
@include grey-button;
padding: 20px 0;
@include green-button;
font-size: 13px;
padding: 8px 20px 10px;
text-align: center;
&.big {
......@@ -367,4 +382,39 @@ body.show-wip {
.delete-icon {
margin-right: 4px;
}
}
.delete-button.standard {
&:hover {
background-color: tint($orange, 75%);
}
}
.tooltip {
position: absolute;
top: 0;
left: 0;
z-index: 99999;
padding: 0 10px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.85);
font-size: 11px;
font-weight: normal;
line-height: 26px;
color: #fff;
pointer-events: none;
opacity: 0;
@include transition(opacity 0.1s ease-out);
&:after {
content: '▾';
display: block;
position: absolute;
bottom: -14px;
left: 50%;
margin-left: -7px;
font-size: 20px;
color: rgba(0, 0, 0, 0.85);
}
}
\ No newline at end of file
......@@ -47,19 +47,34 @@
}
}
@mixin white-button {
@include button;
border: 1px solid $darkGrey;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0));
background-color: #dfe5eb;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: #778192;
@mixin green-button {
@include button;
border: 1px solid #0d7011;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $green;
color: #fff;
&:hover {
background-color: #129416;
color: #fff;
}
}
&:hover {
background-color: #f2f6f9;
color: #778192;
}
@mixin white-button {
@include button;
border: 1px solid $mediumGrey;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0));
background-color: #dfe5eb;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: rgb(92, 103, 122);
text-shadow: 0 1px 0 rgba(255, 255, 255, .5);
&:hover {
background-color: rgb(222, 236, 247);
color: rgb(92, 103, 122);
}
}
@mixin orange-button {
......@@ -92,6 +107,28 @@
}
}
@mixin green-button {
@include button;
border: 1px solid $darkGreen;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $green;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: #fff;
&:hover {
background-color: $brightGreen;
color: #fff;
}
&.disabled {
border: 1px solid $disabledGreen !important;
background: $disabledGreen !important;
color: #fff !important;
@include box-shadow(none);
}
}
@mixin dark-grey-button {
@include button;
border: 1px solid #1c1e20;
......@@ -109,18 +146,17 @@
@mixin edit-box {
padding: 15px 20px;
border-radius: 3px;
border: 1px solid #5597dd;
@include linear-gradient(top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
background-color: #5597dd;
background-color: $lightBluishGrey2;
color: #3c3c3c;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .2) inset);
label {
color: #fff;
color: $baseFontColor;
}
input,
textarea {
border: 1px solid #3c3c3c;
border: 1px solid $darkGrey;
}
textarea {
......@@ -140,21 +176,19 @@
}
.save-button {
@include orange-button;
border-color: #3c3c3c;
@include blue-button;
margin-top: 0;
}
.cancel-button {
@include white-button;
border-color: #30649C;
margin-top: 0;
}
}
@mixin tree-view {
border: 1px solid #ced2db;
background: #edf1f5;
border: 1px solid $mediumGrey;
background: $lightGrey;
.branch {
margin-bottom: 10px;
......@@ -200,15 +234,10 @@
content: "- draft";
}
.public-item:after {
content: "- public";
}
.private-item:after {
content: "- private";
}
.public-item,
.private-item {
color: #a4aab7;
}
......@@ -219,7 +248,11 @@
}
a {
color: #2c2e33;
color: $baseFontColor;
&.new-unit-item {
color: #6d788b;
}
}
ol {
......@@ -242,3 +275,14 @@
}
}
}
@mixin sr-text {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
\ No newline at end of file
input.courseware-unit-search-input {
float: left;
width: 260px;
background-color: #fff;
}
.courseware-overview {
}
.branch {
.section-item {
@include clearfix();
.details {
display: block;
float: left;
margin-bottom: 0;
width: 650px;
}
.gradable-status {
float: right;
position: relative;
top: -4px;
right: 50px;
width: 145px;
.status-label {
position: absolute;
top: 2px;
right: -5px;
display: none;
width: 110px;
padding: 5px 40px 5px 10px;
@include border-radius(3px);
color: $lightGrey;
text-align: right;
font-size: 12px;
font-weight: bold;
line-height: 16px;
}
.menu-toggle {
z-index: 10;
position: absolute;
top: 0;
right: 5px;
padding: 5px;
color: $mediumGrey;
&:hover, &.is-active {
color: $blue;
}
}
.menu {
z-index: 1;
display: none;
opacity: 0.0;
position: absolute;
top: -1px;
left: 5px;
margin: 0;
padding: 8px 12px;
background: $white;
border: 1px solid $mediumGrey;
font-size: 12px;
@include border-radius(4px);
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
@include transition(opacity .15s);
li {
width: 115px;
margin-bottom: 3px;
padding-bottom: 3px;
border-bottom: 1px solid $lightGrey;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border: none;
a {
color: $darkGrey;
}
}
}
a {
color: $blue;
&.is-selected {
font-weight: bold;
}
}
}
// dropdown state
&.is-active {
.menu {
z-index: 1000;
display: block;
opacity: 1.0;
}
.menu-toggle {
z-index: 10000;
}
}
// set state
&.is-set {
.menu-toggle {
color: $blue;
}
.status-label {
display: block;
color: $blue;
}
}
}
}
}
.courseware-section {
position: relative;
background: #fff;
border: 1px solid $darkGrey;
border-radius: 3px;
margin: 10px 0;
border: 1px solid $mediumGrey;
margin-top: 15px;
padding-bottom: 12px;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1));
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
&:first-child {
margin-top: 0;
......@@ -49,7 +167,7 @@ input.courseware-unit-search-input {
margin-right: 15px;
strong {
font-weight: 700;
font-weight: bold;
}
}
......@@ -76,7 +194,7 @@ input.courseware-unit-search-input {
background: none;
@include box-shadow(none);
font-size: 13px;
font-weight: 700;
font-weight: bold;
color: $blue;
cursor: pointer;
}
......@@ -96,13 +214,200 @@ input.courseware-unit-search-input {
}
header {
height: 75px;
min-height: 75px;
@include clearfix();
.item-details, .section-published-date {
.item-details {
float: left;
padding: 21px 0 0;
}
.item-details {
display: inline-block;
padding: 20px 0 10px 0;
@include clearfix();
.section-name {
float: left;
margin-right: 10px;
width: 350px;
font-size: 19px;
font-weight: bold;
color: $blue;
}
.section-name-span {
cursor: pointer;
@include transition(color .15s);
&:hover {
color: $orange;
}
}
.section-name-edit {
position: relative;
width: 400px;
background: $white;
input {
font-size: 16px;
}
.save-button {
@include blue-button;
padding: 7px 20px 7px;
margin-right: 5px;
}
.cancel-button {
@include white-button;
padding: 7px 20px 7px;
}
}
.section-published-date {
float: right;
width: 265px;
margin-right: 220px;
@include border-radius(3px);
background: $lightGrey;
.published-status {
font-size: 12px;
margin-right: 15px;
strong {
font-weight: bold;
}
}
.schedule-button {
@include blue-button;
}
.edit-button {
@include blue-button;
}
.schedule-button,
.edit-button {
font-size: 11px;
padding: 3px 15px 5px;
}
}
.gradable-status {
position: absolute;
top: 20px;
right: 70px;
width: 145px;
.status-label {
position: absolute;
top: 0;
right: 2px;
display: none;
width: 100px;
padding: 10px 35px 10px 10px;
@include border-radius(3px);
background: $lightGrey;
color: $lightGrey;
text-align: right;
font-size: 12px;
font-weight: bold;
line-height: 16px;
}
.menu-toggle {
z-index: 10;
position: absolute;
top: 2px;
right: 5px;
padding: 5px;
color: $lightGrey;
&:hover, &.is-active {
color: $blue;
}
}
.menu {
z-index: 1;
display: none;
opacity: 0.0;
position: absolute;
top: -1px;
left: 2px;
margin: 0;
padding: 8px 12px;
background: $white;
border: 1px solid $mediumGrey;
font-size: 12px;
@include border-radius(4px);
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
@include transition(opacity .15s);
@include transition(display .15s);
li {
width: 115px;
margin-bottom: 3px;
padding-bottom: 3px;
border-bottom: 1px solid $lightGrey;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border: none;
a {
color: $darkGrey;
}
}
}
a {
&.is-selected {
font-weight: bold;
}
}
}
// dropdown state
&.is-active {
.menu {
z-index: 1000;
display: block;
opacity: 1.0;
}
.menu-toggle {
z-index: 10000;
}
}
// set state
&.is-set {
.menu-toggle {
color: $blue;
}
.status-label {
display: block;
color: $blue;
}
}
float: left;
padding: 21px 0 0;
}
}
.item-actions {
margin-top: 21px;
margin-right: 12px;
......@@ -139,6 +444,10 @@ input.courseware-unit-search-input {
}
}
.section-name-form {
margin-bottom: 15px;
}
.section-name-edit {
input {
font-size: 16px;
......@@ -146,13 +455,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;
}
}
......@@ -161,7 +470,7 @@ input.courseware-unit-search-input {
color: #878e9d;
strong {
font-weight: 700;
font-weight: bold;
}
}
......@@ -183,7 +492,7 @@ input.courseware-unit-search-input {
&.new-section {
header {
height: auto;
@include clearfix;
@include clearfix();
}
.expand-collapse-icon {
......@@ -192,6 +501,17 @@ input.courseware-unit-search-input {
}
}
.collapse-all-button {
float: right;
margin-top: 10px;
font-size: 13px;
color: $darkGrey;
.collapse-all-icon {
margin-right: 6px;
}
}
.new-section-name,
.new-subsection-name-input {
width: 515px;
......@@ -200,7 +520,7 @@ input.courseware-unit-search-input {
.new-section-name-save,
.new-subsection-name-save {
@include blue-button;
padding: 6px 20px 8px;
padding: 4px 20px 7px;
margin: 0 5px;
color: #fff !important;
}
......@@ -208,7 +528,7 @@ input.courseware-unit-search-input {
.new-section-name-cancel,
.new-subsection-name-cancel {
@include white-button;
padding: 6px 20px 8px;
padding: 4px 20px 7px;
color: #8891a1 !important;
}
......@@ -291,4 +611,11 @@ input.courseware-unit-search-input {
.cancel-button {
font-size: 16px;
}
}
.collapse-all-button {
float: right;
margin-top: 10px;
font-size: 13px;
color: $darkGrey;
}
\ No newline at end of file
......@@ -36,13 +36,6 @@
}
}
.new-course-button {
@include grey-button;
display: block;
padding: 20px;
text-align: center;
}
.new-course {
padding: 15px 25px;
margin-top: 20px;
......@@ -89,7 +82,6 @@
.new-course-save {
@include blue-button;
// padding: ;
}
.new-course-cancel {
......
......@@ -34,6 +34,14 @@
background: url(../img/video-icon.png) no-repeat;
}
.upload-icon {
display: inline-block;
width: 22px;
height: 13px;
margin-right: 5px;
background: url(../img/upload-icon.png) no-repeat;
}
.list-icon {
display: inline-block;
width: 14px;
......@@ -56,6 +64,27 @@
background: url(../img/home-icon.png) no-repeat;
}
.small-home-icon {
display: inline-block;
width: 16px;
height: 14px;
background: url(../img/small-home-icon.png) no-repeat;
}
.log-out-icon {
display: inline-block;
width: 15px;
height: 13px;
background: url(../img/log-out-icon.png) no-repeat;
}
.collapse-all-icon {
display: inline-block;
width: 15px;
height: 9px;
background: url(../img/collapse-all-icon.png) no-repeat;
}
.calendar-icon {
display: inline-block;
width: 12px;
......
......@@ -5,18 +5,15 @@ body.no-header {
}
@mixin active {
@include linear-gradient(top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.3));
@include box-shadow(0 2px 8px rgba(0, 0, 0, .7) inset);
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: rgba(255, 255, 255, .3);
@include box-shadow(0 -1px 0 rgba(0, 0, 0, .2) inset, 0 1px 0 #fff inset);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
}
.primary-header {
width: 100%;
height: 36px;
border-bottom: 1px solid #2c2e33;
@include linear-gradient(top, #686b76, #54565e);
font-size: 13px;
color: #fff;
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.2), 0 -1px 0px rgba(255, 255, 255, 0.05) inset);
margin-bottom: 30px;
&.active-tab-courseware #courseware-tab {
@include active;
......@@ -34,23 +31,16 @@ body.no-header {
@include active;
}
&.active-tab-import #import-tab {
&.active-tab-settings #settings-tab {
@include active;
}
#import-tab {
@include box-shadow(1px 0 0 #787981 inset, -1px 0 0 #3d3e44 inset, 1px 0 0 #787981, -1px 0 0 #3d3e44);
}
.left {
width: 750px;
&.active-tab-import #import-tab {
@include active;
}
.class-name {
max-width: 350px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
&.active-tab-export #export-tab {
@include active;
}
.drop-icon {
......@@ -63,26 +53,57 @@ body.no-header {
line-height: 18px;
}
a,
.username {
float: left;
display: inline-block;
height: 29px;
padding: 7px 15px 0;
color: #e4e6ee;
.class-nav-bar {
clear: both;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
@include box-shadow(0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset);
}
.class-nav,
.class-nav li {
float: left;
.class-nav {
@include clearfix;
a {
float: left;
display: inline-block;
padding: 15px 25px 17px;
font-size: 15px;
color: #3c3c3c;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
&:hover {
@include linear-gradient(top, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0));
}
}
li {
float: left;
}
}
a {
@include box-shadow(1px 0 0 #787981 inset, -1px 0 0 #3d3e44 inset, 1px 0 0 #787981, -1px 0 0 #3d3e44);
.class {
@include clearfix;
height: 100%;
font-size: 12px;
color: rgb(163, 171, 184);
@include linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, .1));
background-color: rgb(47, 53, 63);
a {
display: inline-block;
height: 20px;
padding: 5px 10px 6px;
color: rgb(163, 171, 184);
}
&:hover {
background: rgba(255, 255, 255, .1);
.home {
position: relative;
top: 1px;
}
.log-out {
position: relative;
top: 3px;
}
}
}
\ No newline at end of file
.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 {
width: 85px !important;
min-width: 85px !important;
}
&:disabled {
border: none;
@include box-shadow(none);
padding: 0;
color: $darkGrey !important;
font-weight: bold;
background: #fff;
}
}
textarea.tinymce {
border: 1px solid $darkGrey;
height: 300px;
}
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;
@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%;
}
}
}
}
// input-list
.input-list {
.input {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px dotted $lightGrey;
@include clearfix();
&: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
.delete-button {
float: right;
}
// 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 {
.setting-grading-assignment-types {
.row .field.enum {
width: 684px;
}
}
.course-grading-assignment-list-item {
}
.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 !important;
}
.grades {
position: relative;
li {
position: absolute;
top: 0;
height: 50px;
text-align: right;
@include border-radius(2px);
&:hover,
&.is-dragging {
.remove-button {
display: block;
}
}
&.is-dragging {
}
.remove-button {
display: none;
position: absolute;
top: -17px;
right: 1px;
height: 17px;
font-size: 10px;
}
&:nth-child(1) {
background: #4fe696;
}
&:nth-child(2) {
background: #ffdf7e;
}
&:nth-child(3) {
background: #ffb657;
}
&:nth-child(4) {
background: #ef54a1;
}
&:nth-child(5),
&.bar-fail {
background: #fb336c;
}
.letter-grade {
display: block;
margin: 10px 15px 0 0;
font-size: 16px;
font-weight: 700;
line-height: 14px;
}
.range {
display: block;
margin-right: 15px;
font-size: 10px;
line-height: 12px;
}
.drag-bar {
position: absolute;
top: 0;
right: -1px;
height: 50px;
width: 2px;
background-color: #fff;
@include box-shadow(-1px 0 3px rgba(0,0,0,0.1));
cursor: ew-resize;
@include transition(none);
&:hover {
width: 6px;
right: -2px;
}
}
}
}
}
}
}
\ No newline at end of file
......@@ -7,7 +7,20 @@
}
.unit-body {
padding: 30px 40px;
padding: 0;
.details {
display: block !important;
h2 {
margin: 0 0 5px 0;
}
}
}
.component-editor {
border: none;
border-radius: 0;
}
.components > li {
......@@ -36,7 +49,7 @@
}
.drag-handle {
background: url(../img/drag-handles.png) center no-repeat $lightGrey;
background: url(../img/drag-handles.png) center no-repeat #fff;
}
}
......@@ -46,10 +59,10 @@
z-index: 11;
width: 35px;
border: none;
background: url(../img/drag-handles.png) center no-repeat $lightGrey;
background: url(../img/drag-handles.png) center no-repeat #fff;
&:hover {
background: url(../img/drag-handles.png) center no-repeat $lightGrey;
background: url(../img/drag-handles.png) center no-repeat #fff;
}
}
......@@ -60,16 +73,24 @@
}
.component.editing {
border-left: 1px solid $mediumGrey;
border-right: 1px solid $mediumGrey;
.xmodule_display {
display: none;
}
}
.new .xmodule_display {
background: $yellow;
}
.xmodule_display {
padding: 20px 20px 22px;
font-size: 24px;
font-weight: 300;
background: $lightGrey;
background: #fff;
@include transition(background-color 3s);
}
.static-page-item {
......
......@@ -137,8 +137,7 @@
a {
font-size: 11px;
font-weight: 700;
line-height: 31px;
font-weight: bold;
text-transform: uppercase;
}
......@@ -180,3 +179,109 @@
}
}
}
.gradable {
label {
display: inline-block;
vertical-align: top;
}
.gradable-status {
position: relative;
top: -4px;
display: inline-block;
margin-left: 10px;
width: 65%;
.status-label {
margin: 0;
padding: 0;
background: transparent;
color: $blue;
border: none;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
}
.menu-toggle {
z-index: 100;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
background: transparent;
&:hover, &.is-active {
color: $blue;
}
}
.menu {
z-index: 1;
position: absolute;
top: -12px;
left: -7px;
display: none;
width: 100%;
margin: 0;
padding: 8px 12px;
opacity: 0.0;
background: $white;
border: 1px solid $mediumGrey;
font-size: 12px;
@include border-radius(4px);
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
@include transition(opacity .15s);
li {
margin-bottom: 3px;
padding-bottom: 3px;
border-bottom: 1px solid $lightGrey;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border: none;
}
}
a {
&.is-selected {
font-weight: bold;
}
}
}
// dropdown state
&.is-active {
.menu {
z-index: 10000;
display: block;
opacity: 1.0;
}
.menu-toggle {
z-index: 1000;
}
}
// set state
&.is-set {
.menu-toggle {
color: $blue;
}
.status-label {
display: block;
color: $blue;
}
}
}
}
\ No newline at end of file
......@@ -42,7 +42,7 @@
}
h2 {
margin: 30px 40px;
margin: 30px 40px 30px 0;
color: #646464;
font-size: 19px;
font-weight: 300;
......@@ -57,14 +57,10 @@
margin: 20px 40px;
&.new-component-item {
padding: 0;
padding: 20px;
border: none;
border-radius: 0;
&.adding {
background-color: $blue;
border-color: #437fbf;
}
border-radius: 3px;
background: $lightGrey;
.new-component-button {
display: block;
......@@ -85,12 +81,13 @@
border-radius: 3px 3px 0 0;
}
.new-component-type, .new-component-template {
@include clearfix;
.new-component-type {
a,
li {
display: inline-block;
}
a {
position: relative;
float: left;
width: 100px;
height: 100px;
margin-right: 10px;
......@@ -98,14 +95,8 @@
border-radius: 8px;
font-size: 13px;
line-height: 14px;
color: #fff;
text-align: center;
@include box-shadow(0 1px 1px rgba(0, 0, 0, .4), 0 1px 0 rgba(255, 255, 255, .4) inset);
@include transition(background-color .15s);
&:hover {
background-color: rgba(255, 255, 255, .2);
}
@include box-shadow(0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset);
.name {
position: absolute;
......@@ -118,23 +109,62 @@
}
}
.new-component-type,
.new-component-template {
@include clearfix;
a {
position: relative;
border: 1px solid $darkGreen;
background: $green;
color: #fff;
@include transition(background-color .15s);
&:hover {
background: $brightGreen;
}
}
}
.new-component-template {
margin-bottom: 20px;
li:first-child {
a {
border-radius: 3px 3px 0 0;
}
}
li:last-child {
a {
border-radius: 0 0 3px 3px;
}
}
a {
height: 60px;
display: block;
padding: 7px 20px;
border-bottom: none;
font-weight: 300;
}
}
.new-component,
.new-component {
text-align: center;
h5 {
color: $green;
}
}
.new-component-templates {
display: none;
position: absolute;
width: 100%;
padding: 20px;
@include clearfix;
.cancel-button {
@include blue-button;
border-color: #30649c;
@include white-button;
}
}
}
......@@ -142,7 +172,7 @@
}
.component {
border: 1px solid #d1ddec;
border: 1px solid $lightBluishGrey2;
border-radius: 3px;
background: #fff;
@include transition(none);
......@@ -157,7 +187,8 @@
}
&.editing {
border-color: #6696d7;
border: 1px solid $lightBluishGrey2;
z-index: 9999;
.drag-handle,
.component-actions {
......@@ -173,11 +204,6 @@
position: absolute;
top: 7px;
right: 9px;
@include transition(opacity .15s);
a {
color: $darkGrey;
}
}
.drag-handle {
......@@ -189,10 +215,10 @@
width: 15px;
height: 100%;
border-radius: 0 3px 3px 0;
border: 1px solid #d1ddec;
background: url(../img/white-drag-handles.png) center no-repeat #d1ddec;
border: 1px solid $lightBluishGrey2;
background: url(../img/white-drag-handles.png) center no-repeat $lightBluishGrey2;
cursor: move;
@include transition(all .15s);
@include transition(none);
}
}
......@@ -205,9 +231,6 @@
display: none;
padding: 20px;
border-radius: 2px 2px 0 0;
@include linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, .1));
background-color: $blue;
color: #fff;
@include box-shadow(none);
.metadata_edit {
......@@ -224,12 +247,6 @@
}
}
.CodeMirror {
border: 1px solid #3c3c3c;
background: #fff;
color: #3c3c3c;
}
h3 {
margin-bottom: 10px;
font-size: 18px;
......@@ -446,3 +463,26 @@
display: none;
}
}
// editing units from courseware
body.unit {
.component {
padding-top: 30px;
.component-actions {
@include box-sizing(border-box);
position: absolute;
width: 811px;
padding: 15px;
top: 0;
left: 0;
border-bottom: 1px solid $lightBluishGrey2;
background: $lightGrey;
}
&.editing {
padding-top: 0;
}
}
}
.users {
.user-overview {
@extend .window;
padding: 30px 40px;
}
.new-user-button {
@include grey-button;
margin: 5px 8px;
padding: 3px 10px 4px 10px;
font-size: 12px;
.plus-icon {
position: relative;
top: 1px;
}
}
.list-header {
@include linear-gradient(top, transparent, rgba(0, 0, 0, .1));
background-color: #ced2db;
border-radius: 3px 3px 0 0;
}
.new-user-form {
display: none;
padding: 15px 20px;
background: $mediumGrey;
background-color: $lightBluishGrey2;
#result {
display: none;
......@@ -55,21 +32,22 @@
.add-button {
@include blue-button;
padding: 5px 20px 9px;
}
.cancel-button {
@include white-button;
padding: 5px 20px 9px;
}
}
.user-list {
border: 1px solid $mediumGrey;
border-top: none;
background: $lightGrey;
background: #fff;
li {
position: relative;
padding: 10px 20px;
padding: 20px;
border-bottom: 1px solid $mediumGrey;
&:last-child {
......@@ -81,12 +59,19 @@
}
.user-name {
width: 30%;
font-weight: 700;
margin-right: 10px;
font-size: 24px;
font-weight: 300;
}
.user-email {
font-size: 14px;
font-style: italic;
color: $mediumGrey;
}
.item-actions {
top: 9px;
top: 24px;
}
}
}
......
......@@ -10,13 +10,28 @@ $fg-min-width: 810px;
$sans-serif: 'Open Sans', $verdana;
$body-line-height: golden-ratio(.875em, 1);
$white: rgb(255,255,255);
$black: rgb(0,0,0);
$pink: rgb(182,37,104);
$error-red: rgb(253, 87, 87);
$baseFontColor: #3c3c3c;
$offBlack: #3c3c3c;
$black: rgb(0,0,0);
$white: rgb(255,255,255);
$blue: #5597dd;
$orange: #edbd3c;
$red: #b20610;
$green: #108614;
$lightGrey: #edf1f5;
$mediumGrey: #ced2db;
$mediumGrey: #b0b6c2;
$darkGrey: #8891a1;
$extraDarkGrey: #3d4043;
$paleYellow: #fffcf1;
\ No newline at end of file
$paleYellow: #fffcf1;
$yellow: rgb(255, 254, 223);
$green: rgb(37, 184, 90);
$brightGreen: rgb(22, 202, 87);
$disabledGreen: rgb(124, 206, 153);
$darkGreen: rgb(52, 133, 76);
$lightBluishGrey: rgb(197, 207, 223);
$lightBluishGrey2: rgb(213, 220, 228);
......@@ -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';
......
......@@ -21,7 +21,7 @@
</div>
</td>
<td class="name-col">
<a href="{{url}}" class="filename">{{displayname}}</a>
<a data-tooltip="Open/download this file" href="{{url}}" class="filename">{{displayname}}</a>
<div class="embeddable-xml"></div>
</td>
<td class="date-col">
......@@ -35,10 +35,11 @@
<div class="main-wrapper">
<div class="inner-wrapper">
<h1>Asset Library</h1>
<div class="page-actions">
<a href="#" class="upload-button">Upload New File</a>
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
<a href="#" class="upload-button new-button">
<span class="upload-icon"></span>Upload New Asset
</a>
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
</div>
<article class="asset-library">
<table>
......@@ -61,7 +62,7 @@
</div>
</td>
<td class="name-col">
<a href="${asset['url']}" class="filename">${asset['displayname']}</a>
<a data-tooltip="Open/download this file" href="${asset['url']}" class="filename">${asset['displayname']}</a>
<div class="embeddable-xml"></div>
</td>
<td class="date-col">
......
......@@ -9,6 +9,9 @@
<%static:css group='base-style'/>
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/markitup/skins/simple/style.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/markitup/sets/wiki/style.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('css/vendor/symbolset.ss-standard.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('css/vendor/symbolset.ss-symbolicons-block.css')}" />
<title><%block name="title"></%block></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
......@@ -26,6 +29,9 @@
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script>
<script src="${static.url('js/vendor/symbolset.ss-standard.js')}"></script>
<script src="${static.url('js/vendor/symbolset.ss-symbolicons.js')}"></script>
<%static:js group='main'/>
<%static:js group='module-js'/>
<script src="${static.url('js/vendor/jquery.inlineedit.js')}"></script>
......
......@@ -9,5 +9,5 @@
<a href="#" class="edit-button standard"><span class="edit-icon"></span>Edit</a>
<a href="#" class="delete-button standard"><span class="delete-icon"></span>Delete</a>
</div>
<a href="#" class="drag-handle"></a>
<a data-tooltip="Drag to reorder" href="#" class="drag-handle"></a>
${preview}
\ No newline at end of file
......@@ -17,13 +17,18 @@
<%block name="content">
<div class="main-wrapper">
<div class="inner-wrapper">
<div>
<h1>Static Tabs</h1>
</div>
<article class="unit-body window">
<article class="unit-body">
<div class="details">
<p>Here you can add and manage additional pages for your course. These pages will be added to the primary navigation menu alongside Courseware, Course Info, Discussion, etc.</p>
<h2>Here you can add and manage additional pages for your course</h2>
<p>These pages will be added to the primary navigation menu alongside Courseware, Course Info, Discussion, etc.</p>
</div>
<div class="page-actions">
<a href="#" class="new-button new-tab">
<span class="plus-icon white"></span>New Page
</a>
</div>
<div class="tab-list">
<ol class='components'>
% for id in components:
......@@ -31,9 +36,7 @@
% endfor
<li class="new-component-item">
<a href="#" class="new-button big new-tab">
<span class="plus-icon"></span>New Tab
</a>
</li>
</ol>
</div>
......
......@@ -31,21 +31,6 @@
<label>Units:</label>
${units.enum_units(subsection, subsection_units=subsection_units)}
</div>
<div>
<label>Policy:</label>
<ol class='policy-list'>
% for policy_name in policy_metadata.keys():
<li class="policy-list-element">
<input type="text" class="policy-list-name" name="${policy_name}" value="${policy_name}" disabled size="15"/>:&nbsp;<input type="text" class="policy-list-value" name="${policy_metadata[policy_name]}" value="${policy_metadata[policy_name]}" size="40"/>
<a href="#" class="cancel-button">Cancel</a>
<a href="#" class="delete-icon remove-policy-data"></a>
</li>
% endfor
<a href="#" class="new-policy-item add-policy-data">
<span class="plus-icon-small"></span>New Policy Data
</a>
</ol>
</div>
</article>
</div>
......@@ -62,7 +47,7 @@
</div>
<div class="sidebar">
<div class="unit-settings window">
<div class="unit-settings window id-holder" data-id="${subsection.location}">
<h4>Subsection Settings</h4>
<div class="window-contents">
<div class="scheduled-date-input row">
......@@ -84,6 +69,13 @@
<a href="#" class="sync-date no-spinner">Sync to ${parent_item.display_name}.</a></p>
% endif
</div>
<div class="row gradable">
<label>Graded as:</label>
<div class="gradable-status" data-initial-status="${subsection.metadata.get('format', 'Not Graded')}">
</div>
<div class="due-date-input row">
<label>Due date:</label>
<a href="#" class="set-date">Set a due date</a>
......@@ -116,6 +108,10 @@
<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/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/grader-select-view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
<script type="text/javascript">
$(document).ready(function() {
// expand the due-date area if the values are set
......@@ -124,6 +120,23 @@
$('.set-date').hide();
$block.find('.date-setter').show();
}
// TODO figure out whether these should be in window or someplace else or whether they're only needed as local vars
// I believe that current (New Section/New Subsection) cause full page reloads which means these aren't needed globally
// but we really should change that behavior.
if (!window.graderTypes) {
window.graderTypes = new CMS.Models.Settings.CourseGraderCollection();
window.graderTypes.course_location = new CMS.Models.Location('${parent_location}');
window.graderTypes.reset(${course_graders|n});
}
$(".gradable-status").each(function(index, ele) {
var gradeView = new CMS.Views.OverviewAssignmentGrader({
el : ele,
graders : window.graderTypes,
hideSymbol : true
});
});
})
</script>
</%block>
......@@ -4,7 +4,7 @@ ${content}
<a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a>
<a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a>
</div>
<a href="#" class="drag-handle"></a>
<a data-tooltip="Drag to reorder" href="#" class="drag-handle"></a>
<div class="component-editor">
<h5>Edit Video Component</h5>
<textarea class="component-source"><video youtube="1.50:q1xkuPsOY6Q,1.25:9WOY2dHz5i4,1.0:4rpg8Bq6hb4,0.75:KLim9Xkp7IY"/></textarea>
......
......@@ -8,7 +8,6 @@
<%block name="content">
<div class="main-wrapper">
<div class="inner-wrapper">
<h1>Import</h1>
<article class="import-overview">
<div class="description">
<h2>Please <a href="https://edge.edx.org/courses/edX/edx101/edX_Studio_Reference/about" target="_blank">read the documentation</a> before attempting an import!</h2>
......
......@@ -37,7 +37,7 @@
<h1>My Courses</h1>
<article class="my-classes">
% if user.is_active:
<a href="#" class="new-course-button"><span class="plus-icon"></span> New Course</a>
<a href="#" class="new-button new-course-button"><span class="plus-icon white"></span> New Course</a>
<ul class="class-list">
%for course, url in courses:
<li>
......
......@@ -5,28 +5,28 @@
<%block name="content">
<div class="main-wrapper">
<div class="inner-wrapper">
<h1>Users</h1>
<div class="page-actions">
%if allow_actions:
<a href="#" class="new-button new-user-button">
<span class="plus-icon white"></span>New User
</a>
%endif
</div>
<article class="user-overview">
<div class="details">
<p>The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.</p>
</div>
<div class="list-header">
%if allow_actions:
<a href="#" class="new-user-button">
<span class="plus-icon"></span>New User
</a>
%endif
</div>
<div class="details">
<p>The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.</p>
</div>
<article class="user-overview">
%if allow_actions:
<div class="new-user-form">
<form class="new-user-form">
<div id="result"></div>
<div class="form-elements">
<label>email: </label><input type="text" id="email" class="email-input" autocomplete="off" placeholder="email@example.com">
<a href="#" id="add_user" class="add-button">add user</a>
<a href="#" class="cancel-button">cancel</a>
<input type="submit" value="Add User" id="add_user" class="add-button" />
<input type="button" value="Cancel" class="cancel-button" />
</div>
</div>
</form>
%endif
<div>
<ol class="user-list">
......@@ -57,6 +57,7 @@
function showNewUserForm(e) {
e.preventDefault();
$newUserForm.slideDown(150);
$newUserForm.find('.email-input').focus();
}
function hideNewUserForm(e) {
......@@ -66,26 +67,37 @@
$('#email').val('');
}
function checkForCancel(e) {
if(e.which == 27) {
e.data.$cancelButton.click();
}
}
function addUser(e) {
e.preventDefault();
$.ajax({
url: '${add_user_postback_url}',
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data:JSON.stringify({ 'email': $('#email').val()}),
}).done(function(data) {
if (data.ErrMsg != undefined)
$('#result').show().empty().append(data.ErrMsg);
else
location.reload();
});
}
$(document).ready(function() {
$newUserForm = $('.new-user-form');
$newUserForm.find('.cancel-button').bind('click', hideNewUserForm);
var $cancelButton = $newUserForm.find('.cancel-button');
$newUserForm.bind('submit', addUser);
$cancelButton.bind('click', hideNewUserForm);
$('.new-user-button').bind('click', showNewUserForm);
$('#add_user').click(function() {
$.ajax({
url: '${add_user_postback_url}',
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data:JSON.stringify({ 'email': $('#email').val()}),
}).done(function(data) {
if (data.ErrMsg != undefined)
$('#result').show().empty().append(data.ErrMsg);
else
location.reload();
})
});
$body.bind('keyup', { $cancelButton: $cancelButton }, checkForCancel);
$('.remove-user').click(function() {
$.ajax({
......
......@@ -16,13 +16,37 @@
<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/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/grader-select-view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
<script type="text/javascript">
$(document).ready(function(){
// TODO figure out whether these should be in window or someplace else or whether they're only needed as local vars
// I believe that current (New Section/New Subsection) cause full page reloads which means these aren't needed globally
// but we really should change that behavior.
if (!window.graderTypes) {
window.graderTypes = new CMS.Models.Settings.CourseGraderCollection();
window.graderTypes.course_location = new CMS.Models.Location('${parent_location}');
window.graderTypes.reset(${course_graders|n});
}
$(".gradable-status").each(function(index, ele) {
var gradeView = new CMS.Views.OverviewAssignmentGrader({
el : ele,
graders : window.graderTypes
});
});
});
</script>
</%block>
<%block name="header_extras">
<script type="text/template" id="new-section-template">
<section class="courseware-section branch new-section">
<header>
<a href="#" class="expand-collapse-icon collapse"></a>
<a href="#" data-tooltip="Collapse/expand this section" class="expand-collapse-icon collapse"></a>
<div class="item-details">
<h3 class="section-name">
<form class="section-name-form">
......@@ -38,7 +62,7 @@
<script type="text/template" id="blank-slate-template">
<section class="courseware-section branch new-section">
<header>
<a href="#" class="expand-collapse-icon collapse"></a>
<a href="#" data-tooltip="Collapse/expand this section" class="expand-collapse-icon collapse"></a>
<div class="item-details">
<h3 class="section-name">
<span class="section-name-span">Click here to set the section name</span>
......@@ -49,8 +73,8 @@
</form>
</div>
<div class="item-actions">
<a href="#" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
<a href="#" class="drag-handle"></a>
<a href="#" data-tooltip="Delete this section" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
<a href="#" data-tooltip="Drag to re-order" class="drag-handle"></a>
</div>
</header>
</section>
......@@ -77,8 +101,6 @@
</ol>
</li>
</script>
</%block>
<%block name="content">
......@@ -89,7 +111,7 @@
<input class="start-date date" type="text" name="start_date" value="" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
<input class="start-time time" type="text" name="start_time" value="" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
<div class="description">
<p>On the date set above, this section – <strong class="section-name"></strong> – will be released to students along with the 5 subsections within it. Any units marked private will only be visible to admins.</p>
<p>On the date set above, this section – <strong class="section-name"></strong> – will be released to students. Any units marked private will only be visible to admins.</p>
</div>
</div>
<a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a>
......@@ -98,17 +120,19 @@
<div class="main-wrapper">
<div class="inner-wrapper">
<h1>Courseware</h1>
<div class="page-actions"></div>
<article class="courseware-overview" data-course-id="${context_course.location.url()}">
<a href="#" class="new-button big new-courseware-section-button"><span class="plus-icon"></span> New Section</a>
<div class="page-actions">
<a href="#" class="new-button new-courseware-section-button"><span class="plus-icon white"></span> New Section</a>
<a href="#" class="collapse-all-button"><span class="collapse-all-icon"></span>Collapse All</a>
</div>
<article class="courseware-overview" data-course-id="${context_course.location.url()}">
% for section in sections:
<section class="courseware-section branch" data-id="${section.location}">
<header>
<a href="#" class="expand-collapse-icon collapse"></a>
<a href="#" data-tooltip="Expand/collapse this section" class="expand-collapse-icon collapse"></a>
<div class="item-details" data-id="${section.location}">
<h3 class="section-name">
<span class="section-name-span">${section.display_name}</span>
<span data-tooltip="Edit this section's name" class="section-name-span">${section.display_name}</span>
<form class="section-name-edit" style="display:none">
<input type="text" value="${section.display_name}" class="edit-section-name" autocomplete="off"/>
<input type="submit" class="save-button edit-section-name-save" value="Save" />
......@@ -128,11 +152,12 @@
<span class="published-status"><strong>Will Release:</strong> ${start_date_str} at ${start_time_str}</span>
<a href="#" class="edit-button" data-date="${start_date_str}" data-time="${start_time_str}" data-id="${section.location}">Edit</a>
%endif
</div>
</div>
</div>
<div class="item-actions">
<a href="#" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
<a href="#" class="drag-handle"></a>
<a href="#" data-tooltip="Delete this section" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
<a href="#" data-tooltip="Drag to reorder" class="drag-handle"></a>
</div>
</header>
<div class="subsection-list">
......@@ -143,18 +168,22 @@
</div>
<ol data-section-id="${section.location.url()}">
% for subsection in section.get_children():
<li class="branch collapsed" data-id="${subsection.location}">
<li class="branch collapsed id-holder" data-id="${subsection.location}">
<div class="section-item">
<div>
<a href="#" class="expand-collapse-icon expand"></a>
<div class="details">
<a href="#" data-tooltip="Expand/collapse this subsection" class="expand-collapse-icon expand"></a>
<a href="${reverse('edit_subsection', args=[subsection.location])}">
<span class="folder-icon"></span>
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name}</span></span>
</a>
</div>
<div class="gradable-status" data-initial-status="${subsection.metadata.get('format', 'Not Graded')}">
</div>
<div class="item-actions">
<a href="#" class="delete-button delete-subsection-button"><span class="delete-icon"></span></a>
<a href="#" class="drag-handle"></a>
<a href="#" data-tooltip="Delete this subsection" class="delete-button delete-subsection-button"><span class="delete-icon"></span></a>
<a href="#" data-tooltip="Drag to reorder" class="drag-handle"></a>
</div>
</div>
${units.enum_units(subsection)}
......
<%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_about_page(context_course.location)}">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_about_page(context_course.location)}">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-stacked">This is used in <a href="${utils.get_lms_link_for_about_page(context_course.location)}">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 tinymce text-editor" id="course-overview"></textarea>
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a href="${utils.get_lms_link_for_about_page(context_course.location)}">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="long new-course-introduction-video add-video-data" id="course-introduction-video" value="" placeholder="id" autocomplete="off">
<span class="tip tip-stacked">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-inline">Time spent 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">
<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-inline">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-button new-course-grading-item add-grading-data">
<span class="plus-icon white"></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>
<footer></footer>
</%block>
......@@ -32,12 +32,9 @@
% for id in components:
<li class="component" data-id="${id}"/>
% endfor
<li class="new-component-item">
<a href="#" class="new-component-button new-button big">
<span class="plus-icon"></span>New Component
</a>
<li class="new-component-item adding">
<div class="new-component">
<h5>Select Component Type</h5>
<h5>Add New Component</h5>
<ul class="new-component-type">
% for type in sorted(component_templates.keys()):
<li>
......@@ -48,7 +45,6 @@
</li>
% endfor
</ul>
<a href="#" class="cancel-button">Cancel</a>
</div>
% for type, templates in sorted(component_templates.items()):
<div class="new-component-templates new-component-${type}">
......@@ -85,7 +81,11 @@
<p class="publish-draft-message">This is a draft of the published unit. To update the live version, you must <a href="#" class="publish-draft">replace it with this draft</a>.</p>
</div>
<div class="row status">
<p>This unit is scheduled to be released to <strong>students</strong> on <strong>${release_date}</strong> with the subsection <a href="${reverse('edit_subsection', kwargs={'location': subsection.location})}">"${subsection.display_name}"</a></p>
<p>This unit is scheduled to be released to <strong>students</strong>
% if release_date is not None:
on <strong>${release_date}</strong>
% endif
with the subsection <a href="${reverse('edit_subsection', kwargs={'location': subsection.location})}">"${subsection.display_name}"</a></p>
</div>
<div class="row unit-actions">
<a href="#" class="delete-draft delete-button">Delete Draft</a>
......
<%! from django.core.urlresolvers import reverse %>
<% active_tab_class = 'active-tab-' + active_tab if active_tab else '' %>
<header class="primary-header ${active_tab_class}">
<nav class="inner-wrapper">
<div class="left">
<a href="/"><span class="home-icon"></span></a>
% if context_course:
<% ctx_loc = context_course.location %>
<a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" class="class-name">${context_course.display_name}</a>
<ul class="class-nav">
<li><a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseware-tab'>Courseware</a></li>
<li><a href="${reverse('course_info', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseinfo-tab'>Course Info</a></li>
<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('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='import-tab'>Import</a></li>
</ul>
% endif
</div>
<div class="right">
<span class="username">${ user.username }</span>
% if user.is_authenticated():
<a href="${reverse('logout')}">Log out</a>
% else:
<a href="${reverse('login')}">Log in</a>
% endif
<header class="primary-header ${active_tab_class}">
<div class="class">
<div class="inner-wrapper">
<div class="left">
% if context_course:
<% ctx_loc = context_course.location %>
<a href="/" class="home"><span class="small-home-icon"></span></a>
<a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" class="class-name">${context_course.display_name}</a>
% endif
</div>
<div class="right">
<span class="username">${ user.username }</span>
% if user.is_authenticated():
<a href="${reverse('logout')}" class="log-out"><span class="log-out-icon"></span></a>
% else:
<a href="${reverse('login')}">Log in</a>
% endif
</div>
</div>
</div>
<nav class="class-nav-bar">
% if context_course:
<% ctx_loc = context_course.location %>
<ul class="class-nav inner-wrapper">
<li><a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseware-tab'>Courseware</a></li>
<li><a href="${reverse('course_info', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseinfo-tab'>Course Info</a></li>
<li><a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}" id='pages-tab'>Pages</a></li>
<li><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}" id='users-tab'>Users</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('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
</nav>
</header>
<%include file="metadata-edit.html" />
<section class="html-edit">
<textarea name="" class="edit-box" rows="8" cols="40">${data}</textarea>
<div name="" class="edit-box">${data}</div>
</section>
......@@ -4,7 +4,6 @@
hlskey = hashlib.md5(module.location.url()).hexdigest()
%>
<section class="metadata_edit">
<h3>Metadata</h3>
<ul>
% for keyname in editable_metadata_fields:
<li>
......
......@@ -26,8 +26,8 @@ This def will enumerate through a passed in subsection and list all of the units
</a>
% if actions:
<div class="item-actions">
<a href="#" class="delete-button" data-id="${unit.location}"><span class="delete-icon"></span></a>
<a href="#" class="drag-handle"></a>
<a href="#" data-tooltip="Delete this unit" class="delete-button" data-id="${unit.location}"><span class="delete-icon"></span></a>
<a href="#" data-tooltip="Drag to sort" class="drag-handle"></a>
</div>
% endif
</div>
......
<li class="video-box">
<div class="thumb"><img src="http://placehold.it/100x65" /></div>
<div class="thumb"></div>
<div class="meta">
<strong>video-name</strong> 236mb Uploaded 6 hours ago by <em>Anant Agrawal</em>
......
<li class="video-box">
<div class="thumb"><img src="http://placehold.it/155x90" /></div>
<div class="thumb"></div>
<div class="meta">
<strong>video-name</strong> 236mb
......
......@@ -35,9 +35,13 @@ 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'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'),
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'),
......
class CourseRelativeMember:
def __init__(self, location, idx):
self.course_location = location # a Location obj
self.idx = idx # which milestone this represents. Hopefully persisted # so we don't have race conditions
### ??? If 2+ courses use the same textbook or other asset, should they point to the same db record?
class linked_asset(CourseRelativeMember):
"""
Something uploaded to our asset lib which has a name/label and location. Here it's tracked by course and index, but
we could replace the label/url w/ a pointer to a real asset and keep the join info here.
"""
def __init__(self, location, idx):
CourseRelativeMember.__init__(self, location, idx)
self.label = ""
self.url = None
class summary_detail_pair(CourseRelativeMember):
"""
A short text with an arbitrary html descriptor used for paired label - details elements.
"""
def __init__(self, location, idx):
CourseRelativeMember.__init__(self, location, idx)
self.summary = ""
self.detail = ""
\ No newline at end of file
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)
elif isinstance(field, time.struct_time):
return field
\ No newline at end of file
......@@ -1133,7 +1133,13 @@ class CodeResponse(LoncapaResponse):
xml = self.xml
# TODO: XML can override external resource (grader/queue) URL
self.url = xml.get('url', None)
self.queue_name = xml.get('queuename', self.system.xqueue['default_queuename'])
# We do not support xqueue within Studio.
if self.system.xqueue is not None:
default_queuename = self.system.xqueue['default_queuename']
else:
default_queuename = None
self.queue_name = xml.get('queuename', default_queuename)
# VS[compat]:
# Check if XML uses the ExternalResponse format or the generic CodeResponse format
......@@ -1230,6 +1236,13 @@ class CodeResponse(LoncapaResponse):
(err, self.answer_id, convert_files_to_filenames(student_answers)))
raise Exception(err)
# We do not support xqueue within Studio.
if self.system.xqueue is None:
cmap = CorrectMap()
cmap.set(self.answer_id, queuestate=None,
msg='Error checking problem: no external queueing server is configured.')
return cmap
# Prepare xqueue request
#------------------------------------------------------------
......
......@@ -12,6 +12,7 @@
<script type="text/javascript" src="<%= common_coffee_root %>/logger.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.min.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/jasmine-jquery.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/CodeMirror/codemirror.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>
<script type="text/javascript">
......
......@@ -18,7 +18,7 @@ class StaticContent(object):
self.content_type = content_type
self.data = data
self.last_modified_at = last_modified_at
self.thumbnail_location = thumbnail_location
self.thumbnail_location = Location(thumbnail_location)
# optional information about where this file was imported from. This is needed to support import/export
# cycles
self.import_path = import_path
......
......@@ -31,8 +31,7 @@ class MongoContentStore(ContentStore):
id = content.get_id()
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
if self.fs.exists({"_id" : id}):
self.fs.delete(id)
self.delete(id)
with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type,
displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp:
......@@ -41,13 +40,16 @@ class MongoContentStore(ContentStore):
return content
def delete(self, id):
if self.fs.exists({"_id" : id}):
self.fs.delete(id)
def find(self, location):
id = StaticContent.get_id_from_location(location)
try:
with self.fs.get(id) as fp:
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
fp.uploadDate, thumbnail_location = fp.thumbnail_location if 'thumbnail_location' in fp else None,
fp.uploadDate, thumbnail_location = fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
import_path = fp.import_path if hasattr(fp, 'import_path') else None)
except NoFile:
raise NotFoundError()
......
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,
......@@ -129,37 +116,44 @@ class CourseDescriptor(SequenceDescriptor):
"type" : "Lab",
"min_count" : 12,
"drop_count" : 2,
"category" : "Labs",
"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
......@@ -168,8 +162,8 @@ class CourseDescriptor(SequenceDescriptor):
@classmethod
def read_grading_policy(cls, paths, system):
"""Load a grading policy from the specified paths, in order, if it exists."""
# Default to a blank policy
policy_str = '""'
# Default to a blank policy dict
policy_str = '{}'
for policy_path in paths:
if not system.resources_fs.exists(policy_path):
......@@ -252,12 +246,52 @@ class CourseDescriptor(SequenceDescriptor):
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):
......
<section class="html-edit">
<div name="" class="edit-box" rows="8" cols="40">&lt;problem>
&lt;p>&lt;/p>
&lt;multiplechoiceresponse>
<pre>&lt;problem>
&lt;p>&lt;/p></pre>
<div><foo>bar</foo></div></div>
</section>
\ No newline at end of file
describe 'HTMLEditingDescriptor', ->
describe 'Read data from server, create Editor, and get data back out', ->
it 'Does not munge &lt', ->
# This is a test for Lighthouse #22,
# "html names are automatically converted to the symbols they describe"
# A better test would be a Selenium test to avoid duplicating the
# mako template structure in html-edit-formattingbug.html.
# However, we currently have no working Selenium tests.
loadFixtures 'html-edit-formattingbug.html'
@descriptor = new HTMLEditingDescriptor($('.html-edit'))
data = @descriptor.save().data
expect(data).toEqual("""&lt;problem&gt;
&lt;p&gt;&lt;/p&gt;
&lt;multiplechoiceresponse&gt;
<pre>&lt;problem&gt;
&lt;p&gt;&lt;/p&gt;</pre>
<div><foo>bar</foo></div>""")
\ No newline at end of file
class @HTMLEditingDescriptor
constructor: (@element) ->
@edit_box = CodeMirror.fromTextArea($(".edit-box", @element)[0], {
text = $(".edit-box", @element)[0];
replace_func = (elt) -> text.parentNode.replaceChild(elt, text)
@edit_box = CodeMirror(replace_func, {
value: text.innerHTML
mode: "text/html"
lineNumbers: true
lineWrapping: true
})
lineWrapping: true})
save: ->
data: @edit_box.getValue()
......@@ -51,6 +51,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
self.modulestore = modulestore
self.module_data = module_data
self.default_class = default_class
# cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's
# define an attribute here as well, even though it's None
self.course_id = None
def load_item(self, location):
location = Location(location)
......
import logging
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
# first check to see if the modulestore is Mongo backed
if not isinstance(modulestore, MongoModuleStore):
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
# check to see if the dest_location exists as an empty course
# we need an empty course because the app layers manage the permissions and users
if not modulestore.has_item(dest_location):
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
# verify that the dest_location really is an empty course, which means only one
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
if len(dest_modules) != 1:
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
# check to see if the source course is actually there
if not modulestore.has_item(source_location):
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
# Get all modules under this namespace which is (tag, org, course) tuple
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
for module in modules:
original_loc = Location(module.location)
if original_loc.category != 'course':
module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org,
course = dest_location.course)
else:
# on the course module we also have to update the module name
module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org,
course = dest_location.course, name=dest_location.name)
print "Cloning module {0} to {1}....".format(original_loc, module.location)
if 'data' in module.definition:
modulestore.update_item(module.location, module.definition['data'])
# repoint children
if 'children' in module.definition:
new_children = []
for child_loc_url in module.definition['children']:
child_loc = Location(child_loc_url)
child_loc = child_loc._replace(tag = dest_location.tag, org = dest_location.org,
course = dest_location.course)
new_children = new_children + [child_loc.url()]
modulestore.update_children(module.location, new_children)
# save metadata
modulestore.update_metadata(module.location, module.metadata)
# now iterate through all of the assets and clone them
# first the thumbnails
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
for thumb in thumbs:
thumb_loc = Location(thumb["_id"])
content = contentstore.find(thumb_loc)
content.location = content.location._replace(org = dest_location.org,
course = dest_location.course)
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
contentstore.save(content)
# now iterate through all of the assets, also updating the thumbnail pointer
assets = contentstore.get_all_content_for_course(source_location)
for asset in assets:
asset_loc = Location(asset["_id"])
content = contentstore.find(asset_loc)
content.location = content.location._replace(org = dest_location.org,
course = dest_location.course)
# be sure to update the pointer to the thumbnail
if content.thumbnail_location is not None:
content.thumbnail_location = content.thumbnail_location._replace(org = dest_location.org,
course = dest_location.course)
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
contentstore.save(content)
return True
def delete_course(modulestore, contentstore, source_location):
# first check to see if the modulestore is Mongo backed
if not isinstance(modulestore, MongoModuleStore):
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
# check to see if the source course is actually there
if not modulestore.has_item(source_location):
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
# first delete all of the thumbnails
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
for thumb in thumbs:
thumb_loc = Location(thumb["_id"])
id = StaticContent.get_id_from_location(thumb_loc)
print "Deleting {0}...".format(id)
contentstore.delete(id)
# then delete all of the assets
assets = contentstore.get_all_content_for_course(source_location)
for asset in assets:
asset_loc = Location(asset["_id"])
id = StaticContent.get_id_from_location(asset_loc)
print "Deleting {0}...".format(id)
contentstore.delete(id)
# then delete all course modules
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
for module in modules:
if module.category != 'course': # save deleting the course module for last
print "Deleting {0}...".format(module.location)
modulestore.delete_item(module.location)
# finally delete the top-level course module itself
print "Deleting {0}...".format(source_location)
modulestore.delete_item(source_location)
return True
\ No newline at end of file
---
metadata:
display_name: Formula Repsonse
display_name: Formula Response
rerandomize: never
showanswer: always
data: |
......
......@@ -22,7 +22,8 @@ class VideoModule(XModule):
resource_string(__name__, 'js/src/video/display.coffee')] +
[resource_string(__name__, 'js/src/video/display/' + filename)
for filename
in sorted(resource_listdir(__name__, 'js/src/video/display'))]}
in sorted(resource_listdir(__name__, 'js/src/video/display'))
if filename.endswith('.coffee')]}
css = {'scss': [resource_string(__name__, 'css/video/display.scss')]}
js_module_name = "Video"
......
......@@ -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):
"""
......
......@@ -12,10 +12,10 @@ class @TooltipManager
'click': @hideTooltip
showTooltip: (e) =>
tooltipText = $(e.target).attr('data-tooltip')
$target = $(e.target).closest('[data-tooltip]')
tooltipText = $target.attr('data-tooltip')
@$tooltip.html(tooltipText)
@$body.append(@$tooltip)
$(e.target).children().css('pointer-events', 'none')
tooltipCoords =
x: e.pageX - (@$tooltip.outerWidth() / 2)
......@@ -26,8 +26,8 @@ class @TooltipManager
'top': tooltipCoords.y
@tooltipTimer = setTimeout ()=>
@$tooltip.show().css('opacity', 1)
@$tooltip.show().css('opacity', 1)
@tooltipTimer = setTimeout ()=>
@hideTooltip()
, 3000
......
// Underscore.js 1.3.3
// (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc.
// Underscore is freely distributable under the MIT license.
// Portions of Underscore are inspired or borrowed from Prototype,
// Oliver Steele's Functional, and John Resig's Micro-Templating.
// For all details and documentation:
// http://documentcloud.github.com/underscore
(function(){function r(a,c,d){if(a===c)return 0!==a||1/a==1/c;if(null==a||null==c)return a===c;a._chain&&(a=a._wrapped);c._chain&&(c=c._wrapped);if(a.isEqual&&b.isFunction(a.isEqual))return a.isEqual(c);if(c.isEqual&&b.isFunction(c.isEqual))return c.isEqual(a);var e=l.call(a);if(e!=l.call(c))return!1;switch(e){case "[object String]":return a==""+c;case "[object Number]":return a!=+a?c!=+c:0==a?1/a==1/c:a==+c;case "[object Date]":case "[object Boolean]":return+a==+c;case "[object RegExp]":return a.source==
c.source&&a.global==c.global&&a.multiline==c.multiline&&a.ignoreCase==c.ignoreCase}if("object"!=typeof a||"object"!=typeof c)return!1;for(var f=d.length;f--;)if(d[f]==a)return!0;d.push(a);var f=0,g=!0;if("[object Array]"==e){if(f=a.length,g=f==c.length)for(;f--&&(g=f in a==f in c&&r(a[f],c[f],d)););}else{if("constructor"in a!="constructor"in c||a.constructor!=c.constructor)return!1;for(var h in a)if(b.has(a,h)&&(f++,!(g=b.has(c,h)&&r(a[h],c[h],d))))break;if(g){for(h in c)if(b.has(c,h)&&!f--)break;
g=!f}}d.pop();return g}var s=this,I=s._,o={},k=Array.prototype,p=Object.prototype,i=k.slice,J=k.unshift,l=p.toString,K=p.hasOwnProperty,y=k.forEach,z=k.map,A=k.reduce,B=k.reduceRight,C=k.filter,D=k.every,E=k.some,q=k.indexOf,F=k.lastIndexOf,p=Array.isArray,L=Object.keys,t=Function.prototype.bind,b=function(a){return new m(a)};"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(exports=module.exports=b),exports._=b):s._=b;b.VERSION="1.3.3";var j=b.each=b.forEach=function(a,
c,d){if(a!=null)if(y&&a.forEach===y)a.forEach(c,d);else if(a.length===+a.length)for(var e=0,f=a.length;e<f;e++){if(e in a&&c.call(d,a[e],e,a)===o)break}else for(e in a)if(b.has(a,e)&&c.call(d,a[e],e,a)===o)break};b.map=b.collect=function(a,c,b){var e=[];if(a==null)return e;if(z&&a.map===z)return a.map(c,b);j(a,function(a,g,h){e[e.length]=c.call(b,a,g,h)});if(a.length===+a.length)e.length=a.length;return e};b.reduce=b.foldl=b.inject=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(A&&
a.reduce===A){e&&(c=b.bind(c,e));return f?a.reduce(c,d):a.reduce(c)}j(a,function(a,b,i){if(f)d=c.call(e,d,a,b,i);else{d=a;f=true}});if(!f)throw new TypeError("Reduce of empty array with no initial value");return d};b.reduceRight=b.foldr=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(B&&a.reduceRight===B){e&&(c=b.bind(c,e));return f?a.reduceRight(c,d):a.reduceRight(c)}var g=b.toArray(a).reverse();e&&!f&&(c=b.bind(c,e));return f?b.reduce(g,c,d,e):b.reduce(g,c)};b.find=b.detect=function(a,
c,b){var e;G(a,function(a,g,h){if(c.call(b,a,g,h)){e=a;return true}});return e};b.filter=b.select=function(a,c,b){var e=[];if(a==null)return e;if(C&&a.filter===C)return a.filter(c,b);j(a,function(a,g,h){c.call(b,a,g,h)&&(e[e.length]=a)});return e};b.reject=function(a,c,b){var e=[];if(a==null)return e;j(a,function(a,g,h){c.call(b,a,g,h)||(e[e.length]=a)});return e};b.every=b.all=function(a,c,b){var e=true;if(a==null)return e;if(D&&a.every===D)return a.every(c,b);j(a,function(a,g,h){if(!(e=e&&c.call(b,
a,g,h)))return o});return!!e};var G=b.some=b.any=function(a,c,d){c||(c=b.identity);var e=false;if(a==null)return e;if(E&&a.some===E)return a.some(c,d);j(a,function(a,b,h){if(e||(e=c.call(d,a,b,h)))return o});return!!e};b.include=b.contains=function(a,c){var b=false;if(a==null)return b;if(q&&a.indexOf===q)return a.indexOf(c)!=-1;return b=G(a,function(a){return a===c})};b.invoke=function(a,c){var d=i.call(arguments,2);return b.map(a,function(a){return(b.isFunction(c)?c||a:a[c]).apply(a,d)})};b.pluck=
function(a,c){return b.map(a,function(a){return a[c]})};b.max=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.max.apply(Math,a);if(!c&&b.isEmpty(a))return-Infinity;var e={computed:-Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b>=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b<e.computed&&
(e={value:a,computed:b})});return e.value};b.shuffle=function(a){var b=[],d;j(a,function(a,f){d=Math.floor(Math.random()*(f+1));b[f]=b[d];b[d]=a});return b};b.sortBy=function(a,c,d){var e=b.isFunction(c)?c:function(a){return a[c]};return b.pluck(b.map(a,function(a,b,c){return{value:a,criteria:e.call(d,a,b,c)}}).sort(function(a,b){var c=a.criteria,d=b.criteria;return c===void 0?1:d===void 0?-1:c<d?-1:c>d?1:0}),"value")};b.groupBy=function(a,c){var d={},e=b.isFunction(c)?c:function(a){return a[c]};
j(a,function(a,b){var c=e(a,b);(d[c]||(d[c]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e<f;){var g=e+f>>1;d(a[g])<d(c)?e=g+1:f=g}return e};b.toArray=function(a){return!a?[]:b.isArray(a)||b.isArguments(a)?i.call(a):a.toArray&&b.isFunction(a.toArray)?a.toArray():b.values(a)};b.size=function(a){return b.isArray(a)?a.length:b.keys(a).length};b.first=b.head=b.take=function(a,b,d){return b!=null&&!d?i.call(a,0,b):a[0]};b.initial=function(a,b,d){return i.call(a,
0,a.length-(b==null||d?1:b))};b.last=function(a,b,d){return b!=null&&!d?i.call(a,Math.max(a.length-b,0)):a[a.length-1]};b.rest=b.tail=function(a,b,d){return i.call(a,b==null||d?1:b)};b.compact=function(a){return b.filter(a,function(a){return!!a})};b.flatten=function(a,c){return b.reduce(a,function(a,e){if(b.isArray(e))return a.concat(c?e:b.flatten(e));a[a.length]=e;return a},[])};b.without=function(a){return b.difference(a,i.call(arguments,1))};b.uniq=b.unique=function(a,c,d){var d=d?b.map(a,d):a,
e=[];a.length<3&&(c=true);b.reduce(d,function(d,g,h){if(c?b.last(d)!==g||!d.length:!b.include(d,g)){d.push(g);e.push(a[h])}return d},[]);return e};b.union=function(){return b.uniq(b.flatten(arguments,true))};b.intersection=b.intersect=function(a){var c=i.call(arguments,1);return b.filter(b.uniq(a),function(a){return b.every(c,function(c){return b.indexOf(c,a)>=0})})};b.difference=function(a){var c=b.flatten(i.call(arguments,1),true);return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a=
i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e<c;e++)d[e]=b.pluck(a,""+e);return d};b.indexOf=function(a,c,d){if(a==null)return-1;var e;if(d){d=b.sortedIndex(a,c);return a[d]===c?d:-1}if(q&&a.indexOf===q)return a.indexOf(c);d=0;for(e=a.length;d<e;d++)if(d in a&&a[d]===c)return d;return-1};b.lastIndexOf=function(a,b){if(a==null)return-1;if(F&&a.lastIndexOf===F)return a.lastIndexOf(b);for(var d=a.length;d--;)if(d in a&&a[d]===b)return d;return-1};b.range=function(a,b,d){if(arguments.length<=
1){b=a||0;a=0}for(var d=arguments[2]||1,e=Math.max(Math.ceil((b-a)/d),0),f=0,g=Array(e);f<e;){g[f++]=a;a=a+d}return g};var H=function(){};b.bind=function(a,c){var d,e;if(a.bind===t&&t)return t.apply(a,i.call(arguments,1));if(!b.isFunction(a))throw new TypeError;e=i.call(arguments,2);return d=function(){if(!(this instanceof d))return a.apply(c,e.concat(i.call(arguments)));H.prototype=a.prototype;var b=new H,g=a.apply(b,e.concat(i.call(arguments)));return Object(g)===g?g:b}};b.bindAll=function(a){var c=
i.call(arguments,1);c.length==0&&(c=b.functions(a));j(c,function(c){a[c]=b.bind(a[c],a)});return a};b.memoize=function(a,c){var d={};c||(c=b.identity);return function(){var e=c.apply(this,arguments);return b.has(d,e)?d[e]:d[e]=a.apply(this,arguments)}};b.delay=function(a,b){var d=i.call(arguments,2);return setTimeout(function(){return a.apply(null,d)},b)};b.defer=function(a){return b.delay.apply(b,[a,1].concat(i.call(arguments,1)))};b.throttle=function(a,c){var d,e,f,g,h,i,j=b.debounce(function(){h=
g=false},c);return function(){d=this;e=arguments;f||(f=setTimeout(function(){f=null;h&&a.apply(d,e);j()},c));g?h=true:i=a.apply(d,e);j();g=true;return i}};b.debounce=function(a,b,d){var e;return function(){var f=this,g=arguments;d&&!e&&a.apply(f,g);clearTimeout(e);e=setTimeout(function(){e=null;d||a.apply(f,g)},b)}};b.once=function(a){var b=false,d;return function(){if(b)return d;b=true;return d=a.apply(this,arguments)}};b.wrap=function(a,b){return function(){var d=[a].concat(i.call(arguments,0));
return b.apply(this,d)}};b.compose=function(){var a=arguments;return function(){for(var b=arguments,d=a.length-1;d>=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return a<=0?b():function(){if(--a<1)return b.apply(this,arguments)}};b.keys=L||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var c=[],d;for(d in a)b.has(a,d)&&(c[c.length]=d);return c};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&&
c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]=b[d]});return a};b.pick=function(a){var c={};j(b.flatten(i.call(arguments,1)),function(b){b in a&&(c[b]=a[b])});return c};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]=b[d])});return a};b.clone=function(a){return!b.isObject(a)?a:b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return r(a,b,[])};b.isEmpty=
function(a){if(a==null)return true;if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(b.has(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=p||function(a){return l.call(a)=="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return l.call(a)=="[object Arguments]"};b.isArguments(arguments)||(b.isArguments=function(a){return!(!a||!b.has(a,"callee"))});b.isFunction=function(a){return l.call(a)=="[object Function]"};
b.isString=function(a){return l.call(a)=="[object String]"};b.isNumber=function(a){return l.call(a)=="[object Number]"};b.isFinite=function(a){return b.isNumber(a)&&isFinite(a)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||l.call(a)=="[object Boolean]"};b.isDate=function(a){return l.call(a)=="[object Date]"};b.isRegExp=function(a){return l.call(a)=="[object RegExp]"};b.isNull=function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.has=function(a,
b){return K.call(a,b)};b.noConflict=function(){s._=I;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e<a;e++)b.call(d,e)};b.escape=function(a){return(""+a).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;").replace(/\//g,"&#x2F;")};b.result=function(a,c){if(a==null)return null;var d=a[c];return b.isFunction(d)?d.call(a):d};b.mixin=function(a){j(b.functions(a),function(c){M(c,b[c]=a[c])})};var N=0;b.uniqueId=
function(a){var b=N++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var u=/.^/,n={"\\":"\\","'":"'",r:"\r",n:"\n",t:"\t",u2028:"\u2028",u2029:"\u2029"},v;for(v in n)n[n[v]]=v;var O=/\\|'|\r|\n|\t|\u2028|\u2029/g,P=/\\(\\|'|r|n|t|u2028|u2029)/g,w=function(a){return a.replace(P,function(a,b){return n[b]})};b.template=function(a,c,d){d=b.defaults(d||{},b.templateSettings);a="__p+='"+a.replace(O,function(a){return"\\"+n[a]}).replace(d.escape||
u,function(a,b){return"'+\n_.escape("+w(b)+")+\n'"}).replace(d.interpolate||u,function(a,b){return"'+\n("+w(b)+")+\n'"}).replace(d.evaluate||u,function(a,b){return"';\n"+w(b)+"\n;__p+='"})+"';\n";d.variable||(a="with(obj||{}){\n"+a+"}\n");var a="var __p='';var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n"+a+"return __p;\n",e=new Function(d.variable||"obj","_",a);if(c)return e(c,b);c=function(a){return e.call(this,a,b)};c.source="function("+(d.variable||"obj")+"){\n"+a+"}";return c};
b.chain=function(a){return b(a).chain()};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var x=function(a,c){return c?b(a).chain():a},M=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);J.call(a,this._wrapped);return x(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),function(a){var b=k[a];m.prototype[a]=function(){var d=this._wrapped;b.apply(d,arguments);var e=d.length;(a=="shift"||a=="splice")&&e===0&&delete d[0];return x(d,
this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return x(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}}).call(this);
(function(){var n=this,t=n._,r={},e=Array.prototype,u=Object.prototype,i=Function.prototype,a=e.push,o=e.slice,c=e.concat,l=u.toString,f=u.hasOwnProperty,s=e.forEach,p=e.map,v=e.reduce,h=e.reduceRight,g=e.filter,d=e.every,m=e.some,y=e.indexOf,b=e.lastIndexOf,x=Array.isArray,_=Object.keys,j=i.bind,w=function(n){return n instanceof w?n:this instanceof w?(this._wrapped=n,void 0):new w(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=w),exports._=w):n._=w,w.VERSION="1.4.3";var A=w.each=w.forEach=function(n,t,e){if(null!=n)if(s&&n.forEach===s)n.forEach(t,e);else if(n.length===+n.length){for(var u=0,i=n.length;i>u;u++)if(t.call(e,n[u],u,n)===r)return}else for(var a in n)if(w.has(n,a)&&t.call(e,n[a],a,n)===r)return};w.map=w.collect=function(n,t,r){var e=[];return null==n?e:p&&n.map===p?n.map(t,r):(A(n,function(n,u,i){e[e.length]=t.call(r,n,u,i)}),e)};var O="Reduce of empty array with no initial value";w.reduce=w.foldl=w.inject=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),v&&n.reduce===v)return e&&(t=w.bind(t,e)),u?n.reduce(t,r):n.reduce(t);if(A(n,function(n,i,a){u?r=t.call(e,r,n,i,a):(r=n,u=!0)}),!u)throw new TypeError(O);return r},w.reduceRight=w.foldr=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),h&&n.reduceRight===h)return e&&(t=w.bind(t,e)),u?n.reduceRight(t,r):n.reduceRight(t);var i=n.length;if(i!==+i){var a=w.keys(n);i=a.length}if(A(n,function(o,c,l){c=a?a[--i]:--i,u?r=t.call(e,r,n[c],c,l):(r=n[c],u=!0)}),!u)throw new TypeError(O);return r},w.find=w.detect=function(n,t,r){var e;return E(n,function(n,u,i){return t.call(r,n,u,i)?(e=n,!0):void 0}),e},w.filter=w.select=function(n,t,r){var e=[];return null==n?e:g&&n.filter===g?n.filter(t,r):(A(n,function(n,u,i){t.call(r,n,u,i)&&(e[e.length]=n)}),e)},w.reject=function(n,t,r){return w.filter(n,function(n,e,u){return!t.call(r,n,e,u)},r)},w.every=w.all=function(n,t,e){t||(t=w.identity);var u=!0;return null==n?u:d&&n.every===d?n.every(t,e):(A(n,function(n,i,a){return(u=u&&t.call(e,n,i,a))?void 0:r}),!!u)};var E=w.some=w.any=function(n,t,e){t||(t=w.identity);var u=!1;return null==n?u:m&&n.some===m?n.some(t,e):(A(n,function(n,i,a){return u||(u=t.call(e,n,i,a))?r:void 0}),!!u)};w.contains=w.include=function(n,t){return null==n?!1:y&&n.indexOf===y?-1!=n.indexOf(t):E(n,function(n){return n===t})},w.invoke=function(n,t){var r=o.call(arguments,2);return w.map(n,function(n){return(w.isFunction(t)?t:n[t]).apply(n,r)})},w.pluck=function(n,t){return w.map(n,function(n){return n[t]})},w.where=function(n,t){return w.isEmpty(t)?[]:w.filter(n,function(n){for(var r in t)if(t[r]!==n[r])return!1;return!0})},w.max=function(n,t,r){if(!t&&w.isArray(n)&&n[0]===+n[0]&&65535>n.length)return Math.max.apply(Math,n);if(!t&&w.isEmpty(n))return-1/0;var e={computed:-1/0,value:-1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;a>=e.computed&&(e={value:n,computed:a})}),e.value},w.min=function(n,t,r){if(!t&&w.isArray(n)&&n[0]===+n[0]&&65535>n.length)return Math.min.apply(Math,n);if(!t&&w.isEmpty(n))return 1/0;var e={computed:1/0,value:1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;e.computed>a&&(e={value:n,computed:a})}),e.value},w.shuffle=function(n){var t,r=0,e=[];return A(n,function(n){t=w.random(r++),e[r-1]=e[t],e[t]=n}),e};var F=function(n){return w.isFunction(n)?n:function(t){return t[n]}};w.sortBy=function(n,t,r){var e=F(t);return w.pluck(w.map(n,function(n,t,u){return{value:n,index:t,criteria:e.call(r,n,t,u)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||void 0===r)return 1;if(e>r||void 0===e)return-1}return n.index<t.index?-1:1}),"value")};var k=function(n,t,r,e){var u={},i=F(t||w.identity);return A(n,function(t,a){var o=i.call(r,t,a,n);e(u,o,t)}),u};w.groupBy=function(n,t,r){return k(n,t,r,function(n,t,r){(w.has(n,t)?n[t]:n[t]=[]).push(r)})},w.countBy=function(n,t,r){return k(n,t,r,function(n,t){w.has(n,t)||(n[t]=0),n[t]++})},w.sortedIndex=function(n,t,r,e){r=null==r?w.identity:F(r);for(var u=r.call(e,t),i=0,a=n.length;a>i;){var o=i+a>>>1;u>r.call(e,n[o])?i=o+1:a=o}return i},w.toArray=function(n){return n?w.isArray(n)?o.call(n):n.length===+n.length?w.map(n,w.identity):w.values(n):[]},w.size=function(n){return null==n?0:n.length===+n.length?n.length:w.keys(n).length},w.first=w.head=w.take=function(n,t,r){return null==n?void 0:null==t||r?n[0]:o.call(n,0,t)},w.initial=function(n,t,r){return o.call(n,0,n.length-(null==t||r?1:t))},w.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:o.call(n,Math.max(n.length-t,0))},w.rest=w.tail=w.drop=function(n,t,r){return o.call(n,null==t||r?1:t)},w.compact=function(n){return w.filter(n,w.identity)};var R=function(n,t,r){return A(n,function(n){w.isArray(n)?t?a.apply(r,n):R(n,t,r):r.push(n)}),r};w.flatten=function(n,t){return R(n,t,[])},w.without=function(n){return w.difference(n,o.call(arguments,1))},w.uniq=w.unique=function(n,t,r,e){w.isFunction(t)&&(e=r,r=t,t=!1);var u=r?w.map(n,r,e):n,i=[],a=[];return A(u,function(r,e){(t?e&&a[a.length-1]===r:w.contains(a,r))||(a.push(r),i.push(n[e]))}),i},w.union=function(){return w.uniq(c.apply(e,arguments))},w.intersection=function(n){var t=o.call(arguments,1);return w.filter(w.uniq(n),function(n){return w.every(t,function(t){return w.indexOf(t,n)>=0})})},w.difference=function(n){var t=c.apply(e,o.call(arguments,1));return w.filter(n,function(n){return!w.contains(t,n)})},w.zip=function(){for(var n=o.call(arguments),t=w.max(w.pluck(n,"length")),r=Array(t),e=0;t>e;e++)r[e]=w.pluck(n,""+e);return r},w.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},w.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=w.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}if(y&&n.indexOf===y)return n.indexOf(t,r);for(;u>e;e++)if(n[e]===t)return e;return-1},w.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=null!=r;if(b&&n.lastIndexOf===b)return e?n.lastIndexOf(t,r):n.lastIndexOf(t);for(var u=e?r:n.length;u--;)if(n[u]===t)return u;return-1},w.range=function(n,t,r){1>=arguments.length&&(t=n||0,n=0),r=arguments[2]||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=0,i=Array(e);e>u;)i[u++]=n,n+=r;return i};var I=function(){};w.bind=function(n,t){var r,e;if(n.bind===j&&j)return j.apply(n,o.call(arguments,1));if(!w.isFunction(n))throw new TypeError;return r=o.call(arguments,2),e=function(){if(!(this instanceof e))return n.apply(t,r.concat(o.call(arguments)));I.prototype=n.prototype;var u=new I;I.prototype=null;var i=n.apply(u,r.concat(o.call(arguments)));return Object(i)===i?i:u}},w.bindAll=function(n){var t=o.call(arguments,1);return 0==t.length&&(t=w.functions(n)),A(t,function(t){n[t]=w.bind(n[t],n)}),n},w.memoize=function(n,t){var r={};return t||(t=w.identity),function(){var e=t.apply(this,arguments);return w.has(r,e)?r[e]:r[e]=n.apply(this,arguments)}},w.delay=function(n,t){var r=o.call(arguments,2);return setTimeout(function(){return n.apply(null,r)},t)},w.defer=function(n){return w.delay.apply(w,[n,1].concat(o.call(arguments,1)))},w.throttle=function(n,t){var r,e,u,i,a=0,o=function(){a=new Date,u=null,i=n.apply(r,e)};return function(){var c=new Date,l=t-(c-a);return r=this,e=arguments,0>=l?(clearTimeout(u),u=null,a=c,i=n.apply(r,e)):u||(u=setTimeout(o,l)),i}},w.debounce=function(n,t,r){var e,u;return function(){var i=this,a=arguments,o=function(){e=null,r||(u=n.apply(i,a))},c=r&&!e;return clearTimeout(e),e=setTimeout(o,t),c&&(u=n.apply(i,a)),u}},w.once=function(n){var t,r=!1;return function(){return r?t:(r=!0,t=n.apply(this,arguments),n=null,t)}},w.wrap=function(n,t){return function(){var r=[n];return a.apply(r,arguments),t.apply(this,r)}},w.compose=function(){var n=arguments;return function(){for(var t=arguments,r=n.length-1;r>=0;r--)t=[n[r].apply(this,t)];return t[0]}},w.after=function(n,t){return 0>=n?t():function(){return 1>--n?t.apply(this,arguments):void 0}},w.keys=_||function(n){if(n!==Object(n))throw new TypeError("Invalid object");var t=[];for(var r in n)w.has(n,r)&&(t[t.length]=r);return t},w.values=function(n){var t=[];for(var r in n)w.has(n,r)&&t.push(n[r]);return t},w.pairs=function(n){var t=[];for(var r in n)w.has(n,r)&&t.push([r,n[r]]);return t},w.invert=function(n){var t={};for(var r in n)w.has(n,r)&&(t[n[r]]=r);return t},w.functions=w.methods=function(n){var t=[];for(var r in n)w.isFunction(n[r])&&t.push(r);return t.sort()},w.extend=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]=t[r]}),n},w.pick=function(n){var t={},r=c.apply(e,o.call(arguments,1));return A(r,function(r){r in n&&(t[r]=n[r])}),t},w.omit=function(n){var t={},r=c.apply(e,o.call(arguments,1));for(var u in n)w.contains(r,u)||(t[u]=n[u]);return t},w.defaults=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)null==n[r]&&(n[r]=t[r])}),n},w.clone=function(n){return w.isObject(n)?w.isArray(n)?n.slice():w.extend({},n):n},w.tap=function(n,t){return t(n),n};var S=function(n,t,r,e){if(n===t)return 0!==n||1/n==1/t;if(null==n||null==t)return n===t;n instanceof w&&(n=n._wrapped),t instanceof w&&(t=t._wrapped);var u=l.call(n);if(u!=l.call(t))return!1;switch(u){case"[object String]":return n==t+"";case"[object Number]":return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case"[object Date]":case"[object Boolean]":return+n==+t;case"[object RegExp]":return n.source==t.source&&n.global==t.global&&n.multiline==t.multiline&&n.ignoreCase==t.ignoreCase}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]==n)return e[i]==t;r.push(n),e.push(t);var a=0,o=!0;if("[object Array]"==u){if(a=n.length,o=a==t.length)for(;a--&&(o=S(n[a],t[a],r,e)););}else{var c=n.constructor,f=t.constructor;if(c!==f&&!(w.isFunction(c)&&c instanceof c&&w.isFunction(f)&&f instanceof f))return!1;for(var s in n)if(w.has(n,s)&&(a++,!(o=w.has(t,s)&&S(n[s],t[s],r,e))))break;if(o){for(s in t)if(w.has(t,s)&&!a--)break;o=!a}}return r.pop(),e.pop(),o};w.isEqual=function(n,t){return S(n,t,[],[])},w.isEmpty=function(n){if(null==n)return!0;if(w.isArray(n)||w.isString(n))return 0===n.length;for(var t in n)if(w.has(n,t))return!1;return!0},w.isElement=function(n){return!(!n||1!==n.nodeType)},w.isArray=x||function(n){return"[object Array]"==l.call(n)},w.isObject=function(n){return n===Object(n)},A(["Arguments","Function","String","Number","Date","RegExp"],function(n){w["is"+n]=function(t){return l.call(t)=="[object "+n+"]"}}),w.isArguments(arguments)||(w.isArguments=function(n){return!(!n||!w.has(n,"callee"))}),w.isFunction=function(n){return"function"==typeof n},w.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},w.isNaN=function(n){return w.isNumber(n)&&n!=+n},w.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"==l.call(n)},w.isNull=function(n){return null===n},w.isUndefined=function(n){return void 0===n},w.has=function(n,t){return f.call(n,t)},w.noConflict=function(){return n._=t,this},w.identity=function(n){return n},w.times=function(n,t,r){for(var e=Array(n),u=0;n>u;u++)e[u]=t.call(r,u);return e},w.random=function(n,t){return null==t&&(t=n,n=0),n+(0|Math.random()*(t-n+1))};var T={escape:{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#x27;","/":"&#x2F;"}};T.unescape=w.invert(T.escape);var M={escape:RegExp("["+w.keys(T.escape).join("")+"]","g"),unescape:RegExp("("+w.keys(T.unescape).join("|")+")","g")};w.each(["escape","unescape"],function(n){w[n]=function(t){return null==t?"":(""+t).replace(M[n],function(t){return T[n][t]})}}),w.result=function(n,t){if(null==n)return null;var r=n[t];return w.isFunction(r)?r.call(n):r},w.mixin=function(n){A(w.functions(n),function(t){var r=w[t]=n[t];w.prototype[t]=function(){var n=[this._wrapped];return a.apply(n,arguments),z.call(this,r.apply(w,n))}})};var N=0;w.uniqueId=function(n){var t=""+ ++N;return n?n+t:t},w.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var q=/(.)^/,B={"'":"'","\\":"\\","\r":"r","\n":"n"," ":"t","\u2028":"u2028","\u2029":"u2029"},D=/\\|'|\r|\n|\t|\u2028|\u2029/g;w.template=function(n,t,r){r=w.defaults({},r,w.templateSettings);var e=RegExp([(r.escape||q).source,(r.interpolate||q).source,(r.evaluate||q).source].join("|")+"|$","g"),u=0,i="__p+='";n.replace(e,function(t,r,e,a,o){return i+=n.slice(u,o).replace(D,function(n){return"\\"+B[n]}),r&&(i+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'"),e&&(i+="'+\n((__t=("+e+"))==null?'':__t)+\n'"),a&&(i+="';\n"+a+"\n__p+='"),u=o+t.length,t}),i+="';\n",r.variable||(i="with(obj||{}){\n"+i+"}\n"),i="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+i+"return __p;\n";try{var a=Function(r.variable||"obj","_",i)}catch(o){throw o.source=i,o}if(t)return a(t,w);var c=function(n){return a.call(this,n,w)};return c.source="function("+(r.variable||"obj")+"){\n"+i+"}",c},w.chain=function(n){return w(n).chain()};var z=function(n){return this._chain?w(n).chain():n};w.mixin(w),A(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=e[n];w.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!=n&&"splice"!=n||0!==r.length||delete r[0],z.call(this,r)}}),A(["concat","join","slice"],function(n){var t=e[n];w.prototype[n]=function(){return z.call(this,t.apply(this._wrapped,arguments))}}),w.extend(w.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}})}).call(this);
\ No newline at end of file
roots/2012_Fall.xml
\ No newline at end of file
<course org="edX" course="graded" url_name="2012_Fall"/>
\ No newline at end of file
roots/2012_Fall.xml
\ No newline at end of file
<course org="edX" course="sa_test" url_name="2012_Fall"/>
roots/2012_Fall.xml
\ No newline at end of file
<course org="edX" course="test_start_date" url_name="2012_Fall"/>
\ No newline at end of file
roots/2012_Fall.xml
\ No newline at end of file
<course org="edX" course="toy" url_name="2012_Fall"/>
\ No newline at end of file
# Notes on using mongodb backed LMS and CMS
These are some random notes for developers, on how things are stored in mongodb, and how to debug mongodb data.
## Databases
......
......@@ -22,7 +22,8 @@ rake test_lms[false] || TESTS_FAILED=1
rake test_common/lib/capa || TESTS_FAILED=1
rake test_common/lib/xmodule || TESTS_FAILED=1
rake phantomjs_jasmine_lms || true
rake phantomjs_jasmine_cms || true
rake phantomjs_jasmine_cms || TESTS_FAILED=1
rake phantomjs_jasmine_common/lib/xmodule || true
rake coverage:xml coverage:html
[ $TESTS_FAILED == '0' ]
......
../../../common/static/images/edge-logo-small.png
\ No newline at end of file
../../../../common/static/sass/_mixins.scss
\ No newline at end of file
@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;
}
......@@ -424,6 +424,429 @@
"publish_date": "October 1, 2012"
},
{
"title": "The Year of the MOOC",
"url": "http://www.nytimes.com/2012/11/04/education/edlife/massive-open-online-courses-are-multiplying-at-a-rapid-pace.html",
"author": "Laura Pappano",
"image": "nyt_logo_178x138.jpeg",
"deck": null,
"publication": "The New York Times",
"publish_date": "November 2, 2012"
},
{
"title": "The Most Important Education Technology in 200 Years",
"url": "http://www.technologyreview.com/news/506351/the-most-important-education-technology-in-200-years/",
"author": "Antonio Regalado",
"image": "techreview_logo_178x138.jpg",
"deck": null,
"publication": "Technology Review",
"publish_date": "November 2, 2012"
},
{
"title": "Classroom in the Cloud",
"url": "http://harvardmagazine.com/2012/11/classroom-in-the-cloud",
"author": null,
"image": "harvardmagazine_logo_178x138.jpeg",
"deck": null,
"publication": "Harvard Magazine",
"publish_date": "November-December 2012"
},
{
"title": "How do you stop online students cheating?",
"url": "http://www.bbc.co.uk/news/business-19661899",
"author": "Sean Coughlan",
"image": "bbc_logo_178x138.jpeg",
"deck": null,
"publication": "BBC",
"publish_date": "October 31, 2012"
},
{
"title": "VMware to provide software for HarvardX CS50x",
"url": "http://tech.mit.edu/V132/N48/edxvmware.html",
"author": "Stan Gill",
"image": "thetech_logo_178x138.jpg",
"deck": null,
"publication": "The Tech",
"publish_date": "October 26, 2012"
},
{
"title": "EdX platform integrates into classes",
"url": "http://tech.mit.edu/V132/N48/801edx.html",
"author": "Leon Lin",
"image": "thetech_logo_178x138.jpg",
"deck": null,
"publication": "The Tech",
"publish_date": "October 26, 2012"
},
{
"title": "VMware Offers Free Software to edX Learners",
"url": "http://campustechnology.com/articles/2012/10/25/vmware-offers-free-virtualization-software-for-edx-computer-science-students.aspx",
"author": "Joshua Bolkan",
"image": "campustech_logo_178x138.jpg",
"deck": "VMware Offers Free Virtualization Software for EdX Computer Science Students",
"publication": "Campus Technology",
"publish_date": "October 25, 2012"
},
{
"title": "Lone Star moots charges to make Moocs add up",
"url": "http://www.timeshighereducation.co.uk/story.asp?sectioncode=26&storycode=421577&c=1",
"author": "David Matthews",
"image": "timeshighered_logo_178x138.jpg",
"deck": null,
"publication": "Times Higher Education",
"publish_date": "October 25, 2012"
},
{
"title": "Free, high-quality and with mass appeal",
"url": "http://www.ft.com/intl/cms/s/2/73030f44-d4dd-11e1-9444-00144feabdc0.html#axzz2A9qvk48A",
"author": "Rebecca Knight",
"image": "ft_logo_178x138.jpg",
"deck": null,
"publication": "Financial Times",
"publish_date": "October 22, 2012"
},
{
"title": "Getting the most out of an online education",
"url": "http://www.reuters.com/article/2012/10/19/us-education-courses-online-idUSBRE89I17120121019",
"author": "Kathleen Kingsbury",
"image": "reuters_logo_178x138.jpg",
"deck": null,
"publication": "Reuters",
"publish_date": "October 19, 2012"
},
{
"title": "EdX announces partnership with Cengage",
"url": "http://tech.mit.edu/V132/N46/cengage.html",
"author": "Leon Lin",
"image": "thetech_logo_178x138.jpg",
"deck": null,
"publication": "The Tech",
"publish_date": "October 19, 2012"
},
{
"title": "U Texas System Joins EdX",
"url": "http://campustechnology.com/articles/2012/10/18/u-texas-system-joins-edx.aspx",
"author": "Joshua Bolkan",
"image": "campustech_logo_178x138.jpg",
"deck": null,
"publication": "Campus Technology",
"publish_date": "October 18, 2012"
},
{
"title": "San Jose State University Runs Blended Learning Course Using edX ",
"url": "http://chronicle.com/blogs/wiredcampus/san-jose-state-u-says-replacing-live-lectures-with-videos-increased-test-scores/40470",
"author": "Alisha Azevedo",
"image": "chroniclehighered_logo_178x138.jpeg",
"deck": "San Jose State U. Says Replacing Live Lectures With Videos Increased Test Scores",
"publication": "Chronicle of Higher Education",
"publish_date": "October 17, 2012"
},
{
"title": "Online university to charge tuition fees",
"url": "http://www.bbc.co.uk/news/education-19964787",
"author": "Sean Coughlan",
"image": "bbc_logo_178x138.jpeg",
"deck": null,
"publication": "BBC",
"publish_date": "October 17, 2012"
},
{
"title": "HarvardX marks the spot",
"url": "http://news.harvard.edu/gazette/story/2012/10/harvardx-marks-the-spot/",
"author": "Tania delLuzuriaga",
"image": "harvardgazette_logo_178x138.jpeg",
"deck": null,
"publication": "Harvard Gazette",
"publish_date": "October 17, 2012"
},
{
"title": "Harvard EdX Enrolls Near 100000 Students for Free Online Classes",
"url": "http://www.collegeclasses.com/harvard-edx-enrolls-near-100000-students-for-free-online-classes/",
"author": "Keith Koong",
"image": "college_classes_logo_178x138.jpg",
"deck": null,
"publication": "CollegeClasses.com",
"publish_date": "October 17, 2012"
},
{
"title": "Cengage Learning to Provide Book Content and Pedagogy through edX's Not-for-Profit Interactive Study Via the Web",
"url": "http://www.outsellinc.com/our_industry/headlines/1087978",
"author": null,
"image": "outsell_logo_178x138.jpg",
"deck": null,
"publication": "Outsell.com",
"publish_date": "October 17, 2012"
},
{
"title": "University of Texas System Embraces MOOCs",
"url": "http://www.usnewsuniversitydirectory.com/articles/university-of-texas-system-embraces-moocs_12713.aspx#.UIBLVq7bNzo",
"author": "Chris Hassan",
"image": "usnews_logo_178x138.jpeg",
"deck": null,
"publication": "US News",
"publish_date": "October 17, 2012"
},
{
"title": "Texas MOOCs for Credit?",
"url": "http://www.insidehighered.com/news/2012/10/16/u-texas-aims-use-moocs-reduce-costs-increase-completion",
"author": "Steve Kolowich",
"image": "insidehighered_logo_178x138.jpg",
"deck": null,
"publication": "Insider Higher Ed",
"publish_date": "October 16, 2012"
},
{
"title": "University of Texas Joins Harvard-Founded edX",
"url": "http://www.thecrimson.com/article/2012/10/16/University-of-Texas-edX/",
"author": "Kevin J. Wu",
"image": "harvardcrimson_logo_178x138.jpeg",
"deck": null,
"publication": "The Crimson",
"publish_date": "October 16, 2012"
},
{
"title": "Entire UT System to join edX",
"url": "http://tech.mit.edu/V132/N45/edx.html",
"author": "Ethan A. Solomon",
"image": "thetech_logo_178x138.jpg",
"deck": null,
"publication": "The Tech",
"publish_date": "October 16, 2012"
},
{
"title": "First University System Joins edX Platform",
"url": "http://www.govtech.com/education/First-University-System-Joins-edX-platform.html",
"author": "Tanya Roscoria",
"image": "govtech_logo_178x138.jpg",
"deck": null,
"publication": "GovTech.com",
"publish_date": "October 16, 2012"
},
{
"title": "University of Texas Joining Harvard, MIT Online Venture",
"url": "http://www.bloomberg.com/news/2012-10-15/university-of-texas-joining-harvard-mit-online-venture.html",
"author": "David Mildenberg",
"image": "bloomberg_logo_178x138.jpeg",
"deck": null,
"publication": "Bloomberg",
"publish_date": "October 15, 2012"
},
{
"title": "University of Texas Joining Harvard, MIT Online Venture",
"url": "http://www.businessweek.com/news/2012-10-15/university-of-texas-joining-harvard-mit-online-venture",
"author": "David Mildenberg",
"image": "busweek_logo_178x138.jpg",
"deck": null,
"publication": "Business Week",
"publish_date": "October 15, 2012"
},
{
"title": "Univ. of Texas joins online course program edX",
"url": "http://news.yahoo.com/univ-texas-joins-online-course-program-edx-172202035--finance.html",
"author": "Chris Tomlinson",
"image": "ap_logo_178x138.jpg",
"deck": null,
"publication": "Associated Press",
"publish_date": "October 15, 2012"
},
{
"title": "U. of Texas Plans to Join edX",
"url": "http://www.insidehighered.com/quicktakes/2012/10/15/u-texas-plans-join-edx",
"author": null,
"image": "insidehighered_logo_178x138.jpg",
"deck": null,
"publication": "Inside Higher Ed",
"publish_date": "October 15, 2012"
},
{
"title": "U. of Texas System Is Latest to Sign Up With edX for Online Courses",
"url": "http://chronicle.com/blogs/wiredcampus/u-of-texas-system-is-latest-to-sign-up-with-edx-for-online-courses/40440",
"author": "Alisha Azevedo",
"image": "chroniclehighered_logo_178x138.jpeg",
"deck": null,
"publication": "Chronicle of Higher Education",
"publish_date": "October 15, 2012"
},
{
"title": "First University System Joins edX",
"url": "http://www.centerdigitaled.com/news/First-University-System-Joins-edX.html",
"author": "Tanya Roscoria",
"image": "center_digeducation_logo_178x138.jpg",
"deck": null,
"publication": "Center for Digital Education",
"publish_date": "October 15, 2012"
},
{
"title": "University of Texas Joins Harvard, MIT in edX Online Learning Venture",
"url": "http://harvardmagazine.com/2012/10/university-of-texas-joins-harvard-mit-edx",
"author": null,
"image": "harvardmagazine_logo_178x138.jpeg",
"deck": null,
"publication": "Harvard Magazine",
"publish_date": "October 15, 2012"
},
{
"title": "University of Texas joins edX",
"url": "http://www.masshightech.com/stories/2012/10/15/daily13-University-of-Texas-joins-edX.html",
"author": "Don Seiffert",
"image": "masshightech_logo_178x138.jpg",
"deck": null,
"publication": "MassHighTech",
"publish_date": "October 15, 2012"
},
{
"title": "UT System to Forge Partnership with EdX",
"url": "http://www.texastribune.org/texas-education/higher-education/ut-system-announce-partnership-edx/",
"author": "Reeve Hamilton",
"image": "texastribune_logo_178x138.jpg",
"deck": null,
"publication": "Texas Tribune",
"publish_date": "October 15, 2012"
},
{
"title": "UT System puts $5 million into online learning initiative",
"url": "http://www.statesman.com/news/news/local/ut-system-puts-5-million-into-online-learning-init/nSdd5/",
"author": "Ralph K.M. Haurwitz",
"image": "austin_statesman_logo_178x138.jpg",
"deck": null,
"publication": "The Austin Statesman",
"publish_date": "October 15, 2012"
},
{
"title": "Harvard’s Online Classes Sound Pretty Popular",
"url": "http://blogs.bostonmagazine.com/boston_daily/2012/10/15/harvards-online-classes-sound-pretty-popular/",
"author": "Eric Randall",
"image": "bostonmag_logo_178x138.jpg",
"deck": null,
"publication": "Boston Magazine",
"publish_date": "October 15, 2012"
},
{
"title": "Harvard Debuts Free Online Courses",
"url": "http://www.ibtimes.com/harvard-debuts-free-online-courses-846629",
"author": "Eli Epstein",
"image": "ibtimes_logo_178x138.jpg",
"deck": null,
"publication": "International Business Times",
"publish_date": "October 15, 2012"
},
{
"title": "UT System Joins Online Learning Effort",
"url": "http://www.texastechpulse.com/ut_system_joins_online_learning_effort/s-0045632.html",
"author": null,
"image": "texaspulse_logo_178x138.jpg",
"deck": null,
"publication": "Texas Tech Pulse",
"publish_date": "October 15, 2012"
},
{
"title": "University of Texas Joins edX",
"url": "http://www.onlinecolleges.net/2012/10/15/university-of-texas-joins-edx/",
"author": "Alex Wukman",
"image": "online_colleges_logo_178x138.jpg",
"deck": null,
"publication": "Online Colleges.net",
"publish_date": "October 15, 2012"
},
{
"title": "100,000 sign up for first Harvard online courses",
"url": "http://www.masslive.com/news/index.ssf/2012/10/100000_sign_up_for_first_harva.html",
"author": null,
"image": "ap_logo_178x138.jpg",
"deck": null,
"publication": "Associated Press",
"publish_date": "October 15, 2012"
},
{
"title": "In the new Listener, on sale from 14.10.12",
"url": "http://www.listener.co.nz/commentary/the-internaut/in-the-new-listener-on-sale-from-14-10-12/",
"author": null,
"image": "nz_listener_logo_178x138.jpg",
"deck": null,
"publication": "The Listener",
"publish_date": "October 14, 2012"
},
{
"title": "HarvardX Classes to Begin Tomorrow",
"url": "http://www.thecrimson.com/article/2012/10/14/harvardx-classes-start-tomorrow/",
"author": "Hana N. Rouse",
"image": "harvardcrimson_logo_178x138.jpeg",
"deck": null,
"publication": "The Crimson",
"publish_date": "October 14, 2012"
},
{
"title": "Online Harvard University courses draw well",
"url": "http://bostonglobe.com/metro/2012/10/14/harvard-launching-free-online-courses-sign-for-first-two-classes/zBDuHY0zqD4OESrXWfEgML/story.html",
"author": "Brock Parker",
"image": "bostonglobe_logo_178x138.jpeg",
"deck": null,
"publication": "Boston Globe",
"publish_date": "October 14, 2012"
},
{
"title": "Harvard ready to launch its first free online courses Monday",
"url": "http://www.boston.com/yourtown/news/cambridge/2012/10/harvard_ready_to_launch_its_fi.html",
"author": "Brock Parker",
"image": "bostonglobe_logo_178x138.jpeg",
"deck": null,
"publication": "Boston Globe",
"publish_date": "October 12, 2012"
},
{
"title": "edX: Harvard's New Domain",
"url": "http://www.thecrimson.com/article/2012/10/4/edx-scrutiny-online-learning/ ",
"author": "Delphine Rodrik and Kevin Su",
"image": "harvardcrimson_logo_178x138.jpeg",
"deck": null,
"publication": "The Crimson",
"publish_date": "October 4, 2012"
},
{
"title": "New Experiments in the edX Higher Ed Petri Dish",
"url": "http://www.nonprofitquarterly.org/policysocial-context/21116-new-experiments-in-the-edx-higher-ed-petri-dish.html",
"author": "Michelle Shumate",
"image": "npq_logo_178x138.jpg",
"deck": null,
"publication": "Non-Profit Quarterly",
"publish_date": "October 4, 2012"
},
{
"title": "What Campuses Can Learn From Online Teaching",
"url": "http://online.wsj.com/article/SB10000872396390444620104578012262106378182.html?mod=googlenews_wsj",
"author": "Rafael Reif",
"image": "wsj_logo_178x138.jpg",
"deck": null,
"publication": "Wall Street Journal",
"publish_date": "October 2, 2012"
},
{
"title": "MongoDB courses to be offered via edX",
"url": "http://tech.mit.edu/V132/N42/edxmongodb.html",
"author": "Jake H. Gunter",
"image": "thetech_logo_178x138.jpg",
"deck": null,
"publication": "The Tech",
"publish_date": "October 2, 2012"
},
{
"title": "5 Ways That edX Could Change Education",
"url": "http://chronicle.com/article/5-Ways-That-edX-Could-Change/134672/",
"author": "Marc Parry",
"image": "chroniclehighered_logo_178x138.jpeg",
"deck": null,
"publication": "Chronicle of Higher Education",
"publish_date": "October 1, 2012"
},
{
"title": "MIT profs wait to teach you, for free",
"url": "http://www.dnaindia.com/mumbai/report_mit-profs-wait-to-teach-you-for-free_1747273",
"author": "Kanchan Srivastava",
"image": "dailynews_india_logo_178x138.jpg",
"deck": null,
"publication": "Daily News and Analysis India",
"publish_date": "October 1, 2012"
},
{
"title": "EdX offers free higher education online",
"url": "http://www.youtube.com/watch?v=yr5Ep7RN4Bs",
"author": "via YouTube",
......
......@@ -61,16 +61,18 @@ category = ${category | h}
<script type="text/javascript">
// assumes courseware.html's loaded this method.
setup_debug('${element_id}',
%if edit_link:
'${edit_link}',
%else:
null,
%endif
{
'location': '${location}',
'xqa_key': '${xqa_key}',
'category': '${category}',
'user': '${user}'
});
% if staff_access:
setup_debug('${element_id}',
%if edit_link:
'${edit_link}',
%else:
null,
%endif
{
'location': '${location}',
'xqa_key': '${xqa_key}',
'category': '${category}',
'user': '${user}'
});
% endif
</script>
......@@ -397,6 +397,29 @@ task :publish => :package do
end
namespace :cms do
desc "Clone existing MongoDB based course"
task :clone do
if ENV['SOURCE_LOC'] and ENV['DEST_LOC']
sh(django_admin(:cms, :dev, :clone, ENV['SOURCE_LOC'], ENV['DEST_LOC']))
else
raise "You must pass in a SOURCE_LOC and DEST_LOC parameters"
end
end
end
namespace :cms do
desc "Delete existing MongoDB based course"
task :delete_course do
if ENV['LOC']
sh(django_admin(:cms, :dev, :delete_course, ENV['LOC']))
else
raise "You must pass in a LOC parameter"
end
end
end
namespace :cms do
desc "Import course data within the given DATA_DIR variable"
task :import do
if ENV['DATA_DIR']
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment