diff --git a/cms/djangoapps/__init__.py b/cms/djangoapps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 460c9d6467..22bbc4bc1c 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -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 diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index b2ee3bf9c8..c2e8348a66 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -4,6 +4,7 @@ from xmodule.modulestore.django import modulestore from lxml import etree import re from django.http import HttpResponseBadRequest +import logging ## TODO store as array of { date, content } and override course_info_module.definition_from_xml ## This should be in a class which inherits from XmlDescriptor @@ -23,27 +24,28 @@ def get_course_updates(location): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True)) + course_html_parsed = etree.fromstring(course_updates.definition['data']) except etree.XMLSyntaxError: course_html_parsed = etree.fromstring("
    ") # Confirm that root is
      , iterate over
    1. , pull out

      subs and then rest of val course_upd_collection = [] if course_html_parsed.tag == 'ol': - # 0 is the oldest so that new ones get unique idx - for idx, update in enumerate(course_html_parsed.iter("li")): + # 0 is the newest + for idx, update in enumerate(course_html_parsed): if (len(update) == 0): continue elif (len(update) == 1): - content = update.find("h2").tail + # could enforce that update[0].tag == 'h2' + content = update[0].tail else: - content = etree.tostring(update[1]) + content = "\n".join([etree.tostring(ele) for ele in update[1:]]) - course_upd_collection.append({"id" : location_base + "/" + str(idx), + # make the id on the client be 1..len w/ 1 being the oldest and len being the newest + course_upd_collection.append({"id" : location_base + "/" + str(len(course_html_parsed) - idx), "date" : update.findtext("h2"), "content" : content}) - # return newest to oldest - course_upd_collection.reverse() + return course_upd_collection def update_course_updates(location, update, passed_id=None): @@ -59,43 +61,25 @@ def update_course_updates(location, update, passed_id=None): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True)) + course_html_parsed = etree.fromstring(course_updates.definition['data']) except etree.XMLSyntaxError: course_html_parsed = etree.fromstring("
        ") - try: - new_html_parsed = etree.fromstring(update['content'], etree.XMLParser(remove_blank_text=True)) - except etree.XMLSyntaxError: - new_html_parsed = None + # No try/catch b/c failure generates an error back to client + new_html_parsed = etree.fromstring('
      1. ' + update['date'] + '

        ' + update['content'] + '
      2. ') # Confirm that root is
          , iterate over
        1. , pull out

          subs and then rest of val if course_html_parsed.tag == 'ol': # ??? Should this use the id in the json or in the url or does it matter? if passed_id: - element = course_html_parsed.findall("li")[get_idx(passed_id)] - element[0].text = update['date'] - if (len(element) == 1): - if new_html_parsed is not None: - element[0].tail = None - element.append(new_html_parsed) - else: - element[0].tail = update['content'] - else: - if new_html_parsed is not None: - element[1] = new_html_parsed - else: - element.pop(1) - element[0].tail = update['content'] + idx = get_idx(passed_id) + # idx is count from end of list + course_html_parsed[-idx] = new_html_parsed else: - idx = len(course_html_parsed.findall("li")) + course_html_parsed.insert(0, new_html_parsed) + + idx = len(course_html_parsed) passed_id = course_updates.location.url() + "/" + str(idx) - element = etree.SubElement(course_html_parsed, "li") - date_element = etree.SubElement(element, "h2") - date_element.text = update['date'] - if new_html_parsed is not None: - element.append(new_html_parsed) - else: - date_element.tail = update['content'] # update db record course_updates.definition['data'] = etree.tostring(course_html_parsed) @@ -121,15 +105,17 @@ def delete_course_update(location, update, passed_id): # TODO use delete_blank_text parser throughout and cache as a static var in a class # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data'], etree.XMLParser(remove_blank_text=True)) + course_html_parsed = etree.fromstring(course_updates.definition['data']) except etree.XMLSyntaxError: course_html_parsed = etree.fromstring("
            ") if course_html_parsed.tag == 'ol': # ??? Should this use the id in the json or in the url or does it matter? - element_to_delete = course_html_parsed.xpath('/ol/li[position()=' + str(get_idx(passed_id) + 1) + "]") - if element_to_delete: - course_html_parsed.remove(element_to_delete[0]) + idx = get_idx(passed_id) + # idx is count from end of list + element_to_delete = course_html_parsed[-idx] + if element_to_delete is not None: + course_html_parsed.remove(element_to_delete) # update db record course_updates.definition['data'] = etree.tostring(course_html_parsed) @@ -143,6 +129,6 @@ def get_idx(passed_id): From the url w/ idx appended, get the idx. """ # TODO compile this regex into a class static and reuse for each call - idx_matcher = re.search(r'.*/(\d)+$', passed_id) + idx_matcher = re.search(r'.*/(\d+)$', passed_id) if idx_matcher: return int(idx_matcher.group(1)) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone.py new file mode 100644 index 0000000000..2357cd1dbd --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/clone.py @@ -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_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) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py new file mode 100644 index 0000000000..0313f7faed --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -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: ") + + 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) + + + diff --git a/cms/djangoapps/contentstore/management/commands/prompt.py b/cms/djangoapps/contentstore/management/commands/prompt.py new file mode 100644 index 0000000000..9c8fd81d45 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/prompt.py @@ -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 . + It must be "yes" (the default), "no" or None (meaning + an answer is required of the user). + + The "answer" return value is one of "yes" or "no". + """ + valid = {"yes":True, "y":True, "ye":True, + "no":False, "n":False} + if default == None: + prompt = " [y/n] " + elif default == "yes": + prompt = " [Y/n] " + elif default == "no": + prompt = " [y/N] " + else: + raise ValueError("invalid default answer: '%s'" % default) + + while True: + sys.stdout.write(question + prompt) + choice = raw_input().lower() + if default is not None and choice == '': + return valid[default] + elif choice in valid: + return valid[choice] + else: + sys.stdout.write("Please respond with 'yes' or 'no' "\ + "(or 'y' or 'n').\n") \ No newline at end of file diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index cd07e4556d..2c77fcf313 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -40,11 +40,8 @@ def set_module_info(store, location, post_data): module = store.clone_item(template_location, location) isNew = True - logging.debug('post = {0}'.format(post_data)) - if post_data.get('data') is not None: data = post_data['data'] - logging.debug('data = {0}'.format(data)) store.update_item(location, data) # cdodge: note calling request.POST.get('children') will return None if children is an empty array diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py new file mode 100644 index 0000000000..74eff6e9cc --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -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 = "bar" + # 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, '
          1. Course Details
          2. ', 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") + + diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py new file mode 100644 index 0000000000..13f6189cc5 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -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") diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index f5ce0b3692..ad4ec8790f 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -13,6 +13,11 @@ from xmodule.modulestore.xml_importer import import_from_xml import copy from factories import * +from xmodule.modulestore.store_utilities import clone_course +from xmodule.modulestore.store_utilities import delete_course +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.django import contentstore +from xmodule.course_module import CourseDescriptor def parse_json(response): """Parse response, which is assumed to be json""" @@ -339,4 +344,45 @@ class ContentStoreTest(TestCase): def test_edit_unit_full(self): self.check_edit_unit('full') + def test_clone_course(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + + ms = modulestore('direct') + cs = contentstore() + + source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course') + + clone_course(ms, cs, source_location, dest_location) + + # now loop through all the units in the course and verify that the clone can render them, which + # means the objects are at least present + items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) + self.assertGreater(len(items), 0) + clone_items = ms.get_items(Location(['i4x', 'MITx','999','vertical', None])) + self.assertGreater(len(clone_items), 0) + for descriptor in items: + new_loc = descriptor.location._replace(org = 'MITx', course='999') + print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) + resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) + self.assertEqual(resp.status_code, 200) + + def test_delete_course(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + cs = contentstore() + + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + delete_course(ms, cs, location) + + items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) + self.assertEqual(len(items), 0) + diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 508236a1e9..da2993e463 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -3,6 +3,19 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] + +def get_modulestore(location): + """ + Returns the correct modulestore to use for modifying the specified location + """ + if not isinstance(location, Location): + location = Location(location) + + if location.category in DIRECT_ONLY_CATEGORIES: + return modulestore('direct') + else: + return modulestore() def get_course_location_for_item(location): ''' @@ -60,20 +73,38 @@ def get_course_for_item(location): def get_lms_link_for_item(location, preview=False): - location = Location(location) if settings.LMS_BASE is not None: lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format( preview='preview.' if preview else '', lms_base=settings.LMS_BASE, - # TODO: These will need to be changed to point to the particular instance of this problem in the particular course - course_id=modulestore().get_containing_courses(location)[0].id, - location=location, + course_id=get_course_id(location), + location=Location(location) ) else: lms_link = None return lms_link +def get_lms_link_for_about_page(location): + """ + Returns the url to the course about page from the location tuple. + """ + if settings.LMS_BASE is not None: + lms_link = "//{lms_base}/courses/{course_id}/about".format( + lms_base=settings.LMS_BASE, + course_id=get_course_id(location) + ) + else: + lms_link = None + + return lms_link + +def get_course_id(location): + """ + Returns the course_id from a given the location tuple. + """ + # TODO: These will need to be changed to point to the particular instance of this problem in the particular course + return modulestore().get_containing_courses(Location(location))[0].id class UnitState(object): draft = 'draft' @@ -103,3 +134,12 @@ def compute_unit_state(unit): def get_date_display(date): return date.strftime("%d %B, %Y at %I:%M %p") + +def update_item(location, value): + """ + If value is None, delete the db entry. Otherwise, update it using the correct modulestore. + """ + if value is None: + get_modulestore(location).delete_item(location) + else: + get_modulestore(location).update_item(location, value) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 938dbc8285..29144ce9fb 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -50,28 +50,23 @@ from contentstore.course_info_model import get_course_updates,\ from cache_toolbox.core import del_cached_content from xmodule.timeparse import stringify_time from contentstore.module_info_model import get_module_info, set_module_info +from cms.djangoapps.models.settings.course_details import CourseDetails,\ + CourseSettingsEncoder +from cms.djangoapps.models.settings.course_grading import CourseGradingModel +from cms.djangoapps.contentstore.utils import get_modulestore +from lxml import etree + +# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] -DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] - # cdodge: these are categories which should not be parented, they are detached from the hierarchy DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] -def _modulestore(location): - """ - Returns the correct modulestore to use for modifying the specified location - """ - if location.category in DIRECT_ONLY_CATEGORIES: - return modulestore('direct') - else: - return modulestore() - - # ==== Public views ================================================== @ensure_csrf_cookie @@ -173,6 +168,7 @@ def course_index(request, org, course, name): 'active_tab': 'courseware', 'context_course': course, 'sections': sections, + 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, 'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'), 'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point... @@ -230,6 +226,8 @@ def edit_subsection(request, location): 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), 'lms_link': lms_link, 'preview_link': preview_link, + 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), + 'parent_location': course.location, 'parent_item': parent, 'policy_metadata' : policy_metadata, 'subsection_units' : subsection_units, @@ -324,7 +322,7 @@ def edit_unit(request, location): 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, 'subsection': containing_subsection, - 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else 'Unset', + 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None, 'section': containing_section, 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), 'unit_state': unit_state, @@ -345,6 +343,24 @@ def preview_component(request, location): 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), }) +@expect_json +@login_required +@ensure_csrf_cookie +def assignment_type_update(request, org, course, category, name): + ''' + CRUD operations on assignment types for sections and subsections and anything else gradable. + ''' + location = Location(['i4x', org, course, category, name]) + if not has_access(request.user, location): + raise HttpResponseForbidden() + + if request.method == 'GET': + return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)), + mimetype="application/json") + elif request.method == 'POST': # post or put, doesn't matter. + return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)), + mimetype="application/json") + def user_author_string(user): '''Get an author string for commits by this user. Format: @@ -499,7 +515,7 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ module, "xmodule_display.html", ) - + module.get_html = replace_static_urls( module.get_html, module.metadata.get('data_dir', module.location.course), @@ -548,7 +564,7 @@ def delete_item(request): item = modulestore().get_item(item_location) - store = _modulestore(item_loc) + store = get_modulestore(item_loc) # @TODO: this probably leaves draft items dangling. My preferance would be for the semantic to be @@ -579,7 +595,7 @@ def save_item(request): if not has_access(request.user, item_location): raise PermissionDenied() - store = _modulestore(Location(item_location)); + store = get_modulestore(Location(item_location)); if request.POST.get('data') is not None: data = request.POST['data'] @@ -669,8 +685,6 @@ def unpublish_unit(request): return HttpResponse() - - @login_required @expect_json def clone_item(request): @@ -682,10 +696,10 @@ def clone_item(request): if not has_access(request.user, parent_location): raise PermissionDenied() - parent = _modulestore(template).get_item(parent_location) + parent = get_modulestore(template).get_item(parent_location) dest_location = parent_location._replace(category=template.category, name=uuid4().hex) - new_item = _modulestore(template).clone_item(template, dest_location) + new_item = get_modulestore(template).clone_item(template, dest_location) # TODO: This needs to be deleted when we have proper storage for static content new_item.metadata['data_dir'] = parent.metadata['data_dir'] @@ -694,10 +708,10 @@ def clone_item(request): if display_name is not None: new_item.metadata['display_name'] = display_name - _modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata) + get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata) if new_item.location.category not in DETACHED_CATEGORIES: - _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) return HttpResponse(json.dumps({'id': dest_location.url()})) @@ -857,7 +871,8 @@ def remove_user(request, location): def landing(request, org, course, coursename): return render_to_response('temp-course-landing.html', {}) - +@login_required +@ensure_csrf_cookie def static_pages(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] @@ -877,14 +892,23 @@ def static_pages(request, org, course, coursename): def edit_static(request, org, course, coursename): return render_to_response('edit-static-page.html', {}) - +@login_required +@ensure_csrf_cookie def edit_tabs(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] course_item = modulestore().get_item(location) static_tabs_loc = Location('i4x', org, course, 'static_tab', None) + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + static_tabs = modulestore('direct').get_items(static_tabs_loc) + # see tabs have been uninitialized (e.g. supporing courses created before tab support in studio) + if course_item.tabs is None or len(course_item.tabs) == 0: + initialize_course_tabs(course_item) + components = [ static_tab.location.url() for static_tab @@ -945,6 +969,11 @@ def course_info_updates(request, org, course, provided_id=None): # ??? No way to check for access permission afaik # get current updates location = ['i4x', org, course, 'course_info', "updates"] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] @@ -953,13 +982,13 @@ def course_info_updates(request, org, course, provided_id=None): if request.method == 'GET': return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json") - elif real_method == 'POST': - # new instance (unless django makes PUT a POST): updates are coming as POST. Not sure why. - return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") - elif real_method == 'PUT': - return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE return HttpResponse(json.dumps(delete_course_update(location, request.POST, provided_id)), mimetype="application/json") + elif request.method == 'POST': + try: + return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") + except etree.XMLSyntaxError: + return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain") @expect_json @@ -967,6 +996,10 @@ def course_info_updates(request, org, course, provided_id=None): @ensure_csrf_cookie def module_info(request, module_location): location = Location(module_location) + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: @@ -979,11 +1012,99 @@ def module_info(request, module_location): raise PermissionDenied() if real_method == 'GET': - return HttpResponse(json.dumps(get_module_info(_modulestore(location), location)), mimetype="application/json") + return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location)), mimetype="application/json") elif real_method == 'POST' or real_method == 'PUT': - return HttpResponse(json.dumps(set_module_info(_modulestore(location), location, request.POST)), mimetype="application/json") + return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json") else: - raise Http400 + return HttpResponseBadRequest + +@login_required +@ensure_csrf_cookie +def get_course_settings(request, org, course, name): + """ + Send models and views as well as html for editing the course settings to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + course_details = CourseDetails.fetch(location) + + return render_to_response('settings.html', { + 'active_tab': 'settings', + '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 @@ -1085,22 +1206,25 @@ def create_new_course(request): # set a default start date to now new_course.metadata['start'] = stringify_time(time.gmtime()) + initialize_course_tabs(new_course) + + create_all_course_groups(request.user, new_course.location) + + return HttpResponse(json.dumps({'id': new_course.location.url()})) + +def initialize_course_tabs(course): # set up the default tabs # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or # at least a list populated with the minimal times # @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better # place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here - new_course.tabs = [{"type": "courseware"}, + course.tabs = [{"type": "courseware"}, {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"}] - modulestore('direct').update_metadata(new_course.location.url(), new_course.own_metadata) - - create_all_course_groups(request.user, new_course.location) - - return HttpResponse(json.dumps({'id': new_course.location.url()})) + modulestore('direct').update_metadata(course.location.url(), course.own_metadata) @ensure_csrf_cookie @login_required diff --git a/cms/djangoapps/models/__init__.py b/cms/djangoapps/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/models/settings/__init__.py b/cms/djangoapps/models/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py new file mode 100644 index 0000000000..2bb9d98be7 --- /dev/null +++ b/cms/djangoapps/models/settings/course_details.py @@ -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('' + 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) diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py new file mode 100644 index 0000000000..e0bab1f225 --- /dev/null +++ b/cms/djangoapps/models/settings/course_grading.py @@ -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 diff --git a/cms/envs/test.py b/cms/envs/test.py index cb84f32ff1..d9a2597cbb 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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. diff --git a/cms/static/client_templates/course_grade_policy.html b/cms/static/client_templates/course_grade_policy.html new file mode 100644 index 0000000000..c9a21280dd --- /dev/null +++ b/cms/static/client_templates/course_grade_policy.html @@ -0,0 +1,69 @@ +
          3. +
            + + +
            +
            + + e.g. Homework, Labs, Midterm Exams, Final Exam +
            +
            +
            + +
            + + +
            +
            + + e.g. HW, Midterm, Final +
            +
            +
            + +
            + + +
            +
            + + e.g. 25% +
            +
            +
            + +
            + + +
            +
            + + total exercises assigned +
            +
            +
            + +
            + + +
            +
            + + total exercises that won't be graded +
            +
            +
            + Delete +
          4. diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index 7b2719a047..b396bec944 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -3,6 +3,6 @@ "/static/js/vendor/jquery.min.js", "/static/js/vendor/json2.js", "/static/js/vendor/underscore-min.js", - "/static/js/vendor/backbone.js" + "/static/js/vendor/backbone-min.js" ] } diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index ad875294a1..0617b01bb4 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -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() diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 34d86a3051..1fbc6ffa7f 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -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'), diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index a250925bf9..fe8f928746 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -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() diff --git a/cms/static/img/collapse-all-icon.png b/cms/static/img/collapse-all-icon.png new file mode 100644 index 0000000000..c468778b02 Binary files /dev/null and b/cms/static/img/collapse-all-icon.png differ diff --git a/cms/static/img/home-icon-blue.png b/cms/static/img/home-icon-blue.png new file mode 100644 index 0000000000..45b4971a2a Binary files /dev/null and b/cms/static/img/home-icon-blue.png differ diff --git a/cms/static/img/log-out-icon.png b/cms/static/img/log-out-icon.png new file mode 100644 index 0000000000..887d59f45d Binary files /dev/null and b/cms/static/img/log-out-icon.png differ diff --git a/cms/static/img/small-home-icon.png b/cms/static/img/small-home-icon.png new file mode 100644 index 0000000000..5755bf659d Binary files /dev/null and b/cms/static/img/small-home-icon.png differ diff --git a/cms/static/img/upload-icon.png b/cms/static/img/upload-icon.png new file mode 100644 index 0000000000..0a78627f87 Binary files /dev/null and b/cms/static/img/upload-icon.png differ diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 47835fda66..9fa4489c36 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -2,8 +2,6 @@ var $body; var $modal; var $modalCover; var $newComponentItem; -var $newComponentStep1; -var $newComponentStep2; var $changedInput; var $spinner; @@ -15,6 +13,10 @@ $(document).ready(function() { // pipelining (note, this doesn't happen on local runtimes). So if we set it on window, when we can access it from other // scopes (namely the course-info tab) window.$modalCover = $modalCover; + + // Control whether template caching in local memory occurs (see template_loader.js). Caching screws up development but may + // be a good optimization in production (it works fairly well) + window.cachetemplates = false; $body.append($modalCover); $newComponentItem = $('.new-component-item'); @@ -39,6 +41,8 @@ $(document).ready(function() { $('.unit .item-actions .delete-button').bind('click', deleteUnit); $('.new-unit-item').bind('click', createNewUnit); + $('.collapse-all-button').bind('click', collapseAll); + // autosave when a field is updated on the subsection page $body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue); $('.subsection-display-name-input, .unit-subtitle, .policy-list-name, .policy-list-value').each(function(i) { @@ -105,15 +109,12 @@ $(document).ready(function() { $('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate); $('.edit-section-start-save').bind('click', saveSetSectionScheduleDate); - // modal upload asset dialog. Bind it in the initializer otherwise multiple hanlders will get registered causing - // pretty wacky stuff to happen - $('.file-input').bind('change', startUpload); $('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu); $body.on('click', '.section-published-date .edit-button', editSectionPublishDate); $body.on('click', '.section-published-date .schedule-button', editSectionPublishDate); $body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate); - $body.on('click', '.edit-subsection-publish-settings .cancel-button', hideModal) + $body.on('click', '.edit-subsection-publish-settings .cancel-button', hideModal); $body.on('change', '.edit-subsection-publish-settings .start-date', function() { if($('.edit-subsection-publish-settings').find('.start-time').val() == '') { $('.edit-subsection-publish-settings').find('.start-time').val('12:00am'); @@ -124,6 +125,11 @@ $(document).ready(function() { }); }); +function collapseAll(e) { + $('.branch').addClass('collapsed'); + $('.expand-collapse-icon').removeClass('collapse').addClass('expand'); +} + function editSectionPublishDate(e) { e.preventDefault(); $modal = $('.edit-subsection-publish-settings').show(); @@ -303,7 +309,7 @@ function checkForNewValue(e) { this.saveTimer = setTimeout(function() { $changedInput = $(e.target); - saveSubsection() + saveSubsection(); this.saveTimer = null; }, 500); } @@ -316,7 +322,7 @@ function autosaveInput(e) { this.saveTimer = setTimeout(function() { $changedInput = $(e.target); - saveSubsection() + saveSubsection(); this.saveTimer = null; }, 500); } @@ -338,23 +344,22 @@ function saveSubsection() { // pull all 'normalized' metadata editable fields on page var metadata_fields = $('input[data-metadata-name]'); - metadata = {}; + var metadata = {}; for(var i=0; i< metadata_fields.length;i++) { - el = metadata_fields[i]; + var el = metadata_fields[i]; metadata[$(el).data("metadata-name")] = el.value; } // now add 'free-formed' metadata which are presented to the user as dual input fields (name/value) $('ol.policy-list > li.policy-list-element').each( function(i, element) { - name = $(element).children('.policy-list-name').val(); - val = $(element).children('.policy-list-value').val(); - metadata[name] = val; + var name = $(element).children('.policy-list-name').val(); + metadata[name] = $(element).children('.policy-list-value').val(); }); // now add any 'removed' policy metadata which is stored in a separate hidden div // 'null' presented to the server means 'remove' $("#policy-to-delete > li.policy-list-element").each(function(i, element) { - name = $(element).children('.policy-list-name').val(); + var name = $(element).children('.policy-list-name').val(); if (name != "") metadata[name] = null; }); @@ -390,7 +395,7 @@ function createNewUnit(e) { $.post('/clone_item', {'parent_location' : parent, 'template' : template, - 'display_name': 'New Unit', + 'display_name': 'New Unit' }, function(data) { // redirect to the edit page @@ -480,7 +485,7 @@ function displayFinishedUpload(xhr) { var template = $('#new-asset-element').html(); var html = Mustache.to_html(template, resp); - $('table > tbody > tr:first').before(html); + $('table > tbody').prepend(html); } @@ -493,6 +498,7 @@ function hideModal(e) { if(e) { e.preventDefault(); } + $('.file-input').unbind('change', startUpload); $modal.hide(); $modalCover.hide(); } @@ -593,9 +599,11 @@ function hideToastMessage(e) { function addNewSection(e, isTemplate) { e.preventDefault(); + $(e.target).addClass('disabled'); + var $newSection = $($('#new-section-template').html()); var $cancelButton = $newSection.find('.new-section-name-cancel'); - $('.new-courseware-section-button').after($newSection); + $('.courseware-overview').prepend($newSection); $newSection.find('.new-section-name').focus().select(); $newSection.find('.section-name-form').bind('submit', saveNewSection); $cancelButton.bind('click', cancelNewSection); @@ -632,11 +640,14 @@ function saveNewSection(e) { function cancelNewSection(e) { e.preventDefault(); + $('.new-courseware-section-button').removeClass('disabled'); $(this).parents('section.new-section').remove(); } function addNewCourse(e) { e.preventDefault(); + + $(e.target).hide(); var $newCourse = $($('#new-course-template').html()); var $cancelButton = $newCourse.find('.new-course-cancel'); $('.new-course-button').after($newCourse); @@ -664,7 +675,7 @@ function saveNewCourse(e) { 'template' : template, 'org' : org, 'number' : number, - 'display_name': display_name, + 'display_name': display_name }, function(data) { if (data.id != undefined) { @@ -677,6 +688,7 @@ function saveNewCourse(e) { function cancelNewCourse(e) { e.preventDefault(); + $('.new-course-button').show(); $(this).parents('section.new-course').remove(); } @@ -692,7 +704,7 @@ function addNewSubsection(e) { var parent = $(this).parents("section.branch").data("id"); - $saveButton.data('parent', parent) + $saveButton.data('parent', parent); $saveButton.data('template', $(this).data('template')); $newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection); @@ -757,7 +769,7 @@ function saveEditSectionName(e) { $spinner.show(); if (display_name == '') { - alert("You must specify a name before saving.") + alert("You must specify a name before saving."); return; } @@ -794,13 +806,12 @@ function cancelSetSectionScheduleDate(e) { function saveSetSectionScheduleDate(e) { e.preventDefault(); - input_date = $('.edit-subsection-publish-settings .start-date').val(); - input_time = $('.edit-subsection-publish-settings .start-time').val(); + var input_date = $('.edit-subsection-publish-settings .start-date').val(); + var input_time = $('.edit-subsection-publish-settings .start-time').val(); - start = getEdxTimeFromDateTimeVals(input_date, input_time); + var start = getEdxTimeFromDateTimeVals(input_date, input_time); - id = $modal.attr('data-id'); - var $_this = $(this); + var id = $modal.attr('data-id'); // call into server to commit the new order $.ajax({ diff --git a/cms/static/js/models/course_relative.js b/cms/static/js/models/course_relative.js new file mode 100644 index 0000000000..99bb1c6d77 --- /dev/null +++ b/cms/static/js/models/course_relative.js @@ -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 +}); \ No newline at end of file diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js new file mode 100644 index 0000000000..ab80179142 --- /dev/null +++ b/cms/static/js/models/settings/course_details.js @@ -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