@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
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 "<p class='title'>pages</p><style><!-- .title { color: red; } --></style>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<p class="title">pages</p>
|
||||
<style><!--
|
||||
.title { color: red; }
|
||||
--></style>
|
||||
"""
|
||||
|
||||
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 "<img src="/static/image.jpg">" 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 "<p><img src="/static/image.jpg" alt="" /></p>"
|
||||
|
||||
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:
|
||||
"""
|
||||
<p><code>display as code</code></p>
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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', {})
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,38 +54,6 @@ define ["js/views/overview", "js/views/feedback_notification", "js/spec/create_s
|
||||
</section>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<section>
|
||||
<ol class="sortable-subsection-list">
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-0" data-locator="subsection-0-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-0">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-0" data-parent="subsection-0-id" data-locator="zero-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-1" data-locator="subsection-1-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-1">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-1" data-parent="subsection-1-id" data-locator="first-unit-id"></li>
|
||||
<li class="courseware-unit unit is-draggable" id="unit-2" data-parent="subsection-1-id" data-locator="second-unit-id"></li>
|
||||
<li class="courseware-unit unit is-draggable" id="unit-3" data-parent="subsection-1-id" data-locator="third-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-2" data-locator="subsection-2-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-2">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-4" data-parent="subsection-2" data-locator="fourth-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-3" data-locator="subsection-3-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-3"></ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-4" data-locator="subsection-4-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-4">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-5" data-parent="subsection-4-id" data-locator="fifth-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
"""
|
||||
|
||||
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')
|
||||
|
||||
313
cms/static/js/spec/utils/drag_and_drop_spec.js
Normal file
313
cms/static/js/spec/utils/drag_and_drop_spec.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,15 @@ function ($, _, IframeBinding) {
|
||||
iframe_html += '<embed type="application/x-shockwave-flash" src="http://www.youtube.com/embed/NHd27UvY-lw" height="315" width="560">';
|
||||
doc.body.innerHTML = iframe_html;
|
||||
|
||||
var verify_no_modification = function (src) {
|
||||
iframe_html = '<iframe width="618" height="350" src="' + src + '" frameborder="0" allowfullscreen></iframe>';
|
||||
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 = '<iframe width="618" height="350" src="" frameborder="0" allowfullscreen></iframe>';
|
||||
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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
346
cms/static/js/utils/drag_and_drop.js
Normal file
346
cms/static/js/utils/drag_and_drop.js
Normal file
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -485,6 +485,7 @@ body.course.unit,.view-unit {
|
||||
|
||||
.row {
|
||||
margin-bottom: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Module Actions, also used for Pages
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<meta name="path_prefix" content="${EDX_ROOT_URL}">
|
||||
|
||||
<%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",
|
||||
|
||||
29
cms/templates/js/mock/mock-outline.underscore
Normal file
29
cms/templates/js/mock/mock-outline.underscore
Normal file
@@ -0,0 +1,29 @@
|
||||
<section>
|
||||
<ol class="sortable-subsection-list">
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-0" data-locator="subsection-0-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-0">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-0" data-parent="subsection-0-id" data-locator="zero-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-1" data-locator="subsection-1-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-1">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-1" data-parent="subsection-1-id" data-locator="first-unit-id"></li>
|
||||
<li class="courseware-unit unit is-draggable" id="unit-2" data-parent="subsection-1-id" data-locator="second-unit-id"></li>
|
||||
<li class="courseware-unit unit is-draggable" id="unit-3" data-parent="subsection-1-id" data-locator="third-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-2" data-locator="subsection-2-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-2">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-4" data-parent="subsection-2" data-locator="fourth-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-3" data-locator="subsection-3-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-3"></ol>
|
||||
</li>
|
||||
<li class="courseware-subsection is-collapsible id-holder is-draggable" id="subsection-4" data-locator="subsection-4-id" style="margin:5px">
|
||||
<ol class="sortable-unit-list" id="subsection-list-4">
|
||||
<li class="courseware-unit unit is-draggable" id="unit-5" data-parent="subsection-4-id" data-locator="fifth-unit-id"></li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
@@ -2,14 +2,8 @@
|
||||
|
||||
<div class="wrapper-comp-editor" id="editor-tab" data-base-asset-url="${base_asset_url}">
|
||||
<section class="html-editor editor">
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="visual-tab tab current" data-tab="visual">${_("Visual")}</a></li>
|
||||
<li><a href="#" class="html-tab tab" data-tab="advanced">${_("HTML")}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<textarea class="tiny-mce">${data | h}</textarea>
|
||||
<textarea name="" class="edit-box">${data | h}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -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']
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'
|
||||
|
||||
205
common/djangoapps/student/tests/test_password_history.py
Normal file
205
common/djangoapps/student/tests/test_password_history.py
Normal file
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
158
common/djangoapps/student/tests/test_reset_password.py
Normal file
158
common/djangoapps/student/tests/test_reset_password.py
Normal file
@@ -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<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', 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)
|
||||
@@ -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<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', 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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ setup(
|
||||
version="0.2",
|
||||
packages=["calc"],
|
||||
install_requires=[
|
||||
"pyparsing==1.5.6",
|
||||
"pyparsing==2.0.1",
|
||||
"numpy",
|
||||
"scipy"
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
top: 11px !important;
|
||||
top: 0 !important;
|
||||
right: 10px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<section class="html-edit">
|
||||
<textarea class="tiny-mce">dummy</textarea>
|
||||
<!--
|
||||
The text passed in is the escaped version of
|
||||
<problem>
|
||||
<p></p>
|
||||
<multiplechoiceresponse>
|
||||
<pre><problem>
|
||||
<p></p></pre>
|
||||
<div><foo>bar</foo></div>
|
||||
-->
|
||||
<textarea name="" class="edit-box">&lt;problem>
|
||||
&lt;p>&lt;/p>
|
||||
&lt;multiplechoiceresponse>
|
||||
<pre>&lt;problem>
|
||||
&lt;p>&lt;/p></pre>
|
||||
<div><foo>bar</foo></div></textarea>
|
||||
</section>
|
||||
@@ -1,10 +0,0 @@
|
||||
<section class="html-edit">
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="visual-tab tab current" data-tab="visual">Visual</a></li>
|
||||
<li><a href="#" class="html-tab tab" data-tab="advanced">HTML</a></li>
|
||||
</ul>
|
||||
<div class="row">
|
||||
<textarea class="tiny-mce">dummy text</textarea>
|
||||
<textarea name="" class="edit-box">Advanced Editor Text with link /static/dummy.jpg</textarea>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,10 +1,3 @@
|
||||
<section class="html-edit">
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="visual-tab tab current" data-tab="visual">Visual</a></li>
|
||||
<li><a href="#" class="html-tab tab" data-tab="advanced">HTML</a></li>
|
||||
</ul>
|
||||
<div class="row">
|
||||
<textarea class="tiny-mce">dummy text</textarea>
|
||||
<textarea name="" class="edit-box">Advanced Editor Text</textarea>
|
||||
</div>
|
||||
</section>
|
||||
<textarea class="tiny-mce">dummy text</textarea>
|
||||
</section>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
<pre><problem>
|
||||
<p></p></pre>
|
||||
<div><foo>bar</foo></div>""")
|
||||
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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ class @Problem
|
||||
alert_elem = "<div class='capa_alert'>" + msg + "</div>"
|
||||
@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)
|
||||
|
||||
@@ -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(/^<img/)
|
||||
content = rewriteStaticLinks(ed.getContent(), '/static/', @base_asset_url)
|
||||
ed.setContent(content)
|
||||
# These events were added to the plugin code as the TinyMCE PluginManager
|
||||
# does not fire any events when plugins are opened or closed.
|
||||
ed.on('SaveImage', @saveImage)
|
||||
ed.on('EditImage', @editImage)
|
||||
ed.on('SaveLink', @saveLink)
|
||||
ed.on('EditLink', @editLink)
|
||||
ed.on('ShowCodeEditor', @showCodeEditor)
|
||||
ed.on('SaveCodeEditor', @saveCodeEditor)
|
||||
|
||||
onSwitchEditor: (e) =>
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -277,7 +277,6 @@ function () {
|
||||
.attr('title', text)
|
||||
.text(text);
|
||||
|
||||
this.trigger('videoCaption.resize', null);
|
||||
this.el.trigger('fullscreen', [this.isFullScreen]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = $('<ol class="langs-list menu">'),
|
||||
currentLang = this.getCurrentLanguage();
|
||||
|
||||
if (_.keys(languages).length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.videoCaption.showLanguageMenu = true;
|
||||
|
||||
$.each(languages, function(code, label) {
|
||||
var li = $('<li data-lang-code="' + code + '" />'),
|
||||
link = $('<a href="javascript:void(0);">' + label + '</a>');
|
||||
|
||||
if (currentLang === code) {
|
||||
li.addClass('active');
|
||||
}
|
||||
|
||||
li.append(link);
|
||||
menu.append(li);
|
||||
});
|
||||
|
||||
this.videoCaption.container.append(menu);
|
||||
|
||||
menu.on('click', 'a', function (e) {
|
||||
var el = $(e.currentTarget).parent(),
|
||||
Caption = self.videoCaption,
|
||||
langCode = el.data('lang-code');
|
||||
|
||||
if (self.lang !== langCode) {
|
||||
self.lang = langCode;
|
||||
self.storage.setItem('language', langCode);
|
||||
el .addClass('active')
|
||||
.siblings('li')
|
||||
.removeClass('active');
|
||||
|
||||
Caption.fetchCaption();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildCaptions (container, start, captions) {
|
||||
var process = function(text, index) {
|
||||
var liEl = $('<li>', {
|
||||
'data-index': index,
|
||||
'data-start': start[index],
|
||||
'tabindex': 0
|
||||
}).html(text);
|
||||
|
||||
return liEl[0];
|
||||
};
|
||||
|
||||
return AsyncProcess.array(captions, process).done(function (list) {
|
||||
container.append(list);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCaption(start, captions) {
|
||||
var Caption = this.videoCaption;
|
||||
|
||||
var onRender = function () {
|
||||
Caption.addPaddings();
|
||||
// Enables or disables automatic scrolling of the captions when the
|
||||
// video is playing. This feature has to be disabled when tabbing
|
||||
// through them as it interferes with that action. Initially, have
|
||||
// this flag enabled as we assume mouse use. Then, if the first
|
||||
// caption (through forward tabbing) or the last caption (through
|
||||
// backwards tabbing) gets the focus, disable that feature.
|
||||
// Re-enable it if tabbing then cycles out of the the captions.
|
||||
Caption.autoScrolling = true;
|
||||
// Keeps track of where the focus is situated in the array of
|
||||
// captions. Used to implement the automatic scrolling behavior and
|
||||
// decide if the outline around a caption has to be hidden or shown
|
||||
// on a mouseenter or mouseleave. Initially, no caption has the
|
||||
// focus, set the index to -1.
|
||||
Caption.currentCaptionIndex = -1;
|
||||
// Used to track if the focus is coming from a click or tabbing. This
|
||||
// has to be known to decide if, when a caption gets the focus, an
|
||||
// outline has to be drawn (tabbing) or not (mouse click).
|
||||
Caption.isMouseFocus = false;
|
||||
Caption.rendered = true;
|
||||
};
|
||||
|
||||
|
||||
Caption.rendered = false;
|
||||
Caption.subtitlesEl.empty();
|
||||
Caption.setSubtitlesHeight();
|
||||
buildCaptions(Caption.subtitlesEl, start, captions).done(onRender);
|
||||
}
|
||||
|
||||
function addPaddings() {
|
||||
// Set top and bottom spacing height and make sure they are taken out of
|
||||
// the tabbing order.
|
||||
this.videoCaption.subtitlesEl
|
||||
.prepend(
|
||||
$('<li class="spacing">')
|
||||
.height(this.videoCaption.topSpacingHeight())
|
||||
.attr('tabindex', -1)
|
||||
)
|
||||
.append(
|
||||
$('<li class="spacing">')
|
||||
.height(this.videoCaption.bottomSpacingHeight())
|
||||
.attr('tabindex', -1)
|
||||
);
|
||||
}
|
||||
|
||||
// On mouseOver, hide the outline of a caption that has been tabbed to.
|
||||
// On mouseOut, show the outline of a caption that has been tabbed to.
|
||||
function captionMouseOverOut(event) {
|
||||
var caption = $(event.target),
|
||||
captionIndex = parseInt(caption.attr('data-index'), 10);
|
||||
if (captionIndex === this.videoCaption.currentCaptionIndex) {
|
||||
if (event.type === 'mouseover') {
|
||||
caption.removeClass('focused');
|
||||
}
|
||||
else { // mouseout
|
||||
caption.addClass('focused');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function captionMouseDown(event) {
|
||||
var caption = $(event.target);
|
||||
this.videoCaption.isMouseFocus = true;
|
||||
this.videoCaption.autoScrolling = true;
|
||||
caption.removeClass('focused');
|
||||
this.videoCaption.currentCaptionIndex = -1;
|
||||
}
|
||||
|
||||
function captionClick(event) {
|
||||
this.videoCaption.seekPlayer(event);
|
||||
}
|
||||
|
||||
function captionFocus(event) {
|
||||
var caption = $(event.target),
|
||||
captionIndex = parseInt(caption.attr('data-index'), 10);
|
||||
// If the focus comes from a mouse click, hide the outline, turn on
|
||||
// automatic scrolling and set currentCaptionIndex to point outside of
|
||||
// caption list (ie -1) to disable mouseenter, mouseleave behavior.
|
||||
if (this.videoCaption.isMouseFocus) {
|
||||
this.videoCaption.autoScrolling = true;
|
||||
caption.removeClass('focused');
|
||||
this.videoCaption.currentCaptionIndex = -1;
|
||||
}
|
||||
// If the focus comes from tabbing, show the outline and turn off
|
||||
// automatic scrolling.
|
||||
else {
|
||||
this.videoCaption.currentCaptionIndex = captionIndex;
|
||||
caption.addClass('focused');
|
||||
// The second and second to last elements turn automatic scrolling
|
||||
// off again as it may have been enabled in captionBlur.
|
||||
if (
|
||||
captionIndex <= 1 ||
|
||||
captionIndex >= this.videoCaption.sjson.getSize() - 2
|
||||
) {
|
||||
this.videoCaption.autoScrolling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function captionBlur(event) {
|
||||
var caption = $(event.target),
|
||||
captionIndex = parseInt(caption.attr('data-index'), 10);
|
||||
|
||||
caption.removeClass('focused');
|
||||
// If we are on first or last index, we have to turn automatic scroll
|
||||
// on again when losing focus. There is no way to know in what
|
||||
// direction we are tabbing. So we could be on the first element and
|
||||
// tabbing back out of the captions or on the last element and tabbing
|
||||
// forward out of the captions.
|
||||
if (captionIndex === 0 ||
|
||||
captionIndex === this.videoCaption.sjson.getSize() - 1) {
|
||||
|
||||
this.videoCaption.autoScrolling = true;
|
||||
}
|
||||
}
|
||||
|
||||
function captionKeyDown(event) {
|
||||
this.videoCaption.isMouseFocus = false;
|
||||
if (event.which === 13) { //Enter key
|
||||
this.videoCaption.seekPlayer(event);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollCaption() {
|
||||
var el = this.videoCaption.subtitlesEl.find('.current:first');
|
||||
|
||||
// Automatic scrolling gets disabled if one of the captions has
|
||||
// received focus through tabbing.
|
||||
if (
|
||||
!this.videoCaption.frozen &&
|
||||
el.length &&
|
||||
this.videoCaption.autoScrolling
|
||||
) {
|
||||
this.videoCaption.subtitlesEl.scrollTo(
|
||||
el,
|
||||
{
|
||||
offset: -this.videoCaption.calculateOffset(el)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (this.videoCaption.loaded) {
|
||||
if (!this.videoCaption.rendered) {
|
||||
var start = this.videoCaption.sjson.getStartTimes(),
|
||||
captions = this.videoCaption.sjson.getCaptions();
|
||||
|
||||
this.videoCaption.renderCaption(start, captions);
|
||||
}
|
||||
|
||||
this.videoCaption.playing = true;
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (this.videoCaption.loaded) {
|
||||
this.videoCaption.playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayTime(time) {
|
||||
var newIndex;
|
||||
|
||||
if (this.videoCaption.loaded) {
|
||||
if (this.isFlashMode()) {
|
||||
time = Time.convert(time, this.speed, '1.0');
|
||||
}
|
||||
|
||||
time = Math.round(time * 1000 + 100);
|
||||
newIndex = this.videoCaption.sjson.search(time);
|
||||
|
||||
if (
|
||||
typeof newIndex !== 'undefined' &&
|
||||
newIndex !== -1 &&
|
||||
this.videoCaption.currentIndex !== newIndex
|
||||
) {
|
||||
if (typeof this.videoCaption.currentIndex !== 'undefined') {
|
||||
this.videoCaption.subtitlesEl
|
||||
.find('li.current')
|
||||
.removeClass('current');
|
||||
}
|
||||
|
||||
this.videoCaption.subtitlesEl
|
||||
.find("li[data-index='" + newIndex + "']")
|
||||
.addClass('current');
|
||||
|
||||
this.videoCaption.currentIndex = newIndex;
|
||||
this.videoCaption.scrollCaption();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seekPlayer(event) {
|
||||
var time = parseInt($(event.target).data('start'), 10);
|
||||
|
||||
if (this.isFlashMode()) {
|
||||
time = Math.round(Time.convert(time, '1.0', this.speed));
|
||||
}
|
||||
|
||||
this.trigger(
|
||||
'videoPlayer.onCaptionSeek',
|
||||
{
|
||||
'type': 'onCaptionSeek',
|
||||
'time': time/1000
|
||||
}
|
||||
);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function calculateOffset(element) {
|
||||
return this.videoCaption.captionHeight() / 2 - element.height() / 2;
|
||||
}
|
||||
|
||||
function topSpacingHeight() {
|
||||
return this.videoCaption.calculateOffset(
|
||||
this.videoCaption.subtitlesEl.find('li:not(.spacing):first')
|
||||
);
|
||||
}
|
||||
|
||||
function bottomSpacingHeight() {
|
||||
return this.videoCaption.calculateOffset(
|
||||
this.videoCaption.subtitlesEl.find('li:not(.spacing):last')
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.el.hasClass('closed')) {
|
||||
this.videoCaption.hideCaptions(false);
|
||||
} else {
|
||||
this.videoCaption.hideCaptions(true);
|
||||
}
|
||||
}
|
||||
|
||||
function hideCaptions(hide_captions, update_cookie) {
|
||||
var hideSubtitlesEl = this.videoCaption.hideSubtitlesEl,
|
||||
type, text;
|
||||
|
||||
if (typeof update_cookie === 'undefined') {
|
||||
update_cookie = true;
|
||||
}
|
||||
|
||||
if (hide_captions) {
|
||||
type = 'hide_transcript';
|
||||
this.captionsHidden = true;
|
||||
|
||||
this.el.addClass('closed');
|
||||
|
||||
text = gettext('Turn on captions');
|
||||
} else {
|
||||
type = 'show_transcript';
|
||||
this.captionsHidden = false;
|
||||
|
||||
this.el.removeClass('closed');
|
||||
this.videoCaption.scrollCaption();
|
||||
|
||||
text = gettext('Turn off captions');
|
||||
}
|
||||
|
||||
hideSubtitlesEl
|
||||
.attr('title', text)
|
||||
.text(gettext(text));
|
||||
|
||||
if (this.videoPlayer) {
|
||||
this.videoPlayer.log(type, {
|
||||
currentTime: this.videoPlayer.currentTime
|
||||
});
|
||||
}
|
||||
|
||||
if (this.resizer) {
|
||||
if (this.isFullScreen) {
|
||||
this.resizer.setMode('both');
|
||||
} else {
|
||||
this.resizer.alignByWidthOnly();
|
||||
this.hideCaptions(true, false);
|
||||
this.hideSubtitlesEl.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
this.videoCaption.setSubtitlesHeight();
|
||||
/**
|
||||
* @desc Bind any necessary function callbacks to DOM events (click,
|
||||
* mousemove, etc.).
|
||||
*
|
||||
*/
|
||||
bindHandlers: function () {
|
||||
var self = this,
|
||||
state = this.state,
|
||||
events = [
|
||||
'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur',
|
||||
'keydown'
|
||||
].join(' ');
|
||||
|
||||
if (update_cookie) {
|
||||
$.cookie('hide_captions', hide_captions, {
|
||||
expires: 3650,
|
||||
path: '/'
|
||||
// Change context to VideoCaption of event handlers using `bind`.
|
||||
this.hideSubtitlesEl.on('click', this.toggle.bind(this));
|
||||
this.subtitlesEl
|
||||
.on({
|
||||
mouseenter: this.onMouseEnter.bind(this),
|
||||
mouseleave: this.onMouseLeave.bind(this),
|
||||
mousemove: this.onMovement.bind(this),
|
||||
mousewheel: this.onMovement.bind(this),
|
||||
DOMMouseScroll: this.onMovement.bind(this)
|
||||
})
|
||||
.on(events, 'li[data-index]', function (event) {
|
||||
switch (event.type) {
|
||||
case 'mouseover':
|
||||
case 'mouseout':
|
||||
self.captionMouseOverOut(event);
|
||||
break;
|
||||
case 'mousedown':
|
||||
self.captionMouseDown(event);
|
||||
break;
|
||||
case 'click':
|
||||
self.captionClick(event);
|
||||
break;
|
||||
case 'focusin':
|
||||
self.captionFocus(event);
|
||||
break;
|
||||
case 'focusout':
|
||||
self.captionBlur(event);
|
||||
break;
|
||||
case 'keydown':
|
||||
self.captionKeyDown(event);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.showLanguageMenu) {
|
||||
this.container.on({
|
||||
mouseenter: this.onContainerMouseEnter,
|
||||
mouseleave: this.onContainerMouseLeave
|
||||
});
|
||||
}
|
||||
|
||||
state.el
|
||||
.on({
|
||||
'caption:fetch': this.fetchCaption.bind(this),
|
||||
'caption:resize': this.onResize.bind(this),
|
||||
'caption:update': function (event, time) {
|
||||
self.updatePlayTime(time);
|
||||
},
|
||||
'ended': this.pause,
|
||||
'fullscreen': this.onResize.bind(this),
|
||||
'pause': this.pause,
|
||||
'play': this.play,
|
||||
});
|
||||
|
||||
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
|
||||
this.subtitlesEl.on('scroll', state.videoControl.showControls);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Opens language menu.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onContainerMouseEnter: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
$(event.currentTarget).addClass('open');
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Closes language menu.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onContainerMouseLeave: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
$(event.currentTarget).removeClass('open');
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Freezes moving of captions when mouse is over them.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onMouseEnter: function (event) {
|
||||
if (this.frozen) {
|
||||
clearTimeout(this.frozen);
|
||||
}
|
||||
|
||||
this.frozen = setTimeout(
|
||||
this.onMouseLeave,
|
||||
this.state.config.captionsFreezeTime
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Unfreezes moving of captions when mouse go out.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onMouseLeave: function (event) {
|
||||
if (this.frozen) {
|
||||
clearTimeout(this.frozen);
|
||||
}
|
||||
|
||||
this.frozen = null;
|
||||
|
||||
if (this.playing) {
|
||||
this.scrollCaption();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Freezes moving of captions when mouse is moving over them.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onMovement: function (event) {
|
||||
this.onMouseEnter();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Fetch the caption file specified by the user. Upon successful
|
||||
* receipt of the file, the captions will be rendered.
|
||||
*
|
||||
* @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 for the Youtube type player.
|
||||
*/
|
||||
fetchCaption: function () {
|
||||
var self = this,
|
||||
state = this.state,
|
||||
language = state.getCurrentLanguage(),
|
||||
data, youtubeId;
|
||||
|
||||
if (this.loaded) {
|
||||
this.hideCaptions(false);
|
||||
} else {
|
||||
this.hideCaptions(state.hide_captions, false);
|
||||
}
|
||||
|
||||
if (this.fetchXHR && this.fetchXHR.abort) {
|
||||
this.fetchXHR.abort();
|
||||
}
|
||||
|
||||
if (state.videoType === 'youtube') {
|
||||
youtubeId = state.youtubeId('1.0');
|
||||
|
||||
if (!youtubeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = {
|
||||
videoId: youtubeId
|
||||
};
|
||||
}
|
||||
|
||||
state.el.removeClass('is-captions-rendered');
|
||||
// Fetch the captions file. If no file was specified, or if an error
|
||||
// occurred, then we hide the captions panel, and the "CC" button
|
||||
this.fetchXHR = $.ajaxWithPrefix({
|
||||
url: state.config.transcriptTranslationUrl + '/' + language,
|
||||
notifyOnError: false,
|
||||
data: data,
|
||||
success: function (sjson) {
|
||||
self.sjson = new Sjson(sjson);
|
||||
|
||||
var start = self.sjson.getStartTimes(),
|
||||
captions = self.sjson.getCaptions();
|
||||
|
||||
if (self.loaded) {
|
||||
if (self.rendered) {
|
||||
self.renderCaption(start, captions);
|
||||
self.updatePlayTime(state.videoPlayer.currentTime);
|
||||
}
|
||||
} else {
|
||||
if (state.isTouch) {
|
||||
self.subtitlesEl.find('li').html(
|
||||
gettext(
|
||||
'Caption will be displayed when ' +
|
||||
'you start playing the video.'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
self.renderCaption(start, captions);
|
||||
}
|
||||
|
||||
self.bindHandlers();
|
||||
}
|
||||
|
||||
self.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(state.config.transcriptLanguages).length > 1) {
|
||||
self.fetchAvailableTranslations();
|
||||
} else {
|
||||
self.hideCaptions(true, false);
|
||||
self.hideSubtitlesEl.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Fetch the list of available translations. Upon successful receipt,
|
||||
* the list of available translations will be updated.
|
||||
*
|
||||
* @returns {jquery Promise}
|
||||
*/
|
||||
fetchAvailableTranslations: function () {
|
||||
var self = this,
|
||||
state = this.state;
|
||||
|
||||
return $.ajaxWithPrefix({
|
||||
url: state.config.transcriptAvailableTranslationsUrl,
|
||||
notifyOnError: false,
|
||||
success: function (response) {
|
||||
var currentLanguages = state.config.transcriptLanguages,
|
||||
newLanguages = _.pick(currentLanguages, response);
|
||||
|
||||
// Update property with available currently translations.
|
||||
state.config.transcriptLanguages = newLanguages;
|
||||
// Remove an old language menu.
|
||||
self.container.find('.langs-list').remove();
|
||||
|
||||
if (_.keys(newLanguages).length) {
|
||||
// And try again to fetch transcript.
|
||||
self.fetchCaption();
|
||||
self.renderLanguageMenu(newLanguages);
|
||||
}
|
||||
},
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
self.hideCaptions(true, false);
|
||||
self.hideSubtitlesEl.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Recalculates and updates the height of the container of captions.
|
||||
*
|
||||
*/
|
||||
onResize: function () {
|
||||
this.subtitlesEl
|
||||
.find('.spacing').first()
|
||||
.height(this.topSpacingHeight()).end()
|
||||
.find('.spacing').last()
|
||||
.height(this.bottomSpacingHeight());
|
||||
|
||||
this.scrollCaption();
|
||||
this.setSubtitlesHeight();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Create any necessary DOM elements, attach them, and set their
|
||||
* initial configuration for the Language menu.
|
||||
*
|
||||
* @param {object} languages Dictionary where key is language code,
|
||||
* value - language label
|
||||
*
|
||||
*/
|
||||
renderLanguageMenu: function (languages) {
|
||||
var self = this,
|
||||
state = this.state,
|
||||
menu = $('<ol class="langs-list menu">'),
|
||||
currentLang = state.getCurrentLanguage();
|
||||
|
||||
if (_.keys(languages).length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.showLanguageMenu = true;
|
||||
|
||||
$.each(languages, function(code, label) {
|
||||
var li = $('<li data-lang-code="' + code + '" />'),
|
||||
link = $('<a href="javascript:void(0);">' + label + '</a>');
|
||||
|
||||
if (currentLang === code) {
|
||||
li.addClass('active');
|
||||
}
|
||||
|
||||
li.append(link);
|
||||
menu.append(li);
|
||||
});
|
||||
|
||||
this.container.append(menu);
|
||||
|
||||
menu.on('click', 'a', function (e) {
|
||||
var el = $(e.currentTarget).parent(),
|
||||
state = self.state,
|
||||
langCode = el.data('lang-code');
|
||||
|
||||
if (state.lang !== langCode) {
|
||||
state.lang = langCode;
|
||||
state.storage.setItem('language', langCode);
|
||||
el .addClass('active')
|
||||
.siblings('li')
|
||||
.removeClass('active');
|
||||
|
||||
self.fetchCaption();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Create any necessary DOM elements, attach them, and set their
|
||||
* initial configuration.
|
||||
*
|
||||
* @param {jQuery element} container Element in which captions will be
|
||||
* inserted.
|
||||
* @param {array} start List of start times for the video.
|
||||
* @param {array} captions List of captions for the video.
|
||||
* @returns {object} jQuery's Promise object
|
||||
*
|
||||
*/
|
||||
buildCaptions: function (container, start, captions) {
|
||||
var process = function(text, index) {
|
||||
var liEl = $('<li>', {
|
||||
'data-index': index,
|
||||
'data-start': start[index],
|
||||
'tabindex': 0
|
||||
}).html(text);
|
||||
|
||||
return liEl[0];
|
||||
};
|
||||
|
||||
return AsyncProcess.array(captions, process).done(function (list) {
|
||||
container.append(list);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Initiates creating of captions and set their initial configuration.
|
||||
*
|
||||
* @param {array} start List of start times for the video.
|
||||
* @param {array} captions List of captions for the video.
|
||||
*
|
||||
*/
|
||||
renderCaption: function (start, captions) {
|
||||
var self = this;
|
||||
|
||||
var onRender = function () {
|
||||
self.addPaddings();
|
||||
// Enables or disables automatic scrolling of the captions when the
|
||||
// video is playing. This feature has to be disabled when tabbing
|
||||
// through them as it interferes with that action. Initially, have
|
||||
// this flag enabled as we assume mouse use. Then, if the first
|
||||
// caption (through forward tabbing) or the last caption (through
|
||||
// backwards tabbing) gets the focus, disable that feature.
|
||||
// Re-enable it if tabbing then cycles out of the the captions.
|
||||
self.autoScrolling = true;
|
||||
// Keeps track of where the focus is situated in the array of
|
||||
// captions. Used to implement the automatic scrolling behavior and
|
||||
// decide if the outline around a caption has to be hidden or shown
|
||||
// on a mouseenter or mouseleave. Initially, no caption has the
|
||||
// focus, set the index to -1.
|
||||
self.currentCaptionIndex = -1;
|
||||
// Used to track if the focus is coming from a click or tabbing. This
|
||||
// has to be known to decide if, when a caption gets the focus, an
|
||||
// outline has to be drawn (tabbing) or not (mouse click).
|
||||
self.isMouseFocus = false;
|
||||
self.rendered = true;
|
||||
self.state.el.addClass('is-captions-rendered');
|
||||
};
|
||||
|
||||
this.rendered = false;
|
||||
this.subtitlesEl.empty();
|
||||
this.setSubtitlesHeight();
|
||||
this.buildCaptions(this.subtitlesEl, start, captions).done(onRender);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Sets top and bottom spacing height and make sure they are taken
|
||||
* out of the tabbing order.
|
||||
*
|
||||
*/
|
||||
addPaddings: function () {
|
||||
|
||||
this.subtitlesEl
|
||||
.prepend(
|
||||
$('<li class="spacing">')
|
||||
.height(this.topSpacingHeight())
|
||||
.attr('tabindex', -1)
|
||||
)
|
||||
.append(
|
||||
$('<li class="spacing">')
|
||||
.height(this.bottomSpacingHeight())
|
||||
.attr('tabindex', -1)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc
|
||||
* On mouseOver: Hides the outline of a caption that has been tabbed to.
|
||||
* On mouseOut: Shows the outline of a caption that has been tabbed to.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionMouseOverOut: function (event) {
|
||||
var caption = $(event.target),
|
||||
captionIndex = parseInt(caption.attr('data-index'), 10);
|
||||
|
||||
if (captionIndex === this.currentCaptionIndex) {
|
||||
if (event.type === 'mouseover') {
|
||||
caption.removeClass('focused');
|
||||
}
|
||||
else { // mouseout
|
||||
caption.addClass('focused');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles mousedown event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionMouseDown: function (event) {
|
||||
var caption = $(event.target);
|
||||
|
||||
this.isMouseFocus = true;
|
||||
this.autoScrolling = true;
|
||||
caption.removeClass('focused');
|
||||
this.currentCaptionIndex = -1;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles click event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionClick: function (event) {
|
||||
this.seekPlayer(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles focus event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionFocus: function (event) {
|
||||
var caption = $(event.target),
|
||||
captionIndex = parseInt(caption.attr('data-index'), 10);
|
||||
// If the focus comes from a mouse click, hide the outline, turn on
|
||||
// automatic scrolling and set currentCaptionIndex to point outside of
|
||||
// caption list (ie -1) to disable mouseenter, mouseleave behavior.
|
||||
if (this.isMouseFocus) {
|
||||
this.autoScrolling = true;
|
||||
caption.removeClass('focused');
|
||||
this.currentCaptionIndex = -1;
|
||||
}
|
||||
// If the focus comes from tabbing, show the outline and turn off
|
||||
// automatic scrolling.
|
||||
else {
|
||||
this.currentCaptionIndex = captionIndex;
|
||||
caption.addClass('focused');
|
||||
// The second and second to last elements turn automatic scrolling
|
||||
// off again as it may have been enabled in captionBlur.
|
||||
if (
|
||||
captionIndex <= 1 ||
|
||||
captionIndex >= this.sjson.getSize() - 2
|
||||
) {
|
||||
this.autoScrolling = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles blur event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionBlur: function (event) {
|
||||
var caption = $(event.target),
|
||||
captionIndex = parseInt(caption.attr('data-index'), 10);
|
||||
|
||||
caption.removeClass('focused');
|
||||
// If we are on first or last index, we have to turn automatic scroll
|
||||
// on again when losing focus. There is no way to know in what
|
||||
// direction we are tabbing. So we could be on the first element and
|
||||
// tabbing back out of the captions or on the last element and tabbing
|
||||
// forward out of the captions.
|
||||
if (captionIndex === 0 ||
|
||||
captionIndex === this.sjson.getSize() - 1) {
|
||||
|
||||
this.autoScrolling = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles keydown event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionKeyDown: function (event) {
|
||||
this.isMouseFocus = false;
|
||||
if (event.which === 13) { //Enter key
|
||||
this.seekPlayer(event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Scrolls caption container to make active caption visible.
|
||||
*
|
||||
*/
|
||||
scrollCaption: function () {
|
||||
var el = this.subtitlesEl.find('.current:first');
|
||||
|
||||
// Automatic scrolling gets disabled if one of the captions has
|
||||
// received focus through tabbing.
|
||||
if (
|
||||
!this.frozen &&
|
||||
el.length &&
|
||||
this.autoScrolling
|
||||
) {
|
||||
this.subtitlesEl.scrollTo(
|
||||
el,
|
||||
{
|
||||
offset: -1 * this.calculateOffset(el)
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Updates flags on play
|
||||
*
|
||||
*/
|
||||
play: function () {
|
||||
if (this.loaded) {
|
||||
if (!this.rendered) {
|
||||
var start = this.sjson.getStartTimes(),
|
||||
captions = this.sjson.getCaptions();
|
||||
|
||||
this.renderCaption(start, captions);
|
||||
}
|
||||
|
||||
this.playing = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Updates flags on pause
|
||||
*
|
||||
*/
|
||||
pause: function () {
|
||||
if (this.loaded) {
|
||||
this.playing = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Updates captions UI on paying.
|
||||
*
|
||||
* @param {number} time Time in seconds.
|
||||
*
|
||||
*/
|
||||
updatePlayTime: function (time) {
|
||||
var state = this.state,
|
||||
newIndex;
|
||||
|
||||
if (this.loaded) {
|
||||
if (state.isFlashMode()) {
|
||||
time = Time.convert(time, state.speed, '1.0');
|
||||
}
|
||||
|
||||
time = Math.round(time * 1000 + 100);
|
||||
newIndex = this.sjson.search(time);
|
||||
|
||||
if (
|
||||
typeof newIndex !== 'undefined' &&
|
||||
newIndex !== -1 &&
|
||||
this.currentIndex !== newIndex
|
||||
) {
|
||||
if (typeof this.currentIndex !== 'undefined') {
|
||||
this.subtitlesEl
|
||||
.find('li.current')
|
||||
.removeClass('current');
|
||||
}
|
||||
|
||||
this.subtitlesEl
|
||||
.find("li[data-index='" + newIndex + "']")
|
||||
.addClass('current');
|
||||
|
||||
this.currentIndex = newIndex;
|
||||
this.scrollCaption();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Sends log to the server on caption seek.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
seekPlayer: function (event) {
|
||||
var state = this.state,
|
||||
time = parseInt($(event.target).data('start'), 10);
|
||||
|
||||
if (state.isFlashMode()) {
|
||||
time = Math.round(Time.convert(time, '1.0', state.speed));
|
||||
}
|
||||
|
||||
state.trigger(
|
||||
'videoPlayer.onCaptionSeek',
|
||||
{
|
||||
'type': 'onCaptionSeek',
|
||||
'time': time/1000
|
||||
}
|
||||
);
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Calculates offset for paddings.
|
||||
*
|
||||
* @param {jquery element} element Top or bottom padding element.
|
||||
* @returns {number} Offset for the passed padding element.
|
||||
*
|
||||
*/
|
||||
calculateOffset: function (element) {
|
||||
return this.captionHeight() / 2 - element.height() / 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Calculates offset for the top padding element.
|
||||
*
|
||||
* @returns {number} Offset for the passed top padding element.
|
||||
*
|
||||
*/
|
||||
topSpacingHeight: function () {
|
||||
return this.calculateOffset(
|
||||
this.subtitlesEl.find('li:not(.spacing)').first()
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Calculates offset for the bottom padding element.
|
||||
*
|
||||
* @returns {number} Offset for the passed bottom padding element.
|
||||
*
|
||||
*/
|
||||
bottomSpacingHeight: function () {
|
||||
return this.calculateOffset(
|
||||
this.subtitlesEl.find('li:not(.spacing)').last()
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Shows/Hides captions on click `CC` button
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
toggle: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.state.el.hasClass('closed')) {
|
||||
this.hideCaptions(false);
|
||||
} else {
|
||||
this.hideCaptions(true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Shows/Hides captions and updates the cookie.
|
||||
*
|
||||
* @param {boolean} hide_captions if `true` hides the caption,
|
||||
* otherwise - show.
|
||||
* @param {boolean} update_cookie Flag to update or not the cookie.
|
||||
*
|
||||
*/
|
||||
hideCaptions: function (hide_captions, update_cookie) {
|
||||
var hideSubtitlesEl = this.hideSubtitlesEl,
|
||||
state = this.state,
|
||||
type, text;
|
||||
|
||||
if (typeof update_cookie === 'undefined') {
|
||||
update_cookie = true;
|
||||
}
|
||||
|
||||
if (hide_captions) {
|
||||
type = 'hide_transcript';
|
||||
state.captionsHidden = true;
|
||||
state.el.addClass('closed');
|
||||
text = gettext('Turn on captions');
|
||||
} else {
|
||||
type = 'show_transcript';
|
||||
state.captionsHidden = false;
|
||||
state.el.removeClass('closed');
|
||||
this.scrollCaption();
|
||||
text = gettext('Turn off captions');
|
||||
}
|
||||
|
||||
hideSubtitlesEl
|
||||
.attr('title', text)
|
||||
.text(gettext(text));
|
||||
|
||||
if (state.videoPlayer) {
|
||||
state.videoPlayer.log(type, {
|
||||
currentTime: state.videoPlayer.currentTime
|
||||
});
|
||||
}
|
||||
|
||||
if (state.resizer) {
|
||||
if (state.isFullScreen) {
|
||||
state.resizer.setMode('both');
|
||||
} else {
|
||||
state.resizer.alignByWidthOnly();
|
||||
}
|
||||
}
|
||||
|
||||
this.setSubtitlesHeight();
|
||||
if (update_cookie) {
|
||||
$.cookie('hide_captions', hide_captions, {
|
||||
expires: 3650,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Return the caption container height.
|
||||
*
|
||||
* @returns {number} event Height of the container in pixels.
|
||||
*
|
||||
*/
|
||||
captionHeight: function () {
|
||||
var state = this.state;
|
||||
|
||||
if (state.isFullScreen) {
|
||||
return state.container.height() - state.videoControl.height;
|
||||
} else {
|
||||
return state.container.height();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Sets the height of the caption container element.
|
||||
*
|
||||
*/
|
||||
setSubtitlesHeight: function () {
|
||||
var height = 0,
|
||||
state = this.state;
|
||||
// on page load captionHidden = undefined
|
||||
if ((state.captionsHidden === undefined && state.hide_captions) ||
|
||||
state.captionsHidden === true
|
||||
) {
|
||||
// In case of html5 autoshowing subtitles, we adjust height of
|
||||
// subs, by height of scrollbar.
|
||||
height = state.videoControl.el.height() +
|
||||
0.5 * state.videoControl.sliderEl.height();
|
||||
// Height of videoControl does not contain height of slider.
|
||||
// css is set to absolute, to avoid yanking when slider
|
||||
// autochanges its height.
|
||||
}
|
||||
|
||||
this.subtitlesEl.css({
|
||||
maxHeight: this.captionHeight() - height
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function captionHeight() {
|
||||
if (this.isFullScreen) {
|
||||
return this.container.height() - this.videoControl.height;
|
||||
} else {
|
||||
return this.container.height();
|
||||
}
|
||||
}
|
||||
|
||||
function setSubtitlesHeight() {
|
||||
var height = 0;
|
||||
// on page load captionHidden = undefined
|
||||
if ((this.captionsHidden === undefined && this.hide_captions) ||
|
||||
this.captionsHidden === true
|
||||
) {
|
||||
// In case of html5 autoshowing subtitles, we adjust height of
|
||||
// subs, by height of scrollbar.
|
||||
height = this.videoControl.el.height() +
|
||||
0.5 * this.videoControl.sliderEl.height();
|
||||
// Height of videoControl does not contain height of slider.
|
||||
// css is set to absolute, to avoid yanking when slider
|
||||
// autochanges its height.
|
||||
}
|
||||
|
||||
this.videoCaption.subtitlesEl.css({
|
||||
maxHeight: this.videoCaption.captionHeight() - height
|
||||
});
|
||||
}
|
||||
return VideoCaption;
|
||||
});
|
||||
|
||||
}(RequireJS.define));
|
||||
|
||||
@@ -36,6 +36,10 @@ class InheritanceMixin(XBlockMixin):
|
||||
default=None,
|
||||
scope=Scope.user_state,
|
||||
)
|
||||
course_edit_method = String(
|
||||
help="Method with which this course is edited.",
|
||||
default="Studio", scope=Scope.settings
|
||||
)
|
||||
giturl = String(
|
||||
help="url root for course data git repository",
|
||||
scope=Scope.settings,
|
||||
|
||||
@@ -20,7 +20,7 @@ from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.contentstore.mongo import MongoContentStore
|
||||
|
||||
from xmodule.modulestore.tests.test_modulestore import check_path_to_location
|
||||
from IPython.testing.nose_assert_methods import assert_in
|
||||
from nose.tools import assert_in
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.exceptions import InsufficientSpecificationError
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from dogapi import dog_stats_api
|
||||
|
||||
import logging
|
||||
from .grading_service_module import GradingService
|
||||
|
||||
@@ -9,33 +11,28 @@ class ControllerQueryService(GradingService):
|
||||
Interface to controller query backend.
|
||||
"""
|
||||
|
||||
METRIC_NAME = 'edxapp.open_ended_grading.controller_query_service'
|
||||
|
||||
def __init__(self, config, system):
|
||||
config['system'] = system
|
||||
super(ControllerQueryService, self).__init__(config)
|
||||
self.url = config['url'] + config['grading_controller']
|
||||
self.login_url = self.url + '/login/'
|
||||
self.check_eta_url = self.url + '/get_submission_eta/'
|
||||
self.is_unique_url = self.url + '/is_name_unique/'
|
||||
self.combined_notifications_url = self.url + '/combined_notifications/'
|
||||
self.grading_status_list_url = self.url + '/get_grading_status_list/'
|
||||
self.flagged_problem_list_url = self.url + '/get_flagged_problem_list/'
|
||||
self.take_action_on_flags_url = self.url + '/take_action_on_flags/'
|
||||
|
||||
def check_if_name_is_unique(self, location, problem_id, course_id):
|
||||
params = {
|
||||
'course_id': course_id,
|
||||
'location': location,
|
||||
'problem_id': problem_id
|
||||
}
|
||||
response = self.get(self.is_unique_url, params)
|
||||
return response
|
||||
|
||||
def check_for_eta(self, location):
|
||||
params = {
|
||||
'location': location,
|
||||
}
|
||||
response = self.get(self.check_eta_url, params)
|
||||
return response
|
||||
data = self.get(self.check_eta_url, params)
|
||||
self._record_result('check_for_eta', data)
|
||||
dog_stats_api.histogram(self._metric_name('check_for_eta.eta'), data.get('eta', 0))
|
||||
|
||||
return data
|
||||
|
||||
def check_combined_notifications(self, course_id, student_id, user_is_staff, last_time_viewed):
|
||||
params = {
|
||||
@@ -45,8 +42,16 @@ class ControllerQueryService(GradingService):
|
||||
'last_time_viewed': last_time_viewed,
|
||||
}
|
||||
log.debug(self.combined_notifications_url)
|
||||
response = self.get(self.combined_notifications_url, params)
|
||||
return response
|
||||
data = self.get(self.combined_notifications_url, params)
|
||||
|
||||
tags = [u'course_id:{}'.format(course_id), u'user_is_staff:{}'.format(user_is_staff)]
|
||||
tags.extend(
|
||||
u'{}:{}'.format(key, value)
|
||||
for key, value in data.items()
|
||||
if key not in ('success', 'version', 'error')
|
||||
)
|
||||
self._record_result('check_combined_notifications', data, tags)
|
||||
return data
|
||||
|
||||
def get_grading_status_list(self, course_id, student_id):
|
||||
params = {
|
||||
@@ -54,16 +59,31 @@ class ControllerQueryService(GradingService):
|
||||
'course_id': course_id,
|
||||
}
|
||||
|
||||
response = self.get(self.grading_status_list_url, params)
|
||||
return response
|
||||
data = self.get(self.grading_status_list_url, params)
|
||||
|
||||
tags = [u'course_id:{}'.format(course_id)]
|
||||
self._record_result('get_grading_status_list', data, tags)
|
||||
dog_stats_api.histogram(
|
||||
self._metric_name('get_grading_status_list.length'),
|
||||
len(data.get('problem_list', [])),
|
||||
tags=tags
|
||||
)
|
||||
return data
|
||||
|
||||
def get_flagged_problem_list(self, course_id):
|
||||
params = {
|
||||
'course_id': course_id,
|
||||
}
|
||||
|
||||
response = self.get(self.flagged_problem_list_url, params)
|
||||
return response
|
||||
data = self.get(self.flagged_problem_list_url, params)
|
||||
|
||||
tags = [u'course_id:{}'.format(course_id)]
|
||||
self._record_result('get_flagged_problem_list', data, tags)
|
||||
dog_stats_api.histogram(
|
||||
self._metric_name('get_flagged_problem_list.length'),
|
||||
len(data.get('flagged_submissions', []))
|
||||
)
|
||||
return data
|
||||
|
||||
def take_action_on_flags(self, course_id, student_id, submission_id, action_type):
|
||||
params = {
|
||||
@@ -73,8 +93,11 @@ class ControllerQueryService(GradingService):
|
||||
'action_type': action_type
|
||||
}
|
||||
|
||||
response = self.post(self.take_action_on_flags_url, params)
|
||||
return response
|
||||
data = self.post(self.take_action_on_flags_url, params)
|
||||
|
||||
tags = [u'course_id:{}'.format(course_id), u'action_type:{}'.format(action_type)]
|
||||
self._record_result('take_action_on_flags', data, tags)
|
||||
return data
|
||||
|
||||
|
||||
class MockControllerQueryService(object):
|
||||
@@ -85,14 +108,6 @@ class MockControllerQueryService(object):
|
||||
def __init__(self, config, system):
|
||||
pass
|
||||
|
||||
def check_if_name_is_unique(self, *args, **kwargs):
|
||||
"""
|
||||
Mock later if needed. Stub function for now.
|
||||
@param params:
|
||||
@return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def check_for_eta(self, *args, **kwargs):
|
||||
"""
|
||||
Mock later if needed. Stub function for now.
|
||||
@@ -102,15 +117,47 @@ class MockControllerQueryService(object):
|
||||
pass
|
||||
|
||||
def check_combined_notifications(self, *args, **kwargs):
|
||||
combined_notifications = '{"flagged_submissions_exist": false, "version": 1, "new_student_grading_to_view": false, "success": true, "staff_needs_to_grade": false, "student_needs_to_peer_grade": true, "overall_need_to_check": true}'
|
||||
combined_notifications = {
|
||||
"flagged_submissions_exist": False,
|
||||
"version": 1,
|
||||
"new_student_grading_to_view": False,
|
||||
"success": True,
|
||||
"staff_needs_to_grade": False,
|
||||
"student_needs_to_peer_grade": True,
|
||||
"overall_need_to_check": True
|
||||
}
|
||||
return combined_notifications
|
||||
|
||||
def get_grading_status_list(self, *args, **kwargs):
|
||||
grading_status_list = '{"version": 1, "problem_list": [{"problem_name": "Science Question -- Machine Assessed", "grader_type": "NA", "eta_available": true, "state": "Waiting to be Graded", "eta": 259200, "location": "i4x://MITx/oe101x/combinedopenended/Science_SA_ML"}, {"problem_name": "Humanities Question -- Peer Assessed", "grader_type": "NA", "eta_available": true, "state": "Waiting to be Graded", "eta": 259200, "location": "i4x://MITx/oe101x/combinedopenended/Humanities_SA_Peer"}], "success": true}'
|
||||
grading_status_list = {
|
||||
"version": 1,
|
||||
"problem_list": [
|
||||
{
|
||||
"problem_name": "Science Question -- Machine Assessed",
|
||||
"grader_type": "NA",
|
||||
"eta_available": True,
|
||||
"state": "Waiting to be Graded",
|
||||
"eta": 259200,
|
||||
"location": "i4x://MITx/oe101x/combinedopenended/Science_SA_ML"
|
||||
}, {
|
||||
"problem_name": "Humanities Question -- Peer Assessed",
|
||||
"grader_type": "NA",
|
||||
"eta_available": True,
|
||||
"state": "Waiting to be Graded",
|
||||
"eta": 259200,
|
||||
"location": "i4x://MITx/oe101x/combinedopenended/Humanities_SA_Peer"
|
||||
}
|
||||
],
|
||||
"success": True
|
||||
}
|
||||
return grading_status_list
|
||||
|
||||
def get_flagged_problem_list(self, *args, **kwargs):
|
||||
flagged_problem_list = '{"version": 1, "success": false, "error": "No flagged submissions exist for course: MITx/oe101x/2012_Fall"}'
|
||||
flagged_problem_list = {
|
||||
"version": 1,
|
||||
"success": False,
|
||||
"error": "No flagged submissions exist for course: MITx/oe101x/2012_Fall"
|
||||
}
|
||||
return flagged_problem_list
|
||||
|
||||
def take_action_on_flags(self, *args, **kwargs):
|
||||
@@ -131,5 +178,4 @@ def convert_seconds_to_human_readable(seconds):
|
||||
else:
|
||||
human_string = "{0} days".format(round(seconds / (60 * 60 * 24), 1))
|
||||
|
||||
eta_string = "{0}".format(human_string)
|
||||
return eta_string
|
||||
return human_string
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from dogapi import dog_stats_api
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
|
||||
from .combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
from .combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from lxml import etree
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -44,40 +45,66 @@ class GradingService(object):
|
||||
|
||||
return response.json()
|
||||
|
||||
def _metric_name(self, suffix):
|
||||
"""
|
||||
Return a metric name for datadog, using `self.METRIC_NAME` as
|
||||
a prefix, and `suffix` as the suffix.
|
||||
|
||||
Arguments:
|
||||
suffix (str): The metric suffix to use.
|
||||
"""
|
||||
return '{}.{}'.format(self.METRIC_NAME, suffix)
|
||||
|
||||
def _record_result(self, action, data, tags=None):
|
||||
"""
|
||||
Log results from an API call to an ORA service to datadog.
|
||||
|
||||
Arguments:
|
||||
action (str): The ORA action being recorded.
|
||||
data (dict): The data returned from the ORA service. Should contain the key 'success'.
|
||||
tags (list): A list of tags to attach to the logged metric.
|
||||
"""
|
||||
if tags is None:
|
||||
tags = []
|
||||
|
||||
tags.append(u'result:{}'.format(data.get('success', False)))
|
||||
tags.append(u'action:{}'.format(action))
|
||||
dog_stats_api.increment(self._metric_name('request.count'), tags=tags)
|
||||
|
||||
def post(self, url, data, allow_redirects=False):
|
||||
"""
|
||||
Make a post request to the grading controller
|
||||
Make a post request to the grading controller. Returns the parsed json results of that request.
|
||||
"""
|
||||
try:
|
||||
op = lambda: self.session.post(url, data=data,
|
||||
allow_redirects=allow_redirects)
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
response_json = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError, ValueError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
#This is a dev_facing_error
|
||||
error_string = "Problem posting data to the grading controller. URL: {0}, data: {1}".format(url, data)
|
||||
log.error(error_string)
|
||||
raise GradingServiceError(error_string)
|
||||
|
||||
return r.text
|
||||
return response_json
|
||||
|
||||
def get(self, url, params, allow_redirects=False):
|
||||
"""
|
||||
Make a get request to the grading controller
|
||||
Make a get request to the grading controller. Returns the parsed json results of that request.
|
||||
"""
|
||||
op = lambda: self.session.get(url,
|
||||
allow_redirects=allow_redirects,
|
||||
params=params)
|
||||
try:
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
response_json = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError, ValueError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
#This is a dev_facing_error
|
||||
error_string = "Problem getting data from the grading controller. URL: {0}, params: {1}".format(url, params)
|
||||
log.error(error_string)
|
||||
raise GradingServiceError(error_string)
|
||||
|
||||
return r.text
|
||||
return response_json
|
||||
|
||||
def _try_with_login(self, operation):
|
||||
"""
|
||||
@@ -92,44 +119,39 @@ class GradingService(object):
|
||||
if (resp_json
|
||||
and resp_json.get('success') is False
|
||||
and resp_json.get('error') == 'login_required'):
|
||||
# apparrently we aren't logged in. Try to fix that.
|
||||
# apparently we aren't logged in. Try to fix that.
|
||||
r = self._login()
|
||||
if r and not r.get('success'):
|
||||
log.warning("Couldn't log into staff_grading backend. Response: %s",
|
||||
log.warning("Couldn't log into ORA backend. Response: %s",
|
||||
r)
|
||||
# try again
|
||||
# try again
|
||||
response = operation()
|
||||
response.raise_for_status()
|
||||
resp_json = response.json()
|
||||
|
||||
return response
|
||||
return resp_json
|
||||
|
||||
def _render_rubric(self, response, view_only=False):
|
||||
"""
|
||||
Given an HTTP Response with the key 'rubric', render out the html
|
||||
Given an HTTP Response json with the key 'rubric', render out the html
|
||||
required to display the rubric and put it back into the response
|
||||
|
||||
returns the updated response as a dictionary that can be serialized later
|
||||
|
||||
"""
|
||||
try:
|
||||
response_json = json.loads(response)
|
||||
except:
|
||||
response_json = response
|
||||
|
||||
try:
|
||||
if 'rubric' in response_json:
|
||||
rubric = response_json['rubric']
|
||||
if 'rubric' in response:
|
||||
rubric = response['rubric']
|
||||
rubric_renderer = CombinedOpenEndedRubric(self.system, view_only)
|
||||
rubric_dict = rubric_renderer.render_rubric(rubric)
|
||||
success = rubric_dict['success']
|
||||
rubric_html = rubric_dict['html']
|
||||
response_json['rubric'] = rubric_html
|
||||
return response_json
|
||||
response['rubric'] = rubric_html
|
||||
return response
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError, RubricParsingError:
|
||||
except (etree.XMLSyntaxError, RubricParsingError):
|
||||
#This is a dev_facing_error
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}".format(response['rubric']))
|
||||
return {'success': False,
|
||||
'error': 'Error displaying submission'}
|
||||
except ValueError:
|
||||
|
||||
@@ -533,10 +533,6 @@ class OpenEndedChild(object):
|
||||
def get_eta(self):
|
||||
if self.controller_qs:
|
||||
response = self.controller_qs.check_for_eta(self.location_string)
|
||||
try:
|
||||
response = json.loads(response)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
from dogapi import dog_stats_api
|
||||
|
||||
from .grading_service_module import GradingService, GradingServiceError
|
||||
|
||||
@@ -11,6 +12,8 @@ class PeerGradingService(GradingService):
|
||||
Interface with the grading controller for peer grading
|
||||
"""
|
||||
|
||||
METRIC_NAME = 'edxapp.open_ended_grading.peer_grading_service'
|
||||
|
||||
def __init__(self, config, system):
|
||||
config['system'] = system
|
||||
super(PeerGradingService, self).__init__(config)
|
||||
@@ -28,54 +31,78 @@ class PeerGradingService(GradingService):
|
||||
|
||||
def get_data_for_location(self, problem_location, student_id):
|
||||
params = {'location': problem_location, 'student_id': student_id}
|
||||
response = self.get(self.get_data_for_location_url, params)
|
||||
return self.try_to_decode(response)
|
||||
result = self.get(self.get_data_for_location_url, params)
|
||||
self._record_result('get_data_for_location', result)
|
||||
for key in result.keys():
|
||||
if key in ('success', 'error', 'version'):
|
||||
continue
|
||||
|
||||
dog_stats_api.histogram(
|
||||
self._metric_name('get_data_for_location.{}'.format(key)),
|
||||
result[key],
|
||||
)
|
||||
return result
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
response = self.get(
|
||||
result = self._render_rubric(self.get(
|
||||
self.get_next_submission_url,
|
||||
{
|
||||
'location': problem_location,
|
||||
'grader_id': grader_id
|
||||
}
|
||||
)
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
))
|
||||
self._record_result('get_next_submission', result)
|
||||
return result
|
||||
|
||||
def save_grade(self, **kwargs):
|
||||
data = kwargs
|
||||
data.update({'rubric_scores_complete': True})
|
||||
return self.try_to_decode(self.post(self.save_grade_url, data))
|
||||
result = self.post(self.save_grade_url, data)
|
||||
self._record_result('save_grade', result)
|
||||
return result
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
params = {'problem_id': problem_location, 'student_id': grader_id}
|
||||
return self.try_to_decode(self.get(self.is_student_calibrated_url, params))
|
||||
result = self.get(self.is_student_calibrated_url, params)
|
||||
self._record_result(
|
||||
'is_student_calibrated',
|
||||
result,
|
||||
tags=['calibrated:{}'.format(result.get('calibrated'))]
|
||||
)
|
||||
return result
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
params = {'problem_id': problem_location, 'student_id': grader_id}
|
||||
response = self.get(self.show_calibration_essay_url, params)
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
result = self._render_rubric(self.get(self.show_calibration_essay_url, params))
|
||||
self._record_result('show_calibration_essay', result)
|
||||
return result
|
||||
|
||||
def save_calibration_essay(self, **kwargs):
|
||||
data = kwargs
|
||||
data.update({'rubric_scores_complete': True})
|
||||
return self.try_to_decode(self.post(self.save_calibration_essay_url, data))
|
||||
result = self.post(self.save_calibration_essay_url, data)
|
||||
self._record_result('show_calibration_essay', result)
|
||||
return result
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_problem_list_url, params)
|
||||
return self.try_to_decode(response)
|
||||
result = self.get(self.get_problem_list_url, params)
|
||||
self._record_result('get_problem_list', result)
|
||||
dog_stats_api.histogram(
|
||||
self._metric_name('get_problem_list.result.length'),
|
||||
len(result.get('problem_list',[]))
|
||||
)
|
||||
return result
|
||||
|
||||
def get_notifications(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_notifications_url, params)
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def try_to_decode(self, text):
|
||||
try:
|
||||
text = json.loads(text)
|
||||
except:
|
||||
pass
|
||||
return text
|
||||
result = self.get(self.get_notifications_url, params)
|
||||
self._record_result(
|
||||
'get_notifications',
|
||||
result,
|
||||
tags=['needs_to_peer_grade:{}'.format(result.get('student_needs_to_peer_grade'))]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@@ -539,8 +539,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
try:
|
||||
problem_list_json = self.peer_gs.get_problem_list(self.course_id, self.system.anonymous_student_id)
|
||||
problem_list_dict = problem_list_json
|
||||
problem_list_dict = self.peer_gs.get_problem_list(self.course_id, self.system.anonymous_student_id)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
|
||||
@@ -11,6 +11,7 @@ from xmodule.vertical_module import VerticalModule, VerticalDescriptor
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import ScopeIds
|
||||
|
||||
from . import get_test_system
|
||||
|
||||
@@ -216,6 +217,7 @@ class FakeChild(XBlock):
|
||||
self.student_view = Mock(return_value=Fragment(self.get_html()))
|
||||
self.save = Mock()
|
||||
self.id = 'i4x://this/is/a/fake/id'
|
||||
self.scope_ids = ScopeIds('fake_user_id', 'fake_block_type', 'fake_definition_id', 'fake_usage_id')
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,6 @@ in-browser HTML5 video method (when in HTML5 mode).
|
||||
- Navigational subtitles can be disabled altogether via an attribute
|
||||
in XML.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
@@ -36,8 +35,6 @@ from .video_utils import create_youtube_string
|
||||
from .video_xfields import VideoFields
|
||||
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
|
||||
|
||||
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
|
||||
from xblock.runtime import KvsFieldData
|
||||
from urlparse import urlparse
|
||||
|
||||
def get_ext(filename):
|
||||
@@ -124,8 +121,9 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
|
||||
else:
|
||||
transcript_language = sorted(self.transcripts.keys())[0]
|
||||
|
||||
native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
|
||||
languages = {
|
||||
lang: display
|
||||
lang: native_languages.get(lang, display)
|
||||
for lang, display in settings.ALL_LANGUAGES
|
||||
if lang in self.transcripts
|
||||
}
|
||||
|
||||
@@ -27,10 +27,13 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from xmodule.exceptions import UndefinedContext
|
||||
from dogapi import dog_stats_api
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
XMODULE_METRIC_NAME = 'edxapp.xmodule'
|
||||
|
||||
|
||||
def dummy_track(_event_type, _event):
|
||||
pass
|
||||
@@ -926,7 +929,52 @@ def descriptor_global_local_resource_url(block, uri): # pylint: disable=invalid
|
||||
raise NotImplementedError("Applications must monkey-patch this function before using local_resource_url for studio_view")
|
||||
|
||||
|
||||
class DescriptorSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
|
||||
class MetricsMixin(object):
|
||||
"""
|
||||
Mixin for adding metric logging for render and handle methods in the DescriptorSystem and ModuleSystem.
|
||||
"""
|
||||
|
||||
def render(self, block, view_name, context=None):
|
||||
try:
|
||||
status = "success"
|
||||
return super(MetricsMixin, self).render(block, view_name, context=context)
|
||||
|
||||
except:
|
||||
status = "failure"
|
||||
raise
|
||||
|
||||
finally:
|
||||
course_id = getattr(self, 'course_id', '')
|
||||
dog_stats_api.increment(XMODULE_METRIC_NAME, tags=[
|
||||
u'view_name:{}'.format(view_name),
|
||||
u'action:render',
|
||||
u'action_status:{}'.format(status),
|
||||
u'course_id:{}'.format(course_id),
|
||||
u'block_type:{}'.format(block.scope_ids.block_type)
|
||||
])
|
||||
|
||||
def handle(self, block, handler_name, request, suffix=''):
|
||||
handle = None
|
||||
try:
|
||||
status = "success"
|
||||
return super(MetricsMixin, self).handle(block, handler_name, request, suffix=suffix)
|
||||
|
||||
except:
|
||||
status = "failure"
|
||||
raise
|
||||
|
||||
finally:
|
||||
course_id = getattr(self, 'course_id', '')
|
||||
dog_stats_api.increment(XMODULE_METRIC_NAME, tags=[
|
||||
u'handler_name:{}'.format(handler_name),
|
||||
u'action:handle',
|
||||
u'action_status:{}'.format(status),
|
||||
u'course_id:{}'.format(course_id),
|
||||
u'block_type:{}'.format(block.scope_ids.block_type)
|
||||
])
|
||||
|
||||
|
||||
class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
|
||||
"""
|
||||
Base class for :class:`Runtime`s to be used with :class:`XModuleDescriptor`s
|
||||
"""
|
||||
@@ -1086,7 +1134,7 @@ class XMLParsingSystem(DescriptorSystem):
|
||||
self.process_xml = process_xml
|
||||
|
||||
|
||||
class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
|
||||
class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
|
||||
"""
|
||||
This is an abstraction such that x_modules can function independent
|
||||
of the courseware (e.g. import into other types of courseware, LMS,
|
||||
|
||||
@@ -112,6 +112,10 @@ if Backbone?
|
||||
@renderResponses()
|
||||
|
||||
collapsePost: (event) ->
|
||||
curScroll = $(window).scrollTop()
|
||||
postTop = @$el.offset().top
|
||||
if postTop < curScroll
|
||||
$('html, body').animate({scrollTop: postTop})
|
||||
@expanded = false
|
||||
@$el.removeClass('expanded')
|
||||
@$el.find('.post-body').html(@model.get('abbreviatedBody'))
|
||||
|
||||
2
common/static/css/tinymce-studio-content-fonts.css
Normal file
2
common/static/css/tinymce-studio-content-fonts.css
Normal file
@@ -0,0 +1,2 @@
|
||||
/* NOTE: This file, which loads all necessary fonts for rendering Studio UI, is 1 of 3 CSS files compiled in our production pipeline */
|
||||
@import url(//fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,400,300,600,700);
|
||||
@@ -1,6 +1,5 @@
|
||||
@import url(//fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,400,300,600,700);
|
||||
|
||||
.mceContentBody {
|
||||
/* NOTE: This file, which customizes mid-editing styling to match xmodule preview rendering, is 3 of 3 CSS files compiled in our production pipeline */
|
||||
.mce-content-body {
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
font-family: 'Open Sans', Verdana, Arial, Helvetica, sans-serif;
|
||||
@@ -17,7 +16,7 @@
|
||||
scrollbar-track-color: #F5F5F5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
.mce-content-body h1 {
|
||||
color: #3c3c3c;
|
||||
font-weight: normal;
|
||||
font-size: 2em;
|
||||
@@ -26,9 +25,9 @@ h1 {
|
||||
margin: 0 0 1.416em 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
.mce-content-body h2 {
|
||||
color: #646464;
|
||||
font-weight: normal;
|
||||
font-weight: 300;
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
letter-spacing: 1px;
|
||||
@@ -37,74 +36,74 @@ h2 {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h3, h4, h5, h6 {
|
||||
.mce-content-body h3, .mce-content-body h4, .mce-content-body h5, .mce-content-body h6 {
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
.mce-content-body h3 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
.mce-content-body h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
.mce-content-body h5 {
|
||||
font-size: .83em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
.mce-content-body h6 {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
p {
|
||||
.mce-content-body p {
|
||||
margin-bottom: 1.416em;
|
||||
font-size: 1em;
|
||||
line-height: 1.6em !important;
|
||||
color: #3c3c3c;
|
||||
}
|
||||
|
||||
em, i {
|
||||
.mce-content-body em, .mce-content-body i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
strong, b {
|
||||
.mce-content-body strong, .mce-content-body b {
|
||||
font-style: bold;
|
||||
}
|
||||
|
||||
p + p, ul + p, ol + p {
|
||||
.mce-content-body p + p, .mce-content-body ul + p, .mce-content-body ol + p {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
.mce-content-body ol, .mce-content-body ul {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
color: #3c3c3c;
|
||||
|
||||
}
|
||||
|
||||
ol li, ul li {
|
||||
.mce-content-body ol li, .mce-content-body ul li {
|
||||
margin-bottom: 0.708em;
|
||||
}
|
||||
|
||||
ol {
|
||||
.mce-content-body ol {
|
||||
list-style: decimal outside none;
|
||||
}
|
||||
|
||||
ul {
|
||||
.mce-content-body ul {
|
||||
list-style: disc outside none;
|
||||
}
|
||||
|
||||
a, a:link, a:visited, a:hover, a:active {
|
||||
.mce-content-body a, .mce-content-body a:link, .mce-content-body a:visited, .mce-content-body a:hover, .mce-content-body a:active {
|
||||
color: #1d9dd9;
|
||||
}
|
||||
|
||||
img {
|
||||
.mce-content-body img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
.mce-content-body pre {
|
||||
margin: 1em 0;
|
||||
color: #3c3c3c;
|
||||
font-family: monospace, serif;
|
||||
@@ -113,25 +112,25 @@ pre {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
code {
|
||||
.mce-content-body code {
|
||||
font-family: monospace, serif;
|
||||
background: none;
|
||||
color: #3c3c3c;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
.mce-content-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
th {
|
||||
.mce-content-body th {
|
||||
background: #eee;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table td, th {
|
||||
.mce-content-body table td, .mce-content-body th {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc !important;
|
||||
@@ -139,14 +138,14 @@ table td, th {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
table td.cont-justified-left, table th.cont-justified-left {
|
||||
.mce-content-body table td.cont-justified-left, .mce-content-body table th.cont-justified-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table td.cont-justified-right, table th.cont-justified-right {
|
||||
.mce-content-body table td.cont-justified-right, .mce-content-body table th.cont-justified-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table td.cont-justified-center, table th.cont-justified-center {
|
||||
.mce-content-body table td.cont-justified-center, .mce-content-body table th.cont-justified-center {
|
||||
text-align: center;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 337 B |
@@ -158,9 +158,7 @@ $(function(){
|
||||
};
|
||||
|
||||
SRAlert.prototype.readElts = function(elts) {
|
||||
var feedback,
|
||||
_this = this;
|
||||
feedback = '';
|
||||
var feedback = '';
|
||||
$.each(elts, function(idx, value) {
|
||||
return feedback += '<p>' + $(value).html() + '</p>\n';
|
||||
});
|
||||
|
||||
7
common/static/js/vendor/CodeMirror/BUILD_README.txt
vendored
Normal file
7
common/static/js/vendor/CodeMirror/BUILD_README.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
Instructions for creating codemirror-compressed.js (in top-level vendor directory).
|
||||
|
||||
1. Install uglifyjs and put it on your path.
|
||||
2. In the CodeMirror directory, run "cat codemirror.js addons/* addons/dialog/dialog.js | uglifyjs > codemirror-compressed.js"
|
||||
3. Replace existing codemirror-compressed.js file with the generated one.
|
||||
|
||||
Additions to codemirror.css are done by manually copying in the new content.
|
||||
32
common/static/js/vendor/CodeMirror/addons/dialog/dialog.css
vendored
Executable file
32
common/static/js/vendor/CodeMirror/addons/dialog/dialog.css
vendored
Executable file
@@ -0,0 +1,32 @@
|
||||
.CodeMirror-dialog {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
background: white;
|
||||
z-index: 15;
|
||||
padding: .1em .8em;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog-top {
|
||||
border-bottom: 1px solid #eee;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog-bottom {
|
||||
border-top: 1px solid #eee;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
width: 20em;
|
||||
color: inherit;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog button {
|
||||
font-size: 70%;
|
||||
}
|
||||
122
common/static/js/vendor/CodeMirror/addons/dialog/dialog.js
vendored
Executable file
122
common/static/js/vendor/CodeMirror/addons/dialog/dialog.js
vendored
Executable file
@@ -0,0 +1,122 @@
|
||||
// Open simple dialogs on top of an editor. Relies on dialog.css.
|
||||
|
||||
(function() {
|
||||
function dialogDiv(cm, template, bottom) {
|
||||
var wrap = cm.getWrapperElement();
|
||||
var dialog;
|
||||
dialog = wrap.appendChild(document.createElement("div"));
|
||||
if (bottom) {
|
||||
dialog.className = "CodeMirror-dialog CodeMirror-dialog-bottom";
|
||||
} else {
|
||||
dialog.className = "CodeMirror-dialog CodeMirror-dialog-top";
|
||||
}
|
||||
if (typeof template == "string") {
|
||||
dialog.innerHTML = template;
|
||||
} else { // Assuming it's a detached DOM element.
|
||||
dialog.appendChild(template);
|
||||
}
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function closeNotification(cm, newVal) {
|
||||
if (cm.state.currentNotificationClose)
|
||||
cm.state.currentNotificationClose();
|
||||
cm.state.currentNotificationClose = newVal;
|
||||
}
|
||||
|
||||
CodeMirror.defineExtension("openDialog", function(template, callback, options) {
|
||||
closeNotification(this, null);
|
||||
var dialog = dialogDiv(this, template, options && options.bottom);
|
||||
var closed = false, me = this;
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
var inp = dialog.getElementsByTagName("input")[0], button;
|
||||
if (inp) {
|
||||
if (options && options.value) inp.value = options.value;
|
||||
CodeMirror.on(inp, "keydown", function(e) {
|
||||
if (options && options.onKeyDown && options.onKeyDown(e, inp.value, close)) { return; }
|
||||
if (e.keyCode == 13 || e.keyCode == 27) {
|
||||
CodeMirror.e_stop(e);
|
||||
close();
|
||||
me.focus();
|
||||
if (e.keyCode == 13) callback(inp.value);
|
||||
}
|
||||
});
|
||||
if (options && options.onKeyUp) {
|
||||
CodeMirror.on(inp, "keyup", function(e) {options.onKeyUp(e, inp.value, close);});
|
||||
}
|
||||
if (options && options.value) inp.value = options.value;
|
||||
inp.focus();
|
||||
CodeMirror.on(inp, "blur", close);
|
||||
} else if (button = dialog.getElementsByTagName("button")[0]) {
|
||||
CodeMirror.on(button, "click", function() {
|
||||
close();
|
||||
me.focus();
|
||||
});
|
||||
button.focus();
|
||||
CodeMirror.on(button, "blur", close);
|
||||
}
|
||||
return close;
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension("openConfirm", function(template, callbacks, options) {
|
||||
closeNotification(this, null);
|
||||
var dialog = dialogDiv(this, template, options && options.bottom);
|
||||
var buttons = dialog.getElementsByTagName("button");
|
||||
var closed = false, me = this, blurring = 1;
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
me.focus();
|
||||
}
|
||||
buttons[0].focus();
|
||||
for (var i = 0; i < buttons.length; ++i) {
|
||||
var b = buttons[i];
|
||||
(function(callback) {
|
||||
CodeMirror.on(b, "click", function(e) {
|
||||
CodeMirror.e_preventDefault(e);
|
||||
close();
|
||||
if (callback) callback(me);
|
||||
});
|
||||
})(callbacks[i]);
|
||||
CodeMirror.on(b, "blur", function() {
|
||||
--blurring;
|
||||
setTimeout(function() { if (blurring <= 0) close(); }, 200);
|
||||
});
|
||||
CodeMirror.on(b, "focus", function() { ++blurring; });
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* openNotification
|
||||
* Opens a notification, that can be closed with an optional timer
|
||||
* (default 5000ms timer) and always closes on click.
|
||||
*
|
||||
* If a notification is opened while another is opened, it will close the
|
||||
* currently opened one and open the new one immediately.
|
||||
*/
|
||||
CodeMirror.defineExtension("openNotification", function(template, options) {
|
||||
closeNotification(this, close);
|
||||
var dialog = dialogDiv(this, template, options && options.bottom);
|
||||
var duration = options && (options.duration === undefined ? 5000 : options.duration);
|
||||
var closed = false, doneTimer;
|
||||
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearTimeout(doneTimer);
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
|
||||
CodeMirror.on(dialog, 'click', function(e) {
|
||||
CodeMirror.e_preventDefault(e);
|
||||
close();
|
||||
});
|
||||
if (duration)
|
||||
doneTimer = setTimeout(close, options.duration);
|
||||
});
|
||||
})();
|
||||
@@ -261,3 +261,37 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror-dialog {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
background: white;
|
||||
z-index: 15;
|
||||
padding: .1em .8em;
|
||||
overflow: hidden;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog-top {
|
||||
border-bottom: 1px solid #eee;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog-bottom {
|
||||
border-top: 1px solid #eee;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
width: 20em;
|
||||
color: inherit;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog button {
|
||||
font-size: 70%;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
(function(c){var b,e,a=[],d=window;c.fn.tinymce=function(j){var p=this,g,k,h,m,i,l="",n="";if(!p.length){return p}if(!j){return tinyMCE.get(p[0].id)}p.css("visibility","hidden");function o(){var r=[],q=0;if(f){f();f=null}p.each(function(t,u){var s,w=u.id,v=j.oninit;if(!w){u.id=w=tinymce.DOM.uniqueId()}s=new tinymce.Editor(w,j);r.push(s);s.onInit.add(function(){var x,y=v;p.css("visibility","");if(v){if(++q==r.length){if(tinymce.is(y,"string")){x=(y.indexOf(".")===-1)?null:tinymce.resolve(y.replace(/\.\w+$/,""));y=tinymce.resolve(y)}y.apply(x||tinymce,r)}}})});c.each(r,function(t,s){s.render()})}if(!d.tinymce&&!e&&(g=j.script_url)){e=1;h=g.substring(0,g.lastIndexOf("/"));if(/_(src|dev)\.js/g.test(g)){n="_src"}m=g.lastIndexOf("?");if(m!=-1){l=g.substring(m+1)}d.tinyMCEPreInit=d.tinyMCEPreInit||{base:h,suffix:n,query:l};if(g.indexOf("gzip")!=-1){i=j.language||"en";g=g+(/\?/.test(g)?"&":"?")+"js=true&core=true&suffix="+escape(n)+"&themes="+escape(j.theme)+"&plugins="+escape(j.plugins)+"&languages="+i;if(!d.tinyMCE_GZ){tinyMCE_GZ={start:function(){tinymce.suffix=n;function q(r){tinymce.ScriptLoader.markDone(tinyMCE.baseURI.toAbsolute(r))}q("langs/"+i+".js");q("themes/"+j.theme+"/editor_template"+n+".js");q("themes/"+j.theme+"/langs/"+i+".js");c.each(j.plugins.split(","),function(s,r){if(r){q("plugins/"+r+"/editor_plugin"+n+".js");q("plugins/"+r+"/langs/"+i+".js")}})},end:function(){}}}}c.ajax({type:"GET",url:g,dataType:"script",cache:true,success:function(){tinymce.dom.Event.domLoaded=1;e=2;if(j.script_loaded){j.script_loaded()}o();c.each(a,function(q,r){r()})}})}else{if(e===1){a.push(o)}else{o()}}return p};c.extend(c.expr[":"],{tinymce:function(g){return !!(g.id&&"tinyMCE" in window&&tinyMCE.get(g.id))}});function f(){function i(l){if(l==="remove"){this.each(function(n,o){var m=h(o);if(m){m.remove()}})}this.find("span.mceEditor,div.mceEditor").each(function(n,o){var m=tinyMCE.get(o.id.replace(/_parent$/,""));if(m){m.remove()}})}function k(n){var m=this,l;if(n!==b){i.call(m);m.each(function(p,q){var o;if(o=tinyMCE.get(q.id)){o.setContent(n)}})}else{if(m.length>0){if(l=tinyMCE.get(m[0].id)){return l.getContent()}}}}function h(m){var l=null;(m)&&(m.id)&&(d.tinymce)&&(l=tinyMCE.get(m.id));return l}function g(l){return !!((l)&&(l.length)&&(d.tinymce)&&(l.is(":tinymce")))}var j={};c.each(["text","html","val"],function(n,l){var o=j[l]=c.fn[l],m=(l==="text");c.fn[l]=function(s){var p=this;if(!g(p)){return o.apply(p,arguments)}if(s!==b){k.call(p.filter(":tinymce"),s);o.apply(p.not(":tinymce"),arguments);return p}else{var r="";var q=arguments;(m?p:p.eq(0)).each(function(u,v){var t=h(v);r+=t?(m?t.getContent().replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g,""):t.getContent({save:true})):o.apply(c(v),q)});return r}}});c.each(["append","prepend"],function(n,m){var o=j[m]=c.fn[m],l=(m==="prepend");c.fn[m]=function(q){var p=this;if(!g(p)){return o.apply(p,arguments)}if(q!==b){p.filter(":tinymce").each(function(s,t){var r=h(t);r&&r.setContent(l?q+r.getContent():r.getContent()+q)});o.apply(p.not(":tinymce"),arguments);return p}}});c.each(["remove","replaceWith","replaceAll","empty"],function(m,l){var n=j[l]=c.fn[l];c.fn[l]=function(){i.call(this,l);return n.apply(this,arguments)}});j.attr=c.fn.attr;c.fn.attr=function(o,q){var m=this,n=arguments;if((!o)||(o!=="value")||(!g(m))){if(q!==b){return j.attr.apply(m,n)}else{return j.attr.apply(m,n)}}if(q!==b){k.call(m.filter(":tinymce"),q);j.attr.apply(m.not(":tinymce"),n);return m}else{var p=m[0],l=h(p);return l?l.getContent({save:true}):j.attr.apply(c(p),n)}}}})(jQuery);
|
||||
1
common/static/js/vendor/tiny_mce/langs/en.js
vendored
1
common/static/js/vendor/tiny_mce/langs/en.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
||||
input.radio {border:1px none #000; background:transparent; vertical-align:middle;}
|
||||
.panel_wrapper div.current {height:80px;}
|
||||
#width {width:50px; vertical-align:middle;}
|
||||
#width2 {width:50px; vertical-align:middle;}
|
||||
#size {width:100px;}
|
||||
@@ -1 +0,0 @@
|
||||
(function(){tinymce.create("tinymce.plugins.AdvancedHRPlugin",{init:function(a,b){a.addCommand("mceAdvancedHr",function(){a.windowManager.open({file:b+"/rule.htm",width:250+parseInt(a.getLang("advhr.delta_width",0)),height:160+parseInt(a.getLang("advhr.delta_height",0)),inline:1},{plugin_url:b})});a.addButton("advhr",{title:"advhr.advhr_desc",cmd:"mceAdvancedHr"});a.onNodeChange.add(function(d,c,e){c.setActive("advhr",e.nodeName=="HR")});a.onClick.add(function(c,d){d=d.target;if(d.nodeName==="HR"){c.selection.select(d)}})},getInfo:function(){return{longname:"Advanced HR",author:"Moxiecode Systems AB",authorurl:"http://tinymce.moxiecode.com",infourl:"http://wiki.moxiecode.com/index.php/TinyMCE:Plugins/advhr",version:tinymce.majorVersion+"."+tinymce.minorVersion}}});tinymce.PluginManager.add("advhr",tinymce.plugins.AdvancedHRPlugin)})();
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* editor_plugin_src.js
|
||||
*
|
||||
* Copyright 2009, Moxiecode Systems AB
|
||||
* Released under LGPL License.
|
||||
*
|
||||
* License: http://tinymce.moxiecode.com/license
|
||||
* Contributing: http://tinymce.moxiecode.com/contributing
|
||||
*/
|
||||
|
||||
(function() {
|
||||
tinymce.create('tinymce.plugins.AdvancedHRPlugin', {
|
||||
init : function(ed, url) {
|
||||
// Register commands
|
||||
ed.addCommand('mceAdvancedHr', function() {
|
||||
ed.windowManager.open({
|
||||
file : url + '/rule.htm',
|
||||
width : 250 + parseInt(ed.getLang('advhr.delta_width', 0)),
|
||||
height : 160 + parseInt(ed.getLang('advhr.delta_height', 0)),
|
||||
inline : 1
|
||||
}, {
|
||||
plugin_url : url
|
||||
});
|
||||
});
|
||||
|
||||
// Register buttons
|
||||
ed.addButton('advhr', {
|
||||
title : 'advhr.advhr_desc',
|
||||
cmd : 'mceAdvancedHr'
|
||||
});
|
||||
|
||||
ed.onNodeChange.add(function(ed, cm, n) {
|
||||
cm.setActive('advhr', n.nodeName == 'HR');
|
||||
});
|
||||
|
||||
ed.onClick.add(function(ed, e) {
|
||||
e = e.target;
|
||||
|
||||
if (e.nodeName === 'HR')
|
||||
ed.selection.select(e);
|
||||
});
|
||||
},
|
||||
|
||||
getInfo : function() {
|
||||
return {
|
||||
longname : 'Advanced HR',
|
||||
author : 'Moxiecode Systems AB',
|
||||
authorurl : 'http://tinymce.moxiecode.com',
|
||||
infourl : 'http://wiki.moxiecode.com/index.php/TinyMCE:Plugins/advhr',
|
||||
version : tinymce.majorVersion + "." + tinymce.minorVersion
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Register plugin
|
||||
tinymce.PluginManager.add('advhr', tinymce.plugins.AdvancedHRPlugin);
|
||||
})();
|
||||
@@ -1,43 +0,0 @@
|
||||
var AdvHRDialog = {
|
||||
init : function(ed) {
|
||||
var dom = ed.dom, f = document.forms[0], n = ed.selection.getNode(), w;
|
||||
|
||||
w = dom.getAttrib(n, 'width');
|
||||
f.width.value = w ? parseInt(w) : (dom.getStyle('width') || '');
|
||||
f.size.value = dom.getAttrib(n, 'size') || parseInt(dom.getStyle('height')) || '';
|
||||
f.noshade.checked = !!dom.getAttrib(n, 'noshade') || !!dom.getStyle('border-width');
|
||||
selectByValue(f, 'width2', w.indexOf('%') != -1 ? '%' : 'px');
|
||||
},
|
||||
|
||||
update : function() {
|
||||
var ed = tinyMCEPopup.editor, h, f = document.forms[0], st = '';
|
||||
|
||||
h = '<hr';
|
||||
|
||||
if (f.size.value) {
|
||||
h += ' size="' + f.size.value + '"';
|
||||
st += ' height:' + f.size.value + 'px;';
|
||||
}
|
||||
|
||||
if (f.width.value) {
|
||||
h += ' width="' + f.width.value + (f.width2.value == '%' ? '%' : '') + '"';
|
||||
st += ' width:' + f.width.value + (f.width2.value == '%' ? '%' : 'px') + ';';
|
||||
}
|
||||
|
||||
if (f.noshade.checked) {
|
||||
h += ' noshade="noshade"';
|
||||
st += ' border-width: 1px; border-style: solid; border-color: #CCCCCC; color: #ffffff;';
|
||||
}
|
||||
|
||||
if (ed.settings.inline_styles)
|
||||
h += ' style="' + tinymce.trim(st) + '"';
|
||||
|
||||
h += ' />';
|
||||
|
||||
ed.execCommand("mceInsertContent", false, h);
|
||||
tinyMCEPopup.close();
|
||||
}
|
||||
};
|
||||
|
||||
tinyMCEPopup.requireLangPack();
|
||||
tinyMCEPopup.onInit.add(AdvHRDialog.init, AdvHRDialog);
|
||||
@@ -1 +0,0 @@
|
||||
tinyMCE.addI18n('en.advhr_dlg',{size:"Height",noshade:"No Shadow",width:"Width",normal:"Normal",widthunits:"Units"});
|
||||
@@ -1,58 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>{#advhr.advhr_desc}</title>
|
||||
<script type="text/javascript" src="../../tiny_mce_popup.js"></script>
|
||||
<script type="text/javascript" src="js/rule.js"></script>
|
||||
<script type="text/javascript" src="../../utils/mctabs.js"></script>
|
||||
<script type="text/javascript" src="../../utils/form_utils.js"></script>
|
||||
<link href="css/advhr.css" rel="stylesheet" type="text/css" />
|
||||
</head>
|
||||
<body role="application">
|
||||
<form onsubmit="AdvHRDialog.update();return false;" action="#">
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li id="general_tab" class="current" aria-controls="general_panel"><span><a href="javascript:mcTabs.displayTab('general_tab','general_panel');" onmousedown="return false;">{#advhr.advhr_desc}</a></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel_wrapper">
|
||||
<div id="general_panel" class="panel current">
|
||||
<table role="presentation" border="0" cellpadding="4" cellspacing="0">
|
||||
<tr role="group" aria-labelledby="width_label">
|
||||
<td><label id="width_label" for="width">{#advhr_dlg.width}</label></td>
|
||||
<td class="nowrap">
|
||||
<input id="width" name="width" type="text" value="" class="mceFocus" />
|
||||
<span style="display:none;" id="width_unit_label">{#advhr_dlg.widthunits}</span>
|
||||
<select name="width2" id="width2" aria-labelledby="width_unit_label">
|
||||
<option value="">px</option>
|
||||
<option value="%">%</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="size">{#advhr_dlg.size}</label></td>
|
||||
<td><select id="size" name="size">
|
||||
<option value="">{#advhr_dlg.normal}</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
</select></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="noshade">{#advhr_dlg.noshade}</label></td>
|
||||
<td><input type="checkbox" name="noshade" id="noshade" class="radio" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mceActionPanel">
|
||||
<input type="submit" id="insert" name="insert" value="{#insert}" />
|
||||
<input type="button" id="cancel" name="cancel" value="{#cancel}" onclick="tinyMCEPopup.close();" />
|
||||
</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
#src_list, #over_list, #out_list {width:280px;}
|
||||
.mceActionPanel {margin-top:7px;}
|
||||
.alignPreview {border:1px solid #000; width:140px; height:140px; overflow:hidden; padding:5px;}
|
||||
.checkbox {border:0;}
|
||||
.panel_wrapper div.current {height:305px;}
|
||||
#prev {margin:0; border:1px solid #000; width:428px; height:150px; overflow:auto;}
|
||||
#align, #classlist {width:150px;}
|
||||
#width, #height {vertical-align:middle; width:50px; text-align:center;}
|
||||
#vspace, #hspace, #border {vertical-align:middle; width:30px; text-align:center;}
|
||||
#class_list {width:180px;}
|
||||
input {width: 280px;}
|
||||
#constrain, #onmousemovecheck {width:auto;}
|
||||
#id, #dir, #lang, #usemap, #longdesc {width:200px;}
|
||||
@@ -1 +0,0 @@
|
||||
(function(){tinymce.create("tinymce.plugins.AdvancedImagePlugin",{init:function(a,b){a.addCommand("mceAdvImage",function(){if(a.dom.getAttrib(a.selection.getNode(),"class","").indexOf("mceItem")!=-1){return}a.windowManager.open({file:b+"/image.htm",width:480+parseInt(a.getLang("advimage.delta_width",0)),height:385+parseInt(a.getLang("advimage.delta_height",0)),inline:1},{plugin_url:b})});a.addButton("image",{title:"advimage.image_desc",cmd:"mceAdvImage"})},getInfo:function(){return{longname:"Advanced image",author:"Moxiecode Systems AB",authorurl:"http://tinymce.moxiecode.com",infourl:"http://wiki.moxiecode.com/index.php/TinyMCE:Plugins/advimage",version:tinymce.majorVersion+"."+tinymce.minorVersion}}});tinymce.PluginManager.add("advimage",tinymce.plugins.AdvancedImagePlugin)})();
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* editor_plugin_src.js
|
||||
*
|
||||
* Copyright 2009, Moxiecode Systems AB
|
||||
* Released under LGPL License.
|
||||
*
|
||||
* License: http://tinymce.moxiecode.com/license
|
||||
* Contributing: http://tinymce.moxiecode.com/contributing
|
||||
*/
|
||||
|
||||
(function() {
|
||||
tinymce.create('tinymce.plugins.AdvancedImagePlugin', {
|
||||
init : function(ed, url) {
|
||||
// Register commands
|
||||
ed.addCommand('mceAdvImage', function() {
|
||||
// Internal image object like a flash placeholder
|
||||
if (ed.dom.getAttrib(ed.selection.getNode(), 'class', '').indexOf('mceItem') != -1)
|
||||
return;
|
||||
|
||||
ed.windowManager.open({
|
||||
file : url + '/image.htm',
|
||||
width : 480 + parseInt(ed.getLang('advimage.delta_width', 0)),
|
||||
height : 385 + parseInt(ed.getLang('advimage.delta_height', 0)),
|
||||
inline : 1
|
||||
}, {
|
||||
plugin_url : url
|
||||
});
|
||||
});
|
||||
|
||||
// Register buttons
|
||||
ed.addButton('image', {
|
||||
title : 'advimage.image_desc',
|
||||
cmd : 'mceAdvImage'
|
||||
});
|
||||
},
|
||||
|
||||
getInfo : function() {
|
||||
return {
|
||||
longname : 'Advanced image',
|
||||
author : 'Moxiecode Systems AB',
|
||||
authorurl : 'http://tinymce.moxiecode.com',
|
||||
infourl : 'http://wiki.moxiecode.com/index.php/TinyMCE:Plugins/advimage',
|
||||
version : tinymce.majorVersion + "." + tinymce.minorVersion
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Register plugin
|
||||
tinymce.PluginManager.add('advimage', tinymce.plugins.AdvancedImagePlugin);
|
||||
})();
|
||||
@@ -1,235 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>{#advimage_dlg.dialog_title}</title>
|
||||
<script type="text/javascript" src="../../tiny_mce_popup.js"></script>
|
||||
<script type="text/javascript" src="../../utils/mctabs.js"></script>
|
||||
<script type="text/javascript" src="../../utils/form_utils.js"></script>
|
||||
<script type="text/javascript" src="../../utils/validate.js"></script>
|
||||
<script type="text/javascript" src="../../utils/editable_selects.js"></script>
|
||||
<script type="text/javascript" src="js/image.js"></script>
|
||||
<link href="css/advimage.css" rel="stylesheet" type="text/css" />
|
||||
</head>
|
||||
<body id="advimage" style="display: none" role="application" aria-labelledby="app_title">
|
||||
<span id="app_title" style="display:none">{#advimage_dlg.dialog_title}</span>
|
||||
<form onsubmit="ImageDialog.insert();return false;" action="#">
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li id="general_tab" class="current" aria-controls="general_panel"><span><a href="javascript:mcTabs.displayTab('general_tab','general_panel');" onmousedown="return false;">{#advimage_dlg.tab_general}</a></span></li>
|
||||
<li id="appearance_tab" aria-controls="appearance_panel"><span><a href="javascript:mcTabs.displayTab('appearance_tab','appearance_panel');" onmousedown="return false;">{#advimage_dlg.tab_appearance}</a></span></li>
|
||||
<li id="advanced_tab" aria-controls="advanced_panel"><span><a href="javascript:mcTabs.displayTab('advanced_tab','advanced_panel');" onmousedown="return false;">{#advimage_dlg.tab_advanced}</a></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel_wrapper">
|
||||
<div id="general_panel" class="panel current">
|
||||
<fieldset>
|
||||
<legend>{#advimage_dlg.general}</legend>
|
||||
|
||||
<table role="presentation" class="properties">
|
||||
<tr>
|
||||
<td class="column1"><label id="srclabel" for="src">{#advimage_dlg.src}</label></td>
|
||||
<td colspan="2"><table role="presentation" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td><input name="src" type="text" id="src" value="" class="mceFocus" onchange="ImageDialog.showPreviewImage(this.value);" aria-required="true" /></td>
|
||||
<td id="srcbrowsercontainer"> </td>
|
||||
</tr>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="src_list">{#advimage_dlg.image_list}</label></td>
|
||||
<td><select id="src_list" name="src_list" onchange="document.getElementById('src').value=this.options[this.selectedIndex].value;document.getElementById('alt').value=this.options[this.selectedIndex].text;document.getElementById('title').value=this.options[this.selectedIndex].text;ImageDialog.showPreviewImage(this.options[this.selectedIndex].value);"><option value=""></option></select></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="column1"><label id="altlabel" for="alt">{#advimage_dlg.alt}</label></td>
|
||||
<td colspan="2"><input id="alt" name="alt" type="text" value="" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="column1"><label id="titlelabel" for="title">{#advimage_dlg.title}</label></td>
|
||||
<td colspan="2"><input id="title" name="title" type="text" value="" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{#advimage_dlg.preview}</legend>
|
||||
<div id="prev"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div id="appearance_panel" class="panel">
|
||||
<fieldset>
|
||||
<legend>{#advimage_dlg.tab_appearance}</legend>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td class="column1"><label id="alignlabel" for="align">{#advimage_dlg.align}</label></td>
|
||||
<td><select id="align" name="align" onchange="ImageDialog.updateStyle('align');ImageDialog.changeAppearance();">
|
||||
<option value="">{#not_set}</option>
|
||||
<option value="baseline">{#advimage_dlg.align_baseline}</option>
|
||||
<option value="top">{#advimage_dlg.align_top}</option>
|
||||
<option value="middle">{#advimage_dlg.align_middle}</option>
|
||||
<option value="bottom">{#advimage_dlg.align_bottom}</option>
|
||||
<option value="text-top">{#advimage_dlg.align_texttop}</option>
|
||||
<option value="text-bottom">{#advimage_dlg.align_textbottom}</option>
|
||||
<option value="left">{#advimage_dlg.align_left}</option>
|
||||
<option value="right">{#advimage_dlg.align_right}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td rowspan="6" valign="top">
|
||||
<div class="alignPreview">
|
||||
<img id="alignSampleImg" src="img/sample.gif" alt="{#advimage_dlg.example_img}" />
|
||||
Lorem ipsum, Dolor sit amet, consectetuer adipiscing loreum ipsum edipiscing elit, sed diam
|
||||
nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.Loreum ipsum
|
||||
edipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam
|
||||
erat volutpat.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr role="group" aria-labelledby="widthlabel">
|
||||
<td class="column1"><label id="widthlabel" for="width">{#advimage_dlg.dimensions}</label></td>
|
||||
<td class="nowrap">
|
||||
<span style="display:none" id="width_voiceLabel">{#advimage_dlg.width}</span>
|
||||
<input name="width" type="text" id="width" value="" size="5" maxlength="5" class="size" onchange="ImageDialog.changeHeight();" aria-labelledby="width_voiceLabel" /> x
|
||||
<span style="display:none" id="height_voiceLabel">{#advimage_dlg.height}</span>
|
||||
<input name="height" type="text" id="height" value="" size="5" maxlength="5" class="size" onchange="ImageDialog.changeWidth();" aria-labelledby="height_voiceLabel" /> px
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td><table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td><input id="constrain" type="checkbox" name="constrain" class="checkbox" /></td>
|
||||
<td><label id="constrainlabel" for="constrain">{#advimage_dlg.constrain_proportions}</label></td>
|
||||
</tr>
|
||||
</table></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="vspacelabel" for="vspace">{#advimage_dlg.vspace}</label></td>
|
||||
<td><input name="vspace" type="text" id="vspace" value="" size="3" maxlength="3" class="number" onchange="ImageDialog.updateStyle('vspace');ImageDialog.changeAppearance();" onblur="ImageDialog.updateStyle('vspace');ImageDialog.changeAppearance();" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="hspacelabel" for="hspace">{#advimage_dlg.hspace}</label></td>
|
||||
<td><input name="hspace" type="text" id="hspace" value="" size="3" maxlength="3" class="number" onchange="ImageDialog.updateStyle('hspace');ImageDialog.changeAppearance();" onblur="ImageDialog.updateStyle('hspace');ImageDialog.changeAppearance();" /></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="borderlabel" for="border">{#advimage_dlg.border}</label></td>
|
||||
<td><input id="border" name="border" type="text" value="" size="3" maxlength="3" class="number" onchange="ImageDialog.updateStyle('border');ImageDialog.changeAppearance();" onblur="ImageDialog.updateStyle('border');ImageDialog.changeAppearance();" /></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><label for="class_list">{#class_name}</label></td>
|
||||
<td colspan="2"><select id="class_list" name="class_list" class="mceEditableSelect"><option value=""></option></select></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="stylelabel" for="style">{#advimage_dlg.style}</label></td>
|
||||
<td colspan="2"><input id="style" name="style" type="text" value="" onchange="ImageDialog.changeAppearance();" /></td>
|
||||
</tr>
|
||||
|
||||
<!-- <tr>
|
||||
<td class="column1"><label id="classeslabel" for="classes">{#advimage_dlg.classes}</label></td>
|
||||
<td colspan="2"><input id="classes" name="classes" type="text" value="" onchange="selectByValue(this.form,'classlist',this.value,true);" /></td>
|
||||
</tr> -->
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div id="advanced_panel" class="panel">
|
||||
<fieldset>
|
||||
<legend>{#advimage_dlg.swap_image}</legend>
|
||||
|
||||
<input type="checkbox" id="onmousemovecheck" name="onmousemovecheck" class="checkbox" onclick="ImageDialog.setSwapImage(this.checked);" aria-controls="onmouseoversrc onmouseoutsrc" />
|
||||
<label id="onmousemovechecklabel" for="onmousemovecheck">{#advimage_dlg.alt_image}</label>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="4" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td class="column1"><label id="onmouseoversrclabel" for="onmouseoversrc">{#advimage_dlg.mouseover}</label></td>
|
||||
<td><table role="presentation" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td><input id="onmouseoversrc" name="onmouseoversrc" type="text" value="" /></td>
|
||||
<td id="onmouseoversrccontainer"> </td>
|
||||
</tr>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="over_list">{#advimage_dlg.image_list}</label></td>
|
||||
<td><select id="over_list" name="over_list" onchange="document.getElementById('onmouseoversrc').value=this.options[this.selectedIndex].value;"><option value=""></option></select></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="column1"><label id="onmouseoutsrclabel" for="onmouseoutsrc">{#advimage_dlg.mouseout}</label></td>
|
||||
<td class="column2"><table role="presentation" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td><input id="onmouseoutsrc" name="onmouseoutsrc" type="text" value="" /></td>
|
||||
<td id="onmouseoutsrccontainer"> </td>
|
||||
</tr>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="out_list">{#advimage_dlg.image_list}</label></td>
|
||||
<td><select id="out_list" name="out_list" onchange="document.getElementById('onmouseoutsrc').value=this.options[this.selectedIndex].value;"><option value=""></option></select></td>
|
||||
</tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{#advimage_dlg.misc}</legend>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="4" cellspacing="0">
|
||||
<tr>
|
||||
<td class="column1"><label id="idlabel" for="id">{#advimage_dlg.id}</label></td>
|
||||
<td><input id="id" name="id" type="text" value="" /></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="dirlabel" for="dir">{#advimage_dlg.langdir}</label></td>
|
||||
<td>
|
||||
<select id="dir" name="dir" onchange="ImageDialog.changeAppearance();">
|
||||
<option value="">{#not_set}</option>
|
||||
<option value="ltr">{#advimage_dlg.ltr}</option>
|
||||
<option value="rtl">{#advimage_dlg.rtl}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="langlabel" for="lang">{#advimage_dlg.langcode}</label></td>
|
||||
<td>
|
||||
<input id="lang" name="lang" type="text" value="" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="usemaplabel" for="usemap">{#advimage_dlg.map}</label></td>
|
||||
<td>
|
||||
<input id="usemap" name="usemap" type="text" value="" />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="column1"><label id="longdesclabel" for="longdesc">{#advimage_dlg.long_desc}</label></td>
|
||||
<td><table role="presentation" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td><input id="longdesc" name="longdesc" type="text" value="" /></td>
|
||||
<td id="longdesccontainer"> </td>
|
||||
</tr>
|
||||
</table></td>
|
||||
</tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mceActionPanel">
|
||||
<input type="submit" id="insert" name="insert" value="{#insert}" />
|
||||
<input type="button" id="cancel" name="cancel" value="{#cancel}" onclick="tinyMCEPopup.close();" />
|
||||
</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,464 +0,0 @@
|
||||
var ImageDialog = {
|
||||
preInit : function() {
|
||||
var url;
|
||||
|
||||
tinyMCEPopup.requireLangPack();
|
||||
|
||||
if (url = tinyMCEPopup.getParam("external_image_list_url"))
|
||||
document.write('<script language="javascript" type="text/javascript" src="' + tinyMCEPopup.editor.documentBaseURI.toAbsolute(url) + '"></script>');
|
||||
},
|
||||
|
||||
init : function(ed) {
|
||||
var f = document.forms[0], nl = f.elements, ed = tinyMCEPopup.editor, dom = ed.dom, n = ed.selection.getNode(), fl = tinyMCEPopup.getParam('external_image_list', 'tinyMCEImageList');
|
||||
|
||||
tinyMCEPopup.resizeToInnerSize();
|
||||
this.fillClassList('class_list');
|
||||
this.fillFileList('src_list', fl);
|
||||
this.fillFileList('over_list', fl);
|
||||
this.fillFileList('out_list', fl);
|
||||
TinyMCE_EditableSelects.init();
|
||||
|
||||
if (n.nodeName == 'IMG') {
|
||||
nl.src.value = dom.getAttrib(n, 'src');
|
||||
nl.width.value = dom.getAttrib(n, 'width');
|
||||
nl.height.value = dom.getAttrib(n, 'height');
|
||||
nl.alt.value = dom.getAttrib(n, 'alt');
|
||||
nl.title.value = dom.getAttrib(n, 'title');
|
||||
nl.vspace.value = this.getAttrib(n, 'vspace');
|
||||
nl.hspace.value = this.getAttrib(n, 'hspace');
|
||||
nl.border.value = this.getAttrib(n, 'border');
|
||||
selectByValue(f, 'align', this.getAttrib(n, 'align'));
|
||||
selectByValue(f, 'class_list', dom.getAttrib(n, 'class'), true, true);
|
||||
nl.style.value = dom.getAttrib(n, 'style');
|
||||
nl.id.value = dom.getAttrib(n, 'id');
|
||||
nl.dir.value = dom.getAttrib(n, 'dir');
|
||||
nl.lang.value = dom.getAttrib(n, 'lang');
|
||||
nl.usemap.value = dom.getAttrib(n, 'usemap');
|
||||
nl.longdesc.value = dom.getAttrib(n, 'longdesc');
|
||||
nl.insert.value = ed.getLang('update');
|
||||
|
||||
if (/^\s*this.src\s*=\s*\'([^\']+)\';?\s*$/.test(dom.getAttrib(n, 'onmouseover')))
|
||||
nl.onmouseoversrc.value = dom.getAttrib(n, 'onmouseover').replace(/^\s*this.src\s*=\s*\'([^\']+)\';?\s*$/, '$1');
|
||||
|
||||
if (/^\s*this.src\s*=\s*\'([^\']+)\';?\s*$/.test(dom.getAttrib(n, 'onmouseout')))
|
||||
nl.onmouseoutsrc.value = dom.getAttrib(n, 'onmouseout').replace(/^\s*this.src\s*=\s*\'([^\']+)\';?\s*$/, '$1');
|
||||
|
||||
if (ed.settings.inline_styles) {
|
||||
// Move attribs to styles
|
||||
if (dom.getAttrib(n, 'align'))
|
||||
this.updateStyle('align');
|
||||
|
||||
if (dom.getAttrib(n, 'hspace'))
|
||||
this.updateStyle('hspace');
|
||||
|
||||
if (dom.getAttrib(n, 'border'))
|
||||
this.updateStyle('border');
|
||||
|
||||
if (dom.getAttrib(n, 'vspace'))
|
||||
this.updateStyle('vspace');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup browse button
|
||||
document.getElementById('srcbrowsercontainer').innerHTML = getBrowserHTML('srcbrowser','src','image','theme_advanced_image');
|
||||
if (isVisible('srcbrowser'))
|
||||
document.getElementById('src').style.width = '260px';
|
||||
|
||||
// Setup browse button
|
||||
document.getElementById('onmouseoversrccontainer').innerHTML = getBrowserHTML('overbrowser','onmouseoversrc','image','theme_advanced_image');
|
||||
if (isVisible('overbrowser'))
|
||||
document.getElementById('onmouseoversrc').style.width = '260px';
|
||||
|
||||
// Setup browse button
|
||||
document.getElementById('onmouseoutsrccontainer').innerHTML = getBrowserHTML('outbrowser','onmouseoutsrc','image','theme_advanced_image');
|
||||
if (isVisible('outbrowser'))
|
||||
document.getElementById('onmouseoutsrc').style.width = '260px';
|
||||
|
||||
// If option enabled default contrain proportions to checked
|
||||
if (ed.getParam("advimage_constrain_proportions", true))
|
||||
f.constrain.checked = true;
|
||||
|
||||
// Check swap image if valid data
|
||||
if (nl.onmouseoversrc.value || nl.onmouseoutsrc.value)
|
||||
this.setSwapImage(true);
|
||||
else
|
||||
this.setSwapImage(false);
|
||||
|
||||
this.changeAppearance();
|
||||
this.showPreviewImage(nl.src.value, 1);
|
||||
},
|
||||
|
||||
insert : function(file, title) {
|
||||
var ed = tinyMCEPopup.editor, t = this, f = document.forms[0];
|
||||
|
||||
if (f.src.value === '') {
|
||||
if (ed.selection.getNode().nodeName == 'IMG') {
|
||||
ed.dom.remove(ed.selection.getNode());
|
||||
ed.execCommand('mceRepaint');
|
||||
}
|
||||
|
||||
tinyMCEPopup.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (tinyMCEPopup.getParam("accessibility_warnings", 1)) {
|
||||
if (!f.alt.value) {
|
||||
tinyMCEPopup.confirm(tinyMCEPopup.getLang('advimage_dlg.missing_alt'), function(s) {
|
||||
if (s)
|
||||
t.insertAndClose();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
t.insertAndClose();
|
||||
},
|
||||
|
||||
insertAndClose : function() {
|
||||
var ed = tinyMCEPopup.editor, f = document.forms[0], nl = f.elements, v, args = {}, el;
|
||||
|
||||
tinyMCEPopup.restoreSelection();
|
||||
|
||||
// Fixes crash in Safari
|
||||
if (tinymce.isWebKit)
|
||||
ed.getWin().focus();
|
||||
|
||||
if (!ed.settings.inline_styles) {
|
||||
args = {
|
||||
vspace : nl.vspace.value,
|
||||
hspace : nl.hspace.value,
|
||||
border : nl.border.value,
|
||||
align : getSelectValue(f, 'align')
|
||||
};
|
||||
} else {
|
||||
// Remove deprecated values
|
||||
args = {
|
||||
vspace : '',
|
||||
hspace : '',
|
||||
border : '',
|
||||
align : ''
|
||||
};
|
||||
}
|
||||
|
||||
tinymce.extend(args, {
|
||||
src : nl.src.value.replace(/ /g, '%20'),
|
||||
width : nl.width.value,
|
||||
height : nl.height.value,
|
||||
alt : nl.alt.value,
|
||||
title : nl.title.value,
|
||||
'class' : getSelectValue(f, 'class_list'),
|
||||
style : nl.style.value,
|
||||
id : nl.id.value,
|
||||
dir : nl.dir.value,
|
||||
lang : nl.lang.value,
|
||||
usemap : nl.usemap.value,
|
||||
longdesc : nl.longdesc.value
|
||||
});
|
||||
|
||||
args.onmouseover = args.onmouseout = '';
|
||||
|
||||
if (f.onmousemovecheck.checked) {
|
||||
if (nl.onmouseoversrc.value)
|
||||
args.onmouseover = "this.src='" + nl.onmouseoversrc.value + "';";
|
||||
|
||||
if (nl.onmouseoutsrc.value)
|
||||
args.onmouseout = "this.src='" + nl.onmouseoutsrc.value + "';";
|
||||
}
|
||||
|
||||
el = ed.selection.getNode();
|
||||
|
||||
if (el && el.nodeName == 'IMG') {
|
||||
ed.dom.setAttribs(el, args);
|
||||
} else {
|
||||
tinymce.each(args, function(value, name) {
|
||||
if (value === "") {
|
||||
delete args[name];
|
||||
}
|
||||
});
|
||||
|
||||
ed.execCommand('mceInsertContent', false, tinyMCEPopup.editor.dom.createHTML('img', args), {skip_undo : 1});
|
||||
ed.undoManager.add();
|
||||
}
|
||||
|
||||
tinyMCEPopup.editor.execCommand('mceRepaint');
|
||||
tinyMCEPopup.editor.focus();
|
||||
tinyMCEPopup.close();
|
||||
},
|
||||
|
||||
getAttrib : function(e, at) {
|
||||
var ed = tinyMCEPopup.editor, dom = ed.dom, v, v2;
|
||||
|
||||
if (ed.settings.inline_styles) {
|
||||
switch (at) {
|
||||
case 'align':
|
||||
if (v = dom.getStyle(e, 'float'))
|
||||
return v;
|
||||
|
||||
if (v = dom.getStyle(e, 'vertical-align'))
|
||||
return v;
|
||||
|
||||
break;
|
||||
|
||||
case 'hspace':
|
||||
v = dom.getStyle(e, 'margin-left')
|
||||
v2 = dom.getStyle(e, 'margin-right');
|
||||
|
||||
if (v && v == v2)
|
||||
return parseInt(v.replace(/[^0-9]/g, ''));
|
||||
|
||||
break;
|
||||
|
||||
case 'vspace':
|
||||
v = dom.getStyle(e, 'margin-top')
|
||||
v2 = dom.getStyle(e, 'margin-bottom');
|
||||
if (v && v == v2)
|
||||
return parseInt(v.replace(/[^0-9]/g, ''));
|
||||
|
||||
break;
|
||||
|
||||
case 'border':
|
||||
v = 0;
|
||||
|
||||
tinymce.each(['top', 'right', 'bottom', 'left'], function(sv) {
|
||||
sv = dom.getStyle(e, 'border-' + sv + '-width');
|
||||
|
||||
// False or not the same as prev
|
||||
if (!sv || (sv != v && v !== 0)) {
|
||||
v = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sv)
|
||||
v = sv;
|
||||
});
|
||||
|
||||
if (v)
|
||||
return parseInt(v.replace(/[^0-9]/g, ''));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (v = dom.getAttrib(e, at))
|
||||
return v;
|
||||
|
||||
return '';
|
||||
},
|
||||
|
||||
setSwapImage : function(st) {
|
||||
var f = document.forms[0];
|
||||
|
||||
f.onmousemovecheck.checked = st;
|
||||
setBrowserDisabled('overbrowser', !st);
|
||||
setBrowserDisabled('outbrowser', !st);
|
||||
|
||||
if (f.over_list)
|
||||
f.over_list.disabled = !st;
|
||||
|
||||
if (f.out_list)
|
||||
f.out_list.disabled = !st;
|
||||
|
||||
f.onmouseoversrc.disabled = !st;
|
||||
f.onmouseoutsrc.disabled = !st;
|
||||
},
|
||||
|
||||
fillClassList : function(id) {
|
||||
var dom = tinyMCEPopup.dom, lst = dom.get(id), v, cl;
|
||||
|
||||
if (v = tinyMCEPopup.getParam('theme_advanced_styles')) {
|
||||
cl = [];
|
||||
|
||||
tinymce.each(v.split(';'), function(v) {
|
||||
var p = v.split('=');
|
||||
|
||||
cl.push({'title' : p[0], 'class' : p[1]});
|
||||
});
|
||||
} else
|
||||
cl = tinyMCEPopup.editor.dom.getClasses();
|
||||
|
||||
if (cl.length > 0) {
|
||||
lst.options.length = 0;
|
||||
lst.options[lst.options.length] = new Option(tinyMCEPopup.getLang('not_set'), '');
|
||||
|
||||
tinymce.each(cl, function(o) {
|
||||
lst.options[lst.options.length] = new Option(o.title || o['class'], o['class']);
|
||||
});
|
||||
} else
|
||||
dom.remove(dom.getParent(id, 'tr'));
|
||||
},
|
||||
|
||||
fillFileList : function(id, l) {
|
||||
var dom = tinyMCEPopup.dom, lst = dom.get(id), v, cl;
|
||||
|
||||
l = typeof(l) === 'function' ? l() : window[l];
|
||||
lst.options.length = 0;
|
||||
|
||||
if (l && l.length > 0) {
|
||||
lst.options[lst.options.length] = new Option('', '');
|
||||
|
||||
tinymce.each(l, function(o) {
|
||||
lst.options[lst.options.length] = new Option(o[0], o[1]);
|
||||
});
|
||||
} else
|
||||
dom.remove(dom.getParent(id, 'tr'));
|
||||
},
|
||||
|
||||
resetImageData : function() {
|
||||
var f = document.forms[0];
|
||||
|
||||
f.elements.width.value = f.elements.height.value = '';
|
||||
},
|
||||
|
||||
updateImageData : function(img, st) {
|
||||
var f = document.forms[0];
|
||||
|
||||
if (!st) {
|
||||
f.elements.width.value = img.width;
|
||||
f.elements.height.value = img.height;
|
||||
}
|
||||
|
||||
this.preloadImg = img;
|
||||
},
|
||||
|
||||
changeAppearance : function() {
|
||||
var ed = tinyMCEPopup.editor, f = document.forms[0], img = document.getElementById('alignSampleImg');
|
||||
|
||||
if (img) {
|
||||
if (ed.getParam('inline_styles')) {
|
||||
ed.dom.setAttrib(img, 'style', f.style.value);
|
||||
} else {
|
||||
img.align = f.align.value;
|
||||
img.border = f.border.value;
|
||||
img.hspace = f.hspace.value;
|
||||
img.vspace = f.vspace.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
changeHeight : function() {
|
||||
var f = document.forms[0], tp, t = this;
|
||||
|
||||
if (!f.constrain.checked || !t.preloadImg) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (f.width.value == "" || f.height.value == "")
|
||||
return;
|
||||
|
||||
tp = (parseInt(f.width.value) / parseInt(t.preloadImg.width)) * t.preloadImg.height;
|
||||
f.height.value = tp.toFixed(0);
|
||||
},
|
||||
|
||||
changeWidth : function() {
|
||||
var f = document.forms[0], tp, t = this;
|
||||
|
||||
if (!f.constrain.checked || !t.preloadImg) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (f.width.value == "" || f.height.value == "")
|
||||
return;
|
||||
|
||||
tp = (parseInt(f.height.value) / parseInt(t.preloadImg.height)) * t.preloadImg.width;
|
||||
f.width.value = tp.toFixed(0);
|
||||
},
|
||||
|
||||
updateStyle : function(ty) {
|
||||
var dom = tinyMCEPopup.dom, b, bStyle, bColor, v, isIE = tinymce.isIE, f = document.forms[0], img = dom.create('img', {style : dom.get('style').value});
|
||||
|
||||
if (tinyMCEPopup.editor.settings.inline_styles) {
|
||||
// Handle align
|
||||
if (ty == 'align') {
|
||||
dom.setStyle(img, 'float', '');
|
||||
dom.setStyle(img, 'vertical-align', '');
|
||||
|
||||
v = getSelectValue(f, 'align');
|
||||
if (v) {
|
||||
if (v == 'left' || v == 'right')
|
||||
dom.setStyle(img, 'float', v);
|
||||
else
|
||||
img.style.verticalAlign = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle border
|
||||
if (ty == 'border') {
|
||||
b = img.style.border ? img.style.border.split(' ') : [];
|
||||
bStyle = dom.getStyle(img, 'border-style');
|
||||
bColor = dom.getStyle(img, 'border-color');
|
||||
|
||||
dom.setStyle(img, 'border', '');
|
||||
|
||||
v = f.border.value;
|
||||
if (v || v == '0') {
|
||||
if (v == '0')
|
||||
img.style.border = isIE ? '0' : '0 none none';
|
||||
else {
|
||||
var isOldIE = tinymce.isIE && (!document.documentMode || document.documentMode < 9);
|
||||
|
||||
if (b.length == 3 && b[isOldIE ? 2 : 1])
|
||||
bStyle = b[isOldIE ? 2 : 1];
|
||||
else if (!bStyle || bStyle == 'none')
|
||||
bStyle = 'solid';
|
||||
if (b.length == 3 && b[isIE ? 0 : 2])
|
||||
bColor = b[isOldIE ? 0 : 2];
|
||||
else if (!bColor || bColor == 'none')
|
||||
bColor = 'black';
|
||||
img.style.border = v + 'px ' + bStyle + ' ' + bColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hspace
|
||||
if (ty == 'hspace') {
|
||||
dom.setStyle(img, 'marginLeft', '');
|
||||
dom.setStyle(img, 'marginRight', '');
|
||||
|
||||
v = f.hspace.value;
|
||||
if (v) {
|
||||
img.style.marginLeft = v + 'px';
|
||||
img.style.marginRight = v + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vspace
|
||||
if (ty == 'vspace') {
|
||||
dom.setStyle(img, 'marginTop', '');
|
||||
dom.setStyle(img, 'marginBottom', '');
|
||||
|
||||
v = f.vspace.value;
|
||||
if (v) {
|
||||
img.style.marginTop = v + 'px';
|
||||
img.style.marginBottom = v + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Merge
|
||||
dom.get('style').value = dom.serializeStyle(dom.parseStyle(img.style.cssText), 'img');
|
||||
}
|
||||
},
|
||||
|
||||
changeMouseMove : function() {
|
||||
},
|
||||
|
||||
showPreviewImage : function(u, st) {
|
||||
if (!u) {
|
||||
tinyMCEPopup.dom.setHTML('prev', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!st && tinyMCEPopup.getParam("advimage_update_dimensions_onchange", true))
|
||||
this.resetImageData();
|
||||
|
||||
u = tinyMCEPopup.editor.documentBaseURI.toAbsolute(u);
|
||||
|
||||
if (!st)
|
||||
tinyMCEPopup.dom.setHTML('prev', '<img id="previewImg" src="' + u + '" border="0" onload="ImageDialog.updateImageData(this);" onerror="ImageDialog.resetImageData();" />');
|
||||
else
|
||||
tinyMCEPopup.dom.setHTML('prev', '<img id="previewImg" src="' + u + '" border="0" onload="ImageDialog.updateImageData(this, 1);" />');
|
||||
}
|
||||
};
|
||||
|
||||
ImageDialog.preInit();
|
||||
tinyMCEPopup.onInit.add(ImageDialog.init, ImageDialog);
|
||||
@@ -1 +0,0 @@
|
||||
tinyMCE.addI18n('en.advimage_dlg',{"image_list":"Image List","align_right":"Right","align_left":"Left","align_textbottom":"Text Bottom","align_texttop":"Text Top","align_bottom":"Bottom","align_middle":"Middle","align_top":"Top","align_baseline":"Baseline",align:"Alignment",hspace:"Horizontal Space",vspace:"Vertical Space",dimensions:"Dimensions",border:"Border",list:"Image List",alt:"Image Description",src:"Image URL","dialog_title":"Insert/Edit Image","missing_alt":"Are you sure you want to continue without including an Image Description? Without it the image may not be accessible to some users with disabilities, or to those using a text browser, or browsing the Web with images turned off.","example_img":"Appearance Preview Image",misc:"Miscellaneous",mouseout:"For Mouse Out",mouseover:"For Mouse Over","alt_image":"Alternative Image","swap_image":"Swap Image",map:"Image Map",id:"ID",rtl:"Right to Left",ltr:"Left to Right",classes:"Classes",style:"Style","long_desc":"Long Description Link",langcode:"Language Code",langdir:"Language Direction","constrain_proportions":"Constrain Proportions",preview:"Preview",title:"Title",general:"General","tab_advanced":"Advanced","tab_appearance":"Appearance","tab_general":"General",width:"Width",height:"Height"});
|
||||
@@ -1,8 +0,0 @@
|
||||
.mceLinkList, .mceAnchorList, #targetlist {width:280px;}
|
||||
.mceActionPanel {margin-top:7px;}
|
||||
.panel_wrapper div.current {height:320px;}
|
||||
#classlist, #title, #href {width:280px;}
|
||||
#popupurl, #popupname {width:200px;}
|
||||
#popupwidth, #popupheight, #popupleft, #popuptop {width:30px;vertical-align:middle;text-align:center;}
|
||||
#id, #style, #classes, #target, #dir, #hreflang, #lang, #charset, #type, #rel, #rev, #tabindex, #accesskey {width:200px;}
|
||||
#events_panel input {width:200px;}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user