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 new file mode 100644 index 0000000000..c2e8348a66 --- /dev/null +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -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" : "
    [
  1. date

    content
  2. ]
"} "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("
    ") + + # 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 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("
        ") + + # 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: + 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("
            ") + + if course_html_parsed.tag == 'ol': + # ??? Should this use the id in the json or in the url or does it matter? + idx = get_idx(passed_id) + # idx is count from end of list + element_to_delete = course_html_parsed[-idx] + if element_to_delete is not None: + course_html_parsed.remove(element_to_delete) + + # update db record + course_updates.definition['data'] = etree.tostring(course_html_parsed) + store = modulestore('direct') + store.update_item(location, course_updates.definition['data']) + + return get_course_updates(location) + +def get_idx(passed_id): + """ + From the url w/ idx appended, get the idx. + """ + # TODO compile this regex into a class static and reuse for each call + idx_matcher = re.search(r'.*/(\d+)$', passed_id) + if idx_matcher: + return int(idx_matcher.group(1)) \ No newline at end of file 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/export.py b/cms/djangoapps/contentstore/management/commands/export.py new file mode 100644 index 0000000000..11b043c2ab --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -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_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) 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 new file mode 100644 index 0000000000..0017010885 --- /dev/null +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -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) 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 9ddbe049ad..b3f13de998 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -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') + + + + + + + + + + + diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 18afd331d0..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): ''' @@ -33,22 +46,65 @@ def get_course_location_for_item(location): return location +def get_course_for_item(location): + ''' + cdodge: for a given Xmodule, return the course that it belongs to + NOTE: This makes a lot of assumptions about the format of the course location + Also we have to assert that this module maps to only one course item - it'll throw an + assert if not + ''' + item_loc = Location(location) + + # @hack! We need to find the course location however, we don't + # know the 'name' parameter in this context, so we have + # to assume there's only one item in this query even though we are not specifying a name + course_search_location = ['i4x', item_loc.org, item_loc.course, 'course', None] + courses = modulestore().get_items(course_search_location) + + # make sure we found exactly one match on this above course search + found_cnt = len(courses) + if found_cnt == 0: + raise BaseException('Could not find course at {0}'.format(course_search_location)) + + if found_cnt > 1: + raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + + return courses[0] + def get_lms_link_for_item(location, preview=False): - location = Location(location) if settings.LMS_BASE is not None: lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format( preview='preview.' if preview else '', lms_base=settings.LMS_BASE, - # TODO: These will need to be changed to point to the particular instance of this problem in the particular course - course_id=modulestore().get_containing_courses(location)[0].id, - location=location, + course_id=get_course_id(location), + location=Location(location) ) else: lms_link = None return lms_link +def get_lms_link_for_about_page(location): + """ + Returns the url to the course about page from the location tuple. + """ + if settings.LMS_BASE is not None: + lms_link = "//{lms_base}/courses/{course_id}/about".format( + lms_base=settings.LMS_BASE, + course_id=get_course_id(location) + ) + else: + lms_link = None + + return lms_link + +def get_course_id(location): + """ + Returns the course_id from a given the location tuple. + """ + # TODO: These will need to be changed to point to the particular instance of this problem in the particular course + return modulestore().get_containing_courses(Location(location))[0].id class UnitState(object): draft = 'draft' @@ -78,3 +134,12 @@ def compute_unit_state(unit): def get_date_display(date): return date.strftime("%d %B, %Y at %I:%M %p") + +def update_item(location, value): + """ + If value is None, delete the db entry. Otherwise, update it using the correct modulestore. + """ + if value is None: + get_modulestore(location).delete_item(location) + else: + get_modulestore(location).update_item(location, value) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 8cc9886396..8f10eadc4b 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -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' : '' + }) 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/client_templates/course_info_handouts.html b/cms/static/client_templates/course_info_handouts.html new file mode 100644 index 0000000000..958a1c77d6 --- /dev/null +++ b/cms/static/client_templates/course_info_handouts.html @@ -0,0 +1,19 @@ +Edit + +

            Course Handouts

            +<%if (model.get('data') != null) { %> +
            + <%= model.get('data') %> +
            +<% } else {%> +

            You have no handouts defined

            +<% } %> +
            +
            + +
            +
            + Save + Cancel +
            +
            diff --git a/cms/static/client_templates/course_info_update.html b/cms/static/client_templates/course_info_update.html new file mode 100644 index 0000000000..79775db5e3 --- /dev/null +++ b/cms/static/client_templates/course_info_update.html @@ -0,0 +1,29 @@ +
          5. + +
            +
            + + + +
            +
            + +
            +
            + + Save + Cancel +
            +
            +
            +
            + Edit + Delete +
            +

            + <%= + updateModel.get('date') %> +

            +
            <%= updateModel.get('content') %>
            +
            +
          6. \ No newline at end of file diff --git a/cms/static/client_templates/load_templates.html b/cms/static/client_templates/load_templates.html new file mode 100644 index 0000000000..3ff88d6fe5 --- /dev/null +++ b/cms/static/client_templates/load_templates.html @@ -0,0 +1,14 @@ + + +<%block name="jsextra"> + + + + \ No newline at end of file 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/delete-icon.png b/cms/static/img/delete-icon.png index 1855a2943d..9c7f65daef 100644 Binary files a/cms/static/img/delete-icon.png and b/cms/static/img/delete-icon.png differ diff --git a/cms/static/img/edit-icon.png b/cms/static/img/edit-icon.png index 2da9551010..748d3d2115 100644 Binary files a/cms/static/img/edit-icon.png and b/cms/static/img/edit-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 79d83a466e..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; @@ -11,6 +9,15 @@ $(document).ready(function() { $body = $('body'); $modal = $('.history-modal'); $modalCover = $(' - {{displayname}} + {{displayname}}
            @@ -35,10 +35,11 @@
            -

            Asset Library

            @@ -61,7 +62,7 @@
            - ${asset['displayname']} + ${asset['displayname']}
            diff --git a/cms/templates/base.html b/cms/templates/base.html index 9861f2a3de..84f10fc2d1 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -9,6 +9,9 @@ <%static:css group='base-style'/> + + + <%block name="title"></%block> @@ -26,6 +29,9 @@ + + + <%static:js group='main'/> <%static:js group='module-js'/> diff --git a/cms/templates/component.html b/cms/templates/component.html index 2ec54d3b29..b7ad9c3c33 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -9,5 +9,5 @@ Edit Delete - + ${preview} \ No newline at end of file diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html new file mode 100644 index 0000000000..f4fa661b6e --- /dev/null +++ b/cms/templates/course_info.html @@ -0,0 +1,60 @@ +<%inherit file="base.html" /> +<%namespace name='static' file='static_content.html'/> + + +<%block name="title">Course Info +<%block name="bodyclass">course-info + +<%block name="jsextra"> + + + + + + + + + + + + +<%block name="content"> +
            +
            +

            Course Info

            +
            +
            + +
            + +
            +
            +
            + + \ No newline at end of file diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index 41dee15a7a..c6ffb14124 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -17,13 +17,18 @@ <%block name="content">
            -
            -

            Static Tabs

            -
            -
            +
            -

            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.

            +

            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.

            + + +
              % for id in components: @@ -31,9 +36,7 @@ % endfor
            1. - - New Tab - +
            diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index e1dd91c162..d3b6f73f13 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -31,21 +31,6 @@ ${units.enum_units(subsection, subsection_units=subsection_units)}
            -
            - -
              - % for policy_name in policy_metadata.keys(): -
            1. - - Cancel - -
            2. - % endfor - - New Policy Data - -
            -
            @@ -62,7 +47,7 @@