Merge branch 'feature/cale/cms-master' into feature/cale/cms-master-merge
Conflicts: requirements.txt
0
cms/djangoapps/__init__.py
Normal file
@@ -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
|
||||
|
||||
134
cms/djangoapps/contentstore/course_info_model.py
Normal file
@@ -0,0 +1,134 @@
|
||||
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))
|
||||
38
cms/djangoapps/contentstore/management/commands/clone.py
Normal file
@@ -0,0 +1,38 @@
|
||||
###
|
||||
### 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)
|
||||
@@ -0,0 +1,40 @@
|
||||
###
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
35
cms/djangoapps/contentstore/management/commands/export.py
Normal file
@@ -0,0 +1,35 @@
|
||||
###
|
||||
### 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)
|
||||
33
cms/djangoapps/contentstore/management/commands/prompt.py
Normal file
@@ -0,0 +1,33 @@
|
||||
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")
|
||||
84
cms/djangoapps/contentstore/module_info_model.py
Normal file
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
275
cms/djangoapps/contentstore/tests/test_course_settings.py
Normal file
@@ -0,0 +1,275 @@
|
||||
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")
|
||||
|
||||
|
||||
18
cms/djangoapps/contentstore/tests/test_utils.py
Normal file
@@ -0,0 +1,18 @@
|
||||
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")
|
||||
@@ -1,21 +1,27 @@
|
||||
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)
|
||||
@@ -1,22 +1,19 @@
|
||||
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
|
||||
@@ -915,6 +933,187 @@ def server_error(request):
|
||||
return render_to_response('error.html', {'error': '500'})
|
||||
|
||||
|
||||
@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):
|
||||
@@ -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' : ''
|
||||
})
|
||||
|
||||
0
cms/djangoapps/models/__init__.py
Normal file
0
cms/djangoapps/models/settings/__init__.py
Normal file
184
cms/djangoapps/models/settings/course_details.py
Normal file
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
265
cms/djangoapps/models/settings/course_grading.py
Normal file
@@ -0,0 +1,265 @@
|
||||
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.
|
||||
|
||||
69
cms/static/client_templates/course_grade_policy.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<li class="input input-existing multi course-grading-assignment-list-item">
|
||||
<div class="row row-col2">
|
||||
<label for="course-grading-assignment-name">Assignment Type Name:</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="input course-grading-assignment-name">
|
||||
<input type="text" class="long"
|
||||
id="course-grading-assignment-name" value="<%= model.get('type') %>">
|
||||
<span class="tip tip-stacked">e.g. Homework, Labs, Midterm Exams, Final Exam</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-col2">
|
||||
<label for="course-grading-shortname">Abbreviation:</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="input course-grading-shortname">
|
||||
<input type="text" class="short"
|
||||
id="course-grading-assignment-shortname"
|
||||
value="<%= model.get('short_label') %>">
|
||||
<span class="tip tip-inline">e.g. HW, Midterm, Final</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-col2">
|
||||
<label for="course-grading-gradeweight">Weight of Total
|
||||
Grade:</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="input course-grading-gradeweight">
|
||||
<input type="text" class="short"
|
||||
id="course-grading-assignment-gradeweight"
|
||||
value = "<%= model.get('weight') %>">
|
||||
<span class="tip tip-inline">e.g. 25%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-col2">
|
||||
<label for="course-grading-assignment-totalassignments">Total
|
||||
Number:</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="input course-grading-totalassignments">
|
||||
<input type="text" class="short"
|
||||
id="course-grading-assignment-totalassignments"
|
||||
value = "<%= model.get('min_count') %>">
|
||||
<span class="tip tip-inline">total exercises assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-col2">
|
||||
<label for="course-grading-assignment-droppable">Number of
|
||||
Droppable:</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="input course-grading-droppable">
|
||||
<input type="text" class="short"
|
||||
id="course-grading-assignment-droppable"
|
||||
value = "<%= model.get('drop_count') %>">
|
||||
<span class="tip tip-inline">total exercises that won't be graded</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
|
||||
</li>
|
||||
19
cms/static/client_templates/course_info_handouts.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
29
cms/static/client_templates/course_info_update.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
14
cms/static/client_templates/load_templates.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- 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>
|
||||
@@ -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()
|
||||
|
||||
BIN
cms/static/img/collapse-all-icon.png
Normal file
|
After Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.9 KiB |
BIN
cms/static/img/home-icon-blue.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
cms/static/img/log-out-icon.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
cms/static/img/small-home-icon.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
cms/static/img/upload-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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({
|
||||
|
||||
36
cms/static/js/models/course_info.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
68
cms/static/js/models/course_relative.js
Normal file
@@ -0,0 +1,68 @@
|
||||
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
|
||||
});
|
||||
10
cms/static/js/models/module_info.js
Normal file
@@ -0,0 +1,10 @@
|
||||
CMS.Models.ModuleInfo = Backbone.Model.extend({
|
||||
url: function() {return "/module_info/" + this.id;},
|
||||
|
||||
defaults: {
|
||||
"id": null,
|
||||
"data": null,
|
||||
"metadata" : null,
|
||||
"children" : null
|
||||
},
|
||||
});
|
||||
83
cms/static/js/models/settings/course_details.js
Normal file
@@ -0,0 +1,83 @@
|
||||
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 "";
|
||||
}
|
||||
});
|
||||
127
cms/static/js/models/settings/course_grading_policy.js
Normal file
@@ -0,0 +1,127 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
43
cms/static/js/models/settings/course_settings.js
Normal file
@@ -0,0 +1,43 @@
|
||||
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
|
||||
CMS.Models.Settings.CourseSettings = Backbone.Model.extend({
|
||||
// a container for the models representing the n possible tabbed states
|
||||
defaults: {
|
||||
courseLocation: null,
|
||||
// NOTE: keep these sync'd w/ the data-section names in settings-page-menu
|
||||
details: null,
|
||||
faculty: null,
|
||||
grading: null,
|
||||
problems: null,
|
||||
discussions: null
|
||||
},
|
||||
|
||||
retrieve: function(submodel, callback) {
|
||||
if (this.get(submodel)) callback();
|
||||
else {
|
||||
var cachethis = this;
|
||||
switch (submodel) {
|
||||
case 'details':
|
||||
var details = new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')});
|
||||
details.fetch( {
|
||||
success : function(model) {
|
||||
cachethis.set('details', model);
|
||||
callback(model);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'grading':
|
||||
var grading = new CMS.Models.Settings.CourseGradingPolicy({course_location: this.get('courseLocation')});
|
||||
grading.fetch( {
|
||||
success : function(model) {
|
||||
cachethis.set('grading', model);
|
||||
callback(model);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
78
cms/static/js/template_loader.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// <!-- 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;
|
||||
})();
|
||||
287
cms/static/js/views/course_info_edit.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/* 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;
|
||||
}
|
||||
});
|
||||
90
cms/static/js/views/grader-select-view.js
Normal file
@@ -0,0 +1,90 @@
|
||||
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">✓</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();
|
||||
}
|
||||
})
|
||||
673
cms/static/js/views/settings/main_settings_view.js
Normal file
@@ -0,0 +1,673 @@
|
||||
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">📄</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();
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,77 +1,179 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,139 @@
|
||||
|
||||
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,12 +214,199 @@ 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;
|
||||
}
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
123
cms/static/sass/_export.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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-settings #settings-tab {
|
||||
@include active;
|
||||
}
|
||||
|
||||
&.active-tab-import #import-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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@include box-shadow(1px 0 0 #787981 inset, -1px 0 0 #3d3e44 inset, 1px 0 0 #787981, -1px 0 0 #3d3e44);
|
||||
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 {
|
||||
background: rgba(255, 255, 255, .1);
|
||||
&:hover {
|
||||
@include linear-gradient(top, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0));
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.log-out {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
800
cms/static/sass/_settings.scss
Normal file
@@ -0,0 +1,800 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
height: 60px;
|
||||
position: relative;
|
||||
border: 1px solid $darkGreen;
|
||||
background: $green;
|
||||
color: #fff;
|
||||
@include transition(background-color .15s);
|
||||
|
||||
&:hover {
|
||||
background: $brightGreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-component,
|
||||
.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 {
|
||||
display: block;
|
||||
padding: 7px 20px;
|
||||
border-bottom: none;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
.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;
|
||||
$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}
|
||||
60
cms/templates/course_info.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<%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>
|
||||
@@ -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"/>: <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>
|
||||
|
||||
93
cms/templates/export.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<%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>
|
||||
@@ -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>
|
||||
|
||||
<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="page-actions">
|
||||
%if allow_actions:
|
||||
<div class="new-user-form">
|
||||
<a href="#" class="new-button new-user-button">
|
||||
<span class="plus-icon white"></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:
|
||||
<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)}
|
||||
|
||||
729
cms/templates/settings.html
Normal file
@@ -0,0 +1,729 @@
|
||||
<%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>
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
<%! 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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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
|
||||
|
||||
20
cms/urls.py
@@ -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'),
|
||||
|
||||
|
||||
0
common/djangoapps/__init__.py
Normal file
0
common/djangoapps/models/__init__.py
Normal file
25
common/djangoapps/models/course_relative.py
Normal file
@@ -0,0 +1,25 @@
|
||||
class CourseRelativeMember:
|
||||
def __init__(self, location, idx):
|
||||
self.course_location = location # a Location obj
|
||||
self.idx = idx # which milestone this represents. Hopefully persisted # so we don't have race conditions
|
||||
|
||||
### ??? If 2+ courses use the same textbook or other asset, should they point to the same db record?
|
||||
class linked_asset(CourseRelativeMember):
|
||||
"""
|
||||
Something uploaded to our asset lib which has a name/label and location. Here it's tracked by course and index, but
|
||||
we could replace the label/url w/ a pointer to a real asset and keep the join info here.
|
||||
"""
|
||||
def __init__(self, location, idx):
|
||||
CourseRelativeMember.__init__(self, location, idx)
|
||||
self.label = ""
|
||||
self.url = None
|
||||
|
||||
class summary_detail_pair(CourseRelativeMember):
|
||||
"""
|
||||
A short text with an arbitrary html descriptor used for paired label - details elements.
|
||||
"""
|
||||
def __init__(self, location, idx):
|
||||
CourseRelativeMember.__init__(self, location, idx)
|
||||
self.summary = ""
|
||||
self.detail = ""
|
||||
|
||||
24
common/djangoapps/util/converters.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import logging
|
||||
import json
|
||||
from cStringIO import StringIO
|
||||
from lxml import etree
|
||||
from path import path # NOTE (THK): Only used for detecting presence of syllabus
|
||||
import requests
|
||||
import time
|
||||
from cStringIO import StringIO
|
||||
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.timeparse import parse_time, stringify_time
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import time
|
||||
import copy
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,10 +91,6 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
log.critical(msg)
|
||||
system.error_tracker(msg)
|
||||
|
||||
self.enrollment_start = self._try_parse_time("enrollment_start")
|
||||
self.enrollment_end = self._try_parse_time("enrollment_end")
|
||||
self.end = self._try_parse_time("end")
|
||||
|
||||
# NOTE: relies on the modulestore to call set_grading_policy() right after
|
||||
# init. (Modulestore is in charge of figuring out where to load the policy from)
|
||||
|
||||
@@ -105,19 +100,11 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
|
||||
|
||||
|
||||
def set_grading_policy(self, course_policy):
|
||||
if course_policy is None:
|
||||
course_policy = {}
|
||||
|
||||
def defaut_grading_policy(self):
|
||||
"""
|
||||
The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is
|
||||
missing, it reverts to the default.
|
||||
Return a dict which is a copy of the default grading policy
|
||||
"""
|
||||
|
||||
default_policy_string = """
|
||||
{
|
||||
"GRADER" : [
|
||||
default = {"GRADER" : [
|
||||
{
|
||||
"type" : "Homework",
|
||||
"min_count" : 12,
|
||||
@@ -129,37 +116,44 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
"type" : "Lab",
|
||||
"min_count" : 12,
|
||||
"drop_count" : 2,
|
||||
"category" : "Labs",
|
||||
"weight" : 0.15
|
||||
},
|
||||
{
|
||||
"type" : "Midterm",
|
||||
"name" : "Midterm Exam",
|
||||
"type" : "Midterm Exam",
|
||||
"short_label" : "Midterm",
|
||||
"min_count" : 1,
|
||||
"drop_count" : 0,
|
||||
"weight" : 0.3
|
||||
},
|
||||
{
|
||||
"type" : "Final",
|
||||
"name" : "Final Exam",
|
||||
"type" : "Final Exam",
|
||||
"short_label" : "Final",
|
||||
"min_count" : 1,
|
||||
"drop_count" : 0,
|
||||
"weight" : 0.4
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS" : {
|
||||
"A" : 0.87,
|
||||
"B" : 0.7,
|
||||
"C" : 0.6
|
||||
}
|
||||
}
|
||||
"Pass" : 0.5
|
||||
}}
|
||||
return copy.deepcopy(default)
|
||||
|
||||
def set_grading_policy(self, course_policy):
|
||||
"""
|
||||
The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is
|
||||
missing, it reverts to the default.
|
||||
"""
|
||||
if course_policy is None:
|
||||
course_policy = {}
|
||||
|
||||
# Load the global settings as a dictionary
|
||||
grading_policy = json.loads(default_policy_string)
|
||||
grading_policy = self.defaut_grading_policy()
|
||||
|
||||
# Override any global settings with the course settings
|
||||
grading_policy.update(course_policy)
|
||||
|
||||
# Here is where we should parse any configurations, so that we can fail early
|
||||
grading_policy['RAW_GRADER'] = grading_policy['GRADER'] # used for cms access
|
||||
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
|
||||
self._grading_policy = grading_policy
|
||||
|
||||
@@ -168,8 +162,8 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
@classmethod
|
||||
def read_grading_policy(cls, paths, system):
|
||||
"""Load a grading policy from the specified paths, in order, if it exists."""
|
||||
# Default to a blank policy
|
||||
policy_str = '""'
|
||||
# Default to a blank policy dict
|
||||
policy_str = '{}'
|
||||
|
||||
for policy_path in paths:
|
||||
if not system.resources_fs.exists(policy_path):
|
||||
@@ -252,13 +246,53 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
|
||||
@property
|
||||
def end(self):
|
||||
return self._try_parse_time("end")
|
||||
@end.setter
|
||||
def end(self, value):
|
||||
if isinstance(value, time.struct_time):
|
||||
self.metadata['end'] = stringify_time(value)
|
||||
@property
|
||||
def enrollment_start(self):
|
||||
return self._try_parse_time("enrollment_start")
|
||||
|
||||
@enrollment_start.setter
|
||||
def enrollment_start(self, value):
|
||||
if isinstance(value, time.struct_time):
|
||||
self.metadata['enrollment_start'] = stringify_time(value)
|
||||
@property
|
||||
def enrollment_end(self):
|
||||
return self._try_parse_time("enrollment_end")
|
||||
|
||||
@enrollment_end.setter
|
||||
def enrollment_end(self, value):
|
||||
if isinstance(value, time.struct_time):
|
||||
self.metadata['enrollment_end'] = stringify_time(value)
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
return self._grading_policy['GRADER']
|
||||
|
||||
@property
|
||||
def raw_grader(self):
|
||||
return self._grading_policy['RAW_GRADER']
|
||||
|
||||
@raw_grader.setter
|
||||
def raw_grader(self, value):
|
||||
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
|
||||
self._grading_policy['RAW_GRADER'] = value
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADER'] = value
|
||||
|
||||
@property
|
||||
def grade_cutoffs(self):
|
||||
return self._grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
@grade_cutoffs.setter
|
||||
def grade_cutoffs(self, value):
|
||||
self._grading_policy['GRADE_CUTOFFS'] = value
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
|
||||
|
||||
|
||||
@property
|
||||
def lowest_passing_grade(self):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'))
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<section class="html-edit">
|
||||
<div name="" class="edit-box" rows="8" cols="40"><problem>
|
||||
<p></p>
|
||||
<multiplechoiceresponse>
|
||||
<pre><problem>
|
||||
<p></p></pre>
|
||||
<div><foo>bar</foo></div></div>
|
||||
</section>
|
||||
2
common/lib/xmodule/xmodule/js/spec/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.js
|
||||
|
||||
17
common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee
Normal file
@@ -0,0 +1,17 @@
|
||||
describe 'HTMLEditingDescriptor', ->
|
||||
describe 'Read data from server, create Editor, and get data back out', ->
|
||||
it 'Does not munge <', ->
|
||||
# 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("""<problem>
|
||||
<p></p>
|
||||
<multiplechoiceresponse>
|
||||
<pre><problem>
|
||||
<p></p></pre>
|
||||
<div><foo>bar</foo></div>""")
|
||||
2
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.js
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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)
|
||||
|
||||
130
common/lib/xmodule/xmodule/modulestore/store_utilities.py
Normal file
@@ -0,0 +1,130 @@
|
||||
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
|
||||