diff --git a/cms/.coveragerc b/cms/.coveragerc index 9b1e59d670..b7ae181e99 100644 --- a/cms/.coveragerc +++ b/cms/.coveragerc @@ -1,7 +1,7 @@ # .coveragerc for cms [run] data_file = reports/cms/.coverage -source = cms +source = cms,common/djangoapps omit = cms/envs/*, cms/manage.py [report] diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 0017010885..3b783c8815 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -1,5 +1,5 @@ import logging -from static_replace import replace_urls +from static_replace import replace_static_urls from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -18,7 +18,17 @@ def get_module_info(store, location, parent_location = None, rewrite_static_link 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])) + data = replace_static_urls( + module.definition['data'], + None, + course_namespace=Location([ + module.location.tag, + module.location.org, + module.location.course, + None, + None + ]) + ) return { 'id': module.location.url(), @@ -47,7 +57,7 @@ def set_module_info(store, location, post_data): 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 diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 80909dad7a..085ecebff1 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -515,6 +515,9 @@ class ContentStoreTest(TestCase): # 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') + def test_missing_static_content(self): + resp = self.client.get("/c4x/asd/asd/asd/asd") + self.assertEqual(resp.status_code, 404) def test_capa_module(self): """Test that a problem treats markdown specially.""" diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index f70164138d..f1402ed840 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -31,7 +31,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str -from static_replace import replace_urls +import static_replace from external_auth.views import ssl_login_shortcut from mitxmako.shortcuts import render_to_response, render_to_string @@ -58,7 +58,7 @@ 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 +from cms.djangoapps.models.settings.course_metadata import CourseMetadata # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' @@ -132,7 +132,7 @@ def has_access(user, location, role=STAFF_ROLE_NAME): Return True if user allowed to access this piece of data Note that the CMS permissions model is with respect to courses There is a super-admin permissions if user.is_staff is set - Also, since we're unifying the user database between LMS and CAS, + Also, since we're unifying the user database between LMS and CAS, I'm presuming that the course instructor (formally known as admin) will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR has all the rights that STAFF do @@ -154,7 +154,7 @@ def course_index(request, org, course, name): 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() @@ -213,7 +213,7 @@ def edit_subsection(request, location): # remove all metadata from the generic dictionary that is presented in a more normalized UI - policy_metadata = dict((key,value) for key, value in item.metadata.iteritems() + policy_metadata = dict((key,value) for key, value in item.metadata.iteritems() if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields) can_view_live = False @@ -291,7 +291,7 @@ def edit_unit(request, location): containing_section = modulestore().get_item(containing_section_locs[0]) # cdodge hack. We're having trouble previewing drafts via jump_to redirect - # so let's generate the link url here + # so let's generate the link url here # need to figure out where this item is in the list of children as the preview will need this index =1 @@ -302,12 +302,12 @@ def edit_unit(request, location): preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format( preview='preview.', - lms_base=settings.LMS_BASE, + lms_base=settings.LMS_BASE, org=course.location.org, - course=course.location.course, - course_name=course.location.name, - section=containing_section.location.name, - subsection=containing_subsection.location.name, + course=course.location.course, + course_name=course.location.name, + section=containing_section.location.name, + subsection=containing_subsection.location.name, index=index) unit_state = compute_unit_state(item) @@ -358,14 +358,14 @@ def assignment_type_update(request, org, course, category, name): 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)), + 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)), + 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: @@ -473,7 +473,7 @@ def preview_module_system(request, preview_id, descriptor): get_module=partial(get_preview_module, request, preview_id), render_template=render_from_lms, debug=True, - replace_urls=replace_urls, + replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location), user=request.user, ) @@ -510,20 +510,20 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ error_msg=exc_info_to_str(sys.exc_info()) ).xmodule_constructor(system)(None, None) - # cdodge: Special case + # cdodge: Special case if module.location.category == 'static_tab': module.get_html = wrap_xmodule( module.get_html, module, "xmodule_tab_display.html", ) - else: + else: module.get_html = wrap_xmodule( module.get_html, module, "xmodule_display.html", ) - + module.get_html = replace_static_urls( module.get_html, module.metadata.get('data_dir', module.location.course), @@ -554,7 +554,7 @@ def _xmodule_recurse(item, action): _xmodule_recurse(child, action) action(item) - + @login_required @expect_json @@ -589,7 +589,7 @@ def delete_item(request): # delete_item on a vertical tries to delete the draft version leaving the # requested delete to never occur if item.location.revision is None and item.location.category=='vertical' and delete_all_versions: - modulestore('direct').delete_item(item.location) + modulestore('direct').delete_item(item.location) return HttpResponse() @@ -608,7 +608,7 @@ def save_item(request): if request.POST.get('data') is not None: data = request.POST['data'] store.update_item(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 @@ -698,7 +698,7 @@ def unpublish_unit(request): def clone_item(request): parent_location = Location(request.POST['parent_location']) template = Location(request.POST['template']) - + display_name = request.POST.get('display_name') if not has_access(request.user, parent_location): @@ -738,9 +738,9 @@ def upload_asset(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] if not has_access(request.user, location): return HttpResponseForbidden() - + # Does the course actually exist?!? Get anything from it to prove its existance - + try: item = modulestore().get_item(location) except: @@ -774,9 +774,9 @@ def upload_asset(request, org, course, coursename): # readback the saved content - we need the database timestamp readback = contentstore().find(content.location) - - response_payload = {'displayname' : content.name, - 'uploadDate' : get_date_display(readback.last_modified_at), + + response_payload = {'displayname' : content.name, + 'uploadDate' : get_date_display(readback.last_modified_at), 'url' : StaticContent.get_url_path_from_location(content.location), 'thumb_url' : StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None, 'msg' : 'Upload completed' @@ -792,7 +792,7 @@ This view will return all CMS users who are editors for the specified course @login_required @ensure_csrf_cookie def manage_users(request, location): - + # check that logged in user has permissions to this item if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): raise PermissionDenied() @@ -808,7 +808,7 @@ def manage_users(request, location): 'allow_actions' : has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), 'request_user_id' : request.user.id }) - + def create_json_response(errmsg = None): if errmsg is not None: @@ -830,13 +830,13 @@ def add_user(request, location): if email=='': return create_json_response('Please specify an email address.') - + # check that logged in user has admin permissions to this course if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): raise PermissionDenied() - + user = get_user_by_email(email) - + # user doesn't exist?!? Return error. if user is None: return create_json_response('Could not find user by email address \'{0}\'.'.format(email)) @@ -859,7 +859,7 @@ the specified course @ensure_csrf_cookie def remove_user(request, location): email = request.POST["email"] - + # check that logged in user has admin permissions on this course if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): raise PermissionDenied() @@ -886,7 +886,7 @@ def landing(request, org, course, coursename): def static_pages(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() @@ -915,7 +915,7 @@ def reorder_static_tabs(request): # get list of existing static tabs in course # make sure they are the same lengths (i.e. the number of passed in tabs equals the number # that we know about) otherwise we can drop some! - + existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab'] if len(existing_static_tabs) != len(tabs): return HttpResponseBadRequest() @@ -934,15 +934,15 @@ def reorder_static_tabs(request): static_tab_idx = 0 for tab in course.tabs: if tab['type'] == 'static_tab': - reordered_tabs.append({'type': 'static_tab', - 'name' : tab_items[static_tab_idx].metadata.get('display_name'), + reordered_tabs.append({'type': 'static_tab', + 'name' : tab_items[static_tab_idx].metadata.get('display_name'), 'url_slug' : tab_items[static_tab_idx].location.name}) static_tab_idx += 1 else: reordered_tabs.append(tab) - # OK, re-assemble the static tabs in the new order + # OK, re-assemble the static tabs in the new order course.tabs = reordered_tabs modulestore('direct').update_metadata(course.location, course.metadata) return HttpResponse() @@ -951,7 +951,7 @@ def reorder_static_tabs(request): @login_required @ensure_csrf_cookie def edit_tabs(request, org, course, coursename): - location = ['i4x', org, course, 'course', coursename] + location = ['i4x', org, course, 'course', coursename] course_item = modulestore().get_item(location) static_tabs_loc = Location('i4x', org, course, 'static_tab', None) @@ -980,7 +980,7 @@ def edit_tabs(request, org, course, coursename): return render_to_response('edit-tabs.html', { 'active_tab': 'pages', - 'context_course':course_item, + 'context_course':course_item, 'components': components }) @@ -1001,13 +1001,13 @@ def course_info(request, org, course, name, provided_id=None): 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"] @@ -1018,7 +1018,7 @@ def course_info(request, org, course, name, provided_id=None): '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 @@ -1032,7 +1032,7 @@ def course_info_updates(request, org, course, provided_id=None): # ??? No way to check for access permission afaik # get current updates location = ['i4x', org, course, 'course_info', "updates"] - + # Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-( # Possibly due to my removing the seemingly redundant pattern in urls.py if provided_id == '': @@ -1047,7 +1047,7 @@ def course_info_updates(request, org, course, provided_id=None): 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 @@ -1064,7 +1064,7 @@ def course_info_updates(request, org, course, provided_id=None): @ensure_csrf_cookie def module_info(request, module_location): location = Location(module_location) - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() @@ -1077,10 +1077,10 @@ def module_info(request, module_location): 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() + 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") @@ -1098,20 +1098,22 @@ def get_course_settings(request, org, course, name): 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', + 'active_tab': 'settings', 'context_course': course_module, + 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST), + 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)), 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) }) - + @expect_json @login_required @ensure_csrf_cookie @@ -1133,14 +1135,17 @@ def course_settings_updates(request, org, course, name, section): manager = CourseDetails elif section == 'grading': manager = CourseGradingModel + elif section == 'advanced': + # not implemented b/c it assumes prefetched and then everything thru course_edit_metadata + return 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), + 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), + return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), mimetype="application/json") @expect_json @@ -1153,7 +1158,7 @@ def course_grader_updates(request, org, course, name, grader_index=None): 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 @@ -1164,13 +1169,13 @@ def course_grader_updates(request, org, course, name, grader_index=None): 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)), + 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? + # ??? 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. @@ -1178,6 +1183,56 @@ def course_grader_updates(request, org, course, name, grader_index=None): mimetype="application/json") +@login_required +@ensure_csrf_cookie +def course_edit_metadata(request, org, course, name): + """ + Send models and views as well as html for editing the course editable metadata 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() + + editable = CourseMetadata.fetch(location) + + # for now defer to settings general until we split the divs out into separate pages + return get_course_settings(request, org, course, name) + +## NB: expect_json failed on ["key", "key2"] and json payload +@login_required +@ensure_csrf_cookie +def course_metadata_rest_access(request, org, course, name): + """ + restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh, + the payload is either a key or a list of keys to delete. + + 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() + + # 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(CourseMetadata.fetch(location)), mimetype="application/json") + elif real_method == 'DELETE': + return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json") + elif request.method == 'POST': + # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key + return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json") + + @login_required @ensure_csrf_cookie def asset_index(request, org, course, name): @@ -1187,7 +1242,7 @@ def asset_index(request, org, course, name): 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() @@ -1200,7 +1255,7 @@ def asset_index(request, org, course, name): }) course_module = modulestore().get_item(location) - + course_reference = StaticContent.compute_location(org, course, name) assets = contentstore().get_all_content_for_course(course_reference) @@ -1214,15 +1269,15 @@ def asset_index(request, org, course, name): display_info = {} display_info['displayname'] = asset['displayname'] display_info['uploadDate'] = get_date_display(asset['uploadDate']) - + asset_location = StaticContent.compute_location(id['org'], id['course'], id['name']) display_info['url'] = StaticContent.get_url_path_from_location(asset_location) - + # note, due to the schema change we may not have a 'thumbnail_location' in the result set _thumbnail_location = asset.get('thumbnail_location', None) thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None - + asset_display.append(display_info) return render_to_response('asset_index.html', { @@ -1241,9 +1296,9 @@ def edge(request): @expect_json def create_new_course(request): template = Location(request.POST['template']) - org = request.POST.get('org') - number = request.POST.get('number') - display_name = request.POST.get('display_name') + org = request.POST.get('org') + number = request.POST.get('number') + display_name = request.POST.get('display_name') try: dest_location = Location('i4x', org, number, 'course', Location.clean(display_name)) @@ -1289,13 +1344,13 @@ def initialize_course_tabs(course): # 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 - course.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, + 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(course.location.url(), course.own_metadata) + modulestore('direct').update_metadata(course.location.url(), course.own_metadata) @ensure_csrf_cookie @login_required @@ -1388,7 +1443,7 @@ def generate_export_course(request, org, course, name): 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) @@ -1400,7 +1455,7 @@ def generate_export_course(request, org, course, name): tf.close() # remove temp dir - shutil.rmtree(root_dir/name) + shutil.rmtree(root_dir/name) wrapper = FileWrapper(export_file) response = HttpResponse(wrapper, content_type='application/x-tgz') @@ -1430,4 +1485,4 @@ def event(request): A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at console logs don't get distracted :-) ''' - return HttpResponse(True) \ No newline at end of file + return HttpResponse(True) diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py new file mode 100644 index 0000000000..bbe2d8a66d --- /dev/null +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -0,0 +1,68 @@ +from xmodule.modulestore import Location +from contentstore.utils import get_modulestore +from xmodule.x_module import XModuleDescriptor + + +class CourseMetadata(object): + ''' + For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. + The objects have no predefined attrs but instead are obj encodings of the editable metadata. + ''' + # __new_advanced_key__ is used by client not server; so, could argue against it being here + FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] + + @classmethod + def fetch(cls, course_location): + """ + Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + course = {} + + descriptor = get_modulestore(course_location).get_item(course_location) + + for k, v in descriptor.metadata.iteritems(): + if k not in cls.FILTERED_LIST: + course[k] = v + + return course + + @classmethod + def update_from_json(cls, course_location, jsondict): + """ + Decode the json into CourseMetadata and save any changed attrs to the db + """ + descriptor = get_modulestore(course_location).get_item(course_location) + + dirty = False + + for k, v in jsondict.iteritems(): + # should it be an error if one of the filtered list items is in the payload? + if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v): + dirty = True + descriptor.metadata[k] = v + + if dirty: + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + # 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 cls.fetch(course_location) + + @classmethod + def delete_key(cls, course_location, payload): + ''' + Remove the given metadata key(s) from the course. payload can be a single key or [key..] + ''' + descriptor = get_modulestore(course_location).get_item(course_location) + + for key in payload['deleteKeys']: + if key in descriptor.metadata: + del descriptor.metadata[key] + + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + return cls.fetch(course_location) + \ No newline at end of file diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 48cfa3cf9a..a147f84531 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -52,10 +52,6 @@ LOGGING = get_logger_config(LOG_DIR, debug=False, service_variant=SERVICE_VARIANT) -with open(ENV_ROOT / "repos.json") as repos_file: - REPOS = json.load(repos_file) - - ################ SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: diff --git a/cms/envs/common.py b/cms/envs/common.py index f2d47dfdc6..3ea532d70d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -285,4 +285,5 @@ INSTALLED_APPS = ( # For asset pipelining 'pipeline', 'staticfiles', + 'static_replace', ) diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html new file mode 100644 index 0000000000..b76cd72a4a --- /dev/null +++ b/cms/static/client_templates/advanced_entry.html @@ -0,0 +1,18 @@ +
  • +
    +
    + +
    + + Keys are case sensitive and cannot contain spaces or start with a number +
    +
    +
    + +
    + +
    +
    +
    + Delete +
  • \ No newline at end of file diff --git a/cms/static/js/models/settings/advanced.js b/cms/static/js/models/settings/advanced.js index 000ac06ae5..40b39427fd 100644 --- a/cms/static/js/models/settings/advanced.js +++ b/cms/static/js/models/settings/advanced.js @@ -2,13 +2,215 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = {}; CMS.Models.Settings.Advanced = Backbone.Model.extend({ defaults: { - + // the properties are whatever the user types in }, - + // which keys to send as the deleted keys on next save + deleteKeys : [], + blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based] initialize: function() { console.log('in initialize'); var editor = ace.edit('course-advanced-policy-1-value'); editor.setTheme("ace/theme/chrome"); editor.getSession().setMode("ace/mode/json"); + }, + validate: function(attrs) { + var errors = {}; + for (key in attrs) { + if (_.contains(this.blacklistKeys, key)) { + errors[key] = key + " is a reserved keyword or has another editor"; + } + } + if (!_.isEmpty(errors)) return errors; + } +}); + +if (!CMS.Views['Settings']) CMS.Views.Settings = {}; + +CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ + // Model class is CMS.Models.Settings.Advanced + events : { + 'click .delete-button' : "deleteEntry", + 'click .save-button' : "saveView", + 'click .cancel-button' : "revertView", + 'click .new-button' : "addEntry", + // update model on changes + 'change #course-advanced-policy-key' : "updateKey", + 'change #course-advanced-policy-value' : "updateValue" + // TODO enable/disable save (add disabled class) based on validation & dirty + // TODO enable/disable new button? + }, + initialize : function() { + var self = this; + // instantiates an editor template for each update in the collection + window.templateLoader.loadRemoteTemplate("advanced_entry", + "/static/client_templates/advanced_entry.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); + } + ); + this.model.on('error', this.handleValidationError, this); + }, + render: function() { + // catch potential outside call before template loaded + if (!this.template) return this; + + var listEle$ = this.$el.find('.course-advanced-policy-list'); + listEle$.empty(); + // same object so manipulations to one keep the other up to date + this.fieldToSelectorMap = this.selectorToField = {}; + + // iterate through model and produce key : value editors for each property in model.get + var self = this; + _.each(_.sortBy(_.keys(this.model.attributes), _.identity), + function(key) { + listEle$.append(self.template({ key : key, value : self.model.get(key)})); + self.fieldToSelectorMap[key] = key; + }); + + // insert the empty one + this.addEntry(); + // Should this be on an event rather than render? +// var editor = ace.edit('course-advanced-policy-1-value'); +// editor.setTheme("ace/theme/monokai"); +// editor.getSession().setMode("ace/mode/javascript"); + + return this; + }, + deleteEntry : function(event) { + event.preventDefault(); + // find out which entry + var li$ = $(event.currentTarget).closest('li'); + // Not data b/c the validation view uses it for a selector + var key = $('.key', li$).attr('id'); + + delete this.fieldToSelectorMap[key]; + if (key !== '__new_advanced_key__') { + this.model.deleteKeys.push(key); + delete this.model[key]; + } + li$.remove(); + }, + saveView : function(event) { + // TODO one last verification scan: + // call validateKey on each to ensure proper format + // check for dupes + + this.model.save({ + success : function() { window.alert("Saved"); }, + error : CMS.ServerError + }); + // FIXME don't delete if the validation didn't succeed in the save call + // remove deleted attrs + if (!_.isEmpty(this.model.deleteKeys)) { + var self = this; + // hmmm, not sure how to do this via backbone since we're not destroying the model + $.ajax({ + url : this.model.url, + // json to and fro + contentType : "application/json", + dataType : "json", + // delete + type : 'DELETE', + // data + data : JSON.stringify({ deleteKeys : this.model.deleteKeys}) + }) + .fail(function(hdr, status, error) { CMS.ServerError(self.model, "Deleting keys:" + status); }) + .done(function(data, status, error) { + // clear deleteKeys on success + self.model.deleteKeys = []; + }); + } + }, + revertView : function(event) { + this.model.deleteKeys = []; + var self = this; + this.model.clear({silent : true}); + this.model.fetch({ + success : function() { self.render(); }, + error : CMS.ServerError + }); + }, + addEntry : function() { + var listEle$ = this.$el.find('.course-advanced-policy-list'); + listEle$.append(this.template({ key : "", value : ""})); + // disable the value entry until there's an acceptable key + listEle$.find('.course-advanced-policy-value').addClass('disabled'); + this.fieldToSelectorMap['__new_advanced_key__'] = '__new_advanced_key__'; + }, + updateKey : function(event) { + // old key: either the key as in the model or __new_advanced_key__. That is, it doesn't change as the val changes until val is accepted + var oldKey = $(event.currentTarget).closest('.key').attr('id'); + var newKey = $(event.currentTarget).val(); + console.log('update ', oldKey, newKey); // REMOVE ME + if (oldKey !== newKey) { + // may erase other errors but difficult to just remove these + this.clearValidationErrors(); + + if (!this.validateKey(oldKey, newKey)) return; + + if (this.model.has(newKey)) { + console.log('dupe key'); + var error = {}; + error[oldKey] = newKey + " has another entry"; + error[newKey] = "Other entry for " + newKey; + this.model.trigger("error", this.model, error); + return false; + } + + // explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success + // method which is uglier I think?) + var newEntryModel = {}; + // set the new key's value to the old one's + newEntryModel[newKey] = (oldKey === '__new_advanced_key__' ? '' : this.model.get(oldKey)); + + var validation = this.model.validate(newEntryModel); + if (validation) { + console.log('reserved key'); + this.model.trigger("error", this.model, validation); + // abandon update + return; + } + + // Now safe to actually do the update + this.model.set(newEntryModel); + + delete this.fieldToSelectorMap[oldKey]; + + if (oldKey !== '__new_advanced_key__') { + // mark the old key for deletion and delete from field maps + this.model.deleteKeys.push(oldKey); + this.model.unset(oldKey) ; + } + else { + // enable the value entry + this.$el.find('.course-advanced-policy-value').removeClass('disabled'); + } + + // update gui (sets all the ids etc) + $(event.currentTarget).closest('li').replaceWith(this.template({key : newKey, value : this.model.get(newKey) })); + + this.fieldToSelectorMap[newKey] = newKey; + } + }, + validateKey : function(oldKey, newKey) { + // model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here + // TODO ensure there's no spaces or illegal chars + if (_.isEmpty(newKey)) { + console.log('no key'); + var error = {}; + error[oldKey] = "Key cannot be an empty string"; + this.model.trigger("error", this.model, error); + return false; + } + else return true; + }, + updateValue : function(event) { + // much simpler than key munging. just update the value + var key = $(event.currentTarget).closest('.row').children('.key').attr('id'); + var value = $(event.currentTarget).val(); + console.log('updating ', key, value); + + this.model.set(key, value, {validate:true}); } }); diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js index cce4e0207d..13957aac51 100644 --- a/cms/static/js/models/settings/course_grading_policy.js +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -1,55 +1,55 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = new Object(); CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ - defaults : { - course_location : null, - graders : null, // CourseGraderCollection - grade_cutoffs : null, // CourseGradeCutoff model + defaults : { + course_location : null, + graders : null, // CourseGraderCollection + grade_cutoffs : null, // CourseGradeCutoff model grace_period : null // either null or { hours: n, minutes: m, ...} - }, - parse: function(attributes) { - if (attributes['course_location']) { - attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); - } - if (attributes['graders']) { - var graderCollection; - if (this.has('graders')) { - graderCollection = this.get('graders'); - graderCollection.reset(attributes.graders); - } - else { - graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders); - graderCollection.course_location = attributes['course_location'] || this.get('course_location'); - } - attributes.graders = graderCollection; - } - return attributes; - }, - url : function() { - var location = this.get('course_location'); - return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading'; - }, - gracePeriodToDate : function() { - var newDate = new Date(); - if (this.has('grace_period') && this.get('grace_period')['hours']) - newDate.setHours(this.get('grace_period')['hours']); - else newDate.setHours(0); - if (this.has('grace_period') && this.get('grace_period')['minutes']) - newDate.setMinutes(this.get('grace_period')['minutes']); - else newDate.setMinutes(0); - if (this.has('grace_period') && this.get('grace_period')['seconds']) - newDate.setSeconds(this.get('grace_period')['seconds']); - else newDate.setSeconds(0); - - return newDate; - }, - dateToGracePeriod : function(date) { - return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() }; - } + }, + parse: function(attributes) { + if (attributes['course_location']) { + attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); + } + if (attributes['graders']) { + var graderCollection; + if (this.has('graders')) { + graderCollection = this.get('graders'); + graderCollection.reset(attributes.graders); + } + else { + graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders); + graderCollection.course_location = attributes['course_location'] || this.get('course_location'); + } + attributes.graders = graderCollection; + } + return attributes; + }, + url : function() { + var location = this.get('course_location'); + return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading'; + }, + gracePeriodToDate : function() { + var newDate = new Date(); + if (this.has('grace_period') && this.get('grace_period')['hours']) + newDate.setHours(this.get('grace_period')['hours']); + else newDate.setHours(0); + if (this.has('grace_period') && this.get('grace_period')['minutes']) + newDate.setMinutes(this.get('grace_period')['minutes']); + else newDate.setMinutes(0); + if (this.has('grace_period') && this.get('grace_period')['seconds']) + newDate.setSeconds(this.get('grace_period')['seconds']); + else newDate.setSeconds(0); + + return newDate; + }, + dateToGracePeriod : function(date) { + return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() }; + } }); CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ - defaults: { + defaults: { "type" : "", // must be unique w/in collection (ie. w/in course) "min_count" : 1, "drop_count" : 0, @@ -57,71 +57,71 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ "weight" : 0 // int 0..100 }, parse : function(attrs) { - if (attrs['weight']) { - if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight); - } - if (attrs['min_count']) { - if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count); - } - if (attrs['drop_count']) { - if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count); - } - return attrs; + if (attrs['weight']) { + if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight); + } + if (attrs['min_count']) { + if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count); + } + if (attrs['drop_count']) { + if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count); + } + return attrs; }, validate : function(attrs) { - var errors = {}; - if (attrs['type']) { - if (_.isEmpty(attrs['type'])) { - errors.type = "The assignment type must have a name."; - } - else { - // FIXME somehow this.collection is unbound sometimes. I can't track down when - var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this); - if (existing) { - errors.type = "There's already another assignment type with this name."; - } - } - } - if (attrs['weight']) { - if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) { - errors.weight = "Please enter an integer between 0 and 100."; - } - else { - attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int - if (this.collection && attrs.weight > 0) { - // FIXME b/c saves don't update the models if validation fails, we should - // either revert the field value to the one in the model and make them make room - // or figure out a wholistic way to balance the vals across the whole -// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100) -// errors.weight = "The weights cannot add to more than 100."; - } - }} - if (attrs['min_count']) { - if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) { - errors.min_count = "Please enter an integer."; - } - else attrs.min_count = parseInt(attrs.min_count); - } - if (attrs['drop_count']) { - if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) { - errors.drop_count = "Please enter an integer."; - } - else attrs.drop_count = parseInt(attrs.drop_count); - } - if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) { - errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned."; - } - if (!_.isEmpty(errors)) return errors; + var errors = {}; + if (attrs['type']) { + if (_.isEmpty(attrs['type'])) { + errors.type = "The assignment type must have a name."; + } + else { + // FIXME somehow this.collection is unbound sometimes. I can't track down when + var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this); + if (existing) { + errors.type = "There's already another assignment type with this name."; + } + } + } + if (attrs['weight']) { + if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) { + errors.weight = "Please enter an integer between 0 and 100."; + } + else { + attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int + if (this.collection && attrs.weight > 0) { + // FIXME b/c saves don't update the models if validation fails, we should + // either revert the field value to the one in the model and make them make room + // or figure out a wholistic way to balance the vals across the whole +// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100) +// errors.weight = "The weights cannot add to more than 100."; + } + }} + if (attrs['min_count']) { + if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) { + errors.min_count = "Please enter an integer."; + } + else attrs.min_count = parseInt(attrs.min_count); + } + if (attrs['drop_count']) { + if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) { + errors.drop_count = "Please enter an integer."; + } + else attrs.drop_count = parseInt(attrs.drop_count); + } + if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) { + errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned."; + } + if (!_.isEmpty(errors)) return errors; } }); CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({ - model : CMS.Models.Settings.CourseGrader, - course_location : null, // must be set to a Location object - url : function() { - return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/'; - }, - sumWeights : function() { - return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); - } + model : CMS.Models.Settings.CourseGrader, + course_location : null, // must be set to a Location object + url : function() { + return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/'; + }, + sumWeights : function() { + return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); + } }); \ No newline at end of file diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js index 89ecc98cc4..6108d96346 100644 --- a/cms/static/js/template_loader.js +++ b/cms/static/js/template_loader.js @@ -5,7 +5,7 @@ if (typeof window.templateLoader == 'function') return; var templateLoader = { - templateVersion: "0.0.12", + templateVersion: "0.0.13", templates: {}, loadRemoteTemplate: function(templateName, filename, callback) { if (!this.templates[templateName]) { diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index c007ef3efc..8cbae177a8 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -8,7 +8,9 @@ $(document).ready(function() { handle: '.drag-handle', zIndex: 999, start: initiateHesitate, - drag: checkHoverState, + // left 2nd arg in as inert selector b/c i was uncertain whether we'd try to get the shove up/down + // to work in the future + drag: generateCheckHoverState('.collapsed', ''), stop: removeHesitate, revert: "invalid" }); @@ -19,7 +21,7 @@ $(document).ready(function() { handle: '.section-item .drag-handle', zIndex: 999, start: initiateHesitate, - drag: checkHoverState, + drag: generateCheckHoverState('.courseware-section.collapsed', ''), stop: removeHesitate, revert: "invalid" }); @@ -56,64 +58,100 @@ $(document).ready(function() { drop: onSectionReordered, greedy: true }); - -}); +}); CMS.HesitateEvent.toggleXpandHesitation = null; function initiateHesitate(event, ui) { CMS.HesitateEvent.toggleXpandHesitation = new CMS.HesitateEvent(expandSection, 'dragLeave', true); $('.collapsed').on('dragEnter', CMS.HesitateEvent.toggleXpandHesitation, CMS.HesitateEvent.toggleXpandHesitation.trigger); - $('.collapsed').each(function() { + $('.collapsed, .unit, .id-holder').each(function() { this.proportions = {width : this.offsetWidth, height : this.offsetHeight }; // reset b/c these were holding values from aborts this.isover = false; }); } -function checkHoverState(event, ui) { + +function computeIntersection(droppable, uiHelper, y) { + /* + * Test whether y falls within the bounds of the droppable on the Y axis + */ + // NOTE: this only judges y axis intersection b/c that's all we're doing right now + // don't expand the thing being carried + if (uiHelper.is(droppable)) { + return null; + } + + $.extend(droppable, {offset : $(droppable).offset()}); + + var t = droppable.offset.top, + b = t + droppable.proportions.height; + + if (t === b) { + // probably wrong values b/c invisible at the time of caching + droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight }; + b = t + droppable.proportions.height; + } + // equivalent to the intersects test + return (t < y && // Bottom Half + y < b ); // Top Half +} + +// NOTE: selectorsToShove is not currently being used but I left this code as it did work but not well +function generateCheckHoverState(selectorsToOpen, selectorsToShove) { + return function(event, ui) { // copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect var draggable = $(this).data("ui-draggable"), - x1 = (draggable.positionAbs || draggable.position.absolute).left + (draggable.helperProportions.width / 2), - y1 = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2); - $('.collapsed').each(function() { - // don't expand the thing being carried - if (ui.helper.is(this)) { - return; - } - - $.extend(this, {offset : $(this).offset()}); + centerY = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2); + $(selectorsToOpen).each(function() { + var intersects = computeIntersection(this, ui.helper, centerY), + c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null); - var droppable = this, - l = droppable.offset.left, - r = l + droppable.proportions.width, - t = droppable.offset.top, - b = t + droppable.proportions.height; - - if (l === r) { - // probably wrong values b/c invisible at the time of caching - droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight }; - r = l + droppable.proportions.width; - b = t + droppable.proportions.height; - } - // equivalent to the intersects test - var intersects = (l < x1 && // Right Half - x1 < r && // Left Half - t < y1 && // Bottom Half - y1 < b ), // Top Half + if(!c) { + return; + } - c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null); - - if(!c) { - return; - } - - this[c] = true; - this[c === "isout" ? "isover" : "isout"] = false; - $(this).trigger(c === "isover" ? "dragEnter" : "dragLeave"); + this[c] = true; + this[c === "isout" ? "isover" : "isout"] = false; + $(this).trigger(c === "isover" ? "dragEnter" : "dragLeave"); }); + + $(selectorsToShove).each(function() { + var intersectsBottom = computeIntersection(this, ui.helper, (draggable.positionAbs || draggable.position.absolute).top); + + if ($(this).hasClass('ui-dragging-pushup')) { + if (!intersectsBottom) { + console.log('not up', $(this).data('id')); + $(this).removeClass('ui-dragging-pushup'); + } + } + else if (intersectsBottom) { + console.log('up', $(this).data('id')); + $(this).addClass('ui-dragging-pushup'); + } + + var intersectsTop = computeIntersection(this, ui.helper, + (draggable.positionAbs || draggable.position.absolute).top + draggable.helperProportions.height); + + if ($(this).hasClass('ui-dragging-pushdown')) { + if (!intersectsTop) { + console.log('not down', $(this).data('id')); + $(this).removeClass('ui-dragging-pushdown'); + } + } + else if (intersectsTop) { + console.log('down', $(this).data('id')); + $(this).addClass('ui-dragging-pushdown'); + } + + }); + } } + function removeHesitate(event, ui) { $('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger); + $('.ui-dragging-pushdown').removeClass('ui-dragging-pushdown'); + $('.ui-dragging-pushup').removeClass('ui-dragging-pushup'); CMS.HesitateEvent.toggleXpandHesitation = null; } @@ -189,3 +227,5 @@ function _handleReorder(event, ui, parentIdField, childrenSelector) { }); } + + diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 826b385dff..377fe587f5 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -1,216 +1,231 @@ if (!CMS.Views['Settings']) CMS.Views.Settings = {}; -// TODO move to common place +//TODO move to common place CMS.Views.ValidatingView = Backbone.View.extend({ - // Intended as an abstract class which catches validation errors on the model and - // decorates the fields. Needs wiring per class, but this initialization shows how - // either have your init call this one or copy the contents - initialize : function() { - this.model.on('error', this.handleValidationError, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - }, - - errorTemplate : _.template('<%= message %>'), + // Intended as an abstract class which catches validation errors on the model and + // decorates the fields. Needs wiring per class, but this initialization shows how + // either have your init call this one or copy the contents + initialize : function() { + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, - events : { - "blur input" : "clearValidationErrors", - "blur textarea" : "clearValidationErrors" - }, - fieldToSelectorMap : { - // Your subclass must populate this w/ all of the model keys and dom selectors - // which may be the subjects of validation errors - }, - _cacheValidationErrors : [], - handleValidationError : function(model, error) { - // error is object w/ fields and error strings - for (var field in error) { - var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); - this._cacheValidationErrors.push(ele); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').addClass('error'); - } - else $(ele).addClass('error'); - $(ele).parent().append(this.errorTemplate({message : error[field]})); - } - }, - - clearValidationErrors : function() { - // error is object w/ fields and error strings - while (this._cacheValidationErrors.length > 0) { - var ele = this._cacheValidationErrors.pop(); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').removeClass('error'); - } - else $(ele).removeClass('error'); - $(ele).nextAll('.message-error').remove(); - } - }, - - saveIfChanged : function(event) { - // returns true if the value changed and was thus sent to server - var field = this.selectorToField[event.currentTarget.id]; - var currentVal = this.model.get(field); - var newVal = $(event.currentTarget).val(); - if (currentVal != newVal) { - this.clearValidationErrors(); - this.model.save(field, newVal, { error : CMS.ServerError}); - return true; - } - else return false; - } + errorTemplate : _.template('<%= message %>'), + + events : { + "change input" : "clearValidationErrors", + "change textarea" : "clearValidationErrors" + }, + fieldToSelectorMap : { + // Your subclass must populate this w/ all of the model keys and dom selectors + // which may be the subjects of validation errors + }, + _cacheValidationErrors : [], + handleValidationError : function(model, error) { + console.log('validation', model, error); + // error is object w/ fields and error strings + for (var field in error) { + var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); + this._cacheValidationErrors.push(ele); + if ($(ele).is('div')) { + // put error on the contained inputs + $(ele).find('input, textarea').addClass('error'); + } + else $(ele).addClass('error'); + $(ele).parent().append(this.errorTemplate({message : error[field]})); + } + }, + + clearValidationErrors : function() { + // error is object w/ fields and error strings + while (this._cacheValidationErrors.length > 0) { + var ele = this._cacheValidationErrors.pop(); + if ($(ele).is('div')) { + // put error on the contained inputs + $(ele).find('input, textarea').removeClass('error'); + } + else $(ele).removeClass('error'); + $(ele).nextAll('.message-error').remove(); + } + }, + + saveIfChanged : function(event) { + // returns true if the value changed and was thus sent to server + var field = this.selectorToField[event.currentTarget.id]; + var currentVal = this.model.get(field); + var newVal = $(event.currentTarget).val(); + if (currentVal != newVal) { + this.clearValidationErrors(); + this.model.save(field, newVal, { error : CMS.ServerError}); + return true; + } + else return false; + } }); CMS.Views.Settings.Main = Backbone.View.extend({ - // Model class is CMS.Models.Settings.CourseSettings - // allow navigation between the tabs - events: { - 'click .settings-page-menu a': "showSettingsTab", - 'mouseover #timezone' : "updateTime" - }, - - currentTab: null, - subviews: {}, // indexed by tab name + // Model class is CMS.Models.Settings.CourseSettings + // allow navigation between the tabs + events: { + 'click .settings-page-menu a': "showSettingsTab", + 'mouseover #timezone' : "updateTime" + }, - initialize: function() { - // load templates - this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section'); - // create the initial subview - this.subviews[this.currentTab] = this.createSubview(); - - // fill in fields - this.$el.find("#course-name").val(this.model.get('courseLocation').get('name')); - this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org')); - this.$el.find("#course-number").val(this.model.get('courseLocation').get('course')); - this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); - this.$el.find(":input, textarea").focus(function() { - $("label[for='" + this.id + "']").addClass("is-focused"); - }).blur(function() { - $("label").removeClass("is-focused"); - }); - this.render(); - }, - - render: function() { - - // create any necessary subviews and put them onto the page - if (!this.model.has(this.currentTab)) { - // TODO disable screen until fetch completes? - var cachethis = this; - this.model.retrieve(this.currentTab, function() { - cachethis.subviews[cachethis.currentTab] = cachethis.createSubview(); - cachethis.subviews[cachethis.currentTab].render(); - }); - } - else this.subviews[this.currentTab].render(); - - var dateIntrospect = new Date(); - this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); - - return this; - }, - - createSubview: function() { - switch (this.currentTab) { - case 'details': - return new CMS.Views.Settings.Details({ - el: this.$el.find('.settings-' + this.currentTab), - model: this.model.get(this.currentTab) - }); - case 'faculty': - break; - case 'grading': - return new CMS.Views.Settings.Grading({ - el: this.$el.find('.settings-' + this.currentTab), - model: this.model.get(this.currentTab) - }); - case 'problems': - break; - case 'discussions': - break; - } - }, - - updateTime : function(e) { - var now = new Date(); - var hours = now.getHours(); - var minutes = now.getMinutes(); - $(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") + - now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)"); - }, - - showSettingsTab: function(e) { - this.currentTab = $(e.target).attr('data-section'); - $('.settings-page-section > section').hide(); - $('.settings-' + this.currentTab).show(); - $('.settings-page-menu .is-shown').removeClass('is-shown'); - $(e.target).addClass('is-shown'); - // fetch model for the tab if not loaded already - this.render(); - } + currentTab: null, + subviews: {}, // indexed by tab name + + initialize: function() { + // load templates + this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section'); + // create the initial subview + this.subviews[this.currentTab] = this.createSubview(); + + // fill in fields + this.$el.find("#course-name").val(this.model.get('courseLocation').get('name')); + this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org')); + this.$el.find("#course-number").val(this.model.get('courseLocation').get('course')); + this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); + this.$el.find(":input, textarea").focus(function() { + $("label[for='" + this.id + "']").addClass("is-focused"); + }).blur(function() { + $("label").removeClass("is-focused"); + }); + this.render(); + }, + + render: function() { + + // create any necessary subviews and put them onto the page + if (!this.model.has(this.currentTab)) { + // TODO disable screen until fetch completes? + var cachethis = this; + this.model.retrieve(this.currentTab, function() { + cachethis.subviews[cachethis.currentTab] = cachethis.createSubview(); + cachethis.subviews[cachethis.currentTab].render(); + }); + } + else { + // Advanced (at least) model gets created at bootstrap but the view does not + if (!this.subviews[this.currentTab]) { + this.subviews[this.currentTab] = this.createSubview(); + } + this.subviews[this.currentTab].render(); + } + + var dateIntrospect = new Date(); + this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); + + return this; + }, + + createSubview: function() { + switch (this.currentTab) { + case 'details': + return new CMS.Views.Settings.Details({ + el: this.$el.find('.settings-' + this.currentTab), + model: this.model.get(this.currentTab) + }); + break; + case 'faculty': + break; + case 'grading': + return new CMS.Views.Settings.Grading({ + el: this.$el.find('.settings-' + this.currentTab), + model: this.model.get(this.currentTab) + }); + break; + case 'advanced': + return new CMS.Views.Settings.Advanced({ + el: this.$el.find('.settings-' + this.currentTab), + model: this.model.get(this.currentTab) + }); + break; + case 'problems': + break; + case 'discussions': + break; + } + }, + + updateTime : function(e) { + var now = new Date(); + var hours = now.getHours(); + var minutes = now.getMinutes(); + $(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") + + now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)"); + }, + + showSettingsTab: function(e) { + this.currentTab = $(e.target).data('section'); + $('.settings-page-section > section').hide(); + $('.settings-' + this.currentTab).show(); + $('.settings-page-menu .is-shown').removeClass('is-shown'); + $(e.target).addClass('is-shown'); + // fetch model for the tab if not loaded already + this.render(); + } }); CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ - // Model class is CMS.Models.Settings.CourseDetails - events : { - "blur input" : "updateModel", - "blur textarea" : "updateModel", - 'click .remove-course-syllabus' : "removeSyllabus", - 'click .new-course-syllabus' : 'assetSyllabus', - 'click .remove-course-introduction-video' : "removeVideo", - 'focus #course-overview' : "codeMirrorize" - }, - initialize : function() { - // TODO move the html frag to a loaded asset - this.fileAnchorTemplate = _.template(' 📄<%= filename %>'); - this.model.on('error', this.handleValidationError, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - }, - - render: function() { - this.setupDatePicker('start_date'); - this.setupDatePicker('end_date'); - this.setupDatePicker('enrollment_start'); - this.setupDatePicker('enrollment_end'); - - if (this.model.has('syllabus')) { - this.$el.find(this.fieldToSelectorMap['syllabus']).html( - this.fileAnchorTemplate({ - fullpath : this.model.get('syllabus'), - filename: 'syllabus'})); - this.$el.find('.remove-course-syllabus').show(); - } - else { - this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html(""); - this.$el.find('.remove-course-syllabus').hide(); - } - - this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); - this.codeMirrorize(null, $('#course-overview')[0]); - - this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); - if (this.model.has('intro_video')) { - this.$el.find('.remove-course-introduction-video').show(); - this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video')); - } - else this.$el.find('.remove-course-introduction-video').hide(); - - this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); - - return this; - }, - fieldToSelectorMap : { - 'start_date' : "course-start", - 'end_date' : 'course-end', - 'enrollment_start' : 'enrollment-start', - 'enrollment_end' : 'enrollment-end', - 'syllabus' : '.current-course-syllabus .doc-filename', - 'overview' : 'course-overview', - 'intro_video' : 'course-introduction-video', - 'effort' : "course-effort" - }, + // Model class is CMS.Models.Settings.CourseDetails + events : { + "change input" : "updateModel", + "change textarea" : "updateModel", + 'click .remove-course-syllabus' : "removeSyllabus", + 'click .new-course-syllabus' : 'assetSyllabus', + 'click .remove-course-introduction-video' : "removeVideo", + 'focus #course-overview' : "codeMirrorize" + }, + initialize : function() { + // TODO move the html frag to a loaded asset + this.fileAnchorTemplate = _.template(' 📄<%= filename %>'); + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, + + render: function() { + this.setupDatePicker('start_date'); + this.setupDatePicker('end_date'); + this.setupDatePicker('enrollment_start'); + this.setupDatePicker('enrollment_end'); + + if (this.model.has('syllabus')) { + this.$el.find(this.fieldToSelectorMap['syllabus']).html( + this.fileAnchorTemplate({ + fullpath : this.model.get('syllabus'), + filename: 'syllabus'})); + this.$el.find('.remove-course-syllabus').show(); + } + else { + this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html(""); + this.$el.find('.remove-course-syllabus').hide(); + } + + this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); + this.codeMirrorize(null, $('#course-overview')[0]); + + this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); + if (this.model.has('intro_video')) { + this.$el.find('.remove-course-introduction-video').show(); + this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video')); + } + else this.$el.find('.remove-course-introduction-video').hide(); + + this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); + + return this; + }, + fieldToSelectorMap : { + 'start_date' : "course-start", + 'end_date' : 'course-end', + 'enrollment_start' : 'enrollment-start', + 'enrollment_end' : 'enrollment-end', + 'syllabus' : '.current-course-syllabus .doc-filename', + 'overview' : 'course-overview', + 'intro_video' : 'course-introduction-video', + 'effort' : "course-effort" + }, setupDatePicker: function (fieldName) { var cacheModel = this.model; @@ -227,7 +242,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ time = 0; } var newVal = new Date(date.getTime() + time * 1000); - if (cacheModel.get(fieldName).getTime() !== newVal.getTime()) { + if (!cacheModel.has(fieldName) || cacheModel.get(fieldName).getTime() !== newVal.getTime()) { cacheModel.save(fieldName, newVal, { error: CMS.ServerError}); } } @@ -245,58 +260,58 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ datefield.datepicker('setDate', this.model.get(fieldName)); if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName)); }, - - updateModel: function(event) { - switch (event.currentTarget.id) { - case 'course-start-date': // handled via onSelect method - case 'course-end-date': - case 'course-enrollment-start-date': - case 'course-enrollment-end-date': - break; - case 'course-overview': - // handled via code mirror - break; + updateModel: function(event) { + switch (event.currentTarget.id) { + case 'course-start-date': // handled via onSelect method + case 'course-end-date': + case 'course-enrollment-start-date': + case 'course-enrollment-end-date': + break; - case 'course-effort': - this.saveIfChanged(event); - break; - case 'course-introduction-video': - this.clearValidationErrors(); - var previewsource = this.model.save_videosource($(event.currentTarget).val()); - this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource); - if (this.model.has('intro_video')) { - this.$el.find('.remove-course-introduction-video').show(); - } - else { - this.$el.find('.remove-course-introduction-video').hide(); - } - break; - - default: - break; - } - - }, - - removeSyllabus: function() { - if (this.model.has('syllabus')) this.model.save({'syllabus': null}, - { error : CMS.ServerError}); - }, - - assetSyllabus : function() { - // TODO implement - }, - - removeVideo: function() { - if (this.model.has('intro_video')) { - this.model.save_videosource(null); - this.$el.find(".current-course-introduction-video iframe").attr("src", ""); - this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(""); - this.$el.find('.remove-course-introduction-video').hide(); - } - }, - codeMirrors : {}, + case 'course-overview': + // handled via code mirror + break; + + case 'course-effort': + this.saveIfChanged(event); + break; + case 'course-introduction-video': + this.clearValidationErrors(); + var previewsource = this.model.save_videosource($(event.currentTarget).val()); + this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource); + if (this.model.has('intro_video')) { + this.$el.find('.remove-course-introduction-video').show(); + } + else { + this.$el.find('.remove-course-introduction-video').hide(); + } + break; + + default: + break; + } + + }, + + removeSyllabus: function() { + if (this.model.has('syllabus')) this.model.save({'syllabus': null}, + { error : CMS.ServerError}); + }, + + assetSyllabus : function() { + // TODO implement + }, + + removeVideo: function() { + if (this.model.has('intro_video')) { + this.model.save_videosource(null); + this.$el.find(".current-course-introduction-video iframe").attr("src", ""); + this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(""); + this.$el.find('.remove-course-introduction-video').hide(); + } + }, + codeMirrors : {}, codeMirrorize: function (e, forcedTarget) { var thisTarget; if (forcedTarget) { @@ -316,42 +331,42 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ cachethis.clearValidationErrors(); var newVal = mirror.getValue(); if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal, - { error: CMS.ServerError}); + { error: CMS.ServerError}); } }); } } - + }); CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ - // Model class is CMS.Models.Settings.CourseGradingPolicy - events : { - "blur input" : "updateModel", - "blur textarea" : "updateModel", - "blur span[contenteditable=true]" : "updateDesignation", - "click .settings-extra header" : "showSettingsExtras", - "click .new-grade-button" : "addNewGrade", - "click .remove-button" : "removeGrade", - "click .add-grading-data" : "addAssignmentType" - }, - initialize : function() { - // load template for grading view - var self = this; + // Model class is CMS.Models.Settings.CourseGradingPolicy + events : { + "change input" : "updateModel", + "change textarea" : "updateModel", + "change span[contenteditable=true]" : "updateDesignation", + "click .settings-extra header" : "showSettingsExtras", + "click .new-grade-button" : "addNewGrade", + "click .remove-button" : "removeGrade", + "click .add-grading-data" : "addAssignmentType" + }, + initialize : function() { + // load template for grading view + var self = this; this.gradeCutoffTemplate = _.template('
  • ' + - '<%= descriptor %>' + - '' + - '<% if (removable) {%>remove<% ;} %>' + - '
  • '); + '<%= descriptor %>' + + '' + + '<% if (removable) {%>remove<% ;} %>' + + ''); // Instrument grading scale // convert cutoffs to inversely ordered list var modelCutoffs = this.model.get('grade_cutoffs'); for (var cutoff in modelCutoffs) { - this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)}); + this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)}); } this.descendingCutoffs = _.sortBy(this.descendingCutoffs, - function (gradeEle) { return -gradeEle['cutoff']; }); + function (gradeEle) { return -gradeEle['cutoff']; }); // Instrument grace period this.$el.find('#course-grading-graceperiod').timepicker(); @@ -359,330 +374,330 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ // instantiates an editor template for each update in the collection // Because this calls render, put it after everything which render may depend upon to prevent race condition. window.templateLoader.loadRemoteTemplate("course_grade_policy", - "/static/client_templates/course_grade_policy.html", - function (raw_template) { - self.template = _.template(raw_template); - self.render(); + "/static/client_templates/course_grade_policy.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); } ); - this.model.on('error', this.handleValidationError, this); - this.model.get('graders').on('remove', this.render, this); - this.model.get('graders').on('reset', this.render, this); - this.model.get('graders').on('add', this.render, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - }, - - render: function() { - // prevent bootstrap race condition by event dispatch - if (!this.template) return; - - // Create and render the grading type subs - var self = this; - var gradelist = this.$el.find('.course-grading-assignment-list'); - // Undo the double invocation error. At some point, fix the double invocation - $(gradelist).empty(); - var gradeCollection = this.model.get('graders'); - gradeCollection.each(function(gradeModel) { - $(gradelist).append(self.template({model : gradeModel })); - var newEle = gradelist.children().last(); - var newView = new CMS.Views.Settings.GraderView({el: newEle, - model : gradeModel, collection : gradeCollection }); - }); - - // render the grade cutoffs - this.renderCutoffBar(); - - var graceEle = this.$el.find('#course-grading-graceperiod'); - graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime - if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate()); - // remove any existing listeners to keep them from piling on b/c render gets called frequently - graceEle.off('change', this.setGracePeriod); - graceEle.on('change', this, this.setGracePeriod); - - return this; - }, - addAssignmentType : function(e) { - e.preventDefault(); - this.model.get('graders').push({}); - }, - fieldToSelectorMap : { - 'grace_period' : 'course-grading-graceperiod' - }, - setGracePeriod : function(event) { - event.data.clearValidationErrors(); - var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime')); - if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal, - { error : CMS.ServerError}); - }, - updateModel : function(event) { - if (!this.selectorToField[event.currentTarget.id]) return; + this.model.on('error', this.handleValidationError, this); + this.model.get('graders').on('remove', this.render, this); + this.model.get('graders').on('reset', this.render, this); + this.model.get('graders').on('add', this.render, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, - switch (this.selectorToField[event.currentTarget.id]) { - case 'grace_period': // handled above - break; + render: function() { + // prevent bootstrap race condition by event dispatch + if (!this.template) return; - default: - this.saveIfChanged(event); - break; - } - }, - - // Grade sliders attributes and methods - // Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ... - // The actual cutoff for each grade is the width % of the next lower grade; so, the hack here - // is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade - // starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F" - - // A does not have a drag bar (cannot change its upper limit) - // Need to insert new bars in right place. - GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators - descendingCutoffs : [], // array of { designation : , cutoff : } - gradeBarWidth : null, // cache of value since it won't change (more certain) - - renderCutoffBar: function() { - var gradeBar =this.$el.find('.grade-bar'); - this.gradeBarWidth = gradeBar.width(); - var gradelist = gradeBar.children('.grades'); - // HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x - gradelist.empty(); - var nextWidth = 100; // first width is 100% + // Create and render the grading type subs + var self = this; + var gradelist = this.$el.find('.course-grading-assignment-list'); + // Undo the double invocation error. At some point, fix the double invocation + $(gradelist).empty(); + var gradeCollection = this.model.get('graders'); + gradeCollection.each(function(gradeModel) { + $(gradelist).append(self.template({model : gradeModel })); + var newEle = gradelist.children().last(); + var newView = new CMS.Views.Settings.GraderView({el: newEle, + model : gradeModel, collection : gradeCollection }); + }); + + // render the grade cutoffs + this.renderCutoffBar(); + + var graceEle = this.$el.find('#course-grading-graceperiod'); + graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime + if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate()); + // remove any existing listeners to keep them from piling on b/c render gets called frequently + graceEle.off('change', this.setGracePeriod); + graceEle.on('change', this, this.setGracePeriod); + + return this; + }, + addAssignmentType : function(e) { + e.preventDefault(); + this.model.get('graders').push({}); + }, + fieldToSelectorMap : { + 'grace_period' : 'course-grading-graceperiod' + }, + setGracePeriod : function(event) { + event.data.clearValidationErrors(); + var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime')); + if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal, + { error : CMS.ServerError}); + }, + updateModel : function(event) { + if (!this.selectorToField[event.currentTarget.id]) return; + + switch (this.selectorToField[event.currentTarget.id]) { + case 'grace_period': // handled above + break; + + default: + this.saveIfChanged(event); + break; + } + }, + + // Grade sliders attributes and methods + // Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ... + // The actual cutoff for each grade is the width % of the next lower grade; so, the hack here + // is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade + // starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F" + + // A does not have a drag bar (cannot change its upper limit) + // Need to insert new bars in right place. + GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators + descendingCutoffs : [], // array of { designation : , cutoff : } + gradeBarWidth : null, // cache of value since it won't change (more certain) + + renderCutoffBar: function() { + var gradeBar =this.$el.find('.grade-bar'); + this.gradeBarWidth = gradeBar.width(); + var gradelist = gradeBar.children('.grades'); + // HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x + gradelist.empty(); + var nextWidth = 100; // first width is 100% // Can probably be simplified to one variable now. var removable = false; var draggable = false; // first and last are not removable, first is not draggable - _.each(this.descendingCutoffs, - function(cutoff, index) { - var newBar = this.gradeCutoffTemplate({ - descriptor : cutoff['designation'] , - width : nextWidth, - removable : removable }); - gradelist.append(newBar); - if (draggable) { - newBar = gradelist.children().last(); // get the dom object not the unparsed string - newBar.resizable({ - handles: "e", - containment : "parent", - start : this.startMoveClosure(), - resize : this.moveBarClosure(), - stop : this.stopDragClosure() - }); - } - // prepare for next - nextWidth = cutoff['cutoff']; - removable = true; // first is not removable, all others are - draggable = true; - }, - this); - // add fail which is not in data - var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(), - width : nextWidth, removable : false}); - $(failBar).find("span[contenteditable=true]").attr("contenteditable", false); - gradelist.append(failBar); - gradelist.children().last().resizable({ - handles: "e", - containment : "parent", - start : this.startMoveClosure(), - resize : this.moveBarClosure(), - stop : this.stopDragClosure() - }); - - this.renderGradeRanges(); - }, - - showSettingsExtras : function(event) { - $(event.currentTarget).toggleClass('active'); - $(event.currentTarget).siblings.toggleClass('is-shown'); - }, - + _.each(this.descendingCutoffs, + function(cutoff, index) { + var newBar = this.gradeCutoffTemplate({ + descriptor : cutoff['designation'] , + width : nextWidth, + removable : removable }); + gradelist.append(newBar); + if (draggable) { + newBar = gradelist.children().last(); // get the dom object not the unparsed string + newBar.resizable({ + handles: "e", + containment : "parent", + start : this.startMoveClosure(), + resize : this.moveBarClosure(), + stop : this.stopDragClosure() + }); + } + // prepare for next + nextWidth = cutoff['cutoff']; + removable = true; // first is not removable, all others are + draggable = true; + }, + this); + // add fail which is not in data + var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(), + width : nextWidth, removable : false}); + $(failBar).find("span[contenteditable=true]").attr("contenteditable", false); + gradelist.append(failBar); + gradelist.children().last().resizable({ + handles: "e", + containment : "parent", + start : this.startMoveClosure(), + resize : this.moveBarClosure(), + stop : this.stopDragClosure() + }); - startMoveClosure : function() { - // set min/max widths - var cachethis = this; - var widthPerPoint = cachethis.gradeBarWidth / 100; - return function(event, ui) { - var barIndex = ui.element.index(); - // min and max represent limits not labels (note, can's make smaller than 3 points wide) - var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3); - // minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it - var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97); - ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint}); - }; - }, + this.renderGradeRanges(); + }, - moveBarClosure : function() { - // 0th ele doesn't have a bar; so, will never invoke this - var cachethis = this; - return function(event, ui) { - var barIndex = ui.element.index(); - // min and max represent limits not labels (note, can's make smaller than 3 points wide) - var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3); - // minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it - var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100); - var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max); - cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage); - cachethis.renderGradeRanges(); - }; - }, - - renderGradeRanges: function() { - // the labels showing the range e.g., 71-80 - var cutoffs = this.descendingCutoffs; - this.$el.find('.range').each(function(i) { - var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0); - var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100); - $(this).text(min + '-' + max); - }); - }, - - stopDragClosure: function() { - var cachethis = this; - return function(event, ui) { - // for some reason the resize is setting height to 0 - cachethis.saveCutoffs(); - }; - }, - - saveCutoffs: function() { - this.model.save('grade_cutoffs', - _.reduce(this.descendingCutoffs, - function(object, cutoff) { - object[cutoff['designation']] = cutoff['cutoff'] / 100.0; - return object; - }, - {}), - { error : CMS.ServerError}); - }, - - addNewGrade: function(e) { - e.preventDefault(); - var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades - if(gradeLength > 3) { - // TODO shouldn't we disable the button - return; - } - var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff']; - // going to split the grade above the insertion point in half leaving fail in same place - var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100); - var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2); - this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth}); - this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth); - - var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength], - width : targetWidth, removable : true }); - var gradeDom = this.$el.find('.grades'); - gradeDom.children().last().before($newGradeBar); - var newEle = gradeDom.children()[gradeLength]; - $(newEle).resizable({ - handles: "e", - containment : "parent", - start : this.startMoveClosure(), - resize : this.moveBarClosure(), - stop : this.stopDragClosure() - }); - - // Munge existing grade labels? - // If going from Pass/Fail to 3 levels, change to Pass to A - if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') { - this.descendingCutoffs[0]['designation'] = this.GRADES[0]; - this.setTopGradeLabel(); - } - this.setFailLabel(); - - this.renderGradeRanges(); - this.saveCutoffs(); - }, - - removeGrade: function(e) { - e.preventDefault(); - var domElement = $(e.currentTarget).closest('li'); - var index = domElement.index(); - // copy the boundary up to the next higher grade then remove - this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff']; - this.descendingCutoffs.splice(index, 1); - domElement.remove(); - - if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) { - this.descendingCutoffs[0]['designation'] = 'Pass'; - this.setTopGradeLabel(); - } - this.setFailLabel(); - this.renderGradeRanges(); - this.saveCutoffs(); - }, - - updateDesignation: function(e) { - var index = $(e.currentTarget).closest('li').index(); - this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html(); - this.saveCutoffs(); - }, + showSettingsExtras : function(event) { + $(event.currentTarget).toggleClass('active'); + $(event.currentTarget).siblings.toggleClass('is-shown'); + }, - failLabel: function() { - if (this.descendingCutoffs.length === 1) return 'Fail'; - else return 'F'; - }, - setFailLabel: function() { - this.$el.find('.grades .letter-grade').last().html(this.failLabel()); - }, - setTopGradeLabel: function() { - this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']); - } + + startMoveClosure : function() { + // set min/max widths + var cachethis = this; + var widthPerPoint = cachethis.gradeBarWidth / 100; + return function(event, ui) { + var barIndex = ui.element.index(); + // min and max represent limits not labels (note, can's make smaller than 3 points wide) + var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3); + // minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it + var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97); + ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint}); + }; + }, + + moveBarClosure : function() { + // 0th ele doesn't have a bar; so, will never invoke this + var cachethis = this; + return function(event, ui) { + var barIndex = ui.element.index(); + // min and max represent limits not labels (note, can's make smaller than 3 points wide) + var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3); + // minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it + var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100); + var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max); + cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage); + cachethis.renderGradeRanges(); + }; + }, + + renderGradeRanges: function() { + // the labels showing the range e.g., 71-80 + var cutoffs = this.descendingCutoffs; + this.$el.find('.range').each(function(i) { + var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0); + var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100); + $(this).text(min + '-' + max); + }); + }, + + stopDragClosure: function() { + var cachethis = this; + return function(event, ui) { + // for some reason the resize is setting height to 0 + cachethis.saveCutoffs(); + }; + }, + + saveCutoffs: function() { + this.model.save('grade_cutoffs', + _.reduce(this.descendingCutoffs, + function(object, cutoff) { + object[cutoff['designation']] = cutoff['cutoff'] / 100.0; + return object; + }, + {}), + { error : CMS.ServerError}); + }, + + addNewGrade: function(e) { + e.preventDefault(); + var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades + if(gradeLength > 3) { + // TODO shouldn't we disable the button + return; + } + var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff']; + // going to split the grade above the insertion point in half leaving fail in same place + var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100); + var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2); + this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth}); + this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth); + + var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength], + width : targetWidth, removable : true }); + var gradeDom = this.$el.find('.grades'); + gradeDom.children().last().before($newGradeBar); + var newEle = gradeDom.children()[gradeLength]; + $(newEle).resizable({ + handles: "e", + containment : "parent", + start : this.startMoveClosure(), + resize : this.moveBarClosure(), + stop : this.stopDragClosure() + }); + + // Munge existing grade labels? + // If going from Pass/Fail to 3 levels, change to Pass to A + if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') { + this.descendingCutoffs[0]['designation'] = this.GRADES[0]; + this.setTopGradeLabel(); + } + this.setFailLabel(); + + this.renderGradeRanges(); + this.saveCutoffs(); + }, + + removeGrade: function(e) { + e.preventDefault(); + var domElement = $(e.currentTarget).closest('li'); + var index = domElement.index(); + // copy the boundary up to the next higher grade then remove + this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff']; + this.descendingCutoffs.splice(index, 1); + domElement.remove(); + + if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) { + this.descendingCutoffs[0]['designation'] = 'Pass'; + this.setTopGradeLabel(); + } + this.setFailLabel(); + this.renderGradeRanges(); + this.saveCutoffs(); + }, + + updateDesignation: function(e) { + var index = $(e.currentTarget).closest('li').index(); + this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html(); + this.saveCutoffs(); + }, + + failLabel: function() { + if (this.descendingCutoffs.length === 1) return 'Fail'; + else return 'F'; + }, + setFailLabel: function() { + this.$el.find('.grades .letter-grade').last().html(this.failLabel()); + }, + setTopGradeLabel: function() { + this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']); + } }); CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ - // Model class is CMS.Models.Settings.CourseGrader - events : { - "blur input" : "updateModel", - "blur textarea" : "updateModel", - "click .remove-grading-data" : "deleteModel" - }, - initialize : function() { - this.model.on('error', this.handleValidationError, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - this.render(); - }, - - render: function() { - return this; - }, - fieldToSelectorMap : { - 'type' : 'course-grading-assignment-name', - 'short_label' : 'course-grading-assignment-shortname', - 'min_count' : 'course-grading-assignment-totalassignments', - 'drop_count' : 'course-grading-assignment-droppable', - 'weight' : 'course-grading-assignment-gradeweight' - }, - updateModel : function(event) { - // HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving - // this in out of paranoia. If this error ever happens, the user will get a warning that they cannot - // give 2 assignments the same name.] - if (!this.model.collection) { - this.model.collection = this.collection; - } - - switch (event.currentTarget.id) { - case 'course-grading-assignment-totalassignments': - this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val()); - this.saveIfChanged(event); - break; - case 'course-grading-assignment-name': - var oldName = this.model.get('type'); - if (this.saveIfChanged(event) && !_.isEmpty(oldName)) { - // overload the error display logic - this._cacheValidationErrors.push(event.currentTarget); - $(event.currentTarget).parent().append( - this.errorTemplate({message : 'For grading to work, you must change all "' + oldName + - '" subsections to "' + this.model.get('type') + '".'})); - } - break; - default: - this.saveIfChanged(event); - break; - } - }, - deleteModel : function(e) { - this.model.destroy( - { error : CMS.ServerError}); - e.preventDefault(); - } - + // Model class is CMS.Models.Settings.CourseGrader + events : { + "change input" : "updateModel", + "change textarea" : "updateModel", + "click .remove-grading-data" : "deleteModel" + }, + initialize : function() { + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + this.render(); + }, + + render: function() { + return this; + }, + fieldToSelectorMap : { + 'type' : 'course-grading-assignment-name', + 'short_label' : 'course-grading-assignment-shortname', + 'min_count' : 'course-grading-assignment-totalassignments', + 'drop_count' : 'course-grading-assignment-droppable', + 'weight' : 'course-grading-assignment-gradeweight' + }, + updateModel : function(event) { + // HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving + // this in out of paranoia. If this error ever happens, the user will get a warning that they cannot + // give 2 assignments the same name.] + if (!this.model.collection) { + this.model.collection = this.collection; + } + + switch (event.currentTarget.id) { + case 'course-grading-assignment-totalassignments': + this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val()); + this.saveIfChanged(event); + break; + case 'course-grading-assignment-name': + var oldName = this.model.get('type'); + if (this.saveIfChanged(event) && !_.isEmpty(oldName)) { + // overload the error display logic + this._cacheValidationErrors.push(event.currentTarget); + $(event.currentTarget).parent().append( + this.errorTemplate({message : 'For grading to work, you must change all "' + oldName + + '" subsections to "' + this.model.get('type') + '".'})); + } + break; + default: + this.saveIfChanged(event); + break; + } + }, + deleteModel : function(e) { + this.model.destroy( + { error : CMS.ServerError}); + e.preventDefault(); + } + }); \ No newline at end of file diff --git a/cms/static/sass/_courseware.scss b/cms/static/sass/_courseware.scss index 4ea110f4c8..f2bd25c601 100644 --- a/cms/static/sass/_courseware.scss +++ b/cms/static/sass/_courseware.scss @@ -1,90 +1,90 @@ input.courseware-unit-search-input { - float: left; - width: 260px; - background-color: #fff; + float: left; + width: 260px; + background-color: #fff; } .branch { - .section-item { - @include clearfix(); + .section-item { + @include clearfix(); - .details { - display: block; - float: left; - margin-bottom: 0; - width: 650px; - } + .details { + display: block; + float: left; + margin-bottom: 0; + width: 650px; + } - .gradable-status { - float: right; - position: relative; - top: -4px; - right: 50px; - width: 145px; + .gradable-status { + float: right; + position: relative; + top: -4px; + right: 50px; + width: 145px; - .status-label { - position: absolute; - top: 2px; - right: -5px; - display: none; - width: 110px; - padding: 5px 40px 5px 10px; - @include border-radius(3px); - color: $lightGrey; - text-align: right; - font-size: 12px; - font-weight: bold; - line-height: 16px; - } + .status-label { + position: absolute; + top: 2px; + right: -5px; + display: none; + width: 110px; + padding: 5px 40px 5px 10px; + @include border-radius(3px); + color: $lightGrey; + text-align: right; + font-size: 12px; + font-weight: bold; + line-height: 16px; + } - .menu-toggle { - z-index: 10; - position: absolute; - top: 0; - right: 5px; - padding: 5px; - color: $mediumGrey; + .menu-toggle { + z-index: 10; + position: absolute; + top: 0; + right: 5px; + padding: 5px; + color: $mediumGrey; - &:hover, &.is-active { - color: $blue; - } - } + &:hover, &.is-active { + color: $blue; + } + } - .menu { - z-index: 1; - display: none; - opacity: 0.0; - position: absolute; - top: -1px; - left: 5px; - margin: 0; - padding: 8px 12px; - background: $white; - border: 1px solid $mediumGrey; - font-size: 12px; - @include border-radius(4px); - @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); - @include transition(opacity .15s); + .menu { + z-index: 1; + display: none; + opacity: 0.0; + position: absolute; + top: -1px; + left: 5px; + margin: 0; + padding: 8px 12px; + background: $white; + border: 1px solid $mediumGrey; + font-size: 12px; + @include border-radius(4px); + @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); + @include transition(opacity .15s); - li { - width: 115px; - margin-bottom: 3px; - padding-bottom: 3px; - border-bottom: 1px solid $lightGrey; + li { + width: 115px; + margin-bottom: 3px; + padding-bottom: 3px; + border-bottom: 1px solid $lightGrey; - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border: none; + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border: none; - a { - color: $darkGrey; - } - } - } + a { + color: $darkGrey; + } + } + } a { color: $blue; @@ -127,262 +127,262 @@ input.courseware-unit-search-input { .courseware-section { - position: relative; - background: #fff; - border-radius: 3px; - border: 1px solid $mediumGrey; - margin-top: 15px; - padding-bottom: 12px; - @include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1)); + position: relative; + background: #fff; + border-radius: 3px; + border: 1px solid $mediumGrey; + margin-top: 15px; + padding-bottom: 12px; + @include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1)); - &:first-child { - margin-top: 0; - } + &:first-child { + margin-top: 0; + } - &.collapsed { - padding-bottom: 0; - } + &.collapsed { + padding-bottom: 0; + } - label { - float: left; - line-height: 29px; - } + label { + float: left; + line-height: 29px; + } - .datepair { - float: left; - margin-left: 10px; - } + .datepair { + float: left; + margin-left: 10px; + } - .section-published-date { - position: absolute; - top: 19px; - right: 90px; - padding: 4px 10px; - border-radius: 3px; - background: $lightGrey; - text-align: right; + .section-published-date { + position: absolute; + top: 19px; + right: 90px; + padding: 4px 10px; + border-radius: 3px; + background: $lightGrey; + text-align: right; - .published-status { - font-size: 12px; - margin-right: 15px; + .published-status { + font-size: 12px; + margin-right: 15px; - strong { - font-weight: bold; - } - } + strong { + font-weight: bold; + } + } - .schedule-button { - @include blue-button; - } + .schedule-button { + @include blue-button; + } - .edit-button { - @include blue-button; - } + .edit-button { + @include blue-button; + } - .schedule-button, - .edit-button { - font-size: 11px; - padding: 3px 15px 5px; - } - } + .schedule-button, + .edit-button { + font-size: 11px; + padding: 3px 15px 5px; + } + } - .datepair .date, - .datepair .time { - padding-left: 0; - padding-right: 0; - border: none; - background: none; - @include box-shadow(none); - font-size: 13px; - font-weight: bold; - color: $blue; - cursor: pointer; - } + .datepair .date, + .datepair .time { + padding-left: 0; + padding-right: 0; + border: none; + background: none; + @include box-shadow(none); + font-size: 13px; + font-weight: bold; + color: $blue; + cursor: pointer; + } - .datepair .date { - width: 80px; - } + .datepair .date { + width: 80px; + } - .datepair .time { - width: 65px; - } + .datepair .time { + width: 65px; + } - &.collapsed .subsection-list, - .collapsed .subsection-list, - .collapsed > ol { - display: none !important; - } + &.collapsed .subsection-list, + .collapsed .subsection-list, + .collapsed > ol { + display: none !important; + } - header { - min-height: 75px; - @include clearfix(); + header { + min-height: 75px; + @include clearfix(); - .item-details, .section-published-date { + .item-details, .section-published-date { - } + } - .item-details { - display: inline-block; - padding: 20px 0 10px 0; - @include clearfix(); + .item-details { + display: inline-block; + padding: 20px 0 10px 0; + @include clearfix(); - .section-name { - float: left; - margin-right: 10px; - width: 350px; - font-size: 19px; - font-weight: bold; - color: $blue; - } + .section-name { + float: left; + margin-right: 10px; + width: 350px; + font-size: 19px; + font-weight: bold; + color: $blue; + } - .section-name-span { - cursor: pointer; - @include transition(color .15s); + .section-name-span { + cursor: pointer; + @include transition(color .15s); - &:hover { - color: $orange; - } - } + &:hover { + color: $orange; + } + } - .section-name-edit { - position: relative; - width: 400px; - background: $white; + .section-name-edit { + position: relative; + width: 400px; + background: $white; - input { - font-size: 16px; - } - - .save-button { - @include blue-button; - padding: 7px 20px 7px; - margin-right: 5px; - } + input { + font-size: 16px; + } + + .save-button { + @include blue-button; + padding: 7px 20px 7px; + margin-right: 5px; + } - .cancel-button { - @include white-button; - padding: 7px 20px 7px; - } - } + .cancel-button { + @include white-button; + padding: 7px 20px 7px; + } + } - .section-published-date { - float: right; - width: 265px; - margin-right: 220px; - @include border-radius(3px); - background: $lightGrey; + .section-published-date { + float: right; + width: 265px; + margin-right: 220px; + @include border-radius(3px); + background: $lightGrey; - .published-status { - font-size: 12px; - margin-right: 15px; + .published-status { + font-size: 12px; + margin-right: 15px; - strong { - font-weight: bold; - } - } + strong { + font-weight: bold; + } + } - .schedule-button { - @include blue-button; - } + .schedule-button { + @include blue-button; + } - .edit-button { - @include blue-button; - } + .edit-button { + @include blue-button; + } - .schedule-button, - .edit-button { - font-size: 11px; - padding: 3px 15px 5px; - - } - } + .schedule-button, + .edit-button { + font-size: 11px; + padding: 3px 15px 5px; + + } + } - .gradable-status { - position: absolute; - top: 20px; - right: 70px; - width: 145px; + .gradable-status { + position: absolute; + top: 20px; + right: 70px; + width: 145px; - .status-label { - position: absolute; - top: 0; - right: 2px; - display: none; - width: 100px; - padding: 10px 35px 10px 10px; - @include border-radius(3px); - background: $lightGrey; - color: $lightGrey; - text-align: right; - font-size: 12px; - font-weight: bold; - line-height: 16px; - } + .status-label { + position: absolute; + top: 0; + right: 2px; + display: none; + width: 100px; + padding: 10px 35px 10px 10px; + @include border-radius(3px); + background: $lightGrey; + color: $lightGrey; + text-align: right; + font-size: 12px; + font-weight: bold; + line-height: 16px; + } - .menu-toggle { - z-index: 10; - position: absolute; - top: 2px; - right: 5px; - padding: 5px; - color: $lightGrey; + .menu-toggle { + z-index: 10; + position: absolute; + top: 2px; + right: 5px; + padding: 5px; + color: $lightGrey; - &:hover, &.is-active { - color: $blue; - } - } + &:hover, &.is-active { + color: $blue; + } + } - .menu { - z-index: 1; - display: none; - opacity: 0.0; - position: absolute; - top: -1px; - left: 2px; - margin: 0; - padding: 8px 12px; - background: $white; - border: 1px solid $mediumGrey; - font-size: 12px; - @include border-radius(4px); - @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); - @include transition(opacity .15s); - @include transition(display .15s); + .menu { + z-index: 1; + display: none; + opacity: 0.0; + position: absolute; + top: -1px; + left: 2px; + margin: 0; + padding: 8px 12px; + background: $white; + border: 1px solid $mediumGrey; + font-size: 12px; + @include border-radius(4px); + @include box-shadow(0 1px 2px rgba(0, 0, 0, .2)); + @include transition(opacity .15s); + @include transition(display .15s); - li { - width: 115px; - margin-bottom: 3px; - padding-bottom: 3px; - border-bottom: 1px solid $lightGrey; + li { + width: 115px; + margin-bottom: 3px; + padding-bottom: 3px; + border-bottom: 1px solid $lightGrey; - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border: none; + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border: none; - a { - color: $darkGrey; - } - } - } + a { + color: $darkGrey; + } + } + } - a { + a { - &.is-selected { - font-weight: bold; - } - } - } + &.is-selected { + font-weight: bold; + } + } + } - // dropdown state - &.is-active { + // dropdown state + &.is-active { - .menu { - z-index: 1000; - display: block; - opacity: 1.0; - } + .menu { + z-index: 1000; + display: block; + opacity: 1.0; + } .menu-toggle { @@ -408,256 +408,272 @@ input.courseware-unit-search-input { } } - .item-actions { - margin-top: 21px; - margin-right: 12px; + .item-actions { + margin-top: 21px; + margin-right: 12px; - .edit-button, - .delete-button { - margin-top: -3px; - } - } + .edit-button, + .delete-button { + margin-top: -3px; + } + } - .expand-collapse-icon { - float: left; - margin: 29px 6px 16px 16px; - @include transition(none); + .expand-collapse-icon { + float: left; + margin: 29px 6px 16px 16px; + @include transition(none); - &.expand { - background-position: 0 0; - } + &.expand { + background-position: 0 0; + } - &.collapsed { - - } - } + &.collapsed { + + } + } - .drag-handle { - margin-left: 11px; - } - } + .drag-handle { + margin-left: 11px; + } + } - h3 { - font-size: 19px; - font-weight: 700; - color: $blue; - } + h3 { + font-size: 19px; + font-weight: 700; + color: $blue; + } - .section-name-span { - cursor: pointer; - @include transition(color .15s); + .section-name-span { + cursor: pointer; + @include transition(color .15s); - &:hover { - color: $orange; - } - } + &:hover { + color: $orange; + } + } - .section-name-form { - margin-bottom: 15px; - } + .section-name-form { + margin-bottom: 15px; + } - .section-name-edit { - input { - font-size: 16px; - } - - .save-button { - @include blue-button; - padding: 7px 20px 7px; - margin-right: 5px; - } + .section-name-edit { + input { + font-size: 16px; + } + + .save-button { + @include blue-button; + padding: 7px 20px 7px; + margin-right: 5px; + } - .cancel-button { - @include white-button; - padding: 7px 20px 7px; - } - } + .cancel-button { + @include white-button; + padding: 7px 20px 7px; + } + } - h4 { - font-size: 12px; - color: #878e9d; + h4 { + font-size: 12px; + color: #878e9d; - strong { - font-weight: bold; - } - } + strong { + font-weight: bold; + } + } - .list-header { - @include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); - background-color: #ced2db; - border-radius: 3px 3px 0 0; - } + .list-header { + @include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); + background-color: #ced2db; + border-radius: 3px 3px 0 0; + } - .subsection-list { - margin: 0 12px; + .subsection-list { + margin: 0 12px; - > ol { - @include tree-view; - border-top-width: 0; - } - } + > ol { + @include tree-view; + border-top-width: 0; + } + } - &.new-section { - header { - height: auto; - @include clearfix(); - } + &.new-section { + header { + height: auto; + @include clearfix(); + } - .expand-collapse-icon { - visibility: hidden; - } - } + .expand-collapse-icon { + visibility: hidden; + } + } } .toggle-button-sections { - display: none; - position: relative; - float: right; - margin-top: 10px; + display: none; + position: relative; + float: right; + margin-top: 10px; - font-size: 13px; - color: $darkGrey; + font-size: 13px; + color: $darkGrey; - &.is-shown { - display: block; - } + &.is-shown { + display: block; + } - .ss-icon { - @include border-radius(20px); - position: relative; - top: -1px; - display: inline-block; - margin-right: 2px; - line-height: 5px; - font-size: 11px; - } + .ss-icon { + @include border-radius(20px); + position: relative; + top: -1px; + display: inline-block; + margin-right: 2px; + line-height: 5px; + font-size: 11px; + } - .label { - display: inline-block; - } + .label { + display: inline-block; + } } .new-section-name, .new-subsection-name-input { - width: 515px; + width: 515px; } .new-section-name-save, .new-subsection-name-save { - @include blue-button; - padding: 4px 20px 7px; - margin: 0 5px; - color: #fff !important; + @include blue-button; + padding: 4px 20px 7px; + margin: 0 5px; + color: #fff !important; } .new-section-name-cancel, .new-subsection-name-cancel { - @include white-button; - padding: 4px 20px 7px; - color: #8891a1 !important; + @include white-button; + padding: 4px 20px 7px; + color: #8891a1 !important; } .dummy-calendar { - display: none; - position: absolute; - top: 55px; - left: 110px; - z-index: 9999; - border: 1px solid #3C3C3C; - @include box-shadow(0 1px 15px rgba(0, 0, 0, .2)); + display: none; + position: absolute; + top: 55px; + left: 110px; + z-index: 9999; + border: 1px solid #3C3C3C; + @include box-shadow(0 1px 15px rgba(0, 0, 0, .2)); } .unit-name-input { - padding: 20px 40px; + padding: 20px 40px; - label { - display: block; - } + label { + display: block; + } - input { - width: 100%; - font-size: 20px; - } + input { + width: 100%; + font-size: 20px; + } } .preview { - background: url(../img/preview.jpg) center top no-repeat; + background: url(../img/preview.jpg) center top no-repeat; } .edit-subsection-publish-settings { - display: none; - position: fixed; - top: 100px; - left: 50%; - z-index: 99999; - width: 600px; - margin-left: -300px; - background: #fff; - text-align: center; + display: none; + position: fixed; + top: 100px; + left: 50%; + z-index: 99999; + width: 600px; + margin-left: -300px; + background: #fff; + text-align: center; - .settings { - padding: 40px; - } + .settings { + padding: 40px; + } - h3 { - font-size: 34px; - font-weight: 300; - } + h3 { + font-size: 34px; + font-weight: 300; + } - .picker { - margin: 30px 0 65px; - } + .picker { + margin: 30px 0 65px; + } - .description { - margin-top: 30px; - font-size: 14px; - line-height: 20px; - } + .description { + margin-top: 30px; + font-size: 14px; + line-height: 20px; + } - strong { - font-weight: 700; - } + strong { + font-weight: 700; + } - .start-date, - .start-time { - font-size: 19px; - } + .start-date, + .start-time { + font-size: 19px; + } - .save-button { - @include blue-button; - margin-right: 10px; - } + .save-button { + @include blue-button; + margin-right: 10px; + } - .cancel-button { - @include white-button; - } + .cancel-button { + @include white-button; + } - .save-button, - .cancel-button { - font-size: 16px; - } + .save-button, + .cancel-button { + font-size: 16px; + } } .collapse-all-button { - float: right; - margin-top: 10px; - font-size: 13px; - color: $darkGrey; + float: right; + margin-top: 10px; + font-size: 13px; + color: $darkGrey; } // sort/drag and drop .ui-droppable { - min-height: 20px; + @include transition (padding 0.5s ease-in-out 0s); + min-height: 20px; + padding: 0; - &.dropover { - padding-top: 10px; - padding-bottom: 10px; - } + &.dropover { + padding: 15px 0; + } +} + +.ui-draggable-dragging { + @include box-shadow(0 1px 2px rgba(0, 0, 0, .3)); + border: 1px solid $darkGrey; + opacity : 0.2; + &:hover { + opacity : 1.0; + .section-item { + background: $yellow !important; + } + } + + // hiding unit button - temporary fix until this semantically corrected + .new-unit-item { + display: none; + } } ol.ui-droppable .branch:first-child .section-item { - border-top: none; + border-top: none; } - - diff --git a/cms/static/sass/_unit.scss b/cms/static/sass/_unit.scss index d8ca1117e9..bdc76c811c 100644 --- a/cms/static/sass/_unit.scss +++ b/cms/static/sass/_unit.scss @@ -305,6 +305,7 @@ .wrapper-component-editor { z-index: 9999; position: relative; + background: $lightBluishGrey2; } .component-editor { diff --git a/cms/templates/settings.html b/cms/templates/settings.html index f70f3bfa91..b2ac3aa772 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -1,4 +1,5 @@ <%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> <%block name="bodyclass">settings <%block name="title">Settings @@ -15,24 +16,28 @@ from contentstore import utils + - - +