diff --git a/cms/djangoapps/__init__.py b/cms/djangoapps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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..67ff10bc50 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -0,0 +1,213 @@ +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 common.djangoapps.util import converters +import calendar + +# YYYY-MM-DDThh:mm:ss.s+/-HH:MM +class ConvertersTestCase(TestCase): + def struct_to_datetime(self, 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) + + def compare_dates(self, date1, date2, expected_delta): + dt1 = self.struct_to_datetime(date1) + dt2 = self.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 CourseDetailsTestCase(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) + + 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" + 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(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) + + def alter_field(self, url, details, field, val): + setattr(details, field, val) +# jsondetails = json.dumps(details, cls=CourseSettingsEncoder) + resp = self.client.post(url, details) + self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + val) + + 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, '
  • Course Details
  • ', 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") + +# self.alter_field(url, details, 'start_date', time.time() * 1000) +# self.alter_field(url, details, 'start_date', time.time() * 1000 + 60 * 60 * 24) +# self.alter_field(url, details, 'end_date', time.time() * 1000 + 60 * 60 * 24 * 100) +# self.alter_field(url, details, 'enrollment_start', time.time() * 1000) +# +# self.alter_field(url, details, 'enrollment_end', time.time() * 1000 + 60 * 60 * 24 * 8) +# self.alter_field(url, details, 'syllabus', "bar") +# 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: + self.assertEqual(encoded[field] / 1000, calendar.timegm(details[field]), "dates not == 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) + \ No newline at end of file diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 508236a1e9..62c46cc9d4 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): ''' diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 938dbc8285..d2f19802af 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -50,28 +50,22 @@ 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 + +# 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 @@ -499,7 +493,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 +542,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 +573,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 +663,6 @@ def unpublish_unit(request): return HttpResponse() - - @login_required @expect_json def clone_item(request): @@ -682,10 +674,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 +686,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()})) @@ -979,11 +971,86 @@ 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-tab', + '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 + """ + 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 + """ + 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 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..90f49a7b32 --- /dev/null +++ b/cms/djangoapps/models/settings/course_details.py @@ -0,0 +1,146 @@ +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 + +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: + course.intro_video = get_modulestore(temploc).get_item(temploc).definition['data'] + 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') + get_modulestore(temploc).update_item(temploc, jsondict['syllabus']) + + temploc = temploc._replace(name='overview') + get_modulestore(temploc).update_item(temploc, jsondict['overview']) + + temploc = temploc._replace(name='effort') + get_modulestore(temploc).update_item(temploc, jsondict['effort']) + + temploc = temploc._replace(name='video') + get_modulestore(temploc).update_item(temploc, jsondict['intro_video']) + + + # 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) + +# 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_faculty.py b/cms/djangoapps/models/settings/course_faculty.py new file mode 100644 index 0000000000..c1812614ec --- /dev/null +++ b/cms/djangoapps/models/settings/course_faculty.py @@ -0,0 +1,22 @@ +from xmodule.modulestore import Location +class CourseFaculty: + def __init__(self, location): + if not isinstance(location, Location): + location = Location(location) + # course_location is used so that updates know where to get the relevant data + self.course_location = location + self.first_name = "" + self.last_name = "" + self.photo = None + self.bio = "" + + + @classmethod + def fetch(cls, course_location): + """ + Fetch a list of faculty for the course + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + # Must always have at least one faculty member (possibly empty) \ No newline at end of file diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py new file mode 100644 index 0000000000..fe10e651e3 --- /dev/null +++ b/cms/djangoapps/models/settings/course_grading.py @@ -0,0 +1,235 @@ +from xmodule.modulestore import Location +from contentstore.utils import get_modulestore +import datetime +import re +from util import converters +import time + + +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. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + 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 defintion + 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) + del descriptor.metadata['graceperiod'] + get_modulestore(course_location).update_metadata(course_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/static/client_templates/course_grade_policy.html b/cms/static/client_templates/course_grade_policy.html new file mode 100644 index 0000000000..97b0c20eb8 --- /dev/null +++ b/cms/static/client_templates/course_grade_policy.html @@ -0,0 +1,69 @@ +
  • +
    + + +
    +
    + + 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 Assignment Type +
  • diff --git a/cms/static/js/models/course_relative.js b/cms/static/js/models/course_relative.js new file mode 100644 index 0000000000..c33339ff48 --- /dev/null +++ b/cms/static/js/models/course_relative.js @@ -0,0 +1,59 @@ +CMS.Models.Location = Backbone.Model.extend({ + defaults: { + tag: "", + org: "", + course: "", + category: "", + name: "" + }, + toUrl: function(overrides) { + return + (overrides['tag'] ? overrides['tag'] : this.get('tag')) + "://" + + (overrides['org'] ? overrides['org'] : this.get('org')) + "/" + + (overrides['course'] ? overrides['course'] : this.get('course')) + "/" + + (overrides['category'] ? overrides['category'] : this.get('category')) + "/" + + (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)) { + 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._fieldPattern.exec(payload)[0], + course: this._fieldPattern.exec(payload)[0], + category: this._fieldPattern.exec(payload)[0], + name: this._fieldPattern.exec(payload)[0] + } + } + else return null; + } + else { + return payload; + } + } +}); + +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..222d2fd11e --- /dev/null +++ b/cms/static/js/models/settings/course_details.js @@ -0,0 +1,166 @@ +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')) { + var videos = this.parse_videosource(newattrs.intro_video); + var vid_errors = new Array(); + var cachethis = this; + for (var i=0; i or just the "speed:key, *" string + // returns the videosource for the preview which iss the key whose speed is closest to 1 + if (newsource == null) this.save({'intro_video': null}); + // TODO remove all whitespace w/in string + else if (this._getNextMatch(this._videoprefix, newsource, 0)) this.save('intro_video', newsource); + else this.save('intro_video', '