diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 3025ee78a4..80909dad7a 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -8,6 +8,8 @@ from django.core.urlresolvers import reverse from path import path from tempfile import mkdtemp import json +from fs.osfs import OSFS + from student.models import Registration from django.contrib.auth.models import User @@ -350,6 +352,33 @@ class ContentStoreTest(TestCase): def test_edit_unit_full(self): self.check_edit_unit('full') + def test_static_tab_reordering(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + course = ms.get_item(Location(['i4x','edX','full','course','6.002_Spring_2012', None])) + + # reverse the ordering + reverse_tabs = [] + for tab in course.tabs: + if tab['type'] == 'static_tab': + reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) + + resp = self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs':reverse_tabs}), "application/json") + + course = ms.get_item(Location(['i4x','edX','full','course','6.002_Spring_2012', None])) + + # compare to make sure that the tabs information is in the expected order after the server call + course_tabs = [] + for tab in course.tabs: + if tab['type'] == 'static_tab': + course_tabs.append('i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) + + self.assertEqual(reverse_tabs, course_tabs) + + + + def test_about_overrides(self): ''' This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html @@ -416,6 +445,17 @@ class ContentStoreTest(TestCase): items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) self.assertEqual(len(items), 0) + def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): + fs = OSFS(root_dir / 'test_export') + self.assertTrue(fs.exists(dirname)) + + query_loc = Location('i4x', location.org, location.course, category_name, None) + items = modulestore.get_items(query_loc) + + for item in items: + fs = OSFS(root_dir / ('test_export/' + dirname)) + self.assertTrue(fs.exists(item.location.name + filename_suffix)) + def test_export_course(self): ms = modulestore('direct') cs = contentstore() @@ -430,6 +470,16 @@ class ContentStoreTest(TestCase): # export out to a tempdir export_to_xml(ms, cs, location, root_dir, 'test_export') + # check for static tabs + self.verify_content_existence(ms, root_dir, location, 'tabs', 'static_tab', '.html') + + # check for custom_tags + self.verify_content_existence(ms, root_dir, location, 'info', 'course_info', '.html') + + # check for custom_tags + self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template') + + # remove old course delete_course(ms, cs, location) @@ -445,6 +495,7 @@ class ContentStoreTest(TestCase): shutil.rmtree(root_dir) + def test_course_handouts_rewrites(self): ms = modulestore('direct') cs = contentstore() diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 816ccab091..f70164138d 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -261,7 +261,6 @@ def edit_unit(request, location): break lms_link = get_lms_link_for_item(item.location) - preview_lms_link = get_lms_link_for_item(item.location, preview=True) component_templates = defaultdict(list) @@ -903,6 +902,52 @@ def static_pages(request, org, course, coursename): def edit_static(request, org, course, coursename): return render_to_response('edit-static-page.html', {}) + +@login_required +@expect_json +def reorder_static_tabs(request): + tabs = request.POST['tabs'] + course = get_course_for_item(tabs[0]) + + if not has_access(request.user, course.location): + raise PermissionDenied() + + # 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() + + # load all reference tabs, return BadRequest if we can't find any of them + tab_items =[] + for tab in tabs: + item = modulestore('direct').get_item(Location(tab)) + if item is None: + return HttpResponseBadRequest() + + tab_items.append(item) + + # now just go through the existing course_tabs and re-order the static tabs + reordered_tabs = [] + 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'), + '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 + course.tabs = reordered_tabs + modulestore('direct').update_metadata(course.location, course.metadata) + return HttpResponse() + + @login_required @ensure_csrf_cookie def edit_tabs(request, org, course, coursename): @@ -914,12 +959,19 @@ def edit_tabs(request, org, course, coursename): if not has_access(request.user, location): raise PermissionDenied() - static_tabs = modulestore('direct').get_items(static_tabs_loc) - # see tabs have been uninitialized (e.g. supporing courses created before tab support in studio) if course_item.tabs is None or len(course_item.tabs) == 0: initialize_course_tabs(course_item) + # first get all static tabs from the tabs list + # we do this because this is also the order in which items are displayed in the LMS + static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab'] + + static_tabs = [] + for static_tab_ref in static_tabs_refs: + static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug']) + static_tabs.append(modulestore('direct').get_item(static_tab_loc)) + components = [ static_tab.location.url() for static_tab @@ -1326,7 +1378,6 @@ def import_course(request, org, course, name): @login_required def generate_export_course(request, org, course, name): location = ['i4x', org, course, 'course', name] - course_module = modulestore().get_item(location) # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() @@ -1373,3 +1424,10 @@ def export_course(request, org, course, name): 'active_tab': 'export', 'successful_import_redirect_url' : '' }) + +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 diff --git a/cms/envs/aws.py b/cms/envs/aws.py index b44baacb0b..48cfa3cf9a 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -5,8 +5,22 @@ import json from .common import * from logsettings import get_logger_config +import os -############################### ALWAYS THE SAME ################################ +# specified as an environment variable. Typically this is set +# in the service's upstart script and corresponds exactly to the service name. +# Service variants apply config differences via env and auth JSON files, +# the names of which correspond to the variant. +SERVICE_VARIANT = os.environ.get('SERVICE_VARIANT', None) + +# when not variant is specified we attempt to load an unvaried +# config set. +CONFIG_PREFIX = "" + +if SERVICE_VARIANT: + CONFIG_PREFIX = SERVICE_VARIANT + "." + +############### ALWAYS THE SAME ################################ DEBUG = False TEMPLATE_DEBUG = False @@ -14,9 +28,9 @@ EMAIL_BACKEND = 'django_ses.SESBackend' SESSION_ENGINE = 'django.contrib.sessions.backends.cache' DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' -########################### NON-SECURE ENV CONFIG ############################## +############# NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. -with open(ENV_ROOT / "cms.env.json") as env_file: +with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: ENV_TOKENS = json.load(env_file) LMS_BASE = ENV_TOKENS.get('LMS_BASE') @@ -35,15 +49,16 @@ for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): LOGGING = get_logger_config(LOG_DIR, logging_env=ENV_TOKENS['LOGGING_ENV'], syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), - debug=False) + debug=False, + service_variant=SERVICE_VARIANT) with open(ENV_ROOT / "repos.json") as repos_file: REPOS = json.load(repos_file) -############################## SECURE AUTH ITEMS ############################### +################ SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. -with open(ENV_ROOT / "cms.auth.json") as auth_file: +with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: AUTH_TOKENS = json.load(auth_file) AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json index ec596063a9..2249813b04 100644 --- a/cms/static/coffee/files.json +++ b/cms/static/coffee/files.json @@ -1,9 +1,12 @@ { "js_files": [ + "/static/js/vendor/RequireJS.js", "/static/js/vendor/jquery.min.js", + "/static/js/vendor/jquery-ui.min.js", + "/static/js/vendor/jquery.ui.draggable.js", + "/static/js/vendor/jquery.cookie.js", "/static/js/vendor/json2.js", "/static/js/vendor/underscore-min.js", - "/static/js/vendor/backbone-min.js", - "/static/js/vendor/RequireJS.js" + "/static/js/vendor/backbone-min.js" ] } diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 1fbc6ffa7f..5a826c1794 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -15,7 +15,7 @@ class CMS.Views.TabsEdit extends Backbone.View @$('.components').sortable( handle: '.drag-handle' - update: (event, ui) => alert 'not yet implemented!' + update: @tabMoved helper: 'clone' opacity: '0.5' placeholder: 'component-placeholder' @@ -24,6 +24,20 @@ class CMS.Views.TabsEdit extends Backbone.View items: '> .component' ) + tabMoved: (event, ui) => + tabs = [] + @$('.component').each((idx, element) => + tabs.push($(element).data('id')) + ) + $.ajax({ + type:'POST', + url: '/reorder_static_tabs', + data: JSON.stringify({ + tabs : tabs + }), + contentType: 'application/json' + }) + addNewTab: (event) => event.preventDefault() diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 41c1ee3cdb..7e55d2b8d8 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -80,64 +80,6 @@ $(document).ready(function() { $('.import .file-input').click(); }); - // making the unit list draggable. Note: sortable didn't work b/c it considered - // drop points which the user hovered over as destinations and proactively changed - // the dom; so, if the user subsequently dropped at an illegal spot, the reversion - // point was the last dom change. - $('.unit').draggable({ - axis: 'y', - handle: '.drag-handle', - zIndex: 999, - start: initiateHesitate, - drag: checkHoverState, - stop: removeHesitate, - revert: "invalid" - }); - - // Subsection reordering - $('.id-holder').draggable({ - axis: 'y', - handle: '.section-item .drag-handle', - zIndex: 999, - start: initiateHesitate, - drag: checkHoverState, - stop: removeHesitate, - revert: "invalid" - }); - - // Section reordering - $('.courseware-section').draggable({ - axis: 'y', - handle: 'header .drag-handle', - stack: '.courseware-section', - revert: "invalid" - }); - - - $('.sortable-unit-list').droppable({ - accept : '.unit', - greedy: true, - tolerance: "pointer", - hoverClass: "dropover", - drop: onUnitReordered - }); - $('.subsection-list > ol').droppable({ - // why don't we have a more useful class for subsections than id-holder? - accept : '.id-holder', // '.unit, .id-holder', - tolerance: "pointer", - hoverClass: "dropover", - drop: onSubsectionReordered, - greedy: true - }); - - // Section reordering - $('.courseware-overview').droppable({ - accept : '.courseware-section', - tolerance: "pointer", - drop: onSectionReordered, - greedy: true - }); - $('.new-course-button').bind('click', addNewCourse); // section name editing @@ -279,136 +221,6 @@ function removePolicyMetadata(e) { saveSubsection() } -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() { - this.proportions = {width : this.offsetWidth, height : this.offsetHeight }; - // reset b/c these were holding values from aborts - this.isover = false; - }); -} -function checkHoverState(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()}); - - 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 - - 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"); - }); -} -function removeHesitate(event, ui) { - $('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger); - CMS.HesitateEvent.toggleXpandHesitation = null; -} - -function expandSection(event) { - $(event.delegateTarget).removeClass('collapsed', 400); - // don't descend to icon's on children (which aren't under first child) only to this element's icon - $(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse'); -} - -function onUnitReordered(event, ui) { - // a unit's been dropped on this subsection, - // figure out where it came from and where it slots in. - _handleReorder(event, ui, 'subsection-id', 'li:.leaf'); -} - -function onSubsectionReordered(event, ui) { - // a subsection has been dropped on this section, - // figure out where it came from and where it slots in. - _handleReorder(event, ui, 'section-id', 'li:.branch'); -} - -function onSectionReordered(event, ui) { - // a section moved w/in the overall (cannot change course via this, so no parentage change possible, just order) - _handleReorder(event, ui, 'course-id', '.courseware-section'); -} - -function _handleReorder(event, ui, parentIdField, childrenSelector) { - // figure out where it came from and where it slots in. - var subsection_id = $(event.target).data(parentIdField); - var _els = $(event.target).children(childrenSelector); - var children = _els.map(function(idx, el) { return $(el).data('id'); }).get(); - // if new to this parent, figure out which parent to remove it from and do so - if (!_.contains(children, ui.draggable.data('id'))) { - var old_parent = ui.draggable.parent(); - var old_children = old_parent.children(childrenSelector).map(function(idx, el) { return $(el).data('id'); }).get(); - old_children = _.without(old_children, ui.draggable.data('id')); - $.ajax({ - url: "/save_item", - type: "POST", - dataType: "json", - contentType: "application/json", - data:JSON.stringify({ 'id' : old_parent.data(parentIdField), 'children' : old_children}) - }); - } - else { - // staying in same parent - // remove so that the replacement in the right place doesn't double it - children = _.without(children, ui.draggable.data('id')); - } - // add to this parent (figure out where) - for (var i = 0; i < _els.length; i++) { - if (!ui.draggable.is(_els[i]) && ui.offset.top < $(_els[i]).offset().top) { - // insert at i in children and _els - ui.draggable.insertBefore($(_els[i])); - // TODO figure out correct way to have it remove the style: top:n; setting (and similar line below) - ui.draggable.attr("style", "position:relative;"); - children.splice(i, 0, ui.draggable.data('id')); - break; - } - } - // see if it goes at end (the above loop didn't insert it) - if (!_.contains(children, ui.draggable.data('id'))) { - $(event.target).append(ui.draggable); - ui.draggable.attr("style", "position:relative;"); // STYLE hack too - children.push(ui.draggable.data('id')); - } - $.ajax({ - url: "/save_item", - type: "POST", - dataType: "json", - contentType: "application/json", - data:JSON.stringify({ 'id' : subsection_id, 'children' : children}) - }); - -} - function getEdxTimeFromDateTimeVals(date_val, time_val, format) { var edxTimeStr = null; diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index ab80179142..bdbb46b3b1 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -68,10 +68,12 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ save_videosource: function(newsource) { // newsource either is