diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 40cf14c1f2..97990e9f41 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Transcript translations should be displayed in their source language (BLD-935). + Blades: Create an upload modal for video transcript translations (BLD-751). +Studio and LMS: Upgrade version of TinyMCE to 4.0.20. Switch from tabbed Visual/HTML +Editor for HTML modules to showing the code editor as a plugin within TinyMCE (triggered +from toolbar). STUD-1422 + Studio: Add ability to reorder Pages and hide the Wiki page. STUD-1375 Blades: Added template for iFrames. BLD-611. diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 8353a58d0c..7afa359e39 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -318,19 +318,23 @@ def i_am_shown_a_notification(step): assert world.is_css_present('.wrapper-prompt') -def type_in_codemirror(index, text): +def type_in_codemirror(index, text, find_prefix="$"): script = """ - var cm = $('div.CodeMirror:eq({})').get(0).CodeMirror; + var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror; cm.getInputField().focus(); cm.setValue(arguments[0]); - cm.getInputField().blur();""".format(index) + cm.getInputField().blur();""".format(index=index, find_prefix=find_prefix) world.browser.driver.execute_script(script, str(text)) world.wait_for_ajax_complete() -def get_codemirror_value(index=0): - return world.browser.driver.execute_script(""" - return $('div.CodeMirror:eq({})').get(0).CodeMirror.getValue(); - """.format(index)) + +def get_codemirror_value(index=0, find_prefix="$"): + return world.browser.driver.execute_script( + """ + return {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror.getValue(); + """.format(index=index, find_prefix=find_prefix) + ) + def attach_file(filename, sub_path): diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature index 6472f367b7..a23bc4650d 100644 --- a/cms/djangoapps/contentstore/features/html-editor.feature +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -22,6 +22,50 @@ Feature: CMS.HTML Editor Scenario: TinyMCE image plugin sets urls correctly Given I have created a Blank HTML Page - When I edit the page and select the Visual Editor - And I add an image with a static link via the Image Plugin Icon - Then the image static link is rewritten to translate the path \ No newline at end of file + When I edit the page + And I add an image with static link "/static/image.jpg" via the Image Plugin Icon + Then the src link is rewritten to "c4x/MITx/999/asset/image.jpg" + And the link is shown as "/static/image.jpg" in the Image Plugin + + Scenario: TinyMCE link plugin sets urls correctly + Given I have created a Blank HTML Page + When I edit the page + And I add a link with static link "/static/image.jpg" via the Link Plugin Icon + Then the href link is rewritten to "c4x/MITx/999/asset/image.jpg" + And the link is shown as "/static/image.jpg" in the Link Plugin + + Scenario: TinyMCE and CodeMirror preserve style tags + Given I have created a Blank HTML Page + When I edit the page + And type "

pages

" in the code editor and press OK + And I save the page + Then the page text contains: + """ +

pages

+ + """ + + Scenario: TinyMCE toolbar buttons are as expected + Given I have created a Blank HTML Page + When I edit the page + Then the expected toolbar buttons are displayed + + Scenario: Static links are converted when switching between code editor and WYSIWYG views + Given I have created a Blank HTML Page + When I edit the page + And type "" in the code editor and press OK + Then the src link is rewritten to "c4x/MITx/999/asset/image.jpg" + And the code editor displays "

" + + Scenario: Code format toolbar button wraps text with code tags + Given I have created a Blank HTML Page + When I edit the page + And I set the text to "display as code" and I select the text + And I select the code toolbar button + And I save the page + Then the page text contains: + """ +

display as code

+ """ diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index 89e4100367..4a39eb299f 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -2,7 +2,10 @@ # pylint: disable=C0111 from lettuce import world, step -from nose.tools import assert_in # pylint: disable=no-name-in-module +from nose.tools import assert_in, assert_equal # pylint: disable=no-name-in-module +from common import type_in_codemirror, get_codemirror_value + +CODEMIRROR_SELECTOR_PREFIX = "$('iframe').contents().find" @step('I have created a Blank HTML Page$') @@ -31,41 +34,168 @@ def i_created_etext_in_latex(step): ) -@step('I edit the page and select the Visual Editor') +@step('I edit the page$') def i_click_on_edit_icon(step): world.edit_component() - world.wait_for(lambda _driver: world.css_visible('a.visual-tab')) - world.css_click('a.visual-tab') -@step('I add an image with a static link via the Image Plugin Icon') -def i_click_on_image_plugin_icon(step): - # Click on image plugin button - world.wait_for(lambda _driver: world.css_visible('a.mce_image')) - world.css_click('a.mce_image') - - # Change to the non-modal TinyMCE Image window - # keeping parent window so we can go back to it. - parent_window = world.browser.current_window - for window in world.browser.windows: - - world.browser.switch_to_window(window) # Switch to a different window - if world.browser.title == 'Insert/Edit Image': - - # This is the Image window so find the url text box, - # enter text in it then hit Insert button. - url_elem = world.browser.find_by_id("src") - url_elem.fill('/static/image.jpg') - world.browser.find_by_id('insert').click() - - world.browser.switch_to_window(parent_window) # Switch back to the main window +@step('I add an image with static link "(.*)" via the Image Plugin Icon$') +def i_click_on_image_plugin_icon(step, path): + use_plugin( + '.mce-i-image', + lambda: world.css_fill('.mce-textbox', path, 0) + ) -@step('the image static link is rewritten to translate the path') -def image_static_link_is_rewritten(step): +@step('the link is shown as "(.*)" in the Image Plugin$') +def check_link_in_image_plugin(step, path): + use_plugin( + '.mce-i-image', + lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value) + ) + + +@step('I add a link with static link "(.*)" via the Link Plugin Icon$') +def i_click_on_link_plugin_icon(step, path): + def fill_in_link_fields(): + world.css_fill('.mce-textbox', path, 0) + world.css_fill('.mce-textbox', 'picture', 1) + + use_plugin('.mce-i-link', fill_in_link_fields) + + +@step('the link is shown as "(.*)" in the Link Plugin$') +def check_link_in_link_plugin(step, path): + # Ensure caret position is within the link just created. + script = """ + var editor = tinyMCE.activeEditor; + editor.selection.select(editor.dom.select('a')[0]);""" + world.browser.driver.execute_script(script) + world.wait_for_ajax_complete() + + use_plugin( + '.mce-i-link', + lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value) + ) + + +@step('type "(.*)" in the code editor and press OK$') +def type_in_codemirror_plugin(step, text): + use_code_editor( + lambda: type_in_codemirror(0, text, CODEMIRROR_SELECTOR_PREFIX) + ) + + +@step('and the code editor displays "(.*)"$') +def verify_code_editor_text(step, text): + use_code_editor( + lambda: assert_equal(text, get_codemirror_value(0, CODEMIRROR_SELECTOR_PREFIX)) + ) + + +def use_plugin(button_class, action): + # Click on plugin button + world.css_click(button_class) + perform_action_in_plugin(action) + + +def use_code_editor(action): + # Click on plugin button + buttons = world.css_find('div.mce-widget>button') + + code_editor = [button for button in buttons if button.text == 'HTML'] + assert_equal(1, len(code_editor)) + code_editor[0].click() + + perform_action_in_plugin(action) + + +def perform_action_in_plugin(action): + # Wait for the plugin window to open. + world.wait_for_visible('.mce-window') + + # Trigger the action + action() + + # Click OK + world.css_click('.mce-primary') + + +@step('I save the page$') +def i_click_on_save(step): + world.save_component(step) + + +@step('the page text contains:') +def check_page_text(step): + assert_in(step.multiline, world.css_find('.xmodule_HtmlModule').html) + + +@step('the src link is rewritten to "(.*)"$') +def image_static_link_is_rewritten(step, path): # Find the TinyMCE iframe within the main window with world.browser.get_iframe('mce_0_ifr') as tinymce: image = tinymce.find_by_tag('img').first + assert_in(path, image['src']) - # Test onExecCommandHandler set the url to absolute. - assert_in('c4x/MITx/999/asset/image.jpg', image['src']) + +@step('the href link is rewritten to "(.*)"$') +def link_static_link_is_rewritten(step, path): + # Find the TinyMCE iframe within the main window + with world.browser.get_iframe('mce_0_ifr') as tinymce: + link = tinymce.find_by_tag('a').first + assert_in(path, link['href']) + + +@step('the expected toolbar buttons are displayed$') +def check_toolbar_buttons(step): + dropdowns = world.css_find('.mce-listbox') + assert_equal(2, len(dropdowns)) + + # Format dropdown + assert_equal('Paragraph', dropdowns[0].text) + # Font dropdown + assert_equal('Font Family', dropdowns[1].text) + + buttons = world.css_find('.mce-ico') + + # Note that the code editor icon is not present because we are now showing text instead of an icon. + # However, other test points user the code editor, so we have already verified its presence. + expected_buttons = [ + 'bold', + 'italic', + 'underline', + 'forecolor', + # This is our custom "code style" button, which uses an image instead of a class. + 'none', + 'bullist', + 'numlist', + 'outdent', + 'indent', + 'blockquote', + 'link', + 'unlink', + 'image' + ] + + assert_equal(len(expected_buttons), len(buttons)) + + for index, button in enumerate(expected_buttons): + class_names = buttons[index]._element.get_attribute('class') + assert_equal("mce-ico mce-i-" + button, class_names) + + +@step('I set the text to "(.*)" and I select the text$') +def set_text_and_select(step, text): + script = """ + var editor = tinyMCE.activeEditor; + editor.setContent(arguments[0]); + editor.selection.select(editor.dom.select('p')[0]);""" + world.browser.driver.execute_script(script, str(text)) + world.wait_for_ajax_complete() + + +@step('I select the code toolbar button$') +def select_code_button(step): + # This is our custom "code style" button. It uses an image instead of a class. + world.css_click(".mce-i-none") diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py index 00f898fbb6..e198ee3f32 100644 --- a/cms/djangoapps/contentstore/features/transcripts.py +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -201,7 +201,8 @@ def upload_file(_step, file_name): @step('I see "([^"]*)" text in the captions') def check_text_in_the_captions(_step, text): - world.wait_for(lambda _: world.css_text('.subtitles')) + world.wait_for_present('.video.is-captions-rendered') + world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30) actual_text = world.css_text('.subtitles') assert (text in actual_text) diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index d5f2033904..6330f571a2 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -26,8 +26,7 @@ def go_to_uploads(_step): @step(u'I upload the( test)? file "([^"]*)"$') def upload_file(_step, is_test_file, file_name): - upload_css = 'a.upload-button' - world.css_click(upload_css) + world.click_link('Upload New File') if not is_test_file: _write_test_file(file_name, "test file") diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index b0460e8163..a7974f020f 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -10,7 +10,12 @@ from django.conf import settings from common import upload_file, attach_file TEST_ROOT = settings.COMMON_TEST_DATA_ROOT -LANGUAGES = {l[0]: l[1] for l in settings.ALL_LANGUAGES} + +NATIVE_LANGUAGES = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2} +LANGUAGES = { + lang: NATIVE_LANGUAGES.get(lang, display) + for lang, display in settings.ALL_LANGUAGES +} TRANSLATION_BUTTONS = { 'add': '.metadata-video-translations .create-action', diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index bb4672c499..e465dc6b5b 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -5,7 +5,7 @@ Feature: CMS Video Component # 1 Scenario: YouTube stub server proxies YouTube API correctly Given youtube stub server proxies YouTube API - Given I have created a Video component + And I have created a Video component Then I can see video button "play" And I click video button "play" Then I can see video button "pause" @@ -13,8 +13,8 @@ Feature: CMS Video Component # 2 Scenario: YouTube stub server can block YouTube API Given youtube stub server blocks YouTube API - Given I have created a Video component - Given We explicitly wait for YouTube API to not load + And I have created a Video component + And I wait for "3" seconds Then I do not see video button "play" # 3 diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index ef48014da6..b8287047af 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -1,6 +1,7 @@ # pylint: disable=C0111 from lettuce import world, step +from nose.tools import assert_less from xmodule.modulestore import Location from contentstore.utils import get_modulestore from selenium.webdriver.common.keys import Keys @@ -32,13 +33,11 @@ def configure_youtube_api(_step, action): raise ValueError('Parameter `action` should be one of "proxies" or "blocks".') -@step('We explicitly wait for YouTube API to not load$') -def wait_for_youtube_api_fail(_step): - world.wait(3) - - @step('I have created a Video component$') def i_created_a_video_component(_step): + + assert_less(world.youtube.config['youtube_api_response'].status_code, 400, "Real Youtube server is unavailable") + world.create_course_with_unit() world.create_component_instance( step=_step, @@ -51,7 +50,8 @@ def i_created_a_video_component(_step): world.wait_for_present('.is-initialized') world.wait(DELAY) world.wait_for_invisible(SELECTORS['spinner']) - + if not world.youtube.config.get('youtube_api_blocked'): + world.wait_for_visible(SELECTORS['controls']) @step('I have created a Video component with subtitles$') def i_created_a_video_with_subs(_step): @@ -197,11 +197,15 @@ def find_caption_line_by_data_index(index): @step('I focus on caption line with data-index "([^"]*)"$') def focus_on_caption_line(_step, index): + world.wait_for_present('.video.is-captions-rendered') + world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30) find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.TAB) @step('I press "enter" button on caption line with data-index "([^"]*)"$') def click_on_the_caption(_step, index): + world.wait_for_present('.video.is-captions-rendered') + world.wait_for(lambda _: world.css_text('.subtitles'), timeout=30) find_caption_line_by_data_index(int(index.strip()))._element.send_keys(Keys.ENTER) @@ -214,7 +218,6 @@ def caption_line_has_class(_step, index, className): @step('I see a range on slider$') def see_a_range_slider_with_proper_range(_step): world.wait_for_visible(VIDEO_BUTTONS['pause']) - assert world.css_visible(".slider-range") diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index ab2a2d60b4..344c6d5c57 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -55,11 +55,10 @@ xmodule.x_module.descriptor_global_local_resource_url = local_resource_url def hash_resource(resource): """ - Hash a :class:`xblock.fragment.FragmentResource + Hash a :class:`xblock.fragment.FragmentResource`. """ md5 = hashlib.md5() - for data in resource: - md5.update(data) + md5.update(repr(resource)) return md5.hexdigest() diff --git a/cms/envs/aws.py b/cms/envs/aws.py index b2de92a738..67fc78e644 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -274,3 +274,6 @@ SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIME ##### X-Frame-Options response header settings ##### X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) + +##### ADVANCED_SECURITY_CONFIG ##### +ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {}) diff --git a/cms/envs/common.py b/cms/envs/common.py index 434f534a27..022e9dd94b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -96,6 +96,9 @@ FEATURES = { # Prevent concurrent logins per user 'PREVENT_CONCURRENT_LOGINS': False, + + # Turn off Advanced Security by default + 'ADVANCED_SECURITY': False, } ENABLE_JASMINE = False @@ -310,10 +313,24 @@ PIPELINE_CSS = { 'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css', 'css/vendor/jquery.qtip.min.css', 'js/vendor/markitup/skins/simple/style.css', - 'js/vendor/markitup/sets/wiki/style.css', + 'js/vendor/markitup/sets/wiki/style.css' ], 'output_filename': 'css/cms-style-vendor.css', }, + 'style-vendor-tinymce-content': { + 'source_filenames': [ + 'css/tinymce-studio-content-fonts.css', + 'js/vendor/tinymce/js/tinymce/skins/studio-tmce4/content.min.css', + 'css/tinymce-studio-content.css' + ], + 'output_filename': 'css/cms-style-vendor-tinymce-content.css', + }, + 'style-vendor-tinymce-skin': { + 'source_filenames': [ + 'js/vendor/tinymce/js/tinymce/skins/studio-tmce4/skin.min.css' + ], + 'output_filename': 'css/cms-style-vendor-tinymce-skin.css', + }, 'style-app': { 'source_filenames': [ 'sass/style-app.css', @@ -566,6 +583,7 @@ OPTIONAL_APPS = ( 'openassessment.xblock' ) + for app_name in OPTIONAL_APPS: # First attempt to only find the module rather than actually importing it, # to avoid circular references - only try to import if it can't be found @@ -578,3 +596,7 @@ for app_name in OPTIONAL_APPS: except ImportError: continue INSTALLED_APPS += (app_name,) + +### ADVANCED_SECURITY_CONFIG +# Empty by default +ADVANCED_SECURITY_CONFIG = {} diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 1cbc3fc805..11f937a2b4 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -25,8 +25,8 @@ requirejs.config({ "backbone": "xmodule_js/common_static/js/vendor/backbone-min", "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min", "backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min", - "tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce", - "jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce", + "tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min", + "jquery.tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce", "xmodule": "xmodule_js/src/xmodule", "xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1", "xblock": "xmodule_js/common_static/coffee/src/xblock", @@ -207,16 +207,17 @@ define([ "js/spec/video/transcripts/videolist_spec", "js/spec/video/transcripts/message_manager_spec", "js/spec/video/transcripts/file_uploader_spec", - "js/spec/models/explicit_url_spec" + "js/spec/models/explicit_url_spec", + "js/spec/utils/drag_and_drop_spec", "js/spec/utils/handle_iframe_binding_spec", "js/spec/utils/module_spec", "js/spec/views/baseview_spec", "js/spec/views/paging_spec", - "js/spec/views/unit_spec" - "js/spec/views/xblock_spec" + "js/spec/views/unit_spec", + "js/spec/views/xblock_spec", # these tests are run separate in the cms-squire suite, due to process # isolation issues with Squire.js diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee index c3ce440bca..e7e6bef00b 100644 --- a/cms/static/coffee/spec/main_squire.coffee +++ b/cms/static/coffee/spec/main_squire.coffee @@ -24,8 +24,8 @@ requirejs.config({ "backbone": "xmodule_js/common_static/js/vendor/backbone-min", "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min", "backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min", - "tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce", - "jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce", + "tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min", + "jquery.tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce", "xmodule": "xmodule_js/src/xmodule", "xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1", "xblock": "xmodule_js/common_static/coffee/src/xblock", diff --git a/cms/static/coffee/spec/views/overview_spec.coffee b/cms/static/coffee/spec/views/overview_spec.coffee index c29d31f414..d89c64c145 100644 --- a/cms/static/coffee/spec/views/overview_spec.coffee +++ b/cms/static/coffee/spec/views/overview_spec.coffee @@ -54,38 +54,6 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s """ - appendSetFixtures """ -
-
    -
  1. -
      -
    1. -
    -
  2. -
  3. -
      -
    1. -
    2. -
    3. -
    -
  4. -
  5. -
      -
    1. -
    -
  6. -
  7. -
      -
    1. -
    2. -
        -
      1. -
      -
    3. -
    -
    - """ - spyOn(Overview, 'saveSetSectionScheduleDate').andCallThrough() # Have to do this here, as it normally gets bound in document.ready() $('a.action-save').click(Overview.saveSetSectionScheduleDate) @@ -96,20 +64,6 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s window.analytics = jasmine.createSpyObj('analytics', ['track']) window.course_location_analytics = jasmine.createSpy() - Overview.overviewDragger.makeDraggable( - '.unit', - '.unit-drag-handle', - 'ol.sortable-unit-list', - 'li.courseware-subsection, article.subsection-body' - ) - - Overview.overviewDragger.makeDraggable( - '.courseware-subsection', - '.subsection-drag-handle', - '.sortable-subsection-list', - 'section' - ) - afterEach -> delete window.analytics delete window.course_location_analytics @@ -143,305 +97,3 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s # $('a.delete-section-button').click() # $('a.action-primary').click() # expect(@notificationSpy).toHaveBeenCalled() - - describe "findDestination", -> - it "correctly finds the drop target of a drag", -> - $ele = $('#unit-1') - $ele.offset( - top: $ele.offset().top + 10, left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 1) - expect(destination.ele).toBe($('#unit-2')) - expect(destination.attachMethod).toBe('before') - - it "can drag and drop across section boundaries, with special handling for single sibling", -> - $ele = $('#unit-1') - $unit4 = $('#unit-4') - $ele.offset( - top: $unit4.offset().top + 8 - left: $ele.offset().left - ) - # Dragging down, we will insert after. - destination = Overview.overviewDragger.findDestination($ele, 1) - expect(destination.ele).toBe($unit4) - expect(destination.attachMethod).toBe('after') - - # Dragging up, we will insert before. - destination = Overview.overviewDragger.findDestination($ele, -1) - expect(destination.ele).toBe($unit4) - expect(destination.attachMethod).toBe('before') - - # If past the end the drop target, will attach after. - $ele.offset( - top: $unit4.offset().top + $unit4.height() + 1 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 0) - expect(destination.ele).toBe($unit4) - expect(destination.attachMethod).toBe('after') - - - $unit0 = $('#unit-0') - # If before the start the drop target, will attach before. - $ele.offset( - top: $unit0.offset().top - 16 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 0) - expect(destination.ele).toBe($unit0) - expect(destination.attachMethod).toBe('before') - - it """can drop before the first element, even if element being dragged is - slightly before the first element""", -> - $ele = $('#subsection-2') - $ele.offset( - top: $('#subsection-0').offset().top - 5 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, -1) - expect(destination.ele).toBe($('#subsection-0')) - expect(destination.attachMethod).toBe('before') - - it "can drag and drop across section boundaries, with special handling for last element", -> - $ele = $('#unit-4') - $ele.offset( - top: $('#unit-3').offset().bottom + 4 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, -1) - expect(destination.ele).toBe($('#unit-3')) - # Dragging down up into last element, we have a fudge factor makes it easier to drag at beginning. - expect(destination.attachMethod).toBe('after') - # Now past the "fudge factor". - $ele.offset( - top: $('#unit-3').offset().top + 4 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, -1) - expect(destination.ele).toBe($('#unit-3')) - expect(destination.attachMethod).toBe('before') - - it """can drop past the last element, even if element being dragged is - slightly before/taller then the last element""", -> - $ele = $('#subsection-2') - $ele.offset( - # Make the top 1 before the top of the last element in the list. - # This mimics the problem when the element being dropped is taller then then - # the last element in the list. - top: $('#subsection-4').offset().top - 1 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 1) - expect(destination.ele).toBe($('#subsection-4')) - expect(destination.attachMethod).toBe('after') - - it "can drag into an empty list", -> - $ele = $('#unit-1') - $ele.offset( - top: $('#subsection-3').offset().top + 10 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 1) - expect(destination.ele).toBe($('#subsection-list-3')) - expect(destination.attachMethod).toBe('prepend') - - it "reports a null destination on a failed drag", -> - $ele = $('#unit-1') - $ele.offset( - top: $ele.offset().top + 200, left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 1) - expect(destination).toEqual( - ele: null - attachMethod: "" - ) - - it "can drag into a collapsed list", -> - $('#subsection-2').addClass('collapsed') - $ele = $('#unit-2') - $ele.offset( - top: $('#subsection-2').offset().top + 3 - left: $ele.offset().left - ) - destination = Overview.overviewDragger.findDestination($ele, 1) - expect(destination.ele).toBe($('#subsection-list-2')) - expect(destination.parentList).toBe($('#subsection-2')) - expect(destination.attachMethod).toBe('prepend') - - describe "onDragStart", -> - it "sets the dragState to its default values", -> - expect(Overview.overviewDragger.dragState).toEqual({}) - # Call with some dummy data - Overview.overviewDragger.onDragStart( - {element: $('#unit-1')}, - null, - null - ) - expect(Overview.overviewDragger.dragState).toEqual( - dropDestination: null, - attachMethod: '', - parentList: null, - lastY: 0, - dragDirection: 0 - ) - - it "collapses expanded elements", -> - expect($('#subsection-1')).not.toHaveClass('collapsed') - Overview.overviewDragger.onDragStart( - {element: $('#subsection-1')}, - null, - null - ) - expect($('#subsection-1')).toHaveClass('collapsed') - expect($('#subsection-1')).toHaveClass('expand-on-drop') - - describe "onDragMove", -> - beforeEach -> - @scrollSpy = spyOn(window, 'scrollBy').andCallThrough() - - it "adds the correct CSS class to the drop destination", -> - $ele = $('#unit-1') - dragY = $ele.offset().top + 10 - dragX = $ele.offset().left - $ele.offset( - top: dragY, left: dragX - ) - Overview.overviewDragger.onDragMove( - {element: $ele, dragPoint: - {y: dragY}}, '', {clientX: dragX} - ) - expect($('#unit-2')).toHaveClass('drop-target drop-target-before') - expect($ele).toHaveClass('valid-drop') - - it "does not add CSS class to the drop destination if out of bounds", -> - $ele = $('#unit-1') - dragY = $ele.offset().top + 10 - $ele.offset( - top: dragY, left: $ele.offset().left - ) - Overview.overviewDragger.onDragMove( - {element: $ele, dragPoint: - {y: dragY}}, '', {clientX: $ele.offset().left - 3} - ) - expect($('#unit-2')).not.toHaveClass('drop-target drop-target-before') - expect($ele).not.toHaveClass('valid-drop') - - it "scrolls up if necessary", -> - Overview.overviewDragger.onDragMove( - {element: $('#unit-1')}, '', {clientY: 2} - ) - expect(@scrollSpy).toHaveBeenCalledWith(0, -10) - - it "scrolls down if necessary", -> - Overview.overviewDragger.onDragMove( - {element: $('#unit-1')}, '', {clientY: (window.innerHeight - 5)} - ) - expect(@scrollSpy).toHaveBeenCalledWith(0, 10) - - describe "onDragEnd", -> - beforeEach -> - @reorderSpy = spyOn(Overview.overviewDragger, 'handleReorder') - - afterEach -> - @reorderSpy.reset() - - it "calls handleReorder on a successful drag", -> - Overview.overviewDragger.dragState.dropDestination = $('#unit-2') - Overview.overviewDragger.dragState.attachMethod = "before" - Overview.overviewDragger.dragState.parentList = $('#subsection-1') - $('#unit-1').offset( - top: $('#unit-1').offset().top + 10 - left: $('#unit-1').offset().left - ) - Overview.overviewDragger.onDragEnd( - {element: $('#unit-1')}, - null, - {clientX: $('#unit-1').offset().left} - ) - expect(@reorderSpy).toHaveBeenCalled() - - it "clears out the drag state", -> - Overview.overviewDragger.onDragEnd( - {element: $('#unit-1')}, - null, - null - ) - expect(Overview.overviewDragger.dragState).toEqual({}) - - it "sets the element to the correct position", -> - Overview.overviewDragger.onDragEnd( - {element: $('#unit-1')}, - null, - null - ) - # Chrome sets the CSS to 'auto', but Firefox uses '0px'. - expect(['0px', 'auto']).toContain($('#unit-1').css('top')) - expect(['0px', 'auto']).toContain($('#unit-1').css('left')) - - it "expands an element if it was collapsed on drag start", -> - $('#subsection-1').addClass('collapsed') - $('#subsection-1').addClass('expand-on-drop') - Overview.overviewDragger.onDragEnd( - {element: $('#subsection-1')}, - null, - null - ) - expect($('#subsection-1')).not.toHaveClass('collapsed') - expect($('#subsection-1')).not.toHaveClass('expand-on-drop') - - it "expands a collapsed element when something is dropped in it", -> - $('#subsection-2').addClass('collapsed') - Overview.overviewDragger.dragState.dropDestination = $('#list-2') - Overview.overviewDragger.dragState.attachMethod = "prepend" - Overview.overviewDragger.dragState.parentList = $('#subsection-2') - Overview.overviewDragger.onDragEnd( - {element: $('#unit-1')}, - null, - {clientX: $('#unit-1').offset().left} - ) - expect($('#subsection-2')).not.toHaveClass('collapsed') - - describe "AJAX", -> - beforeEach -> - @savingSpies = spyOnConstructor(Notification, "Mini", - ["show", "hide"]) - @savingSpies.show.andReturn(@savingSpies) - @clock = sinon.useFakeTimers() - - afterEach -> - @clock.restore() - - it "should send an update on reorder", -> - requests = create_sinon["requests"](this) - - Overview.overviewDragger.dragState.dropDestination = $('#unit-4') - Overview.overviewDragger.dragState.attachMethod = "after" - Overview.overviewDragger.dragState.parentList = $('#subsection-2') - # Drag Unit 1 from Subsection 1 to the end of Subsection 2. - $('#unit-1').offset( - top: $('#unit-4').offset().top + 10 - left: $('#unit-4').offset().left - ) - Overview.overviewDragger.onDragEnd( - {element: $('#unit-1')}, - null, - {clientX: $('#unit-1').offset().left} - ) - expect(requests.length).toEqual(2) - expect(@savingSpies.constructor).toHaveBeenCalled() - expect(@savingSpies.show).toHaveBeenCalled() - expect(@savingSpies.hide).not.toHaveBeenCalled() - savingOptions = @savingSpies.constructor.mostRecentCall.args[0] - expect(savingOptions.title).toMatch(/Saving/) - expect($('#unit-1')).toHaveClass('was-dropped') - # We expect 2 requests to be sent-- the first for removing Unit 1 from Subsection 1, - # and the second for adding Unit 1 to the end of Subsection 2. - expect(requests[0].requestBody).toEqual('{"children":["second-unit-id","third-unit-id"]}') - requests[0].respond(200) - expect(@savingSpies.hide).not.toHaveBeenCalled() - expect(requests[1].requestBody).toEqual('{"children":["fourth-unit-id","first-unit-id"]}') - requests[1].respond(200) - expect(@savingSpies.hide).toHaveBeenCalled() - # Class is removed in a timeout. - @clock.tick(1001) - expect($('#unit-1')).not.toHaveClass('was-dropped') diff --git a/cms/static/js/spec/utils/drag_and_drop_spec.js b/cms/static/js/spec/utils/drag_and_drop_spec.js new file mode 100644 index 0000000000..74558ca22f --- /dev/null +++ b/cms/static/js/spec/utils/drag_and_drop_spec.js @@ -0,0 +1,313 @@ +define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec/create_sinon", "jquery"], + function (ContentDragger, Notification, create_sinon, $) { + describe("Overview drag and drop functionality", function () { + beforeEach(function () { + setFixtures(readFixtures('mock/mock-outline.underscore')); + ContentDragger.makeDraggable('.unit', '.unit-drag-handle', 'ol.sortable-unit-list', 'li.courseware-subsection, article.subsection-body'); + ContentDragger.makeDraggable('.courseware-subsection', '.subsection-drag-handle', '.sortable-subsection-list', 'section'); + }); + + describe("findDestination", function () { + it("correctly finds the drop target of a drag", function () { + var $ele, destination; + $ele = $('#unit-1'); + $ele.offset({ + top: $ele.offset().top + 10, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 1); + expect(destination.ele).toBe($('#unit-2')); + expect(destination.attachMethod).toBe('before'); + }); + it("can drag and drop across section boundaries, with special handling for single sibling", function () { + var $ele, $unit0, $unit4, destination; + $ele = $('#unit-1'); + $unit4 = $('#unit-4'); + $ele.offset({ + top: $unit4.offset().top + 8, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 1); + expect(destination.ele).toBe($unit4); + expect(destination.attachMethod).toBe('after'); + destination = ContentDragger.findDestination($ele, -1); + expect(destination.ele).toBe($unit4); + expect(destination.attachMethod).toBe('before'); + $ele.offset({ + top: $unit4.offset().top + $unit4.height() + 1, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 0); + expect(destination.ele).toBe($unit4); + expect(destination.attachMethod).toBe('after'); + $unit0 = $('#unit-0'); + $ele.offset({ + top: $unit0.offset().top - 16, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 0); + expect(destination.ele).toBe($unit0); + expect(destination.attachMethod).toBe('before'); + }); + it("can drop before the first element, even if element being dragged is\nslightly before the first element", function () { + var $ele, destination; + $ele = $('#subsection-2'); + $ele.offset({ + top: $('#subsection-0').offset().top - 5, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, -1); + expect(destination.ele).toBe($('#subsection-0')); + expect(destination.attachMethod).toBe('before'); + }); + it("can drag and drop across section boundaries, with special handling for last element", function () { + var $ele, destination; + $ele = $('#unit-4'); + $ele.offset({ + top: $('#unit-3').offset().bottom + 4, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, -1); + expect(destination.ele).toBe($('#unit-3')); + expect(destination.attachMethod).toBe('after'); + $ele.offset({ + top: $('#unit-3').offset().top + 4, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, -1); + expect(destination.ele).toBe($('#unit-3')); + expect(destination.attachMethod).toBe('before'); + }); + it("can drop past the last element, even if element being dragged is\nslightly before/taller then the last element", function () { + var $ele, destination; + $ele = $('#subsection-2'); + $ele.offset({ + top: $('#subsection-4').offset().top - 1, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 1); + expect(destination.ele).toBe($('#subsection-4')); + expect(destination.attachMethod).toBe('after'); + }); + it("can drag into an empty list", function () { + var $ele, destination; + $ele = $('#unit-1'); + $ele.offset({ + top: $('#subsection-3').offset().top + 10, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 1); + expect(destination.ele).toBe($('#subsection-list-3')); + expect(destination.attachMethod).toBe('prepend'); + }); + it("reports a null destination on a failed drag", function () { + var $ele, destination; + $ele = $('#unit-1'); + $ele.offset({ + top: $ele.offset().top + 200, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 1); + expect(destination).toEqual({ + ele: null, + attachMethod: "" + }); + }); + it("can drag into a collapsed list", function () { + var $ele, destination; + $('#subsection-2').addClass('collapsed'); + $ele = $('#unit-2'); + $ele.offset({ + top: $('#subsection-2').offset().top + 3, + left: $ele.offset().left + }); + destination = ContentDragger.findDestination($ele, 1); + expect(destination.ele).toBe($('#subsection-list-2')); + expect(destination.parentList).toBe($('#subsection-2')); + expect(destination.attachMethod).toBe('prepend'); + }); + }); + describe("onDragStart", function () { + it("sets the dragState to its default values", function () { + expect(ContentDragger.dragState).toEqual({}); + ContentDragger.onDragStart({ + element: $('#unit-1') + }, null, null); + expect(ContentDragger.dragState).toEqual({ + dropDestination: null, + attachMethod: '', + parentList: null, + lastY: 0, + dragDirection: 0 + }); + }); + it("collapses expanded elements", function () { + expect($('#subsection-1')).not.toHaveClass('collapsed'); + ContentDragger.onDragStart({ + element: $('#subsection-1') + }, null, null); + expect($('#subsection-1')).toHaveClass('collapsed'); + expect($('#subsection-1')).toHaveClass('expand-on-drop'); + }); + }); + describe("onDragMove", function () { + beforeEach(function () { + this.scrollSpy = spyOn(window, 'scrollBy').andCallThrough(); + }); + it("adds the correct CSS class to the drop destination", function () { + var $ele, dragX, dragY; + $ele = $('#unit-1'); + dragY = $ele.offset().top + 10; + dragX = $ele.offset().left; + $ele.offset({ + top: dragY, + left: dragX + }); + ContentDragger.onDragMove({ + element: $ele, + dragPoint: { + y: dragY + } + }, '', { + clientX: dragX + }); + expect($('#unit-2')).toHaveClass('drop-target drop-target-before'); + expect($ele).toHaveClass('valid-drop'); + }); + it("does not add CSS class to the drop destination if out of bounds", function () { + var $ele, dragY; + $ele = $('#unit-1'); + dragY = $ele.offset().top + 10; + $ele.offset({ + top: dragY, + left: $ele.offset().left + }); + ContentDragger.onDragMove({ + element: $ele, + dragPoint: { + y: dragY + } + }, '', { + clientX: $ele.offset().left - 3 + }); + expect($('#unit-2')).not.toHaveClass('drop-target drop-target-before'); + expect($ele).not.toHaveClass('valid-drop'); + }); + it("scrolls up if necessary", function () { + ContentDragger.onDragMove({ + element: $('#unit-1') + }, '', { + clientY: 2 + }); + expect(this.scrollSpy).toHaveBeenCalledWith(0, -10); + }); + it("scrolls down if necessary", function () { + ContentDragger.onDragMove({ + element: $('#unit-1') + }, '', { + clientY: window.innerHeight - 5 + }); + expect(this.scrollSpy).toHaveBeenCalledWith(0, 10); + }); + }); + describe("onDragEnd", function () { + beforeEach(function () { + this.reorderSpy = spyOn(ContentDragger, 'handleReorder'); + }); + afterEach(function () { + this.reorderSpy.reset(); + }); + it("calls handleReorder on a successful drag", function () { + ContentDragger.dragState.dropDestination = $('#unit-2'); + ContentDragger.dragState.attachMethod = "before"; + ContentDragger.dragState.parentList = $('#subsection-1'); + $('#unit-1').offset({ + top: $('#unit-1').offset().top + 10, + left: $('#unit-1').offset().left + }); + ContentDragger.onDragEnd({ + element: $('#unit-1') + }, null, { + clientX: $('#unit-1').offset().left + }); + expect(this.reorderSpy).toHaveBeenCalled(); + }); + it("clears out the drag state", function () { + ContentDragger.onDragEnd({ + element: $('#unit-1') + }, null, null); + expect(ContentDragger.dragState).toEqual({}); + }); + it("sets the element to the correct position", function () { + ContentDragger.onDragEnd({ + element: $('#unit-1') + }, null, null); + expect(['0px', 'auto']).toContain($('#unit-1').css('top')); + expect(['0px', 'auto']).toContain($('#unit-1').css('left')); + }); + it("expands an element if it was collapsed on drag start", function () { + $('#subsection-1').addClass('collapsed'); + $('#subsection-1').addClass('expand-on-drop'); + ContentDragger.onDragEnd({ + element: $('#subsection-1') + }, null, null); + expect($('#subsection-1')).not.toHaveClass('collapsed'); + expect($('#subsection-1')).not.toHaveClass('expand-on-drop'); + }); + it("expands a collapsed element when something is dropped in it", function () { + $('#subsection-2').addClass('collapsed'); + ContentDragger.dragState.dropDestination = $('#list-2'); + ContentDragger.dragState.attachMethod = "prepend"; + ContentDragger.dragState.parentList = $('#subsection-2'); + ContentDragger.onDragEnd({ + element: $('#unit-1') + }, null, { + clientX: $('#unit-1').offset().left + }); + expect($('#subsection-2')).not.toHaveClass('collapsed'); + }); + }); + describe("AJAX", function () { + beforeEach(function () { + this.savingSpies = spyOnConstructor(Notification, "Mini", ["show", "hide"]); + this.savingSpies.show.andReturn(this.savingSpies); + this.clock = sinon.useFakeTimers(); + }); + afterEach(function () { + this.clock.restore(); + }); + it("should send an update on reorder", function () { + var requests, savingOptions; + requests = create_sinon["requests"](this); + ContentDragger.dragState.dropDestination = $('#unit-4'); + ContentDragger.dragState.attachMethod = "after"; + ContentDragger.dragState.parentList = $('#subsection-2'); + $('#unit-1').offset({ + top: $('#unit-4').offset().top + 10, + left: $('#unit-4').offset().left + }); + ContentDragger.onDragEnd({ + element: $('#unit-1') + }, null, { + clientX: $('#unit-1').offset().left + }); + expect(requests.length).toEqual(2); + expect(this.savingSpies.constructor).toHaveBeenCalled(); + expect(this.savingSpies.show).toHaveBeenCalled(); + expect(this.savingSpies.hide).not.toHaveBeenCalled(); + savingOptions = this.savingSpies.constructor.mostRecentCall.args[0]; + expect(savingOptions.title).toMatch(/Saving/); + expect($('#unit-1')).toHaveClass('was-dropped'); + expect(requests[0].requestBody).toEqual('{"children":["second-unit-id","third-unit-id"]}'); + requests[0].respond(200); + expect(this.savingSpies.hide).not.toHaveBeenCalled(); + expect(requests[1].requestBody).toEqual('{"children":["fourth-unit-id","first-unit-id"]}'); + requests[1].respond(200); + expect(this.savingSpies.hide).toHaveBeenCalled(); + this.clock.tick(1001); + expect($('#unit-1')).not.toHaveClass('was-dropped'); + }); + }); + }); + }); + diff --git a/cms/static/js/spec/utils/handle_iframe_binding_spec.js b/cms/static/js/spec/utils/handle_iframe_binding_spec.js index 2375d08083..04dd27d183 100644 --- a/cms/static/js/spec/utils/handle_iframe_binding_spec.js +++ b/cms/static/js/spec/utils/handle_iframe_binding_spec.js @@ -12,6 +12,15 @@ function ($, _, IframeBinding) { iframe_html += ''; doc.body.innerHTML = iframe_html; + var verify_no_modification = function (src) { + iframe_html = ''; + doc.body.innerHTML = iframe_html; + + IframeBinding.iframeBinding(doc); + + expect($(doc).find("iframe")[0].src).toEqual(src); + }; + it("modifies src url of DOM iframe and embed elements when iframeBinding function is executed", function () { expect($(doc).find("iframe")[0].src).toEqual("http://www.youtube.com/embed/NHd27UvY-lw"); expect($(doc).find("iframe")[1].src).toEqual("http://www.youtube.com/embed/NHd27UvY-lw?allowFullScreen=false"); @@ -35,12 +44,11 @@ function ($, _, IframeBinding) { }); it("does not modify src url of DOM iframe if it is empty", function () { - iframe_html = ''; - doc.body.innerHTML = iframe_html; + verify_no_modification(""); + }); - IframeBinding.iframeBinding(doc); - - expect($(doc).find("iframe")[0].src).toEqual(""); + it("does nothing on tinymce iframe", function () { + verify_no_modification("javascript:"); }); }); }); diff --git a/cms/static/js/utils/drag_and_drop.js b/cms/static/js/utils/drag_and_drop.js new file mode 100644 index 0000000000..2479ec47ba --- /dev/null +++ b/cms/static/js/utils/drag_and_drop.js @@ -0,0 +1,346 @@ +define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "draggabilly", + "js/utils/module"], + function ($, ui, _, gettext, NotificationView, Draggabilly, ModuleUtils) { + + var contentDragger = { + droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after', + validDropClass: "valid-drop", + expandOnDropClass: "expand-on-drop", + + /* + * Determine information about where to drop the currently dragged + * element. Returns the element to attach to and the method of + * attachment ('before', 'after', or 'prepend'). + */ + findDestination: function (ele, yChange) { + var eleY = ele.offset().top; + var eleYEnd = eleY + ele.height(); + var containers = $(ele.data('droppable-class')); + + for (var i = 0; i < containers.length; i++) { + var container = $(containers[i]); + // Exclude the 'new unit' buttons, and make sure we don't + // prepend an element to itself + var siblings = container.children().filter(function () { + return $(this).data('locator') !== undefined && !$(this).is(ele); + }); + // If the container is collapsed, check to see if the + // element is on top of its parent list -- don't check the + // position of the container + var parentList = container.parents(ele.data('parent-location-selector')).first(); + if (parentList.hasClass('collapsed')) { + var parentListTop = parentList.offset().top; + // To make it easier to drop subsections into collapsed sections (which have + // a lot of visual padding around them), allow a fudge factor around the + // parent element. + var collapseFudge = 10; + if (Math.abs(eleY - parentListTop) < collapseFudge || + (eleY > parentListTop && + eleYEnd - collapseFudge <= parentListTop + parentList.height()) + ) { + return { + ele: container, + attachMethod: 'prepend', + parentList: parentList + }; + } + } + // Otherwise, do check the container + else { + // If the list is empty, we should prepend to it, + // unless both elements are at the same location -- + // this prevents the user from being unable to expand + // a section + var containerY = container.offset().top; + if (siblings.length === 0 && + containerY !== eleY && + Math.abs(eleY - containerY) < 50) { + return { + ele: container, + attachMethod: 'prepend' + }; + } + // Otherwise the list is populated, and we should attach before/after a sibling + else { + for (var j = 0; j < siblings.length; j++) { + var $sibling = $(siblings[j]); + var siblingY = $sibling.offset().top; + var siblingHeight = $sibling.height(); + var siblingYEnd = siblingY + siblingHeight; + + // Facilitate dropping into the beginning or end of a list + // (coming from opposite direction) via a "fudge factor". Math.min is for Jasmine test. + var fudge = Math.min(Math.ceil(siblingHeight / 2), 20); + + // Dragging to top or bottom of a list with only one element is tricky + // because the element being dragged may be the same size as the sibling. + if (siblings.length === 1) { + // Element being dragged is within the drop target. Use the direction + // of the drag (yChange) to determine before or after. + if (eleY + fudge >= siblingY && eleYEnd - fudge <= siblingYEnd) { + return { + ele: $sibling, + attachMethod: yChange > 0 ? 'after' : 'before' + }; + } + // Element being dragged is before the drop target. + else if (Math.abs(eleYEnd - siblingY) <= fudge) { + return { + ele: $sibling, + attachMethod: 'before' + }; + } + // Element being dragged is after the drop target. + else if (Math.abs(eleY - siblingYEnd) <= fudge) { + return { + ele: $sibling, + attachMethod: 'after' + }; + } + } + else { + // Dragging up into end of list. + if (j === siblings.length - 1 && yChange < 0 && Math.abs(eleY - siblingYEnd) <= fudge) { + return { + ele: $sibling, + attachMethod: 'after' + }; + } + // Dragging up or down into beginning of list. + else if (j === 0 && Math.abs(eleY - siblingY) <= fudge) { + return { + ele: $sibling, + attachMethod: 'before' + }; + } + // Dragging down into end of list. Special handling required because + // the element being dragged may be taller then the element being dragged over + // (if eleY can never be >= siblingY, general case at the end does not work). + else if (j === siblings.length - 1 && yChange > 0 && + Math.abs(eleYEnd - siblingYEnd) <= fudge) { + return { + ele: $sibling, + attachMethod: 'after' + }; + } + else if (eleY >= siblingY && eleY <= siblingYEnd) { + return { + ele: $sibling, + attachMethod: eleY - siblingY <= siblingHeight / 2 ? 'before' : 'after' + }; + } + } + } + } + } + } + // Failed drag + return { + ele: null, + attachMethod: '' + }; + }, + + // Information about the current drag. + dragState: {}, + + onDragStart: function (draggie, event, pointer) { + var ele = $(draggie.element); + this.dragState = { + // Which element will be dropped into/onto on success + dropDestination: null, + // How we attach to the destination: 'before', 'after', 'prepend' + attachMethod: '', + // If dragging to an empty section, the parent section + parentList: null, + // The y location of the last dragMove event (to determine direction). + lastY: 0, + // The direction the drag is moving in (negative means up, positive down). + dragDirection: 0 + }; + if (!ele.hasClass('collapsed')) { + ele.addClass('collapsed'); + ele.find('.expand-collapse').first().addClass('expand').removeClass('collapse'); + // onDragStart gets called again after the collapse, so we can't just store a variable in the dragState. + ele.addClass(this.expandOnDropClass); + } + }, + + onDragMove: function (draggie, event, pointer) { + // Handle scrolling of the browser. + var scrollAmount = 0; + var dragBuffer = 10; + if (window.innerHeight - dragBuffer < pointer.clientY) { + scrollAmount = dragBuffer; + } + else if (dragBuffer > pointer.clientY) { + scrollAmount = -(dragBuffer); + } + if (scrollAmount !== 0) { + window.scrollBy(0, scrollAmount); + return; + } + + var yChange = draggie.dragPoint.y - this.dragState.lastY; + if (yChange !== 0) { + this.dragState.direction = yChange; + } + this.dragState.lastY = draggie.dragPoint.y; + + var ele = $(draggie.element); + var destinationInfo = this.findDestination(ele, this.dragState.direction); + var destinationEle = destinationInfo.ele; + this.dragState.parentList = destinationInfo.parentList; + + // Clear out the old destination + if (this.dragState.dropDestination) { + this.dragState.dropDestination.removeClass(this.droppableClasses); + } + // Mark the new destination + if (destinationEle && this.pointerInBounds(pointer, ele)) { + ele.addClass(this.validDropClass); + destinationEle.addClass('drop-target drop-target-' + destinationInfo.attachMethod); + this.dragState.attachMethod = destinationInfo.attachMethod; + this.dragState.dropDestination = destinationEle; + } + else { + ele.removeClass(this.validDropClass); + this.dragState.attachMethod = ''; + this.dragState.dropDestination = null; + } + }, + + onDragEnd: function (draggie, event, pointer) { + var ele = $(draggie.element); + var destination = this.dragState.dropDestination; + + // Clear dragging state in preparation for the next event. + if (destination) { + destination.removeClass(this.droppableClasses); + } + ele.removeClass(this.validDropClass); + + // If the drag succeeded, rearrange the DOM and send the result. + if (destination && this.pointerInBounds(pointer, ele)) { + // Make sure we don't drop into a collapsed element + if (this.dragState.parentList) { + this.expandElement(this.dragState.parentList); + } + var method = this.dragState.attachMethod; + destination[method](ele); + this.handleReorder(ele); + } + // If the drag failed, send it back + else { + $('.was-dragging').removeClass('was-dragging'); + ele.addClass('was-dragging'); + } + + if (ele.hasClass(this.expandOnDropClass)) { + this.expandElement(ele); + ele.removeClass(this.expandOnDropClass); + } + + // Everything in its right place + ele.css({ + top: 'auto', + left: 'auto' + }); + + this.dragState = {}; + }, + + pointerInBounds: function (pointer, ele) { + return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.width(); + }, + + expandElement: function (ele) { + ele.removeClass('collapsed'); + ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse'); + }, + + /* + * Find all parent-child changes and save them. + */ + handleReorder: function (ele) { + var parentSelector = ele.data('parent-location-selector'); + var childrenSelector = ele.data('child-selector'); + var newParentEle = ele.parents(parentSelector).first(); + var newParentLocator = newParentEle.data('locator'); + var oldParentLocator = ele.data('parent'); + // If the parent has changed, update the children of the old parent. + if (newParentLocator !== oldParentLocator) { + // Find the old parent element. + var oldParentEle = $(parentSelector).filter(function () { + return $(this).data('locator') === oldParentLocator; + }); + this.saveItem(oldParentEle, childrenSelector, function () { + ele.data('parent', newParentLocator); + }); + } + var saving = new NotificationView.Mini({ + title: gettext('Saving…') + }); + saving.show(); + ele.addClass('was-dropped'); + // Timeout interval has to match what is in the CSS. + setTimeout(function () { + ele.removeClass('was-dropped'); + }, 1000); + this.saveItem(newParentEle, childrenSelector, function () { + saving.hide(); + }); + }, + + /* + * Actually save the update to the server. Takes the element + * representing the parent item to save, a CSS selector to find + * its children, and a success callback. + */ + saveItem: function (ele, childrenSelector, success) { + // Find all current child IDs. + var children = _.map( + ele.find(childrenSelector), + function (child) { + return $(child).data('locator'); + } + ); + $.ajax({ + url: ModuleUtils.getUpdateUrl(ele.data('locator')), + type: 'PUT', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify({ + children: children + }), + success: success + }); + }, + + /* + * Make `type` draggable using `handleClass`, able to be dropped + * into `droppableClass`, and with parent type + * `parentLocationSelector`. + */ + makeDraggable: function (type, handleClass, droppableClass, parentLocationSelector) { + _.each( + $(type), + function (ele) { + // Remember data necessary to reconstruct the parent-child relationships + $(ele).data('droppable-class', droppableClass); + $(ele).data('parent-location-selector', parentLocationSelector); + $(ele).data('child-selector', type); + var draggable = new Draggabilly(ele, { + handle: handleClass, + containment: '.wrapper-dnd' + }); + draggable.on('dragStart', _.bind(contentDragger.onDragStart, contentDragger)); + draggable.on('dragMove', _.bind(contentDragger.onDragMove, contentDragger)); + draggable.on('dragEnd', _.bind(contentDragger.onDragEnd, contentDragger)); + } + ); + } + }; + + return contentDragger; + }); diff --git a/cms/static/js/utils/handle_iframe_binding.js b/cms/static/js/utils/handle_iframe_binding.js index e106d01ae5..cb13272e9a 100644 --- a/cms/static/js/utils/handle_iframe_binding.js +++ b/cms/static/js/utils/handle_iframe_binding.js @@ -29,7 +29,10 @@ define(["jquery"], function($) { $(this).attr('src', newString + '?' + wmode + '&' + oldString); } } - else { + // The TinyMCE editor is hosted in an iframe, and before the iframe is + // removed we execute this code. To avoid throwing an error when setting the + // attr, check that the source doesn't start with the value specified by TinyMCE ('javascript:""'). + else if (ifr_source.lastIndexOf("javascript:", 0) !== 0) { $(this).attr('src', ifr_source + '?' + wmode); } } diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index 1a4daa826d..6fb18a39f3 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -1,6 +1,6 @@ -define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "draggabilly", +define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "js/utils/drag_and_drop", "js/utils/cancel_on_escape", "js/utils/get_date", "js/utils/module"], - function (domReady, $, ui, _, gettext, NotificationView, Draggabilly, CancelOnEscape, + function (domReady, $, ui, _, gettext, NotificationView, ContentDragger, CancelOnEscape, DateUtils, ModuleUtils) { var modalSelector = '.edit-section-publish-settings'; @@ -207,345 +207,7 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe $(this).parents('li.courseware-subsection').remove(); }; - var overviewDragger = { - droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after', - validDropClass: "valid-drop", - expandOnDropClass: "expand-on-drop", - /* - * Determine information about where to drop the currently dragged - * element. Returns the element to attach to and the method of - * attachment ('before', 'after', or 'prepend'). - */ - findDestination: function (ele, yChange) { - var eleY = ele.offset().top; - var eleYEnd = eleY + ele.height(); - var containers = $(ele.data('droppable-class')); - - for (var i = 0; i < containers.length; i++) { - var container = $(containers[i]); - // Exclude the 'new unit' buttons, and make sure we don't - // prepend an element to itself - var siblings = container.children().filter(function () { - return $(this).data('locator') !== undefined && !$(this).is(ele); - }); - // If the container is collapsed, check to see if the - // element is on top of its parent list -- don't check the - // position of the container - var parentList = container.parents(ele.data('parent-location-selector')).first(); - if (parentList.hasClass('collapsed')) { - var parentListTop = parentList.offset().top; - // To make it easier to drop subsections into collapsed sections (which have - // a lot of visual padding around them), allow a fudge factor around the - // parent element. - var collapseFudge = 10; - if (Math.abs(eleY - parentListTop) < collapseFudge || - (eleY > parentListTop && - eleYEnd - collapseFudge <= parentListTop + parentList.height()) - ) { - return { - ele: container, - attachMethod: 'prepend', - parentList: parentList - }; - } - } - // Otherwise, do check the container - else { - // If the list is empty, we should prepend to it, - // unless both elements are at the same location -- - // this prevents the user from being unable to expand - // a section - var containerY = container.offset().top; - if (siblings.length == 0 && - containerY != eleY && - Math.abs(eleY - containerY) < 50) { - return { - ele: container, - attachMethod: 'prepend' - }; - } - // Otherwise the list is populated, and we should attach before/after a sibling - else { - for (var j = 0; j < siblings.length; j++) { - var $sibling = $(siblings[j]); - var siblingY = $sibling.offset().top; - var siblingHeight = $sibling.height(); - var siblingYEnd = siblingY + siblingHeight; - - // Facilitate dropping into the beginning or end of a list - // (coming from opposite direction) via a "fudge factor". Math.min is for Jasmine test. - var fudge = Math.min(Math.ceil(siblingHeight / 2), 20); - - // Dragging to top or bottom of a list with only one element is tricky - // because the element being dragged may be the same size as the sibling. - if (siblings.length == 1) { - // Element being dragged is within the drop target. Use the direction - // of the drag (yChange) to determine before or after. - if (eleY + fudge >= siblingY && eleYEnd - fudge <= siblingYEnd) { - return { - ele: $sibling, - attachMethod: yChange > 0 ? 'after' : 'before' - }; - } - // Element being dragged is before the drop target. - else if (Math.abs(eleYEnd - siblingY) <= fudge) { - return { - ele: $sibling, - attachMethod: 'before' - }; - } - // Element being dragged is after the drop target. - else if (Math.abs(eleY - siblingYEnd) <= fudge) { - return { - ele: $sibling, - attachMethod: 'after' - }; - } - } - else { - // Dragging up into end of list. - if (j == siblings.length - 1 && yChange < 0 && Math.abs(eleY - siblingYEnd) <= fudge) { - return { - ele: $sibling, - attachMethod: 'after' - }; - } - // Dragging up or down into beginning of list. - else if (j == 0 && Math.abs(eleY - siblingY) <= fudge) { - return { - ele: $sibling, - attachMethod: 'before' - }; - } - // Dragging down into end of list. Special handling required because - // the element being dragged may be taller then the element being dragged over - // (if eleY can never be >= siblingY, general case at the end does not work). - else if (j == siblings.length - 1 && yChange > 0 && - Math.abs(eleYEnd - siblingYEnd) <= fudge) { - return { - ele: $sibling, - attachMethod: 'after' - }; - } - else if (eleY >= siblingY && eleY <= siblingYEnd) { - return { - ele: $sibling, - attachMethod: eleY - siblingY <= siblingHeight / 2 ? 'before' : 'after' - }; - } - } - } - } - } - } - // Failed drag - return { - ele: null, - attachMethod: '' - } - }, - - // Information about the current drag. - dragState: {}, - - onDragStart: function (draggie, event, pointer) { - var ele = $(draggie.element); - this.dragState = { - // Which element will be dropped into/onto on success - dropDestination: null, - // How we attach to the destination: 'before', 'after', 'prepend' - attachMethod: '', - // If dragging to an empty section, the parent section - parentList: null, - // The y location of the last dragMove event (to determine direction). - lastY: 0, - // The direction the drag is moving in (negative means up, positive down). - dragDirection: 0 - }; - if (!ele.hasClass('collapsed')) { - ele.addClass('collapsed'); - ele.find('.expand-collapse').first().addClass('expand').removeClass('collapse'); - // onDragStart gets called again after the collapse, so we can't just store a variable in the dragState. - ele.addClass(this.expandOnDropClass); - } - }, - - onDragMove: function (draggie, event, pointer) { - // Handle scrolling of the browser. - var scrollAmount = 0; - var dragBuffer = 10; - if (window.innerHeight - dragBuffer < pointer.clientY) { - scrollAmount = dragBuffer; - } - else if (dragBuffer > pointer.clientY) { - scrollAmount = -(dragBuffer); - } - if (scrollAmount !== 0) { - window.scrollBy(0, scrollAmount); - return; - } - - var yChange = draggie.dragPoint.y - this.dragState.lastY; - if (yChange !== 0) { - this.dragState.direction = yChange; - } - this.dragState.lastY = draggie.dragPoint.y; - - var ele = $(draggie.element); - var destinationInfo = this.findDestination(ele, this.dragState.direction); - var destinationEle = destinationInfo.ele; - this.dragState.parentList = destinationInfo.parentList; - - // Clear out the old destination - if (this.dragState.dropDestination) { - this.dragState.dropDestination.removeClass(this.droppableClasses); - } - // Mark the new destination - if (destinationEle && this.pointerInBounds(pointer, ele)) { - ele.addClass(this.validDropClass); - destinationEle.addClass('drop-target drop-target-' + destinationInfo.attachMethod); - this.dragState.attachMethod = destinationInfo.attachMethod; - this.dragState.dropDestination = destinationEle; - } - else { - ele.removeClass(this.validDropClass); - this.dragState.attachMethod = ''; - this.dragState.dropDestination = null; - } - }, - - onDragEnd: function (draggie, event, pointer) { - var ele = $(draggie.element); - var destination = this.dragState.dropDestination; - - // Clear dragging state in preparation for the next event. - if (destination) { - destination.removeClass(this.droppableClasses); - } - ele.removeClass(this.validDropClass); - - // If the drag succeeded, rearrange the DOM and send the result. - if (destination && this.pointerInBounds(pointer, ele)) { - // Make sure we don't drop into a collapsed element - if (this.dragState.parentList) { - this.expandElement(this.dragState.parentList); - } - var method = this.dragState.attachMethod; - destination[method](ele); - this.handleReorder(ele); - } - // If the drag failed, send it back - else { - $('.was-dragging').removeClass('was-dragging'); - ele.addClass('was-dragging'); - } - - if (ele.hasClass(this.expandOnDropClass)) { - this.expandElement(ele); - ele.removeClass(this.expandOnDropClass); - } - - // Everything in its right place - ele.css({ - top: 'auto', - left: 'auto' - }); - - this.dragState = {}; - }, - - pointerInBounds: function (pointer, ele) { - return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.width(); - }, - - expandElement: function (ele) { - ele.removeClass('collapsed'); - ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse'); - }, - - /* - * Find all parent-child changes and save them. - */ - handleReorder: function (ele) { - var parentSelector = ele.data('parent-location-selector'); - var childrenSelector = ele.data('child-selector'); - var newParentEle = ele.parents(parentSelector).first(); - var newParentLocator = newParentEle.data('locator'); - var oldParentLocator = ele.data('parent'); - // If the parent has changed, update the children of the old parent. - if (newParentLocator !== oldParentLocator) { - // Find the old parent element. - var oldParentEle = $(parentSelector).filter(function () { - return $(this).data('locator') === oldParentLocator; - }); - this.saveItem(oldParentEle, childrenSelector, function () { - ele.data('parent', newParentLocator); - }); - } - var saving = new NotificationView.Mini({ - title: gettext('Saving…') - }); - saving.show(); - ele.addClass('was-dropped'); - // Timeout interval has to match what is in the CSS. - setTimeout(function () { - ele.removeClass('was-dropped'); - }, 1000); - this.saveItem(newParentEle, childrenSelector, function () { - saving.hide(); - }); - }, - - /* - * Actually save the update to the server. Takes the element - * representing the parent item to save, a CSS selector to find - * its children, and a success callback. - */ - saveItem: function (ele, childrenSelector, success) { - // Find all current child IDs. - var children = _.map( - ele.find(childrenSelector), - function (child) { - return $(child).data('locator'); - } - ); - $.ajax({ - url: ModuleUtils.getUpdateUrl(ele.data('locator')), - type: 'PUT', - dataType: 'json', - contentType: 'application/json', - data: JSON.stringify({ - children: children - }), - success: success - }); - }, - - /* - * Make `type` draggable using `handleClass`, able to be dropped - * into `droppableClass`, and with parent type - * `parentLocationSelector`. - */ - makeDraggable: function (type, handleClass, droppableClass, parentLocationSelector) { - _.each( - $(type), - function (ele) { - // Remember data necessary to reconstruct the parent-child relationships - $(ele).data('droppable-class', droppableClass); - $(ele).data('parent-location-selector', parentLocationSelector); - $(ele).data('child-selector', type); - var draggable = new Draggabilly(ele, { - handle: handleClass, - containment: '.wrapper-dnd' - }); - draggable.on('dragStart', _.bind(overviewDragger.onDragStart, overviewDragger)); - draggable.on('dragMove', _.bind(overviewDragger.onDragMove, overviewDragger)); - draggable.on('dragEnd', _.bind(overviewDragger.onDragEnd, overviewDragger)); - } - ); - } - }; domReady(function() { // toggling overview section details @@ -566,21 +228,21 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe $('.new-subsection-item').bind('click', addNewSubsection); // Section - overviewDragger.makeDraggable( + ContentDragger.makeDraggable( '.courseware-section', '.section-drag-handle', '.courseware-overview', 'article.courseware-overview' ); // Subsection - overviewDragger.makeDraggable( + ContentDragger.makeDraggable( '.id-holder', '.subsection-drag-handle', '.subsection-list > ol', '.courseware-section' ); // Unit - overviewDragger.makeDraggable( + ContentDragger.makeDraggable( '.unit', '.unit-drag-handle', 'ol.sortable-unit-list', @@ -589,7 +251,6 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe }); return { - overviewDragger: overviewDragger, saveSetSectionScheduleDate: saveSetSectionScheduleDate }; }); diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index d735b6238b..e259623d91 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -828,18 +828,21 @@ border: 1px solid $mediumGrey; border-radius: 2px; background-color: $lightGrey; - font-family: 'Open Sans', sans-serif; + font-family: $f-monospace; color: $baseFontColor; outline: 0; height: auto; min-height: ($baseline*2.25); - max-height: ($baseline*10); &.CodeMirror-focused { @include linear-gradient($paleYellow, tint($paleYellow, 90%)); outline: 0; } + .CodeMirror-sizer { + top: 4px; /* Vertical alignment for monospace font */ + } + // editor color changes just for JSON .CodeMirror-lines { diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index 9676212aca..e97b6849b8 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -485,6 +485,7 @@ body.course.unit,.view-unit { .row { margin-bottom: 0px; + overflow: hidden; } // Module Actions, also used for Pages diff --git a/cms/templates/base.html b/cms/templates/base.html index 2b4a8692a5..93a3ec4ff7 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -23,6 +23,8 @@ <%static:css group='style-vendor'/> + <%static:css group='style-vendor-tinymce-content'/> + <%static:css group='style-vendor-tinymce-skin'/> <%static:css group='style-app'/> <%static:css group='style-app-extend1'/> <%static:css group='style-xmodule'/> @@ -70,8 +72,8 @@ "backbone": "js/vendor/backbone-min", "backbone.associations": "js/vendor/backbone-associations-min", "backbone.paginator": "js/vendor/backbone.paginator.min", - "tinymce": "js/vendor/tiny_mce/tiny_mce", - "jquery.tinymce": "js/vendor/tiny_mce/jquery.tinymce", + "tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min", + "jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min", "xmodule": "/xmodule/xmodule", "xblock": "coffee/src/xblock", "utility": "js/src/utility", diff --git a/cms/templates/js/mock/mock-outline.underscore b/cms/templates/js/mock/mock-outline.underscore new file mode 100644 index 0000000000..2991935827 --- /dev/null +++ b/cms/templates/js/mock/mock-outline.underscore @@ -0,0 +1,29 @@ +
    +
      +
    1. +
        +
      1. +
      +
    2. +
    3. +
        +
      1. +
      2. +
      3. +
      +
    4. +
    5. +
        +
      1. +
      +
    6. +
    7. +
        +
      1. +
      2. +
          +
        1. +
        +
      3. +
      +
      \ No newline at end of file diff --git a/cms/templates/widgets/html-edit.html b/cms/templates/widgets/html-edit.html index 34866321c4..a27225e36b 100644 --- a/cms/templates/widgets/html-edit.html +++ b/cms/templates/widgets/html-edit.html @@ -2,14 +2,8 @@
      - -
      -
      diff --git a/cms/templates/widgets/metadata-edit.html b/cms/templates/widgets/metadata-edit.html index ac904262c3..74c5ac968e 100644 --- a/cms/templates/widgets/metadata-edit.html +++ b/cms/templates/widgets/metadata-edit.html @@ -5,7 +5,7 @@ import hashlib import copy import json - hlskey = hashlib.md5(module.location.url()).hexdigest() + hlskey = hashlib.md5(module.location.url().encode('utf-8')).hexdigest() %> ## js templates diff --git a/cms/templates/widgets/source-edit.html b/cms/templates/widgets/source-edit.html index fbd7e3f0bc..6681fcd997 100644 --- a/cms/templates/widgets/source-edit.html +++ b/cms/templates/widgets/source-edit.html @@ -123,7 +123,16 @@ require(["jquery", "jquery.leanModal", "codemirror/stex"], function($) { if (xml.length == 0) { alert('Conversion failed! error:' + data.message); } else { - el.closest('.component').find('.CodeMirror-wrap')[0].CodeMirror.setValue(xml); + // If a parent CodeMirror editor is open (LaTeX problem being edited), set the text + // there. Otherwise, set the text in the active TinyMCE Editor for the case + // of an HTML component being edited. + var parentCodemirrorEditor = el.closest('.component').find('.CodeMirror-wrap'); + if (parentCodemirrorEditor.length > 0) { + parentCodemirrorEditor[0].CodeMirror.setValue(xml); + } + else if (window.tinyMCE !== undefined && window.tinyMCE.activeEditor !== undefined) { + window.tinyMCE.activeEditor.setContent(xml); + } save_hls(el); } }, diff --git a/common/djangoapps/dark_lang/middleware.py b/common/djangoapps/dark_lang/middleware.py index 3629d44b3f..26ff0f5d49 100644 --- a/common/djangoapps/dark_lang/middleware.py +++ b/common/djangoapps/dark_lang/middleware.py @@ -16,6 +16,42 @@ from django.utils.translation.trans_real import parse_accept_lang_header from dark_lang.models import DarkLangConfig +def dark_parse_accept_lang_header(accept): + ''' + The use of 'zh-cn' for 'Simplified Chinese' and 'zh-tw' for 'Traditional Chinese' + are now deprecated, as discussed here: https://code.djangoproject.com/ticket/18419. + The new language codes 'zh-hans' and 'zh-hant' are now used since django 1.7. + Although majority of browsers still use the old language codes, some new browsers + such as IE11 in Windows 8.1 start to use the new ones, which makes the current + chinese translations of edX don't work properly under these browsers. + This function can keep compatibility between the old and new language codes. If one + day edX uses django 1.7 or higher, this function can be modified to support the old + language codes until there are no browsers use them. + ''' + browser_langs = parse_accept_lang_header(accept) + django_langs = [] + for lang, priority in browser_langs: + lang = CHINESE_LANGUAGE_CODE_MAP.get(lang.lower(), lang) + django_langs.append((lang, priority)) + return django_langs + +# If django 1.7 or higher is used, the right-side can be updated with new-style codes. +CHINESE_LANGUAGE_CODE_MAP = { + # The following are the new-style language codes for chinese language + 'zh-hans': 'zh-CN', # Chinese (Simplified), + 'zh-hans-cn': 'zh-CN', # Chinese (Simplified, China) + 'zh-hans-sg': 'zh-CN', # Chinese (Simplified, Singapore) + 'zh-hant': 'zh-TW', # Chinese (Traditional) + 'zh-hant-hk': 'zh-TW', # Chinese (Traditional, Hongkong) + 'zh-hant-mo': 'zh-TW', # Chinese (Traditional, Macau) + 'zh-hant-tw': 'zh-TW', # Chinese (Traditional, Taiwan) + # The following are the old-style language codes that django does not recognize + 'zh-hk': 'zh-TW', # Chinese (Traditional, Hongkong) + 'zh-mo': 'zh-TW', # Chinese (Traditional, Macau) + 'zh-sg': 'zh-CN', # Chinese (Simplified, Singapore) +} + + class DarkLangMiddleware(object): """ Middleware for dark-launching languages. @@ -65,7 +101,7 @@ class DarkLangMiddleware(object): new_accept = ", ".join( self._format_accept_value(lang, priority) for lang, priority - in parse_accept_lang_header(accept) + in dark_parse_accept_lang_header(accept) if self._is_released(lang) ) diff --git a/common/djangoapps/dark_lang/tests.py b/common/djangoapps/dark_lang/tests.py index 9896851984..d38a95e509 100644 --- a/common/djangoapps/dark_lang/tests.py +++ b/common/djangoapps/dark_lang/tests.py @@ -208,3 +208,15 @@ class DarkLangMiddlewareTests(TestCase): 'rel', self.process_request(preview_lang='unrel', django_language='rel') ) + + def test_accept_chinese_language_codes(self): + DarkLangConfig( + released_languages=('zh-cn, zh-tw'), + changed_by=self.user, + enabled=True + ).save() + + self.assertAcceptEquals( + 'zh-CN;q=1.0, zh-TW;q=0.5, zh-TW;q=0.3', + self.process_request(accept='zh-Hans;q=1.0, zh-Hant-TW;q=0.5, zh-hk;q=0.3') + ) diff --git a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py index c39d2595be..70919c4198 100644 --- a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py +++ b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py @@ -113,6 +113,8 @@ class Migration(SchemaMigration): }, 'student.userprofile': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), @@ -144,4 +146,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py b/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py new file mode 100644 index 0000000000..f46fd07894 --- /dev/null +++ b/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'PasswordHistory' + db.create_table('student_passwordhistory', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('password', self.gf('django.db.models.fields.CharField')(max_length=128)), + ('time_set', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + )) + db.send_create_signal('student', ['PasswordHistory']) + + + def backwards(self, orm): + # Deleting model 'PasswordHistory' + db.delete_table('student_passwordhistory') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index c4fc45950d..fc6ea6e344 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -18,16 +18,18 @@ import logging from pytz import UTC import uuid from collections import defaultdict +from dogapi import dog_stats_api from django.conf import settings +from django.utils import timezone from django.contrib.auth.models import User +from django.contrib.auth.hashers import make_password from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models, IntegrityError from django.db.models import Count from django.db.models.signals import post_save from django.dispatch import receiver, Signal import django.dispatch -from django.forms import ModelForm, forms from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_noop from django_countries import CountryField @@ -36,6 +38,8 @@ from track.views import server_track from eventtracking import tracker from importlib import import_module +from xmodule.modulestore import Location + from course_modes.models import CourseMode import lms.lib.comment_client as cc from util.query import use_read_replica_if_available @@ -79,8 +83,8 @@ def anonymous_id_for_user(user, course_id): # include the secret key as a salt, and to make the ids unique across different LMS installs. hasher = hashlib.md5() hasher.update(settings.SECRET_KEY) - hasher.update(str(user.id)) - hasher.update(course_id) + hasher.update(unicode(user.id)) + hasher.update(course_id.encode('utf-8')) digest = hasher.hexdigest() try: @@ -312,6 +316,185 @@ EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' +class PasswordHistory(models.Model): + """ + This model will keep track of past passwords that a user has used + as well as providing contraints (e.g. can't reuse passwords) + """ + user = models.ForeignKey(User) + password = models.CharField(max_length=128) + time_set = models.DateTimeField(default=timezone.now) + + def create(self, user): + """ + This will copy over the current password, if any of the configuration has been turned on + """ + + if not (PasswordHistory.is_student_password_reuse_restricted() or + PasswordHistory.is_staff_password_reuse_restricted() or + PasswordHistory.is_password_reset_frequency_restricted() or + PasswordHistory.is_staff_forced_password_reset_enabled() or + PasswordHistory.is_student_forced_password_reset_enabled()): + + return + + self.user = user + self.password = user.password + self.save() + + @classmethod + def is_student_password_reuse_restricted(cls): + """ + Returns whether the configuration which limits password reuse has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE', 0 + ) > 0 + + @classmethod + def is_staff_password_reuse_restricted(cls): + """ + Returns whether the configuration which limits password reuse has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE', 0 + ) > 0 + + @classmethod + def is_password_reset_frequency_restricted(cls): + """ + Returns whether the configuration which limits the password reset frequency has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS', None + ) + + @classmethod + def is_staff_forced_password_reset_enabled(cls): + """ + Returns whether the configuration which forces password resets to occur has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS', None + ) + + @classmethod + def is_student_forced_password_reset_enabled(cls): + """ + Returns whether the configuration which forces password resets to occur has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS', None + ) + + @classmethod + def should_user_reset_password_now(cls, user): + """ + Returns whether a password has 'expired' and should be reset. Note there are two different + expiry policies for staff and students + """ + if not settings.FEATURES['ADVANCED_SECURITY']: + return False + + days_before_password_reset = None + if user.is_staff: + if cls.is_staff_forced_password_reset_enabled(): + days_before_password_reset = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS'] + elif cls.is_student_forced_password_reset_enabled(): + days_before_password_reset = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS'] + + if days_before_password_reset: + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + time_last_reset = None + + if history: + # first element should be the last time we reset password + time_last_reset = history[0].time_set + else: + # no history, then let's take the date the user joined + time_last_reset = user.date_joined + + now = timezone.now() + + delta = now - time_last_reset + + return delta.days >= days_before_password_reset + + return False + + @classmethod + def is_password_reset_too_soon(cls, user): + """ + Verifies that the password is not getting reset too frequently + """ + if not cls.is_password_reset_frequency_restricted(): + return False + + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + + if not history: + return False + + now = timezone.now() + + delta = now - history[0].time_set + + return delta.days < settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + + @classmethod + def is_allowable_password_reuse(cls, user, new_password): + """ + Verifies that the password adheres to the reuse policies + """ + if not settings.FEATURES['ADVANCED_SECURITY']: + return True + + if user.is_staff and cls.is_staff_password_reuse_restricted(): + min_diff_passwords_required = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + elif cls.is_student_password_reuse_restricted(): + min_diff_passwords_required = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + else: + min_diff_passwords_required = 0 + + # just limit the result set to the number of different + # password we need + history = PasswordHistory.objects.filter(user=user).order_by('-time_set')[:min_diff_passwords_required] + + for entry in history: + + # be sure to re-use the same salt + # NOTE, how the salt is serialized in the password field is dependent on the algorithm + # in pbkdf2_sha256 [LMS] it's the 3rd element, in sha1 [unit tests] it's the 2nd element + hash_elements = entry.password.split('$') + algorithm = hash_elements[0] + if algorithm == 'pbkdf2_sha256': + hashed_password = make_password(new_password, hash_elements[2]) + elif algorithm == 'sha1': + hashed_password = make_password(new_password, hash_elements[1]) + else: + # This means we got something unexpected. We don't want to throw an exception, but + # log as an error and basically allow any password reuse + AUDIT_LOG.error(''' + Unknown password hashing algorithm "{0}" found in existing password + hash, password reuse policy will not be enforced!!! + '''.format(algorithm)) + return True + + if entry.password == hashed_password: + return False + + return True + + class LoginFailures(models.Model): """ This model will keep track of failed login attempts @@ -496,12 +679,31 @@ class CourseEnrollment(models.Model): if activation_changed or mode_changed: self.save() if activation_changed: + course_id_dict = Location.parse_course_id(self.course_id) if self.is_active: self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED) + + dog_stats_api.increment( + "common.student.enrollment", + tags=[u"org:{org}".format(**course_id_dict), + u"course:{course}".format(**course_id_dict), + u"run:{name}".format(**course_id_dict), + u"mode:{}".format(self.mode)] + ) + else: unenroll_done.send(sender=None, course_enrollment=self) + self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED) + dog_stats_api.increment( + "common.student.unenrollment", + tags=[u"org:{org}".format(**course_id_dict), + u"course:{course}".format(**course_id_dict), + u"run:{name}".format(**course_id_dict), + u"mode:{}".format(self.mode)] + ) + def emit_event(self, event_name): """ Emits an event to explicitly track course enrollment and unenrollment. diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index a0783196b5..e50e7290b3 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -64,7 +64,7 @@ class GlobalStaff(AccessRole): def add_users(self, *users): for user in users: - if (user.is_authenticated and user.is_active): + if (user.is_authenticated() and user.is_active): user.is_staff = True user.save() @@ -98,7 +98,7 @@ class GroupBasedRole(AccessRole): """ Return whether the supplied django user has access to this role. """ - if not (user.is_authenticated and user.is_active): + if not (user.is_authenticated() and user.is_active): return False # pylint: disable=protected-access @@ -113,7 +113,7 @@ class GroupBasedRole(AccessRole): """ # silently ignores anonymous and inactive users so that any that are # legit get updated. - users = [user for user in users if user.is_authenticated and user.is_active] + users = [user for user in users if user.is_authenticated() and user.is_active] group, _ = Group.objects.get_or_create(name=self._group_names[0]) group.user_set.add(*users) # remove cache diff --git a/common/djangoapps/student/tests/test_authz.py b/common/djangoapps/student/tests/test_authz.py index 289c911d4a..dee2eb84f4 100644 --- a/common/djangoapps/student/tests/test_authz.py +++ b/common/djangoapps/student/tests/test_authz.py @@ -4,7 +4,7 @@ Tests authz.py import mock from django.test import TestCase -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser from xmodule.modulestore import Location from django.core.exceptions import PermissionDenied @@ -78,9 +78,10 @@ class CreatorGroupTest(TestCase): """ with mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}): - self.user.is_authenticated = False - add_users(self.admin, CourseCreatorRole(), self.user) - self.assertFalse(has_access(self.user, CourseCreatorRole())) + anonymous_user = AnonymousUser() + role = CourseCreatorRole() + add_users(self.admin, role, anonymous_user) + self.assertFalse(has_access(anonymous_user, role)) def test_add_user_not_active(self): """ diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index ce680d18ec..1abae9fabf 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -5,7 +5,7 @@ import unittest from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory from student.views import reactivation_email_for_user, change_email_request, confirm_email_change from student.models import UserProfile, PendingEmailChange -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser from django.test import TestCase, TransactionTestCase from django.test.client import RequestFactory from mock import Mock, patch @@ -157,10 +157,11 @@ class EmailChangeRequestTests(TestCase): self.assertFalse(self.user.email_user.called) def test_unauthenticated(self): - self.user.is_authenticated = False + self.request.user = AnonymousUser() + self.request.user.email_user = Mock() with self.assertRaises(Http404): change_email_request(self.request) - self.assertFalse(self.user.email_user.called) + self.assertFalse(self.request.user.email_user.called) def test_invalid_password(self): self.request.POST['password'] = 'wrong' diff --git a/common/djangoapps/student/tests/test_password_history.py b/common/djangoapps/student/tests/test_password_history.py new file mode 100644 index 0000000000..efa1e370ed --- /dev/null +++ b/common/djangoapps/student/tests/test_password_history.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +""" +This test file will verify proper password history enforcement +""" +from django.test import TestCase +from django.utils import timezone +from mock import patch +from student.tests.factories import UserFactory, AdminFactory + +from student.models import PasswordHistory +from freezegun import freeze_time +from datetime import timedelta + +from django.test.utils import override_settings + + +@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True}) +class TestPasswordHistory(TestCase): + """ + All the tests that assert proper behavior regarding password history + """ + + def _change_password(self, user, password): + """ + Helper method to change password on user and record in the PasswordHistory + """ + user.set_password(password) + user.save() + history = PasswordHistory() + history.create(user) + + def _user_factory_with_history(self, is_staff=False, set_initial_history=True): + """ + Helper method to generate either an Admin or a User + """ + if is_staff: + user = AdminFactory() + else: + user = UserFactory() + + user.date_joined = timezone.now() + + if set_initial_history: + history = PasswordHistory() + history.create(user) + + return user + + @patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': False}) + def test_disabled_feature(self): + """ + Test that behavior is normal when this feature is not turned on + """ + user = UserFactory() + staff = AdminFactory() + + # if feature is disabled user can keep reusing same password + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + self.assertFalse(PasswordHistory.should_user_reset_password_now(user)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_accounts_password_reuse(self): + """ + Assert against the password reuse policy + """ + user = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # students need to user at least one different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "different")) + self._change_password(user, "different") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + + # staff needs to use at least two different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self._change_password(staff, "different") + + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "third")) + self._change_password(staff, "third") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.PBKDF2PasswordHasher')) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_pbkdf2_sha256_password_reuse(self): + """ + Assert against the password reuse policy but using the normal Django PBKDF2 + """ + user = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # students need to user at least one different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "different")) + self._change_password(user, "different") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + + # staff needs to use at least two different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self._change_password(staff, "different") + + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "third")) + self._change_password(staff, "third") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': 1}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': 5}) + def test_forced_password_change(self): + """ + Assert when passwords must be reset + """ + student = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + grandfathered_student = self._user_factory_with_history(set_initial_history=False) + + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(staff)) + + self._change_password(staff, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + student_reset_time = timezone.now() + timedelta(days=5) + + with freeze_time(student_reset_time): + self.assertTrue(PasswordHistory.should_user_reset_password_now(student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(staff)) + + self._change_password(student, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + + self._change_password(grandfathered_student, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + self._change_password(staff, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': None}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': None}) + def test_no_forced_password_change(self): + """ + Assert that if we skip configuration, then user will never have to force reset password + """ + student = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # also create a user who doesn't have any history + grandfathered_student = UserFactory() + grandfathered_student.date_joined = timezone.now() + + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=100) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': 1}) + def test_too_frequent_password_resets(self): + """ + Assert that a user should not be able to password reset too frequently + """ + student = self._user_factory_with_history() + grandfathered_student = self._user_factory_with_history(set_initial_history=False) + + self.assertTrue(PasswordHistory.is_password_reset_too_soon(student)) + self.assertFalse(PasswordHistory.is_password_reset_too_soon(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=100) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.is_password_reset_too_soon(student)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': None}) + def test_disabled_too_frequent_password_resets(self): + """ + Verify properly default behavior when feature is disabled + """ + student = self._user_factory_with_history() + + self.assertFalse(PasswordHistory.is_password_reset_too_soon(student)) diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py index 647288ad0f..a649c53544 100644 --- a/common/djangoapps/student/tests/test_password_policy.py +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -87,7 +87,7 @@ class TestPasswordPolicy(TestCase): ) @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3}) - def test_password_not_enough_lowercase(self): + def test_password_enough_lowercase(self): self.url_params['password'] = 'ThisShouldPass' response = self.client.post(self.url, self.url_params) self.assertEqual(response.status_code, 200) diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py new file mode 100644 index 0000000000..d0f35bc9c7 --- /dev/null +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -0,0 +1,158 @@ +""" +Test the various password reset flows +""" +import json +import re +import unittest + +from django.core.cache import cache +from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory +from django.contrib.auth.models import User +from django.contrib.auth.hashers import UNUSABLE_PASSWORD +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import int_to_base36 + +from mock import Mock, patch +from textwrap import dedent + +from student.views import password_reset, password_reset_confirm_wrapper +from student.tests.factories import UserFactory +from student.tests.test_email import mock_render_to_string + + +class ResetPasswordTests(TestCase): + """ Tests that clicking reset password sends email, and doesn't activate the user + """ + request_factory = RequestFactory() + + def setUp(self): + self.user = UserFactory.create() + self.user.is_active = False + self.user.save() + self.token = default_token_generator.make_token(self.user) + self.uidb36 = int_to_base36(self.user.id) + + self.user_bad_passwd = UserFactory.create() + self.user_bad_passwd.is_active = False + self.user_bad_passwd.password = UNUSABLE_PASSWORD + self.user_bad_passwd.save() + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_user_bad_password_reset(self): + """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" + + bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) + bad_pwd_resp = password_reset(bad_pwd_req) + # If they've got an unusable password, we return a successful response code + self.assertEquals(bad_pwd_resp.status_code, 200) + obj = json.loads(bad_pwd_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_nonexist_email_password_reset(self): + """Now test the exception cases with of reset_password called with invalid email.""" + + bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email + "makeItFail"}) + bad_email_resp = password_reset(bad_email_req) + # Note: even if the email is bad, we return a successful response code + # This prevents someone potentially trying to "brute-force" find out which + # emails are and aren't registered with edX + self.assertEquals(bad_email_resp.status_code, 200) + obj = json.loads(bad_email_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_password_reset_ratelimited(self): + """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ + cache.clear() + + for i in xrange(30): + good_req = self.request_factory.post('/password_reset/', { + 'email': 'thisdoesnotexist{0}@foo.com'.format(i) + }) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + + # then the rate limiter should kick in and give a HttpForbidden response + bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) + bad_resp = password_reset(bad_req) + self.assertEquals(bad_resp.status_code, 403) + + cache.clear() + + @unittest.skipIf( + settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), + dedent(""" + Skipping Test because CMS has not provided necessary templates for password reset. + If LMS tests print this message, that needs to be fixed. + """) + ) + @patch('django.core.mail.send_mail') + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_reset_password_email(self, send_email): + """Tests contents of reset password email, and that user is not active""" + + good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + obj = json.loads(good_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + (subject, msg, from_addr, to_addrs) = send_email.call_args[0] + self.assertIn("Password reset", subject) + self.assertIn("You're receiving this e-mail because you requested a password reset", msg) + self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) + self.assertEquals(len(to_addrs), 1) + self.assertIn(self.user.email, to_addrs) + + #test that the user is not active + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() + + @patch('student.views.password_reset_confirm') + def test_reset_password_bad_token(self, reset_confirm): + """Tests bad token and uidb36 in password reset""" + + bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') + password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], 'NO') + self.assertEquals(confirm_kwargs['token'], 'OP') + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + @patch('student.views.password_reset_confirm') + def test_reset_password_good_token(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) + + @patch('student.views.password_reset_confirm') + def test_reset_password_with_reused_password(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index f7035db51c..48d8bb642e 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -5,21 +5,15 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ import logging -import json -import re import unittest from datetime import datetime, timedelta import pytz -from django.core.cache import cache from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.test.client import RequestFactory from django.contrib.auth.models import User, AnonymousUser -from django.contrib.auth.hashers import UNUSABLE_PASSWORD -from django.contrib.auth.tokens import default_token_generator -from django.utils.http import int_to_base36 from django.core.urlresolvers import reverse from django.http import HttpResponse @@ -28,13 +22,11 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from mock import Mock, patch, sentinel -from textwrap import dedent from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user -from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, - change_enrollment, complete_course_mode_info, token, course_from_id) +from student.views import (process_survey_link, _cert_info, + change_enrollment, complete_course_mode_info, token) from student.tests.factories import UserFactory, CourseModeFactory -from student.tests.test_email import mock_render_to_string import shoppingcart @@ -44,127 +36,6 @@ COURSE_2 = 'edx/full/6.002_Spring_2012' log = logging.getLogger(__name__) -class ResetPasswordTests(TestCase): - """ Tests that clicking reset password sends email, and doesn't activate the user - """ - request_factory = RequestFactory() - - def setUp(self): - self.user = UserFactory.create() - self.user.is_active = False - self.user.save() - self.token = default_token_generator.make_token(self.user) - self.uidb36 = int_to_base36(self.user.id) - - self.user_bad_passwd = UserFactory.create() - self.user_bad_passwd.is_active = False - self.user_bad_passwd.password = UNUSABLE_PASSWORD - self.user_bad_passwd.save() - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_user_bad_password_reset(self): - """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" - - bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) - bad_pwd_resp = password_reset(bad_pwd_req) - # If they've got an unusable password, we return a successful response code - self.assertEquals(bad_pwd_resp.status_code, 200) - obj = json.loads(bad_pwd_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_nonexist_email_password_reset(self): - """Now test the exception cases with of reset_password called with invalid email.""" - - bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) - bad_email_resp = password_reset(bad_email_req) - # Note: even if the email is bad, we return a successful response code - # This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX - self.assertEquals(bad_email_resp.status_code, 200) - obj = json.loads(bad_email_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_password_reset_ratelimited(self): - """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ - cache.clear() - - for i in xrange(30): - good_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) - good_resp = password_reset(good_req) - self.assertEquals(good_resp.status_code, 200) - - # then the rate limiter should kick in and give a HttpForbidden response - bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) - bad_resp = password_reset(bad_req) - self.assertEquals(bad_resp.status_code, 403) - - cache.clear() - - @unittest.skipIf( - settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), - dedent(""" - Skipping Test because CMS has not provided necessary templates for password reset. - If LMS tests print this message, that needs to be fixed. - """) - ) - @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_reset_password_email(self, send_email): - """Tests contents of reset password email, and that user is not active""" - - good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) - good_resp = password_reset(good_req) - self.assertEquals(good_resp.status_code, 200) - obj = json.loads(good_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - ((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args - self.assertIn("Password reset", subject) - self.assertIn("You're receiving this e-mail because you requested a password reset", msg) - self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) - self.assertEquals(len(to_addrs), 1) - self.assertIn(self.user.email, to_addrs) - - #test that the user is not active - self.user = User.objects.get(pk=self.user.pk) - self.assertFalse(self.user.is_active) - reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() - - @patch('student.views.password_reset_confirm') - def test_reset_password_bad_token(self, reset_confirm): - """Tests bad token and uidb36 in password reset""" - - bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') - password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') - (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], 'NO') - self.assertEquals(confirm_kwargs['token'], 'OP') - self.user = User.objects.get(pk=self.user.pk) - self.assertFalse(self.user.is_active) - - @patch('student.views.password_reset_confirm') - def test_reset_password_good_token(self, reset_confirm): - """Tests good token and uidb36 in password reset""" - - good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) - password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) - (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) - self.assertEquals(confirm_kwargs['token'], self.token) - self.user = User.objects.get(pk=self.user.pk) - self.assertTrue(self.user.is_active) - - class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 54f3e2bfb3..4e9226b325 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -30,6 +30,8 @@ from django.utils.http import cookie_date, base36_to_int from django.utils.translation import ugettext as _, get_language from django.views.decorators.http import require_POST, require_GET +from django.template.response import TemplateResponse + from ratelimitbackend.exceptions import RateLimitException from edxmako.shortcuts import render_to_response, render_to_string @@ -39,7 +41,7 @@ from student.models import ( Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, CourseEnrollmentAllowed, UserStanding, LoginFailures, - create_comments_service_user + create_comments_service_user, PasswordHistory ) from student.forms import PasswordResetFormNoActive from student.firebase_token_generator import create_token @@ -609,15 +611,6 @@ def change_enrollment(request): ) current_mode = available_modes[0] - - course_id_dict = Location.parse_course_id(course_id) - dog_stats_api.increment( - "common.student.enrollment", - tags=[u"org:{org}".format(**course_id_dict), - u"course:{course}".format(**course_id_dict), - u"run:{name}".format(**course_id_dict)] - ) - CourseEnrollment.enroll(user, course.id, mode=current_mode.slug) return HttpResponse() @@ -639,13 +632,6 @@ def change_enrollment(request): if not CourseEnrollment.is_enrolled(user, course_id): return HttpResponseBadRequest(_("You are not enrolled in this course")) CourseEnrollment.unenroll(user, course_id) - course_id_dict = Location.parse_course_id(course_id) - dog_stats_api.increment( - "common.student.unenrollment", - tags=[u"org:{org}".format(**course_id_dict), - u"course:{course}".format(**course_id_dict), - u"run:{name}".format(**course_id_dict)] - ) return HttpResponse() else: return HttpResponseBadRequest(_("Enrollment action is invalid")) @@ -747,6 +733,15 @@ def login_user(request, error=""): "value": _('This account has been temporarily locked due to excessive login failures. Try again later.'), }) # TODO: this should be status code 429 # pylint: disable=fixme + # see if the user must reset his/her password due to any policy settings + if PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup): + return JsonResponse({ + "success": False, + "value": _('Your password has expired due to password policy on this account. You must ' + 'reset your password before you can log in again. Please click the ' + 'Forgot Password" link on this page to reset your password before logging in again.'), + }) # TODO: this should be status code 403 # pylint: disable=fixme + # if the user doesn't exist, we want to set the username to an invalid # username so that authentication is guaranteed to fail and we can take # advantage of the ratelimited backend @@ -971,6 +966,7 @@ def _do_create_account(post_vars): is_active=False) user.set_password(post_vars['password']) registration = Registration() + # TODO: Rearrange so that if part of the process fails, the whole process fails. # Right now, we can have e.g. no registration e-mail sent out and a zombie account try: @@ -990,6 +986,11 @@ def _do_create_account(post_vars): else: raise + # add this account creation to password history + # NOTE, this will be a NOP unless the feature has been turned on in configuration + password_history_entry = PasswordHistory() + password_history_entry.create(user) + registration.register(user) profile = UserProfile(user=user) @@ -1419,12 +1420,71 @@ def password_reset_confirm_wrapper( user.save() except (ValueError, User.DoesNotExist): pass - # we also want to pass settings.PLATFORM_NAME in as extra_context - extra_context = {"platform_name": settings.PLATFORM_NAME} - return password_reset_confirm( - request, uidb36=uidb36, token=token, extra_context=extra_context - ) + # tie in password strength enforcement as an optional level of + # security protection + err_msg = None + + if request.method == 'POST': + password = request.POST['new_password1'] + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + validate_password_length(password) + validate_password_complexity(password) + validate_password_dictionary(password) + except ValidationError, err: + err_msg = _('Password: ') + '; '.join(err.messages) + + # also, check the password reuse policy + if not PasswordHistory.is_allowable_password_reuse(user, password): + if user.is_staff: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + else: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + err_msg = _("You are re-using a password that you have used recently. You must " + "have {0} distinct password(s) before reusing a previous password.").format(num_distinct) + + # also, check to see if passwords are getting reset too frequent + if PasswordHistory.is_password_reset_too_soon(user): + num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + err_msg = _("You are resetting passwords too frequently. Due to security policies, " + "{0} day(s) must elapse between password resets").format(num_days) + + if err_msg: + # We have an password reset attempt which violates some security policy, use the + # existing Django template to communicate this back to the user + context = { + 'validlink': True, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': err_msg, + } + return TemplateResponse(request, 'registration/password_reset_confirm.html', context) + else: + # we also want to pass settings.PLATFORM_NAME in as extra_context + extra_context = {"platform_name": settings.PLATFORM_NAME} + + if request.method == 'POST': + # remember what the old password hash is before we call down + old_password_hash = user.password + + result = password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) + + # get the updated user + updated_user = User.objects.get(id=uid_int) + + # did the password hash change, if so record it in the PasswordHistory + if updated_user.password != old_password_hash: + entry = PasswordHistory() + entry.create(updated_user) + + return result + else: + return password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) def reactivation_email_for_user(user): @@ -1462,7 +1522,7 @@ def change_email_request(request): """ AJAX call from the profile page. User wants a new e-mail. """ ## Make sure it checks for existing e-mail conflicts - if not request.user.is_authenticated: + if not request.user.is_authenticated(): raise Http404 user = request.user diff --git a/common/djangoapps/terrain/start_stubs.py b/common/djangoapps/terrain/start_stubs.py index 3e663364e7..b859e95df4 100644 --- a/common/djangoapps/terrain/start_stubs.py +++ b/common/djangoapps/terrain/start_stubs.py @@ -1,7 +1,7 @@ """ Initialize and teardown fake HTTP services for use in acceptance tests. """ - +import requests from lettuce import before, after, world from django.conf import settings from terrain.stubs.youtube import StubYouTubeService @@ -14,6 +14,8 @@ SERVICES = { "lti": {"port": settings.LTI_PORT, "class": StubLtiService}, } +YOUTUBE_API_RESPONSE = requests.get('http://www.youtube.com/iframe_api') + @before.each_scenario def start_stubs(_): @@ -22,6 +24,8 @@ def start_stubs(_): """ for name, service in SERVICES.iteritems(): fake_server = service['class'](port_num=service['port']) + if name == 'youtube': + fake_server.config['youtube_api_response'] = YOUTUBE_API_RESPONSE setattr(world, name, fake_server) diff --git a/common/djangoapps/terrain/stubs/youtube.py b/common/djangoapps/terrain/stubs/youtube.py index 5e7a0de8ce..15489c9fb8 100644 --- a/common/djangoapps/terrain/stubs/youtube.py +++ b/common/djangoapps/terrain/stubs/youtube.py @@ -80,7 +80,7 @@ class StubYouTubeHandler(StubHttpRequestHandler): if self.server.config.get('youtube_api_blocked'): self.send_response(404, content='', headers={'Content-type': 'text/plain'}) else: - response = requests.get('http://www.youtube.com/iframe_api') + response = self.server.config['youtube_api_response'] self.send_response(200, content=response.text, headers={'Content-type': 'text/html'}) else: diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 21fb55c460..77d590777f 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -17,6 +17,8 @@ from xmodule.seq_module import SequenceModule from xmodule.vertical_module import VerticalModule from xmodule.x_module import shim_xmodule_js, XModuleDescriptor, XModule from lms.lib.xblock.runtime import quote_slashes +from xmodule.modulestore import MONGO_MODULESTORE_TYPE +from xmodule.modulestore.django import modulestore, loc_mapper log = logging.getLogger(__name__) @@ -72,13 +74,13 @@ def wrap_xblock(runtime_class, block, view, frag, context, display_name_only=Fal data['runtime-class'] = runtime_class data['runtime-version'] = frag.js_init_version data['block-type'] = block.scope_ids.block_type - data['usage-id'] = quote_slashes(unicode(block.scope_ids.usage_id).encode('utf-8')) + data['usage-id'] = quote_slashes(unicode(block.scope_ids.usage_id)) template_context = { 'content': block.display_name if display_name_only else frag.content, 'classes': css_classes, 'display_name': block.display_name_with_default, - 'data_attributes': ' '.join(u'data-{}="{}"'.format(key, value) for key, value in data.items()), + 'data_attributes': u' '.join(u'data-{}="{}"'.format(key, value) for key, value in data.items()), } return wrap_fragment(frag, render_to_string('xblock_wrapper.html', template_context)) @@ -152,17 +154,33 @@ def grade_histogram(module_id): return grades -def add_staff_debug_info(user, block, view, frag, context): # pylint: disable=unused-argument +def add_staff_markup(user, block, view, frag, context): # pylint: disable=unused-argument """ Updates the supplied module with a new get_html function that wraps the output of the old get_html function with additional information - for admin users only, including a histogram of student answers and the - definition of the xmodule + for admin users only, including a histogram of student answers, the + definition of the xmodule, and a link to view the module in Studio + if it is a Studio edited, mongo stored course. - Does nothing if module is a SequenceModule or a VerticalModule. + Does nothing if module is a SequenceModule. """ # TODO: make this more general, eg use an XModule attribute instead - if isinstance(block, (SequenceModule, VerticalModule)): + if isinstance(block, VerticalModule): + # check that the course is a mongo backed Studio course before doing work + is_mongo_course = modulestore().get_modulestore_type(block.course_id) == MONGO_MODULESTORE_TYPE + is_studio_course = block.course_edit_method == "Studio" + + if is_studio_course and is_mongo_course: + # get relative url/location of unit in Studio + locator = loc_mapper().translate_location(block.course_id, block.location, False, True) + # build edit link to unit in CMS + edit_link = "//" + settings.CMS_BASE + locator.url_reverse('unit', '') + # return edit link in rendered HTML for display + return wrap_fragment(frag, render_to_string("edit_unit_link.html", {'frag_content': frag.content, 'edit_link': edit_link})) + else: + return frag + + if isinstance(block, SequenceModule): return frag block_id = block.id diff --git a/common/lib/calc/setup.py b/common/lib/calc/setup.py index 361884babf..e072e44a9a 100644 --- a/common/lib/calc/setup.py +++ b/common/lib/calc/setup.py @@ -5,7 +5,7 @@ setup( version="0.2", packages=["calc"], install_requires=[ - "pyparsing==1.5.6", + "pyparsing==2.0.1", "numpy", "scipy" ], diff --git a/common/lib/chem/setup.py b/common/lib/chem/setup.py index 642c9a4fe5..f275ea4d25 100644 --- a/common/lib/chem/setup.py +++ b/common/lib/chem/setup.py @@ -5,7 +5,7 @@ setup( version="0.1.1", packages=["chem"], install_requires=[ - "pyparsing==1.5.6", + "pyparsing==2.0.1", "numpy", "scipy", "nltk==2.0.4", diff --git a/common/lib/xmodule/xmodule/capa_base.py b/common/lib/xmodule/xmodule/capa_base.py index b88f83f552..88ffa33927 100644 --- a/common/lib/xmodule/xmodule/capa_base.py +++ b/common/lib/xmodule/xmodule/capa_base.py @@ -9,6 +9,13 @@ import traceback import struct import sys +# We don't want to force a dependency on datadog, so make the import conditional +try: + from dogapi import dog_stats_api +except ImportError: + # pylint: disable=invalid-name + dog_stats_api = None + from pkg_resources import resource_string from capa.capa_problem import LoncapaProblem, LoncapaSystem @@ -869,18 +876,24 @@ class CapaMixin(CapaFields): answers_without_files = convert_files_to_filenames(answers) event_info['answers'] = answers_without_files + metric_name = u'capa.check_problem.{}'.format + _ = self.runtime.service(self, "i18n").ugettext # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' self.runtime.track_function('problem_check_fail', event_info) + if dog_stats_api: + dog_stats_api.increment(metric_name('checks'), tags=[u'result:failed', u'failure:closed']) raise NotFoundError(_("Problem is closed.")) # Problem submitted. Student should reset before checking again if self.done and self.rerandomize == "always": event_info['failure'] = 'unreset' self.runtime.track_function('problem_check_fail', event_info) + if dog_stats_api: + dog_stats_api.increment(metric_name('checks'), tags=[u'result:failed', u'failure:unreset']) raise NotFoundError(_("Problem must be reset before it can be checked again.")) # Problem queued. Students must wait a specified waittime before they are allowed to submit @@ -948,6 +961,17 @@ class CapaMixin(CapaFields): event_info['submission'] = self.get_submission_metadata_safe(answers_without_files, correct_map) self.runtime.track_function('problem_check', event_info) + if dog_stats_api: + dog_stats_api.increment(metric_name('checks'), tags=[u'result:success']) + dog_stats_api.histogram( + metric_name('correct_pct'), + float(published_grade['grade']) / published_grade['max_grade'], + ) + dog_stats_api.histogram( + metric_name('attempts'), + self.attempts, + ) + if hasattr(self.runtime, 'psychometrics_handler'): # update PsychometricsData using callback self.runtime.psychometrics_handler(self.get_state_for_lcp()) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 7462babf4e..3afc44b85b 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -224,6 +224,7 @@ class CourseFields(object): scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings) + course_edit_method = String(help="Method with which this course is edited.", default="Studio", scope=Scope.settings) show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) tabs = CourseTabList(help="List of tabs to enable in this course", scope=Scope.settings, default=[]) end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/css/html/edit.scss b/common/lib/xmodule/xmodule/css/html/edit.scss index bd9722df67..8b8e48d944 100644 --- a/common/lib/xmodule/xmodule/css/html/edit.scss +++ b/common/lib/xmodule/xmodule/css/html/edit.scss @@ -19,7 +19,7 @@ } .editor-tabs { - top: 11px !important; + top: 0 !important; right: 10px; z-index: 99; } diff --git a/common/lib/xmodule/xmodule/js/fixtures/html-edit-formattingbug.html b/common/lib/xmodule/xmodule/js/fixtures/html-edit-formattingbug.html deleted file mode 100644 index 5db864373d..0000000000 --- a/common/lib/xmodule/xmodule/js/fixtures/html-edit-formattingbug.html +++ /dev/null @@ -1,18 +0,0 @@ -
      - - - -
      \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/fixtures/html-edit-with-links.html b/common/lib/xmodule/xmodule/js/fixtures/html-edit-with-links.html deleted file mode 100644 index 46d6313699..0000000000 --- a/common/lib/xmodule/xmodule/js/fixtures/html-edit-with-links.html +++ /dev/null @@ -1,10 +0,0 @@ -
      - -
      - - -
      -
      \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/fixtures/html-edit.html b/common/lib/xmodule/xmodule/js/fixtures/html-edit.html index 22dfc97dcb..11f7868ff7 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/html-edit.html +++ b/common/lib/xmodule/xmodule/js/fixtures/html-edit.html @@ -1,10 +1,3 @@
      - -
      - - -
      -
      \ No newline at end of file + + diff --git a/common/lib/xmodule/xmodule/js/js_test.yml b/common/lib/xmodule/xmodule/js/js_test.yml index 8aeeaba122..95d4df3e3f 100644 --- a/common/lib/xmodule/xmodule/js/js_test.yml +++ b/common/lib/xmodule/xmodule/js/js_test.yml @@ -49,8 +49,8 @@ lib_paths: - common_static/js/vendor/backbone-min.js - common_static/js/vendor/jquery.leanModal.min.js - common_static/js/vendor/CodeMirror/codemirror.js - - common_static/js/vendor/tiny_mce/jquery.tinymce.js - - common_static/js/vendor/tiny_mce/tiny_mce.js + - common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce.min.js + - common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min.js - common_static/js/vendor/mathjax-MathJax-c9db6ac/MathJax.js - common_static/js/vendor/jquery.timeago.js - common_static/js/vendor/sinon-1.7.1.js @@ -58,6 +58,7 @@ lib_paths: - common_static/js/test/add_ajax_prefix.js - common_static/js/src/utility.js - public/js/split_test_staff.js + - common_static/js/src/accessibility_tools.js # Paths to spec (test) JavaScript files spec_paths: diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee index 33927ed1f9..c0ec84a912 100644 --- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee @@ -7,10 +7,8 @@ describe 'Problem', -> @stubbedJax = root: jasmine.createSpyObj('jax.root', ['toMathML']) MathJax.Hub.getAllJax.andReturn [@stubbedJax] window.update_schematics = -> - # mock the screen reader alert - window.SR = - readElts: `function(){}` - readText: `function(){}` + spyOn SR, 'readElts' + spyOn SR, 'readText' # Load this function from spec/helper.coffee # Note that if your test fails with a message like: @@ -168,6 +166,7 @@ describe 'Problem', -> callback(success: 'correct', contents: 'Correct!') @problem.check() expect(@problem.el.html()).toEqual 'Correct!' + expect(window.SR.readElts).toHaveBeenCalled() describe 'when the response is incorrect', -> it 'call render with returned content', -> @@ -175,6 +174,7 @@ describe 'Problem', -> callback(success: 'incorrect', contents: 'Incorrect!') @problem.check() expect(@problem.el.html()).toEqual 'Incorrect!' + expect(window.SR.readElts).toHaveBeenCalled() # TODO: figure out why failing xdescribe 'when the response is undetermined', -> @@ -237,12 +237,25 @@ describe 'Problem', -> spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {}) @problem.show() expect($('.show .show-label')).toHaveText 'Hide Answer' + expect(window.SR.readElts).toHaveBeenCalled() it 'add the showed class to element', -> spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {}) @problem.show() expect(@problem.el).toHaveClass 'showed' + it 'reads the answers', -> + runs -> + spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: 'answers') + @problem.show() + + waitsFor (-> + return jQuery.active == 0 + ), "jQuery requests finished", 1000 + + runs -> + expect(window.SR.readElts).toHaveBeenCalled() + describe 'multiple choice question', -> beforeEach -> @problem.el.prepend ''' @@ -487,6 +500,17 @@ describe 'Problem', -> expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save', 'foo=1&bar=2', jasmine.any(Function) + it 'reads the save message', -> + runs -> + spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'OK') + @problem.save() + waitsFor (-> + return jQuery.active == 0 + ), "jQuery requests finished", 1000 + + runs -> + expect(window.SR.readElts).toHaveBeenCalled() + # TODO: figure out why failing xit 'alert to the user', -> spyOn window, 'alert' diff --git a/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee index 4920002541..9b5cb0c582 100644 --- a/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/html/edit_spec.coffee @@ -3,53 +3,24 @@ describe 'HTMLEditingDescriptor', -> window.baseUrl = "/static/deadbeef" afterEach -> delete window.baseUrl - describe 'Read data from server, create Editor, and get data back out', -> - it 'Does not munge <', -> -# This is a test for Lighthouse #22, -# "html names are automatically converted to the symbols they describe" -# A better test would be a Selenium test to avoid duplicating the -# mako template structure in html-edit-formattingbug.html. -# However, we currently have no working Selenium tests. - loadFixtures 'html-edit-formattingbug.html' - @descriptor = new HTMLEditingDescriptor($('.html-edit')) - visualEditorStub = - isDirty: () -> false - spyOn(@descriptor, 'getVisualEditor').andCallFake () -> - visualEditorStub - data = @descriptor.save().data - expect(data).toEqual("""<problem> - <p></p> - <multiplechoiceresponse> -
      <problem>
      -                               <p></p>
      -
      bar
      """) - describe 'Saves HTML', -> + describe 'HTML Editor', -> beforeEach -> loadFixtures 'html-edit.html' @descriptor = new HTMLEditingDescriptor($('.html-edit')) - it 'Returns data from Advanced Editor if Visual Editor is not dirty', -> - visualEditorStub = - isDirty: () -> false - spyOn(@descriptor, 'getVisualEditor').andCallFake () -> - visualEditorStub - expect(@descriptor.showingVisualEditor).toEqual(true) - data = @descriptor.save().data - expect(data).toEqual('Advanced Editor Text') - it 'Returns data from Advanced Editor if Visual Editor is not showing (even if Visual Editor is dirty)', -> - visualEditorStub = - isDirty: () -> true - spyOn(@descriptor, 'getVisualEditor').andCallFake () -> - visualEditorStub - @descriptor.showingVisualEditor = false - data = @descriptor.save().data - expect(data).toEqual('Advanced Editor Text') - it 'Returns data from Visual Editor if Visual Editor is dirty and showing', -> + it 'Returns data from Visual Editor if Visual Editor is dirty', -> visualEditorStub = isDirty: () -> true getContent: () -> 'from visual editor' spyOn(@descriptor, 'getVisualEditor').andCallFake () -> visualEditorStub - expect(@descriptor.showingVisualEditor).toEqual(true) + data = @descriptor.save().data + expect(data).toEqual('from visual editor') + it 'Returns data from Visual Editor even if Visual Editor is not dirty', -> + visualEditorStub = + isDirty: () -> false + getContent: () -> 'from visual editor' + spyOn(@descriptor, 'getVisualEditor').andCallFake () -> + visualEditorStub data = @descriptor.save().data expect(data).toEqual('from visual editor') it 'Performs link rewriting for static assets when saving', -> @@ -58,63 +29,19 @@ describe 'HTMLEditingDescriptor', -> getContent: () -> 'from visual editor with /c4x/foo/bar/asset/image.jpg' spyOn(@descriptor, 'getVisualEditor').andCallFake () -> visualEditorStub - expect(@descriptor.showingVisualEditor).toEqual(true) @descriptor.base_asset_url = '/c4x/foo/bar/asset/' data = @descriptor.save().data expect(data).toEqual('from visual editor with /static/image.jpg') - describe 'Can switch to Advanced Editor', -> - beforeEach -> - loadFixtures 'html-edit.html' - @descriptor = new HTMLEditingDescriptor($('.html-edit')) - it 'Populates from Visual Editor if Advanced Visual is dirty', -> - expect(@descriptor.showingVisualEditor).toEqual(true) - visualEditorStub = - isDirty: () -> true - getContent: () -> 'from visual editor' - @descriptor.showAdvancedEditor(visualEditorStub) - expect(@descriptor.showingVisualEditor).toEqual(false) - expect(@descriptor.advanced_editor.getValue()).toEqual('from visual editor') - it 'Does not populate from Visual Editor if Visual Editor is not dirty', -> - expect(@descriptor.showingVisualEditor).toEqual(true) - visualEditorStub = - isDirty: () -> false - getContent: () -> 'from visual editor' - @descriptor.showAdvancedEditor(visualEditorStub) - expect(@descriptor.showingVisualEditor).toEqual(false) - expect(@descriptor.advanced_editor.getValue()).toEqual('Advanced Editor Text') - describe 'Can switch to Visual Editor', -> - it 'Always populates from the Advanced Editor', -> - loadFixtures 'html-edit.html' - @descriptor = new HTMLEditingDescriptor($('.html-edit')) - @descriptor.showingVisualEditor = false - - visualEditorStub = - content: 'not set' - startContent: 'not set', - focus: () -> true - isDirty: () -> false - setContent: (x) -> @content = x - getContent: -> @content - - @descriptor.showVisualEditor(visualEditorStub) - expect(@descriptor.showingVisualEditor).toEqual(true) - expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text') - expect(visualEditorStub.startContent).toEqual('Advanced Editor Text') - it 'When switching to visual editor links are rewritten to c4x format', -> - loadFixtures 'html-edit-with-links.html' + it 'When showing visual editor links are rewritten to c4x format', -> @descriptor = new HTMLEditingDescriptor($('.html-edit')) @descriptor.base_asset_url = '/c4x/foo/bar/asset/' - @descriptor.showingVisualEditor = false visualEditorStub = - content: 'not set' - startContent: 'not set', - focus: () -> true - isDirty: () -> false + content: 'text /static/image.jpg' + startContent: 'text /static/image.jpg' + focus: -> setContent: (x) -> @content = x getContent: -> @content - @descriptor.showVisualEditor(visualEditorStub) - expect(@descriptor.showingVisualEditor).toEqual(true) - expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg') - expect(visualEditorStub.startContent).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg') + @descriptor.initInstanceCallback(visualEditorStub) + expect(visualEditorStub.getContent()).toEqual('text /c4x/foo/bar/asset/image.jpg') diff --git a/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js b/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js index 31ffe79a11..e08ba56a75 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js @@ -396,6 +396,89 @@ function (Initialize) { }); }); }); + + describe('setPlayerMode', function () { + beforeEach(function () { + state = { + currentPlayerMode: 'flash', + }; + }); + + it('updates player mode', function () { + var setPlayerMode = Initialize.prototype.setPlayerMode; + + setPlayerMode.call(state, 'html5'); + expect(state.currentPlayerMode).toBe('html5'); + setPlayerMode.call(state, 'flash'); + expect(state.currentPlayerMode).toBe('flash'); + }); + + it('sets default mode if passed is not supported', function () { + var setPlayerMode = Initialize.prototype.setPlayerMode; + + setPlayerMode.call(state, '77html77'); + expect(state.currentPlayerMode).toBe('html5'); + }); + }); + + describe('getPlayerMode', function () { + beforeEach(function () { + state = { + currentPlayerMode: 'flash', + }; + }); + + it('returns current player mode', function () { + var getPlayerMode = Initialize.prototype.getPlayerMode, + actual = getPlayerMode.call(state); + + expect(actual).toBe(state.currentPlayerMode); + }); + }); + + describe('isFlashMode', function () { + it('returns `true` if player in `flash` mode', function () { + var state = { + getPlayerMode: jasmine.createSpy().andReturn('flash'), + }, + isFlashMode = Initialize.prototype.isFlashMode, + actual = isFlashMode.call(state); + + expect(actual).toBeTruthy(); + }); + + it('returns `false` if player is not in `flash` mode', function () { + var state = { + getPlayerMode: jasmine.createSpy().andReturn('html5'), + }, + isFlashMode = Initialize.prototype.isFlashMode, + actual = isFlashMode.call(state); + + expect(actual).toBeFalsy(); + }); + }); + + describe('isHtml5Mode', function () { + it('returns `true` if player in `html5` mode', function () { + var state = { + getPlayerMode: jasmine.createSpy().andReturn('html5'), + }, + isHtml5Mode = Initialize.prototype.isHtml5Mode, + actual = isHtml5Mode.call(state); + + expect(actual).toBeTruthy(); + }); + + it('returns `false` if player is not in `html5` mode', function () { + var state = { + getPlayerMode: jasmine.createSpy().andReturn('flash'), + }, + isHtml5Mode = Initialize.prototype.isHtml5Mode, + actual = isHtml5Mode.call(state); + + expect(actual).toBeFalsy(); + }); + }); }); }); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 34ae9e9b66..3bc7b5c858 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -123,28 +123,16 @@ it('bind the hide caption button', function () { state = jasmine.initializePlayer(); - expect($('.hide-subtitles')).toHandleWith( - 'click', state.videoCaption.toggle - ); + expect($('.hide-subtitles')).toHandle('click'); }); it('bind the mouse movement', function () { state = jasmine.initializePlayer(); - expect($('.subtitles')).toHandleWith( - 'mouseover', state.videoCaption.onMouseEnter - ); - expect($('.subtitles')).toHandleWith( - 'mouseout', state.videoCaption.onMouseLeave - ); - expect($('.subtitles')).toHandleWith( - 'mousemove', state.videoCaption.onMovement - ); - expect($('.subtitles')).toHandleWith( - 'mousewheel', state.videoCaption.onMovement - ); - expect($('.subtitles')).toHandleWith( - 'DOMMouseScroll', state.videoCaption.onMovement - ); + expect($('.subtitles')).toHandle('mouseover'); + expect($('.subtitles')).toHandle('mouseout'); + expect($('.subtitles')).toHandle('mousemove'); + expect($('.subtitles')).toHandle('mousewheel'); + expect($('.subtitles')).toHandle('DOMMouseScroll'); }); it('bind the scroll', function () { @@ -859,7 +847,7 @@ runs(function () { videoControl = state.videoControl; $('.subtitles li[data-index=1]').addClass('current'); - state.videoCaption.resize(); + state.videoCaption.onResize(); }); }); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index b5811e01e7..6c6685e4fc 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -26,7 +26,6 @@ function (VideoPlayer) { describe('always', function () { beforeEach(function () { state = jasmine.initializePlayer(); - state.videoEl = $('video, iframe'); }); @@ -211,7 +210,7 @@ function (VideoPlayer) { state.videoEl = $('video, iframe'); spyOn(state.videoControl, 'pause').andCallThrough(); - spyOn(state.videoCaption, 'pause').andCallThrough(); + spyOn($.fn, 'trigger').andCallThrough(); state.videoPlayer.onStateChange({ data: YT.PlayerState.PAUSED @@ -223,7 +222,7 @@ function (VideoPlayer) { }); it('pause the video caption', function () { - expect(state.videoCaption.pause).toHaveBeenCalled(); + expect($.fn.trigger).toHaveBeenCalledWith('pause', {}); }); }); @@ -245,7 +244,7 @@ function (VideoPlayer) { spyOn(state.videoPlayer, 'log').andCallThrough(); spyOn(window, 'setInterval').andReturn(100); spyOn(state.videoControl, 'play'); - spyOn(state.videoCaption, 'play'); + spyOn($.fn, 'trigger').andCallThrough(); state.videoPlayer.onStateChange({ data: YT.PlayerState.PLAYING @@ -281,7 +280,7 @@ function (VideoPlayer) { }); it('play the video caption', function () { - expect(state.videoCaption.play).toHaveBeenCalled(); + expect($.fn.trigger).toHaveBeenCalledWith('play', {}); }); }); @@ -295,7 +294,7 @@ function (VideoPlayer) { spyOn(state.videoPlayer, 'log').andCallThrough(); spyOn(state.videoControl, 'pause').andCallThrough(); - spyOn(state.videoCaption, 'pause').andCallThrough(); + spyOn($.fn, 'trigger').andCallThrough(); state.videoPlayer.onStateChange({ data: YT.PlayerState.PLAYING @@ -323,7 +322,7 @@ function (VideoPlayer) { }); it('pause the video caption', function () { - expect(state.videoCaption.pause).toHaveBeenCalled(); + expect($.fn.trigger).toHaveBeenCalledWith('pause', {}); }); }); @@ -334,7 +333,7 @@ function (VideoPlayer) { state.videoEl = $('video, iframe'); spyOn(state.videoControl, 'pause').andCallThrough(); - spyOn(state.videoCaption, 'pause').andCallThrough(); + spyOn($.fn, 'trigger').andCallThrough(); state.videoPlayer.onStateChange({ data: YT.PlayerState.ENDED @@ -346,7 +345,7 @@ function (VideoPlayer) { }); it('pause the video caption', function () { - expect(state.videoCaption.pause).toHaveBeenCalled(); + expect($.fn.trigger).toHaveBeenCalledWith('ended', {}); }); }); }); @@ -709,6 +708,7 @@ function (VideoPlayer) { describe('updatePlayTime with invalid endTime', function () { beforeEach(function () { state = { + el: $('#video_id'), videoPlayer: { duration: function () { // The video will be 60 seconds long. @@ -756,10 +756,7 @@ function (VideoPlayer) { describe('when the video player is not full screen', function () { beforeEach(function () { state = jasmine.initializePlayer(); - state.videoEl = $('video, iframe'); - - spyOn(state.videoCaption, 'resize').andCallThrough(); spyOn($.fn, 'trigger').andCallThrough(); state.videoControl.toggleFullScreen(jQuery.Event('click')); }); @@ -774,7 +771,7 @@ function (VideoPlayer) { }); it('tell VideoCaption to resize', function () { - expect(state.videoCaption.resize).toHaveBeenCalled(); + expect($.fn.trigger).toHaveBeenCalledWith('fullscreen', [true]); expect(state.resizer.setMode).toHaveBeenCalledWith('both'); expect(state.resizer.delta.substract).toHaveBeenCalled(); }); @@ -783,11 +780,8 @@ function (VideoPlayer) { describe('when the video player already full screen', function () { beforeEach(function () { state = jasmine.initializePlayer(); - state.videoEl = $('video, iframe'); - - spyOn(state.videoCaption, 'resize').andCallThrough(); - + spyOn($.fn, 'trigger').andCallThrough(); state.el.addClass('video-fullscreen'); state.videoControl.fullScreenState = true; state.videoControl.isFullScreen = true; @@ -806,7 +800,7 @@ function (VideoPlayer) { }); it('tell VideoCaption to resize', function () { - expect(state.videoCaption.resize).toHaveBeenCalled(); + expect($.fn.trigger).toHaveBeenCalledWith('fullscreen', [false]); expect(state.resizer.setMode) .toHaveBeenCalledWith('width'); expect(state.resizer.delta.reset).toHaveBeenCalled(); @@ -1076,6 +1070,9 @@ function (VideoPlayer) { beforeEach(function () { state = { youtubeId: jasmine.createSpy().andReturn('videoId'), + isFlashMode: jasmine.createSpy().andReturn(false), + isHtml5Mode: jasmine.createSpy().andReturn(true), + setPlayerMode: jasmine.createSpy(), videoPlayer: { currentTime: 60, isPlaying: jasmine.createSpy(), @@ -1089,7 +1086,8 @@ function (VideoPlayer) { }); it('in Flash mode and video is playing', function () { - state.currentPlayerMode = 'flash'; + state.isFlashMode.andReturn(true); + state.isHtml5Mode.andReturn(false); state.videoPlayer.isPlaying.andReturn(true); VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); @@ -1098,7 +1096,8 @@ function (VideoPlayer) { }); it('in Flash mode and video not started', function () { - state.currentPlayerMode = 'flash'; + state.isFlashMode.andReturn(true); + state.isHtml5Mode.andReturn(false); state.videoPlayer.isPlaying.andReturn(false); VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); @@ -1107,13 +1106,11 @@ function (VideoPlayer) { }); it('in HTML5 mode', function () { - state.currentPlayerMode = 'html5'; VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('0.75'); }); it('Youtube video in FF, with new speed equal 1.0', function () { - state.currentPlayerMode = 'html5'; state.videoType = 'youtube'; state.browserIsFirefox = true; diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index c79a858a6c..97dd7cf10b 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -366,6 +366,7 @@ class @Problem alert_elem = "
      " + msg + "
      " @el.find('.action').after(alert_elem) @el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700) + window.SR.readElts @el.find('.capa_alert') save: => if not @check_save_waitfor(@save_internal) diff --git a/common/lib/xmodule/xmodule/js/src/html/edit.coffee b/common/lib/xmodule/xmodule/js/src/html/edit.coffee index e99922c873..97e713dad5 100644 --- a/common/lib/xmodule/xmodule/js/src/html/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/html/edit.coffee @@ -1,5 +1,4 @@ class @HTMLEditingDescriptor - @isInactiveClass : "is-inactive" constructor: (element) -> @element = element; @@ -7,152 +6,120 @@ class @HTMLEditingDescriptor if @base_asset_url == undefined @base_asset_url = null - @advanced_editor = CodeMirror.fromTextArea($(".edit-box", @element)[0], { - mode: "text/html" - lineNumbers: true - lineWrapping: true - }) - - @$advancedEditorWrapper = $(@advanced_editor.getWrapperElement()) - @$advancedEditorWrapper.addClass(HTMLEditingDescriptor.isInactiveClass) + # Create an array of all content CSS links to use in and pass to Tiny MCE. + # We create this dynamically in order to support hashed files from our Django pipeline. + # CSS files that are to be used by Tiny MCE should contain the string "tinymce" so + # they can be found by the search below. + # We filter for only those files that are "content" files (as opposed to "skin" files). + tiny_mce_css_links = [] + $("link[rel=stylesheet][href*='tinymce']").filter("[href*='content']").each -> + tiny_mce_css_links.push $(this).attr("href") + return # This is a workaround for the fact that tinyMCE's baseURL property is not getting correctly set on AWS # instances (like sandbox). It is not necessary to explicitly set baseURL when running locally. - tinyMCE.baseURL = "#{baseUrl}/js/vendor/tiny_mce" + tinyMCE.baseURL = "#{baseUrl}/js/vendor/tinymce/js/tinymce" +# This is necessary for the LMS bulk e-mail acceptance test. In that particular scenario, +# tinyMCE incorrectly decides that the suffix should be "", which means it fails to load files. + tinyMCE.suffix = ".min" @tiny_mce_textarea = $(".tiny-mce", @element).tinymce({ - script_url : "#{baseUrl}/js/vendor/tiny_mce/tiny_mce.js", - theme : "advanced", - skin: 'studio', + script_url : "#{baseUrl}/js/vendor/tinymce/js/tinymce/tinymce.full.min.js", + theme : "modern", + skin: 'studio-tmce4', schema: "html5", # Necessary to preserve relative URLs to our images. convert_urls : false, - # TODO: we should share this CSS with studio (and LMS) - content_css : "#{baseUrl}/css/tiny-mce.css", - # The default popup_css path uses an absolute path referencing page in which tinyMCE is being hosted. - # Supply the correct relative path instead. - popup_css: "#{baseUrl}/js/vendor/tiny_mce/themes/advanced/skins/default/dialog.css", + content_css : tiny_mce_css_links.join(", "), formats : { - # Disable h4, h5, and h6 styles as we don't have CSS for them. - h4: {}, - h5: {}, - h6: {}, # tinyMCE does block level for code by default code: {inline: 'code'} }, # Disable visual aid on borderless table. - visual:false, + visual: false, + plugins: "textcolor, link, image, codemirror", + codemirror: { + path: "#{baseUrl}/js/vendor" + }, + image_advtab: true, # We may want to add "styleselect" when we collect all styles used throughout the LMS - theme_advanced_buttons1 : "formatselect,fontselect,bold,italic,underline,forecolor,|,bullist,numlist,outdent,indent,|,link,unlink,image,|,blockquote,wrapAsCode", - theme_advanced_toolbar_location : "top", - theme_advanced_toolbar_align : "left", - theme_advanced_statusbar_location : "none", - theme_advanced_resizing : true, - theme_advanced_blockformats : "p,pre,h1,h2,h3", + toolbar: "formatselect | fontselect | bold italic underline forecolor wrapAsCode | bullist numlist outdent indent blockquote | link unlink image | code", + block_formats: "Paragraph=p;Preformatted=pre;Heading 1=h1;Heading 2=h2;Heading 3=h3", width: '100%', height: '400px', - setup : @setupTinyMCE, + menubar: false, + statusbar: false, + # Necessary to avoid stripping of style tags. + valid_children : "+body[style]", + setup: @setupTinyMCE, # Cannot get access to tinyMCE Editor instance (for focusing) until after it is rendered. - # The tinyMCE callback passes in the editor as a paramter. + # The tinyMCE callback passes in the editor as a parameter. init_instance_callback: @initInstanceCallback }) - @showingVisualEditor = true - # Doing these find operations within onSwitchEditor leads to sporadic failures on Chrome (version 20 and older). - $element = $(element) - @$htmlTab = $element.find('.html-tab') - @$visualTab = $element.find('.visual-tab') - - @element.on('click', '.editor-tabs .tab', @onSwitchEditor) - setupTinyMCE: (ed) => ed.addButton('wrapAsCode', { - title : 'Code', + title : 'Code block', image : "#{baseUrl}/images/ico-tinymce-code.png", onclick : () -> ed.formatter.toggle('code') - # Without this, the dirty flag does not get set unless the user also types in text. - # Visual Editor must be marked as dirty or else we won't populate the Advanced Editor from it. - ed.isNotDirty = false }) - ed.onNodeChange.add((editor, command, e) -> - command.setActive('wrapAsCode', e.nodeName == 'CODE') - ) - @visualEditor = ed - - ed.onExecCommand.add(@onExecCommandHandler) - # Intended to run after the "image" plugin is used so that static urls are set - # correctly in the Visual editor immediately after command use. - onExecCommandHandler: (ed, cmd, ui, val) => - if cmd == 'mceInsertContent' and val.match(/^ - e.preventDefault(); + editImage: (data) => + # Called when the image plugin will be shown. Input arg is the JSON version of the image data. + if data['src'] + data['src'] = rewriteStaticLinks(data['src'], @base_asset_url, '/static/') - $currentTarget = $(e.currentTarget) - if not $currentTarget.hasClass('current') - $currentTarget.addClass('current') + saveImage: (data) => + # Called when the image plugin is saved. Input arg is the JSON version of the image data. + if data['src'] + data['src'] = rewriteStaticLinks(data['src'], '/static/', @base_asset_url) - # Initializing $mceToolbar if undefined. - if not @$mceToolbar? - @$mceToolbar = $(@element).find('table.mceToolbar') - @$mceToolbar.toggleClass(HTMLEditingDescriptor.isInactiveClass) - @$advancedEditorWrapper.toggleClass(HTMLEditingDescriptor.isInactiveClass) + editLink: (data) => + # Called when the link plugin will be shown. Input arg is the JSON version of the link data. + if data['href'] + data['href'] = rewriteStaticLinks(data['href'], @base_asset_url, '/static/') - visualEditor = @getVisualEditor() - if $currentTarget.data('tab') is 'visual' - @$htmlTab.removeClass('current') - @showVisualEditor(visualEditor) - else - @$visualTab.removeClass('current') - @showAdvancedEditor(visualEditor) + saveLink: (data) => + # Called when the link plugin is saved. Input arg is the JSON version of the link data. + if data['href'] + data['href'] = rewriteStaticLinks(data['href'], '/static/', @base_asset_url) - # Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing. - showAdvancedEditor: (visualEditor) -> - if visualEditor.isDirty() - content = rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') - @advanced_editor.setValue(content) - @advanced_editor.setCursor(0) - @advanced_editor.refresh() - @advanced_editor.focus() - @showingVisualEditor = false + showCodeEditor: (source) => + # Called when the CodeMirror Editor is displayed to convert links to show static prefix. + # The input argument is a dict with the text content. + content = rewriteStaticLinks(source.content, @base_asset_url, '/static/') + source.content = content - # Show the Visual (tinyMCE) Editor. Pulled out as a helper method for unit testing. - showVisualEditor: (visualEditor) -> - # In order for isDirty() to return true ONLY if edits have been made after setting the text, - # both the startContent must be sync'ed up and the dirty flag set to false. - content = rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url) - visualEditor.setContent(content) - visualEditor.startContent = visualEditor.getContent({format : 'raw'}) - @focusVisualEditor(visualEditor) - @showingVisualEditor = true + saveCodeEditor: (source) => + # Called when the CodeMirror Editor is saved to convert links back to the full form. + # The input argument is a dict with the text content. + content = rewriteStaticLinks(source.content, '/static/', @base_asset_url) + source.content = content initInstanceCallback: (visualEditor) => - visualEditor.setContent(rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url)) - @focusVisualEditor(visualEditor) - - focusVisualEditor: (visualEditor) => + visualEditor.setContent(rewriteStaticLinks(visualEditor.getContent({no_events: 1}), '/static/', @base_asset_url)) visualEditor.focus() - if not @$mceToolbar? - @$mceToolbar = $(@element).find('table.mceToolbar') getVisualEditor: () -> ### Returns the instance of TinyMCE. - This is different from the textarea that exists in the HTML template (@tiny_mce_textarea. Pulled out as a helper method for unit test. ### return @visualEditor save: -> - @element.off('click', '.editor-tabs .tab', @onSwitchEditor) - text = @advanced_editor.getValue() visualEditor = @getVisualEditor() - if @showingVisualEditor and visualEditor.isDirty() - text = rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') + text = rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/') data: text diff --git a/common/lib/xmodule/xmodule/js/src/video/00_async_process.js b/common/lib/xmodule/xmodule/js/src/video/00_async_process.js index 55a2cb1448..faffff7ca0 100644 --- a/common/lib/xmodule/xmodule/js/src/video/00_async_process.js +++ b/common/lib/xmodule/xmodule/js/src/video/00_async_process.js @@ -10,7 +10,7 @@ function() { * @param {array} list Array to process. * @param {function} process Calls this function on each item in the list. * @return {array} Returns a Promise object to observe when all actions of a - certain type bound to the collection, queued or not, have finished. + * certain type bound to the collection, queued or not, have finished. */ var AsyncProcess = { array: function (list, process) { diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index cf37e68e79..79a1d76af0 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -63,13 +63,16 @@ function (VideoPlayer, VideoStorage) { fetchMetadata: fetchMetadata, getCurrentLanguage: getCurrentLanguage, getDuration: getDuration, + getPlayerMode: getPlayerMode, getVideoMetadata: getVideoMetadata, initialize: initialize, + isHtml5Mode: isHtml5Mode, isFlashMode: isFlashMode, parseSpeed: parseSpeed, parseVideoSources: parseVideoSources, parseYoutubeStreams: parseYoutubeStreams, saveState: saveState, + setPlayerMode: setPlayerMode, setSpeed: setSpeed, trigger: trigger, youtubeId: youtubeId @@ -250,18 +253,6 @@ function (VideoPlayer, VideoStorage) { } } - // function _setPlayerMode(state) - // By default we will be forcing HTML5 player mode. Only in the case - // when, after initializtion, we will get one available playback rate, - // we will change to Flash player mode. There is a need to store this - // setting in cookies because otherwise we will have to change from - // HTML5 to Flash on every page load in a browser that doesn't fully - // support HTML5. When we have this setting in cookies, we can select - // the proper mode from the start (not having to change mode later on). - function _setPlayerMode(state) { - state.currentPlayerMode = 'html5'; - } - // function _parseYouTubeIDs(state) // The function parse YouTube stream ID's. // @return @@ -339,8 +330,7 @@ function (VideoPlayer, VideoStorage) { function _setConfigurations(state) { _configureCaptions(state); - _setPlayerMode(state); - + state.setPlayerMode(state.config.mode); // Possible value are: 'visible', 'hiding', and 'invisible'. state.controlState = 'visible'; state.controlHideTimeout = null; @@ -520,7 +510,8 @@ function (VideoPlayer, VideoStorage) { element: element, fadeOutTimeout: 1400, captionsFreezeTime: 10000, - availableQualities: ['hd720', 'hd1080', 'highres'] + availableQualities: ['hd720', 'hd1080', 'highres'], + mode: $.cookie('edX_video_player_mode') }); if (this.config.endTime < this.config.startTime) { @@ -811,8 +802,46 @@ function (VideoPlayer, VideoStorage) { } } + /** + * Sets player mode. + * + * @param {string} mode Mode to set for the video player if it is supported. + * Otherwise, `html5` is used by default. + */ + function setPlayerMode(mode) { + var supportedModes = ['html5', 'flash']; + + mode = _.contains(supportedModes, mode) ? mode : 'html5'; + this.currentPlayerMode = mode; + } + + /** + * Returns current player mode. + * + * @return {string} Returns string that describes player mode + */ + function getPlayerMode() { + return this.currentPlayerMode; + } + + /** + * Checks if current player mode is Flash. + * + * @return {boolean} Returns `true` if current mode is `flash`, otherwise + * it returns `false` + */ function isFlashMode() { - return this.currentPlayerMode === 'flash'; + return this.getPlayerMode() === 'flash'; + } + + /** + * Checks if current player mode is Html5. + * + * @return {boolean} Returns `true` if current mode is `html5`, otherwise + * it returns `false` + */ + function isHtml5Mode() { + return this.getPlayerMode() === 'html5'; } function getCurrentLanguage() { diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index 19fad9bd1c..8623f67045 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -223,20 +223,20 @@ function (HTML5Video, Resizer) { container: state.container }) .callbacks.once(function() { - state.trigger('videoCaption.resize', null); + state.el.trigger('caption:resize'); }) .setMode('width'); // Update captions size when controls becomes visible on iPad or Android if (/iPad|Android/i.test(state.isTouch[0])) { state.el.on('controls:show', function () { - state.trigger('videoCaption.resize', null); + state.el.trigger('caption:resize'); }); } $(window).on('resize', _.debounce(function () { state.trigger('videoControl.updateControlsHeight', null); - state.trigger('videoCaption.resize', null); + state.el.trigger('caption:resize'); state.resizer.align(); }, 100)); } @@ -251,7 +251,7 @@ function (HTML5Video, Resizer) { // Remove from the page current iFrame with HTML5 video. state.videoPlayer.player.destroy(); - state.currentPlayerMode = 'flash'; + state.setPlayerMode('flash'); console.log('[Video info]: Changing YouTube player mode to "flash".'); @@ -271,7 +271,7 @@ function (HTML5Video, Resizer) { }); _updateVcrAndRegion(state, true); - state.trigger('videoCaption.fetchCaption', null); + state.el.trigger('caption:fetch'); state.resizer.setElement(state.el.find('iframe')).align(); } @@ -334,7 +334,7 @@ function (HTML5Video, Resizer) { methodName, youtubeId; if ( - this.currentPlayerMode === 'html5' && + this.isHtml5Mode() && !( this.browserIsFirefox && newSpeed === '1.0' && @@ -447,10 +447,6 @@ function (HTML5Video, Resizer) { end: true }); - if (this.config.showCaptions) { - this.trigger('videoCaption.pause', null); - } - if (this.videoPlayer.skipOnEndedStartEndReset) { this.videoPlayer.skipOnEndedStartEndReset = undefined; } @@ -475,11 +471,6 @@ function (HTML5Video, Resizer) { delete this.videoPlayer.updateInterval; this.trigger('videoControl.pause', null); - - if (this.config.showCaptions) { - this.trigger('videoCaption.pause', null); - } - this.saveState(true); this.el.trigger('pause', arguments); } @@ -501,17 +492,10 @@ function (HTML5Video, Resizer) { } this.trigger('videoControl.play', null); - this.trigger('videoProgressSlider.notifyThroughHandleEnd', { end: false }); - - if (this.config.showCaptions) { - this.trigger('videoCaption.play', null); - } - this.videoPlayer.ready(); - this.el.trigger('play', arguments); } @@ -570,7 +554,7 @@ function (HTML5Video, Resizer) { // For more information, please see the PR that introduced this change: // https://github.com/edx/edx-platform/pull/2841 if ( - (this.currentPlayerMode === 'html5' || availablePlaybackRates.length > 1) && + (this.isHtml5Mode() || availablePlaybackRates.length > 1) && this.videoType === 'youtube' ) { if (availablePlaybackRates.length === 1 && !this.isTouch) { @@ -584,7 +568,7 @@ function (HTML5Video, Resizer) { _restartUsingFlash(this); } else if (availablePlaybackRates.length > 1) { - this.currentPlayerMode = 'html5'; + this.setPlayerMode('html5'); // We need to synchronize available frame rates with the ones // that the user specified. @@ -623,7 +607,7 @@ function (HTML5Video, Resizer) { this.trigger('videoSpeedControl.setSpeed', this.speed); } - if (this.currentPlayerMode === 'html5') { + if (this.isHtml5Mode()) { this.videoPlayer.player.setPlaybackRate(this.speed); } @@ -803,7 +787,7 @@ function (HTML5Video, Resizer) { } ); - this.trigger('videoCaption.updatePlayTime', time); + this.el.trigger('caption:update', [time]); } function isEnded() { diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js index 205d062551..4d2b3adcd9 100644 --- a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js @@ -277,7 +277,6 @@ function () { .attr('title', text) .text(text); - this.trigger('videoCaption.resize', null); this.el.trigger('fullscreen', [this.isFullScreen]); } diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index 3fd5832f8a..bfb4fab566 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -5,734 +5,859 @@ define( 'video/09_video_caption.js', ['video/00_sjson.js', 'video/00_async_process.js'], function (Sjson, AsyncProcess) { - /** * @desc VideoCaption module exports a function. * * @type {function} * @access public * - * @param {object} state - The object containg the state of the video + * @param {object} state - The object containing the state of the video * player. All other modules, their parameters, public variables, etc. * are available via this object. * * @this {object} The global window object. * - * @returns {undefined} + * @returns {jquery Promise} */ - return function (state) { - state.videoCaption = {}; - _makeFunctionsPublic(state); - state.videoCaption.renderElements(); + var VideoCaption = function (state) { + if (!(this instanceof VideoCaption)) { + return new VideoCaption(state); + } + + this.state = state; + this.state.videoCaption = this; + this.renderElements(); return $.Deferred().resolve().promise(); }; - // *************************************************************** - // Private functions start here. - // *************************************************************** + VideoCaption.prototype = { + /** + * @desc Initiate rendering of elements, and set their initial configuration. + * + */ + renderElements: function () { + var state = this.state, + languages = this.state.config.transcriptLanguages; - // function _makeFunctionsPublic(state) - // - // Functions which will be accessible via 'state' object. When called, - // these functions will get the 'state' object as a context. - function _makeFunctionsPublic(state) { - var methodsDict = { - addPaddings: addPaddings, - bindHandlers: bindHandlers, - bottomSpacingHeight: bottomSpacingHeight, - calculateOffset: calculateOffset, - captionBlur: captionBlur, - captionClick: captionClick, - captionFocus: captionFocus, - captionHeight: captionHeight, - captionKeyDown: captionKeyDown, - captionMouseDown: captionMouseDown, - captionMouseOverOut: captionMouseOverOut, - fetchCaption: fetchCaption, - fetchAvailableTranslations: fetchAvailableTranslations, - hideCaptions: hideCaptions, - onMouseEnter: onMouseEnter, - onMouseLeave: onMouseLeave, - onMovement: onMovement, - pause: pause, - play: play, - renderCaption: renderCaption, - renderElements: renderElements, - renderLanguageMenu: renderLanguageMenu, - resize: resize, - scrollCaption: scrollCaption, - seekPlayer: seekPlayer, - setSubtitlesHeight: setSubtitlesHeight, - toggle: toggle, - topSpacingHeight: topSpacingHeight, - updatePlayTime: updatePlayTime - }; + this.loaded = false; + this.subtitlesEl = state.el.find('ol.subtitles'); + this.container = state.el.find('.lang'); + this.hideSubtitlesEl = state.el.find('a.hide-subtitles'); - state.bindTo(methodsDict, state.videoCaption, state); - } + if (_.keys(languages).length) { + this.renderLanguageMenu(languages); - // *************************************************************** - // Public functions start here. - // These are available via the 'state' object. Their context ('this' - // keyword) is the 'state' object. The magic private function that makes - // them available and sets up their context is makeFunctionsPublic(). - // *************************************************************** - - /** - * @desc Create any necessary DOM elements, attach them, and set their - * initial configuration. Also make the created DOM elements available - * via the 'state' object. Much easier to work this way - you don't - * have to do repeated jQuery element selects. - * - * @type {function} - * @access public - * - * @this {object} - The object containg the state of the video - * player. All other modules, their parameters, public variables, etc. - * are available via this object. - * - * @returns {boolean} - * true: The function fethched captions successfully, and compltely - * rendered everything related to captions. - * false: The captions were not fetched. Nothing will be rendered, - * and the CC button will be hidden. - */ - function renderElements() { - var Caption = this.videoCaption, - languages = this.config.transcriptLanguages; - - Caption.loaded = false; - Caption.subtitlesEl = this.el.find('ol.subtitles'); - Caption.container = this.el.find('.lang'); - Caption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); - - if (_.keys(languages).length) { - Caption.renderLanguageMenu(languages); - - if (!Caption.fetchCaption()) { - Caption.hideCaptions(true); - Caption.hideSubtitlesEl.hide(); - } - } else { - Caption.hideCaptions(true, false); - Caption.hideSubtitlesEl.hide(); - } - } - - // function bindHandlers() - // - // Bind any necessary function callbacks to DOM events (click, - // mousemove, etc.). - function bindHandlers() { - var self = this, - Caption = this.videoCaption, - events = [ - 'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur', - 'keydown' - ].join(' '); - - Caption.hideSubtitlesEl.on({ - 'click': Caption.toggle - }); - - Caption.subtitlesEl - .on({ - mouseenter: Caption.onMouseEnter, - mouseleave: Caption.onMouseLeave, - mousemove: Caption.onMovement, - mousewheel: Caption.onMovement, - DOMMouseScroll: Caption.onMovement - }) - .on(events, 'li[data-index]', function (event) { - switch (event.type) { - case 'mouseover': - case 'mouseout': - Caption.captionMouseOverOut(event); - break; - case 'mousedown': - Caption.captionMouseDown(event); - break; - case 'click': - Caption.captionClick(event); - break; - case 'focusin': - Caption.captionFocus(event); - break; - case 'focusout': - Caption.captionBlur(event); - break; - case 'keydown': - Caption.captionKeyDown(event); - break; + if (!this.fetchCaption()) { + this.hideCaptions(true); + this.hideSubtitlesEl.hide(); } - }); - - if (Caption.showLanguageMenu) { - Caption.container.on({ - mouseenter: onContainerMouseEnter, - mouseleave: onContainerMouseLeave - }); - } - - if ((this.videoType === 'html5') && (this.config.autohideHtml5)) { - Caption.subtitlesEl.on('scroll', this.videoControl.showControls); - } - } - - function onContainerMouseEnter(event) { - event.preventDefault(); - - $(event.currentTarget).addClass('open'); - } - - function onContainerMouseLeave(event) { - event.preventDefault(); - - $(event.currentTarget).removeClass('open'); - } - - function onMouseEnter() { - if (this.videoCaption.frozen) { - clearTimeout(this.videoCaption.frozen); - } - - this.videoCaption.frozen = setTimeout( - this.videoCaption.onMouseLeave, - this.config.captionsFreezeTime - ); - } - - function onMouseLeave() { - if (this.videoCaption.frozen) { - clearTimeout(this.videoCaption.frozen); - } - - this.videoCaption.frozen = null; - - if (this.videoCaption.playing) { - this.videoCaption.scrollCaption(); - } - } - - function onMovement() { - this.videoCaption.onMouseEnter(); - } - - /** - * @desc Fetch the caption file specified by the user. Upn successful - * receival of the file, the captions will be rendered. - * - * @type {function} - * @access public - * - * @this {object} - The object containg the state of the video - * player. All other modules, their parameters, public variables, etc. - * are available via this object. - * - * @returns {boolean} - * true: The user specified a caption file. NOTE: if an error happens - * while the specified file is being retrieved (for example the - * file is missing on the server), this function will still return - * true. - * false: No caption file was specified, or an empty string was - * specified. - */ - function fetchCaption() { - var self = this, - Caption = self.videoCaption, - language = this.getCurrentLanguage(), - data; - - if (Caption.loaded) { - Caption.hideCaptions(false); - } else { - Caption.hideCaptions(this.hide_captions, false); - } - - if (Caption.fetchXHR && Caption.fetchXHR.abort) { - Caption.fetchXHR.abort(); - } - - if (this.videoType === 'youtube') { - data = { - videoId: this.youtubeId('1.0') - }; - } - - // Fetch the captions file. If no file was specified, or if an error - // occurred, then we hide the captions panel, and the "CC" button - Caption.fetchXHR = $.ajaxWithPrefix({ - url: self.config.transcriptTranslationUrl + '/' + language, - notifyOnError: false, - data: data, - success: function (response) { - Caption.sjson = new Sjson(response); - - var start = Caption.sjson.getStartTimes(), - captions = Caption.sjson.getCaptions(); - - if (Caption.loaded) { - if (Caption.rendered) { - Caption.renderCaption(start, captions); - Caption.updatePlayTime(self.videoPlayer.currentTime); - } - } else { - if (self.isTouch) { - Caption.subtitlesEl.find('li').html( - gettext( - 'Caption will be displayed when ' + - 'you start playing the video.' - ) - ); - } else { - Caption.renderCaption(start, captions); - } - - Caption.bindHandlers(); - } - - Caption.loaded = true; - }, - error: function (jqXHR, textStatus, errorThrown) { - console.log('[Video info]: ERROR while fetching captions.'); - console.log( - '[Video info]: STATUS:', textStatus + - ', MESSAGE:', '' + errorThrown - ); - // If initial list of languages has more than 1 item, check - // for availability other transcripts. - if (_.keys(self.config.transcriptLanguages).length > 1) { - Caption.fetchAvailableTranslations(); - } else { - Caption.hideCaptions(true, false); - Caption.hideSubtitlesEl.hide(); - } - } - }); - - return true; - } - - function fetchAvailableTranslations() { - var self = this, - Caption = this.videoCaption; - - return $.ajaxWithPrefix({ - url: self.config.transcriptAvailableTranslationsUrl, - notifyOnError: false, - success: function (response) { - var currentLanguages = self.config.transcriptLanguages, - newLanguages = _.pick(currentLanguages, response); - - // Update property with available currently translations. - self.config.transcriptLanguages = newLanguages; - // Remove an old language menu. - Caption.container.find('.langs-list').remove(); - - if (_.keys(newLanguages).length) { - // And try again to fetch transcript. - Caption.fetchCaption(); - Caption.renderLanguageMenu(newLanguages); - } - }, - error: function (jqXHR, textStatus, errorThrown) { - Caption.hideCaptions(true, false); - Caption.hideSubtitlesEl.hide(); - } - }); - } - - function resize() { - this.videoCaption.subtitlesEl - .find('.spacing:first') - .height(this.videoCaption.topSpacingHeight()) - .find('.spacing:last') - .height(this.videoCaption.bottomSpacingHeight()); - - this.videoCaption.scrollCaption(); - this.videoCaption.setSubtitlesHeight(); - } - - function renderLanguageMenu(languages) { - var self = this, - menu = $('