Commit e70db062 by Chris Dodge

Merge branch 'feature/cale/cms-master' into feature/cale/cms-master-merge

Conflicts:
	requirements.txt
parents 7229a9cd edd9b2bc
......@@ -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
......
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
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
def get_course_updates(location):
"""
Retrieve the relevant course_info updates and unpack into the model which the client expects:
[{id : location.url() + idx to make unique, date : string, content : html string}]
"""
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
course_updates = modulestore('direct').clone_item(template, Location(location))
# current db rep: {"_id" : locationjson, "definition" : { "data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "metadata" : ignored}
location_base = course_updates.location.url()
# 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'])
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 newest
for idx, update in enumerate(course_html_parsed):
if (len(update) == 0):
continue
elif (len(update) == 1):
# could enforce that update[0].tag == 'h2'
content = update[0].tail
else:
content = "\n".join([etree.tostring(ele) for ele in update[1:]])
# 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 course_upd_collection
def update_course_updates(location, update, passed_id=None):
"""
Either add or update the given course update. It will add it if the passed_id is absent or None. It will update it if
it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index
into the html structure.
"""
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
# 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'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
# 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:
idx = get_idx(passed_id)
# idx is count from end of list
course_html_parsed[-idx] = new_html_parsed
else:
course_html_parsed.insert(0, new_html_parsed)
idx = len(course_html_parsed)
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.definition['data'])
return {"id" : passed_id,
"date" : update['date'],
"content" :update['content']}
def delete_course_update(location, update, passed_id):
"""
Delete the given course_info update from the db.
Returns the resulting course_updates b/c their ids change.
"""
if not passed_id:
return HttpResponseBadRequest
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
return HttpResponseBadRequest
# 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'])
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?
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)
store = modulestore('direct')
store.update_item(location, course_updates.definition['data'])
return get_course_updates(location)
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)
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)
###
### Script for exporting courseware from Mongo to a tar.gz file
###
import os
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
unnamed_modules = 0
class Command(BaseCommand):
help = \
'''Import the specified data directory into the default ModuleStore'''
def handle(self, *args, **options):
if len(args) != 2:
raise CommandError("import requires two arguments: <course location> <output path>")
course_id = args[0]
output_path = args[1]
print "Exporting course id = {0} to {1}".format(course_id, output_path)
location = CourseDescriptor.id_to_location(course_id)
root_dir = os.path.dirname(output_path)
course_dir = os.path.splitext(os.path.basename(output_path))[0]
export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir)
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
import logging
from static_replace import replace_urls
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from lxml import etree
import re
from django.http import HttpResponseBadRequest, Http404
def get_module_info(store, location, parent_location = None, rewrite_static_links = False):
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
except ItemNotFoundError:
raise Http404
data = module.definition['data']
if rewrite_static_links:
data = replace_urls(module.definition['data'], course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None]))
return {
'id': module.location.url(),
'data': data,
'metadata': module.metadata
}
def set_module_info(store, location, post_data):
module = None
isNew = False
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
except:
pass
if module is None:
# new module at this location
# presume that we have an 'Empty' template
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
isNew = True
if post_data.get('data') is not None:
data = post_data['data']
store.update_item(location, data)
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
# deleting the children object from the children collection
if 'children' in post_data and post_data['children'] is not None:
children = post_data['children']
store.update_children(location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key in posted_metadata.keys():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
if metadata_key in module.system_metadata_fields:
del posted_metadata[metadata_key]
elif posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in module.metadata:
del module.metadata[metadata_key]
del posted_metadata[metadata_key]
# overlay the new metadata over the modulestore sourced collection to support partial updates
module.metadata.update(posted_metadata)
# commit to datastore
store.update_metadata(location, module.metadata)
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")
import json
import shutil
from django.test import TestCase
from django.test.client import Client
from mock import patch, Mock
from override_settings import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
from tempfile import mkdtemp
import json
from student.models import Registration
from django.contrib.auth.models import User
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
from xmodule.modulestore import Location
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
from xmodule.modulestore.xml_exporter import export_to_xml
def parse_json(response):
"""Parse response, which is assumed to be json"""
......@@ -23,33 +29,33 @@ def parse_json(response):
def user(email):
'''look up a user by email'''
"""look up a user by email"""
return User.objects.get(email=email)
def registration(email):
'''look up registration object by email'''
"""look up registration object by email"""
return Registration.objects.get(user__email=email)
class ContentStoreTestCase(TestCase):
def _login(self, email, pw):
'''Login. View should always return 200. The success/fail is in the
returned json'''
"""Login. View should always return 200. The success/fail is in the
returned json"""
resp = self.client.post(reverse('login_post'),
{'email': email, 'password': pw})
self.assertEqual(resp.status_code, 200)
return resp
def login(self, email, pw):
'''Login, check that it worked.'''
"""Login, check that it worked."""
resp = self._login(email, pw)
data = parse_json(resp)
self.assertTrue(data['success'])
return resp
def _create_account(self, username, email, pw):
'''Try to create an account. No error checking'''
"""Try to create an account. No error checking"""
resp = self.client.post('/create_account', {
'username': username,
'email': email,
......@@ -63,7 +69,7 @@ class ContentStoreTestCase(TestCase):
return resp
def create_account(self, username, email, pw):
'''Create the account and check that it worked'''
"""Create the account and check that it worked"""
resp = self._create_account(username, email, pw)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
......@@ -75,8 +81,8 @@ class ContentStoreTestCase(TestCase):
return resp
def _activate_user(self, email):
'''Look up the activation key for the user, then hit the activate view.
No error checking'''
"""Look up the activation key for the user, then hit the activate view.
No error checking"""
activation_key = registration(email).activation_key
# and now we try to activate
......@@ -257,6 +263,16 @@ class ContentStoreTest(TestCase):
self.assertEqual(data['ErrMsg'],
'There is already a course defined with the same organization and course number.')
def test_create_course_with_bad_organization(self):
"""Test new course creation - error path for bad organization name"""
self.course_data['org'] = 'University of California, Berkeley'
resp = self.client.post(reverse('create_new_course'), self.course_data)
data = parse_json(resp)
self.assertEqual(resp.status_code, 200)
self.assertEqual(data['ErrMsg'],
"Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.")
def test_course_index_view_with_no_courses(self):
"""Test viewing the index page with no courses"""
# Create a course so there is something to view
......@@ -332,4 +348,103 @@ 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)
def test_export_course(self):
ms = modulestore('direct')
cs = contentstore()
import_from_xml(ms, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp())
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
export_to_xml(ms, cs, location, root_dir, 'test_export')
# remove old course
delete_course(ms, cs, location)
# reimport
import_from_xml(ms, root_dir, ['test_export'])
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertGreater(len(items), 0)
for descriptor in items:
print "Checking {0}....".format(descriptor.location.url())
resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
self.assertEqual(resp.status_code, 200)
shutil.rmtree(root_dir)
def test_course_handouts_rewrites(self):
ms = modulestore('direct')
cs = contentstore()
# import a test course
import_from_xml(ms, 'common/test/data/', ['full'])
handout_location= Location(['i4x', 'edX', 'full', 'course_info', 'handouts'])
# get module info
resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location}))
# make sure we got a successful response
self.assertEqual(resp.status_code, 200)
# check that /static/ has been converted to the full path
# note, we know the link it should be because that's what in the 'full' course in the test data
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
......@@ -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):
'''
......@@ -33,22 +46,65 @@ def get_course_location_for_item(location):
return location
def get_course_for_item(location):
'''
cdodge: for a given Xmodule, return the course that it belongs to
NOTE: This makes a lot of assumptions about the format of the course location
Also we have to assert that this module maps to only one course item - it'll throw an
assert if not
'''
item_loc = Location(location)
# @hack! We need to find the course location however, we don't
# know the 'name' parameter in this context, so we have
# to assume there's only one item in this query even though we are not specifying a name
course_search_location = ['i4x', item_loc.org, item_loc.course, 'course', None]
courses = modulestore().get_items(course_search_location)
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
raise BaseException('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
return courses[0]
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'
......@@ -78,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
import traceback
from util.json_request import expect_json
import exceptions
import json
import logging
import mimetypes
import os
import StringIO
import sys
import time
import tarfile
import shutil
import tempfile
from datetime import datetime
from collections import defaultdict
from uuid import uuid4
from lxml import etree
from path import path
from shutil import rmtree
from xmodule.modulestore.xml_exporter import export_to_xml
from tempfile import mkdtemp
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
......@@ -28,11 +25,9 @@ from django.core.context_processors import csrf
from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.conf import settings
from django import forms
from django.shortcuts import redirect
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
......@@ -43,45 +38,39 @@ from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule.modulestore.django import modulestore
from xmodule_modifiers import replace_static_urls, wrap_xmodule
from xmodule.exceptions import NotFoundError
from xmodule.timeparse import parse_time, stringify_time
from functools import partial
from itertools import groupby
from operator import attrgetter
from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from cache_toolbox.core import set_cached_content, get_cached_content, del_cached_content
from auth.authz import is_user_in_course_group_role, get_users_in_course_group_by_role
from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group
from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME, create_all_course_groups
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState
from .utils import get_course_location_for_item, get_lms_link_for_item, compute_unit_state, get_date_display, UnitState, get_course_for_item
from xmodule.templates import all_templates
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.xml import edx_xml_parser
from contentstore.course_info_model import get_course_updates,\
update_course_updates, delete_course_update
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
......@@ -183,6 +172,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...
......@@ -240,6 +230,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,
......@@ -334,7 +326,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,
......@@ -346,7 +338,7 @@ def edit_unit(request, location):
def preview_component(request, location):
# TODO (vshnayder): change name from id to location in coffee+html as well.
if not has_access(request.user, location):
raise Http404 # TODO (vshnayder): better error
raise HttpResponseForbidden()
component = modulestore().get_item(location)
......@@ -355,6 +347,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:
......@@ -509,7 +519,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),
......@@ -558,7 +568,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
......@@ -589,7 +599,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']
......@@ -679,8 +689,6 @@ def unpublish_unit(request):
return HttpResponse()
@login_required
@expect_json
def clone_item(request):
......@@ -692,10 +700,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']
......@@ -704,10 +712,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()}))
......@@ -867,7 +875,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]
......@@ -887,14 +896,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
......@@ -917,6 +935,187 @@ def server_error(request):
@login_required
@ensure_csrf_cookie
def course_info(request, org, course, name, provided_id=None):
"""
Send models and views as well as html for editing the course info 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)
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
return render_to_response('course_info.html', {
'active_tab': 'courseinfo-tab',
'context_course': course_module,
'url_base' : "/" + org + "/" + course + "/",
'course_updates' : json.dumps(get_course_updates(location)),
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
})
@expect_json
@login_required
@ensure_csrf_cookie
def course_info_updates(request, org, course, provided_id=None):
"""
restful CRUD operations on course_info updates.
org, course: Attributes of the Location for the item to edit
provided_id should be none if it's new (create) and a composite of the update db id + index otherwise.
"""
# ??? 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']
else:
real_method = request.method
if request.method == 'GET':
return HttpResponse(json.dumps(get_course_updates(location)), 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
@login_required
@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:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
rewrite_static_links = request.GET.get('rewrite_url_links','True') in ['True', 'true']
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links','False'), rewrite_static_links))
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
if real_method == 'GET':
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
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:
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
@ensure_csrf_cookie
def asset_index(request, org, course, name):
"""
Display an editable asset library
......@@ -982,7 +1181,10 @@ def create_new_course(request):
number = request.POST.get('number')
display_name = request.POST.get('display_name')
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
try:
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
except InvalidLocationError as e:
return HttpResponse(json.dumps({'ErrMsg': "Unable to create course '" + display_name + "'.\n\n" + e.message}))
# see if the course already exists
existing_course = None
......@@ -1011,22 +1213,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
......@@ -1104,3 +1309,55 @@ def import_course(request, org, course, name):
course_module.location.course,
course_module.location.name])
})
@ensure_csrf_cookie
@login_required
def generate_export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
loc = Location(location)
export_file = NamedTemporaryFile(prefix=name+'.', suffix=".tar.gz")
root_dir = path(mkdtemp())
# export out to a tempdir
logging.debug('root = {0}'.format(root_dir))
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name)
#filename = root_dir / name + '.tar.gz'
logging.debug('tar file being generated at {0}'.format(export_file.name))
tf = tarfile.open(name=export_file.name, mode='w:gz')
tf.add(root_dir/name, arcname=name)
tf.close()
# remove temp dir
shutil.rmtree(root_dir/name)
wrapper = FileWrapper(export_file)
response = HttpResponse(wrapper, content_type='application/x-tgz')
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name)
response['Content-Length'] = os.path.getsize(export_file.name)
return response
@ensure_csrf_cookie
@login_required
def export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
return render_to_response('export.html', {
'context_course': course_module,
'active_tab': 'export',
'successful_import_redirect_url' : ''
})
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>
<a href="#" class="edit-button"><span class="edit-icon"></span>Edit</a>
<h2>Course Handouts</h2>
<%if (model.get('data') != null) { %>
<div class="handouts-content">
<%= model.get('data') %>
</div>
<% } else {%>
<p>You have no handouts defined</p>
<% } %>
<form class="edit-handouts-form" style="display: block;">
<div class="row">
<textarea class="handouts-content-editor text-editor"></textarea>
</div>
<div class="row">
<a href="#" class="save-button">Save</a>
<a href="#" class="cancel-button">Cancel</a>
</div>
</form>
<li name="<%- updateModel.cid %>">
<!-- FIXME what style should we use for initially hidden? --> <!-- TODO decide whether this should use codemirror -->
<form class="new-update-form">
<div class="row">
<label class="inline-label">Date:</label>
<!-- TODO replace w/ date widget and actual date (problem is that persisted version is "Month day" not an actual date obj -->
<input type="text" class="date" value="<%= updateModel.get('date') %>">
</div>
<div class="row">
<textarea class="new-update-content text-editor"><%= updateModel.get('content') %></textarea>
</div>
<div class="row">
<!-- cid rather than id b/c new ones have cid's not id's -->
<a href="#" class="save-button" name="<%= updateModel.cid %>">Save</a>
<a href="#" class="cancel-button" name="<%= updateModel.cid %>">Cancel</a>
</div>
</form>
<div class="post-preview">
<div class="post-actions">
<a href="#" class="edit-button" name="<%- updateModel.cid %>"><span class="edit-icon"></span>Edit</a>
<a href="#" class="delete-button" name="<%- updateModel.cid %>"><span class="delete-icon"></span>Delete</a>
</div>
<h2>
<span class="calendar-icon"></span><span class="date-display"><%=
updateModel.get('date') %></span>
</h2>
<div class="update-contents"><%= updateModel.get('content') %></div>
</div>
</li>
\ No newline at end of file
<!-- In order to enable better debugging of templates, put them in
the script tag section.
TODO add lazy load fn to load templates as needed (called
from backbone view initialize to set this.template of the view)
-->
<%block name="jsextra">
<script type="text/javascript" charset="utf-8">
// How do I load an html file server side so I can
// Precompiling your templates can be a big help when debugging errors you can't reproduce. This is because precompiled templates can provide line numbers and a stack trace, something that is not possible when compiling templates on the client. The source property is available on the compiled template function for easy precompilation.
// <script>CMS.course_info_update = <%= _.template(jstText).source %>;</script>
</script>
</%block>
\ No newline at end of file
......@@ -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()
......
cms/static/img/delete-icon.png

970 Bytes | W: | H:

cms/static/img/delete-icon.png

2.77 KB | W: | H:

cms/static/img/delete-icon.png
cms/static/img/delete-icon.png
cms/static/img/delete-icon.png
cms/static/img/delete-icon.png
  • 2-up
  • Swipe
  • Onion skin
cms/static/img/edit-icon.png

1.04 KB | W: | H:

cms/static/img/edit-icon.png

2.86 KB | W: | H:

cms/static/img/edit-icon.png
cms/static/img/edit-icon.png
cms/static/img/edit-icon.png
cms/static/img/edit-icon.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -2,8 +2,6 @@ var $body;
var $modal;
var $modalCover;
var $newComponentItem;
var $newComponentStep1;
var $newComponentStep2;
var $changedInput;
var $spinner;
......@@ -11,6 +9,15 @@ $(document).ready(function() {
$body = $('body');
$modal = $('.history-modal');
$modalCover = $('<div class="modal-cover">');
// cdodge: this looks funny, but on AWS instances, this base.js get's wrapped in a separate scope as part of Django static
// 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');
$newComponentTypePicker = $('.new-component');
......@@ -34,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) {
......@@ -100,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');
......@@ -119,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();
......@@ -298,7 +309,7 @@ function checkForNewValue(e) {
this.saveTimer = setTimeout(function() {
$changedInput = $(e.target);
saveSubsection()
saveSubsection();
this.saveTimer = null;
}, 500);
}
......@@ -311,7 +322,7 @@ function autosaveInput(e) {
this.saveTimer = setTimeout(function() {
$changedInput = $(e.target);
saveSubsection()
saveSubsection();
this.saveTimer = null;
}, 500);
}
......@@ -333,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;
});
......@@ -385,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
......@@ -475,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);
}
......@@ -488,6 +498,7 @@ function hideModal(e) {
if(e) {
e.preventDefault();
}
$('.file-input').unbind('change', startUpload);
$modal.hide();
$modalCover.hide();
}
......@@ -588,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);
......@@ -627,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);
......@@ -659,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) {
......@@ -672,6 +688,7 @@ function saveNewCourse(e) {
function cancelNewCourse(e) {
e.preventDefault();
$('.new-course-button').show();
$(this).parents('section.new-course').remove();
}
......@@ -687,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);
......@@ -752,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;
}
......@@ -789,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({
......
// single per course holds the updates and handouts
CMS.Models.CourseInfo = Backbone.Model.extend({
// This model class is not suited for restful operations and is considered just a server side initialized container
url: '',
defaults: {
"courseId": "", // the location url
"updates" : null, // UpdateCollection
"handouts": null // HandoutCollection
},
idAttribute : "courseId"
});
// course update -- biggest kludge here is the lack of a real id to map updates to originals
CMS.Models.CourseUpdate = Backbone.Model.extend({
defaults: {
"date" : $.datepicker.formatDate('MM d, yy', new Date()),
"content" : ""
}
});
/*
The intitializer of this collection must set id to the update's location.url and courseLocation to the course's location. Must pass the
collection of updates as [{ date : "month day", content : "html"}]
*/
CMS.Models.CourseUpdateCollection = Backbone.Collection.extend({
url : function() {return this.urlbase + "course_info/updates/";},
model : CMS.Models.CourseUpdate
});
\ No newline at end of file
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
CMS.Models.ModuleInfo = Backbone.Model.extend({
url: function() {return "/module_info/" + this.id;},
defaults: {
"id": null,
"data": null,
"metadata" : null,
"children" : null
},
});
\ 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
// <!-- from https://github.com/Gazler/Underscore-Template-Loader/blob/master/index.html -->
// TODO Figure out how to initialize w/ static views from server (don't call .load but instead inject in django as strings)
// so this only loads the lazily loaded ones.
(function() {
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
templateVersion: "0.0.12",
templates: {},
loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) {
var self = this;
jQuery.ajax({url : filename,
success : function(data) {
self.addTemplate(templateName, data);
self.saveLocalTemplates();
callback(data);
},
error : function(xhdr, textStatus, errorThrown) {
console.log(textStatus); },
dataType : "html"
})
}
else {
callback(this.templates[templateName]);
}
},
addTemplate: function(templateName, data) {
// is there a reason this doesn't go ahead and compile the template? _.template(data)
// I suppose localstorage use would still req raw string rather than compiled version, but that sd work
// if it maintains a separate cache of uncompiled ones
this.templates[templateName] = data;
},
localStorageAvailable: function() {
try {
// 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;
}
},
saveLocalTemplates: function() {
if (this.localStorageAvailable) {
localStorage.setItem("templates", JSON.stringify(this.templates));
localStorage.setItem("templateVersion", this.templateVersion);
}
},
loadLocalTemplates: function() {
if (this.localStorageAvailable) {
var templateVersion = localStorage.getItem("templateVersion");
if (templateVersion && templateVersion == this.templateVersion) {
var templates = localStorage.getItem("templates");
if (templates) {
templates = JSON.parse(templates);
for (var x in templates) {
if (!this.templates[x]) {
this.addTemplate(x, templates[x]);
}
}
}
}
else {
localStorage.removeItem("templates");
localStorage.removeItem("templateVersion");
}
}
}
};
templateLoader.loadLocalTemplates();
window.templateLoader = templateLoader;
})();
/* this view should own everything on the page which has controls effecting its operation
generate other views for the individual editors.
The render here adds views for each update/handout by delegating to their collections but does not
generate any html for the surrounding page.
*/
CMS.Views.CourseInfoEdit = Backbone.View.extend({
// takes CMS.Models.CourseInfo as model
tagName: 'div',
render: function() {
// instantiate the ClassInfoUpdateView and delegate the proper dom to it
new CMS.Views.ClassInfoUpdateView({
el: this.$('#course-update-view'),
collection: this.model.get('updates')
});
new CMS.Views.ClassInfoHandoutsView({
el: this.$('#course-handouts-view'),
model: this.model.get('handouts')
});
return this;
}
});
// ??? Programming style question: should each of these classes be in separate files?
CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// collection is CourseUpdateCollection
events: {
"click .new-update-button" : "onNew",
"click .save-button" : "onSave",
"click .cancel-button" : "onCancel",
"click .edit-button" : "onEdit",
"click .delete-button" : "onDelete"
},
initialize: function() {
var self = this;
// instantiates an editor template for each update in the collection
window.templateLoader.loadRemoteTemplate("course_info_update",
// TODO Where should the template reside? how to use the static.url to create the path?
"/static/client_templates/course_info_update.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
},
render: function () {
// iterate over updates and create views for each using the template
var updateEle = this.$el.find("#course-update-list");
// remove and then add all children
$(updateEle).empty();
var self = this;
this.collection.each(function (update) {
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
});
this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
return this;
},
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();
this.collection.add(newModel, {at : 0});
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), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true,
});
}
$newForm.addClass('editing');
this.$currentPost = $newForm.closest('li');
window.$modalCover.show();
window.$modalCover.bind('click', function() {
self.closeEditor(self, true);
});
$('.date').datepicker('destroy');
$('.date').datepicker({ 'dateFormat': 'MM d, yy' });
},
onSave: function(event) {
event.preventDefault();
var targetModel = this.eventModel(event);
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);
},
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);
this.closeEditor(this, !targetModel.id);
},
onEdit: function(event) {
event.preventDefault();
var self = this;
this.$currentPost = $(event.target).closest('li');
this.$currentPost.addClass('editing');
$(this.editor(event)).show();
var $textArea = this.$currentPost.find(".new-update-content").first();
if (this.$codeMirror == null ) {
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true,
});
}
window.$modalCover.show();
var targetModel = this.eventModel(event);
window.$modalCover.bind('click', function() {
self.closeEditor(self);
});
},
onDelete: function(event) {
event.preventDefault();
// TODO ask for confirmation
// remove the dom element and delete the model
var targetModel = this.eventModel(event);
this.modelDom(event).remove();
var cacheThis = this;
targetModel.destroy({success : function (model, response) {
cacheThis.collection.fetch({success : function() {cacheThis.render();}});
}
});
},
closeEditor: function(self, removePost) {
var targetModel = self.collection.getByCid(self.$currentPost.attr('name'));
if(removePost) {
self.$currentPost.remove();
}
// close the modal and insert the appropriate data
self.$currentPost.removeClass('editing');
self.$currentPost.find('.date-display').html(targetModel.get('date'));
self.$currentPost.find('.date').val(targetModel.get('date'));
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
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
eventModel: function(event) {
// not sure if it should be currentTarget or delegateTarget
return this.collection.getByCid($(event.currentTarget).attr("name"));
},
modelDom: function(event) {
return $(event.currentTarget).closest("li");
},
editor: function(event) {
var li = $(event.currentTarget).closest("li");
if (li) return $(li).find("form").first();
},
dateEntry: function(event) {
var li = $(event.currentTarget).closest("li");
if (li) return $(li).find(".date").first();
},
contentEntry: function(event) {
return $(event.currentTarget).closest("li").find(".new-update-content").first();
},
dateDisplay: function(event) {
return $(event.currentTarget).closest("li").find("#date-display").first();
},
contentDisplay: function(event) {
return $(event.currentTarget).closest("li").find(".update-contents").first();
}
});
// the handouts view is dumb right now; it needs tied to a model and all that jazz
CMS.Views.ClassInfoHandoutsView = Backbone.View.extend({
// collection is CourseUpdateCollection
events: {
"click .save-button" : "onSave",
"click .cancel-button" : "onCancel",
"click .edit-button" : "onEdit"
},
initialize: function() {
var self = this;
this.model.fetch(
{
complete: function() {
window.templateLoader.loadRemoteTemplate("course_info_handouts",
"/static/client_templates/course_info_handouts.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
}
}
);
},
render: function () {
var updateEle = this.$el;
var self = this;
this.$el.html(
$(this.template( {
model: this.model
})
)
);
this.$preview = this.$el.find('.handouts-content');
this.$form = this.$el.find(".edit-handouts-form");
this.$editor = this.$form.find('.handouts-content-editor');
this.$form.hide();
return this;
},
onEdit: function(event) {
var self = this;
this.$editor.val(this.$preview.html());
this.$form.show();
if (this.$codeMirror == null) {
this.$codeMirror = CodeMirror.fromTextArea(this.$editor.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true,
});
}
window.$modalCover.show();
window.$modalCover.bind('click', function() {
self.closeEditor(self);
});
},
onSave: function(event) {
this.model.set('data', this.$codeMirror.getValue());
this.render();
this.model.save();
this.$form.hide();
this.closeEditor(this);
},
onCancel: function(event) {
this.$form.hide();
this.closeEditor(this);
},
closeEditor: function(self) {
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,18 +47,33 @@
}
}
@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;
}
}
@mixin white-button {
@include button;
border: 1px solid $darkGrey;
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: #778192;
color: rgb(92, 103, 122);
text-shadow: 0 1px 0 rgba(255, 255, 255, .5);
&:hover {
background-color: #f2f6f9;
color: #778192;
background-color: rgb(222, 236, 247);
color: rgb(92, 103, 122);
}
}
......@@ -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
body.updates {
.course-info {
h2 {
margin-bottom: 24px;
font-size: 22px;
font-weight: 300;
}
.course-info-wrapper {
display: table;
width: 100%;
clear: both;
}
.main-column,
.course-handouts {
float: none;
display: table-cell;
}
.main-column {
border-radius: 3px 0 0 3px;
border-right-color: $mediumGrey;
}
.CodeMirror {
border: 1px solid #3c3c3c;
background: #fff;
color: #3c3c3c;
}
}
.course-updates {
padding: 30px 40px;
margin: 0;
li {
padding: 24px 0 32px;
.update-list > li {
padding: 34px 0 42px;
border-top: 1px solid #cbd1db;
}
h3 {
margin-bottom: 18px;
font-size: 14px;
font-weight: 700;
color: #646464;
letter-spacing: 1px;
text-transform: uppercase;
&.editing {
position: relative;
z-index: 1001;
padding: 0;
border-top: none;
border-radius: 3px;
background: #fff;
.post-preview {
display: none;
}
}
h1 {
float: none;
font-size: 24px;
font-weight: 300;
}
h2 {
margin-bottom: 18px;
font-size: 14px;
font-weight: 700;
line-height: 30px;
color: #646464;
letter-spacing: 1px;
text-transform: uppercase;
}
h3 {
margin: 34px 0 11px;
font-size: 16px;
font-weight: 700;
}
}
.update-contents {
padding-left: 30px;
p {
font-size: 14px;
line-height: 18px;
font-size: 16px;
line-height: 25px;
}
p + p {
margin-top: 18px;
margin-top: 25px;
}
.primary {
border: 1px solid #ddd;
background: #f6f6f6;
padding: 20px;
}
}
.new-update-button {
@include grey-button;
@include blue-button;
display: block;
text-align: center;
padding: 12px 0;
padding: 18px 0;
margin-bottom: 28px;
}
.new-update-form {
@include edit-box;
margin-bottom: 24px;
padding: 30px;
border: none;
textarea {
height: 180px;
}
}
.post-actions {
float: right;
.edit-button,
.delete-button{
float: left;
@include white-button;
padding: 3px 10px 4px;
margin-left: 7px;
font-size: 12px;
font-weight: 400;
.edit-icon,
.delete-icon {
margin-right: 4px;
}
}
}
}
.course-handouts {
padding: 15px 20px;
width: 30%;
padding: 20px 30px;
margin: 0;
border-radius: 0 3px 3px 0;
border-left: none;
background: $lightGrey;
.new-handout-button {
@include grey-button;
display: block;
text-align: center;
padding: 12px 0;
margin-bottom: 28px;
h2 {
font-size: 18px;
font-weight: 700;
}
li {
margin-bottom: 10px;
.edit-button {
float: right;
@include white-button;
padding: 3px 10px 4px;
margin-left: 7px;
font-size: 12px;
font-weight: 400;
.edit-icon,
.delete-icon {
margin-right: 4px;
}
}
.handouts-content {
font-size: 14px;
}
.new-handout-form {
@include edit-box;
margin-bottom: 24px;
.treeview-handoutsnav li {
margin-bottom: 12px;
}
}
.edit-handouts-form {
@include edit-box;
position: absolute;
right: 0;
z-index: 10001;
width: 800px;
padding: 30px;
textarea {
height: 300px;
}
}
\ 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 {
......
.export {
.export-overview {
@extend .window;
@include clearfix;
padding: 30px 40px;
}
.description {
float: left;
width: 62%;
margin-right: 3%;
font-size: 14px;
h2 {
font-weight: 700;
font-size: 19px;
margin-bottom: 20px;
}
strong {
font-weight: 700;
}
p + p {
margin-top: 20px;
}
ul {
margin: 20px 0;
list-style: disc inside;
li {
margin: 0 0 5px 0;
}
}
}
.export-form-wrapper {
.export-form {
float: left;
width: 35%;
padding: 25px 30px 35px;
@include box-sizing(border-box);
border: 1px solid $mediumGrey;
border-radius: 3px;
background: $lightGrey;
text-align: center;
h2 {
margin-bottom: 30px;
font-size: 26px;
font-weight: 300;
}
.error-block {
display: none;
margin-bottom: 15px;
font-size: 13px;
}
.error-block {
color: $error-red;
}
.button-export {
@include green-button;
padding: 10px 50px 11px;
font-size: 17px;
}
.message-status {
margin-top: 10px;
font-size: 12px;
}
.progress-bar {
display: none;
width: 350px;
height: 30px;
margin: 30px auto 10px;
border: 1px solid $blue;
&.loaded {
border-color: #66b93d;
.progress-fill {
background: #66b93d;
}
}
}
.progress-fill {
width: 0%;
height: 30px;
background: $blue;
color: #fff;
line-height: 48px;
}
}
// downloading state
&.is-downloading {
.progress-bar {
display: block;
}
.button-export {
padding: 10px 50px 11px;
font-size: 17px;
&.disabled {
pointer-events: none;
cursor: default;
}
}
}
}
}
\ No newline at end of file
......@@ -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
......@@ -4,6 +4,7 @@
}
.main-column {
clear: both;
float: left;
width: 70%;
}
......@@ -41,7 +42,7 @@
}
h2 {
margin: 30px 40px;
margin: 30px 40px 30px 0;
color: #646464;
font-size: 19px;
font-weight: 300;
......@@ -56,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;
......@@ -84,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;
......@@ -97,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;
......@@ -117,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;
}
}
}
......@@ -141,7 +172,7 @@
}
.component {
border: 1px solid #d1ddec;
border: 1px solid $lightBluishGrey2;
border-radius: 3px;
background: #fff;
@include transition(none);
......@@ -156,7 +187,8 @@
}
&.editing {
border-color: #6696d7;
border: 1px solid $lightBluishGrey2;
z-index: 9999;
.drag-handle,
.component-actions {
......@@ -172,11 +204,6 @@
position: absolute;
top: 7px;
right: 9px;
@include transition(opacity .15s);
a {
color: $darkGrey;
}
}
.drag-handle {
......@@ -184,19 +211,20 @@
display: block;
top: -1px;
right: -16px;
z-index: -1;
z-index: 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);
}
}
.xmodule_display {
padding: 40px 20px 20px;
overflow-x: auto;
}
.component-editor {
......@@ -204,9 +232,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 {
......@@ -223,12 +248,6 @@
}
}
.CodeMirror {
border: 1px solid #3c3c3c;
background: #fff;
color: #3c3c3c;
}
h3 {
margin-bottom: 10px;
font-size: 18px;
......@@ -445,3 +464,26 @@
display: none;
}
}
// editing units from courseware
body.unit {
.component {
padding-top: 30px;
.component-actions {
@include box-sizing(border-box);
position: absolute;
width: 100%;
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,14 @@
@import "static-pages";
@import "users";
@import "import";
@import "export";
@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
<%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>
<!-- TODO decode course # from context_course into title -->
<%block name="title">Course Info</%block>
<%block name="bodyclass">course-info</%block>
<%block name="jsextra">
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_info.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/module_info.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/course_info_edit.js')}"></script>
<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" charset="utf-8">
$(document).ready(function(){
var course_updates = new CMS.Models.CourseUpdateCollection();
course_updates.reset(${course_updates|n});
course_updates.urlbase = '${url_base}';
var course_handouts = new CMS.Models.ModuleInfo({
id: '${handouts_location}'
});
course_handouts.urlbase = '${url_base}';
var editor = new CMS.Views.CourseInfoEdit({
el: $('.main-wrapper'),
model : new CMS.Models.CourseInfo({
courseId : '${context_course.location}',
updates : course_updates,
handouts : course_handouts
})
});
editor.render();
});
</script>
</%block>
<%block name="content">
<div class="main-wrapper">
<div class="inner-wrapper">
<h1>Course Info</h1>
<div class="course-info-wrapper">
<div class="main-column window">
<article class="course-updates" id="course-update-view">
<h2>Course Updates & News</h2>
<a href="#" class="new-update-button">New Update</a>
<ol class="update-list" id="course-update-list"></ol>
<!-- probably replace w/ a vertical where each element of the vertical is a separate update w/ a date and html field -->
</article>
</div>
<div class="sidebar window course-handouts" id="course-handouts-view"></div>
</div>
</div>
</div>
</div>
</%block>
\ 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>
......
<%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>
<%! from django.core.urlresolvers import reverse %>
<%block name="title">Export</%block>
<%block name="bodyclass">export</%block>
<%block name="content">
<div class="main-wrapper">
<div class="inner-wrapper">
<article class="export-overview">
<div class="description">
<h2>About Exporting Courses</h2>
<p>When exporting your course, you will receive a .tar.gz formatted file that contains the following course data:</p>
<ul>
<li>Course Structure (Sections and sub-section ordering)</li>
<li>Individual Units</li>
<li>Individual Problems</li>
<li>Static Pages</li>
<li>Course Assets</li>
</ul>
<p>Your course export <strong>will not include</strong>: student data, forum/discussion data, course settings, certificates, grading information, or user data.</p>
</div>
<!-- default state -->
<div class="export-form-wrapper">
<form action="${reverse('generate_export_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="export-form">
<h2>Export Course:</h2>
<p class="error-block"></p>
<a href="${reverse('generate_export_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" class="button-export">Download Files</a>
</form>
</div>
<!-- download state: after user clicks download buttons -->
<%doc>
<div class="export-form-wrapper is-downloading">
<form action="${reverse('export_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="export-form">
<h2>Export Course:</h2>
<p class="error-block"></p>
<a href="#" class="button-export disabled">Files Downloading</a>
<p class="message-status">Download not start? <a href="#" class="text-export">Try again</a></p>
</form>
</div>
</%doc>
</article>
</div>
</div>
</%block>
<%block name="jsextra">
<script>
(function() {
var bar = $('.progress-bar');
var fill = $('.progress-fill');
var percent = $('.percent');
var status = $('#status');
var submitBtn = $('.submit-button');
$('form').ajaxForm({
beforeSend: function() {
status.empty();
var percentVal = '0%';
bar.show();
fill.width(percentVal);
percent.html(percentVal);
submitBtn.hide();
},
uploadProgress: function(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
fill.width(percentVal);
percent.html(percentVal);
},
complete: function(xhr) {
if (xhr.status == 200) {
alert('Your import was successful.');
window.location = '${successful_import_redirect_url}';
}
else
alert('Your import has failed.\n\n' + xhr.responseText);
submitBtn.show();
bar.hide();
}
});
})();
</script>
</%block>
\ No newline at end of file
......@@ -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('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>
<li><a href="${reverse('export_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='export-tab'>Export</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
......
......@@ -23,6 +23,11 @@ urlpatterns = ('',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/import/(?P<name>[^/]+)$',
'contentstore.views.import_course', name='import_course'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/export/(?P<name>[^/]+)$',
'contentstore.views.export_course', name='export_course'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/generate_export/(?P<name>[^/]+)$',
'contentstore.views.generate_export_course', name='generate_export_course'),
url(r'^preview/modx/(?P<preview_id>[^/]*)/(?P<location>.*?)/(?P<dispatch>[^/]*)$',
'contentstore.views.preview_dispatch', name='preview_dispatch'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
......@@ -34,11 +39,24 @@ urlpatterns = ('',
'contentstore.views.remove_user', name='remove_user'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<name>[^/]+)/remove_user$',
'contentstore.views.remove_user', name='remove_user'),
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages', name='static_pages'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/info/(?P<name>[^/]+)$', 'contentstore.views.course_info', 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'),
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', 'contentstore.views.asset_index', name='asset_index'),
# this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$', 'contentstore.views.module_info', name='module_info'),
# temporary landing page for a course
url(r'^edge/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.landing', name='landing'),
......
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
......@@ -13,7 +13,7 @@ def expect_json(view_function):
def expect_json_with_cloned_request(request, *args, **kwargs):
# cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information
# e.g. 'charset', so we can't do a direct string compare
if request.META['CONTENT_TYPE'].lower().startswith("application/json"):
if request.META.get('CONTENT_TYPE','').lower().startswith("application/json"):
cloned_request = copy.copy(request)
cloned_request.POST = cloned_request.POST.copy()
cloned_request.POST.update(json.loads(request.body))
......
......@@ -1145,7 +1145,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
......@@ -1234,6 +1240,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">
......
......@@ -12,13 +12,16 @@ from .django import contentstore
from PIL import Image
class StaticContent(object):
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None):
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None):
self.location = loc
self.name = name #a display string which can be edited, and thus not part of the location which needs to be fixed
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
@property
def is_thumbnail(self):
......
......@@ -11,6 +11,8 @@ import logging
from .content import StaticContent, ContentStore
from xmodule.exceptions import NotFoundError
from fs.osfs import OSFS
import os
class MongoContentStore(ContentStore):
......@@ -29,26 +31,50 @@ 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) as fp:
displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp:
fp.write(content.data)
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()
def export(self, location, output_directory):
content = self.find(location)
if content.import_path is not None:
output_directory = output_directory + '/' + os.path.dirname(content.import_path)
if not os.path.exists(output_directory):
os.makedirs(output_directory)
disk_fs = OSFS(output_directory)
with disk_fs.open(content.name, 'wb') as asset_file:
asset_file.write(content.data)
def export_all_for_course(self, course_location, output_directory):
assets = self.get_all_content_for_course(course_location)
for asset in assets:
asset_location = Location(asset['_id'])
self.export(asset_location, output_directory)
def get_all_content_thumbnails_for_course(self, location):
return self._get_all_content_for_course(location, get_thumbnails = True)
......
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):
......@@ -253,12 +247,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 lowest_passing_grade(self):
......
import abc
import inspect
import json
import logging
import random
import sys
......@@ -66,17 +65,27 @@ def grader_from_conf(conf):
for subgraderconf in conf:
subgraderconf = subgraderconf.copy()
weight = subgraderconf.pop("weight", 0)
# NOTE: 'name' used to exist in SingleSectionGrader. We are deprecating SingleSectionGrader
# and converting everything into an AssignmentFormatGrader by adding 'min_count' and
# 'drop_count'. AssignmentFormatGrader does not expect 'name', so if it appears
# in bad_args, go ahead remove it (this causes no errors). Eventually, SingleSectionGrader
# should be completely removed.
name = 'name'
try:
if 'min_count' in subgraderconf:
#This is an AssignmentFormatGrader
subgrader_class = AssignmentFormatGrader
elif 'name' in subgraderconf:
elif name in subgraderconf:
#This is an SingleSectionGrader
subgrader_class = SingleSectionGrader
else:
raise ValueError("Configuration has no appropriate grader class.")
bad_args = invalid_args(subgrader_class.__init__, subgraderconf)
# See note above concerning 'name'.
if bad_args.issuperset({name}):
bad_args = bad_args - {name}
del subgraderconf[name]
if len(bad_args) > 0:
log.warning("Invalid arguments for a subgrader: %s", bad_args)
for key in bad_args:
......
......@@ -159,7 +159,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
filepath = u'{category}/{pathname}.html'.format(category=self.category,
pathname=pathname)
resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True)
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
file.write(self.definition['data'].encode('utf-8'))
......
<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()
......@@ -153,7 +153,7 @@ class Location(_LocationBase):
def check(val, regexp):
if val is not None and regexp.search(val) is not None:
log.debug('invalid characters val="%s", list_="%s"' % (val, list_))
raise InvalidLocationError(location)
raise InvalidLocationError("Invalid characters in '%s'." % (val))
list_ = list(list_)
for val in list_[:4] + [list_[5]]:
......
......@@ -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
import logging
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from fs.osfs import OSFS
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir):
course = modulestore.get_item(course_location)
fs = OSFS(root_dir)
export_fs = fs.makeopendir(course_dir)
xml = course.export_to_xml(export_fs)
with export_fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
# export the static assets
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
\ No newline at end of file
......@@ -32,7 +32,7 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
with open(content_path, 'rb') as f:
data = f.read()
content = StaticContent(content_loc, filename, mime_type, data)
content = StaticContent(content_loc, filename, mime_type, data, import_path = fullname_with_subpath)
# first let's save a thumbnail so we can get back a thumbnail location
thumbnail_content = static_content_store.generate_thumbnail(content)
......@@ -66,7 +66,7 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic
with open(static_pathname, 'rb') as f:
data = f.read()
content = StaticContent(content_loc, filename, mime_type, data)
content = StaticContent(content_loc, filename, mime_type, data, import_path = path)
# first let's save a thumbnail so we can get back a thumbnail location
thumbnail_content = static_content_store.generate_thumbnail(content)
......
---
metadata:
display_name: Empty
data: "<p>This is where you can add additional information about your course.</p>"
data: "<ol></ol>"
children: []
\ No newline at end of file
---
metadata:
display_name: Formula Repsonse
display_name: Formula Response
rerandomize: never
showanswer: always
data: |
......
......@@ -25,7 +25,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__)
......@@ -519,6 +520,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):
"""
......
......@@ -99,20 +99,30 @@ class XmlDescriptor(XModuleDescriptor):
metadata_to_strip = ('data_dir',
# cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course
'tabs', 'grading_policy',
'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date',
'discussion_blackouts',
# VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename')
metadata_to_export_to_policy = ('discussion_topics')
# A dictionary mapping xml attribute names AttrMaps that describe how
# to import and export them
# Allow json to specify either the string "true", or the bool True. The string is preferred.
to_bool = lambda val: val == 'true' or val == True
from_bool = lambda val: str(val).lower()
bool_map = AttrMap(to_bool, from_bool)
to_int = lambda val: int(val)
from_int = lambda val: str(val)
int_map = AttrMap(to_int, from_int)
xml_attribute_map = {
# type conversion: want True/False in python, "true"/"false" in xml
'graded': bool_map,
'hide_progress_tab': bool_map,
'allow_anonymous': bool_map,
'allow_anonymous_to_peers': bool_map,
'weight':int_map
}
......@@ -360,8 +370,9 @@ class XmlDescriptor(XModuleDescriptor):
# Add the non-inherited metadata
for attr in sorted(self.own_metadata):
# don't want e.g. data_dir
if attr not in self.metadata_to_strip:
if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
val = val_for_xml(attr)
#logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr))
xml_object.set(attr, val)
if self.export_to_file():
......
......@@ -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
<a href='/static/handouts/schematic_tutorial.pdf'>Schematic Tutorial</a>
\ 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
......
......@@ -37,7 +37,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' ]
......
......@@ -30,6 +30,19 @@ def course_wiki_redirect(request, course_id):
course = get_course_by_id(course_id)
course_slug = course.wiki_slug
# cdodge: fix for cases where self.location.course can be interpreted as an number rather than
# a string. We're seeing in Studio created courses that people often will enter in a stright number
# for 'course' (e.g. 201). This Wiki library expects a string to "do the right thing". We haven't noticed this before
# because - to now - 'course' has always had non-numeric characters in them
try:
float(course_slug)
# if the float() doesn't throw an exception, that means it's a number
course_slug = course_slug + "_"
except:
pass
valid_slug = True
if not course_slug:
......
......@@ -148,7 +148,7 @@ def get_course_about_section(course, section_key):
request = get_request_for_thread()
loc = course.location._replace(category='about', name=section_key)
course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = False)
course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = True)
html = ''
......@@ -186,7 +186,7 @@ def get_course_info_section(request, cache, course, section_key):
loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key)
course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = False)
course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = True)
html = ''
if course_module is not None:
......
../../../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>
......@@ -396,6 +396,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']
......@@ -421,6 +444,18 @@ namespace :cms do
end
end
namespace :cms do
desc "Export course data to a tar.gz file"
task :export do
if ENV['COURSE_ID'] and ENV['OUTPUT_PATH']
sh(django_admin(:cms, :dev, :export, ENV['COURSE_ID'], ENV['OUTPUT_PATH']))
else
raise "Please specify a COURSE_ID and OUTPUT_PATH.\n" +
"Example: \`rake cms:export COURSE_ID=MITx/12345/name OUTPUT_PATH=foo.tar.gz\`"
end
end
end
desc "Build a properties file used to trigger autodeploy builds"
task :autodeploy_properties do
File.open("autodeploy.properties", "w") do |file|
......
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