Merge pull request #3334 from edx/rc/2014-04-14

Rc/2014 04 14
This commit is contained in:
chrisndodge
2014-04-14 10:57:58 -04:00
762 changed files with 126770 additions and 65150 deletions

View File

@@ -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.

View File

@@ -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):

View File

@@ -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>
"""

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View 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',

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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', {})

View File

@@ -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 = {}

View File

@@ -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

View File

@@ -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",

View File

@@ -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')

View 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');
});
});
});
});

View File

@@ -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:");
});
});
});

View 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&hellip;')
});
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;
});

View File

@@ -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);
}
}

View File

@@ -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&hellip;')
});
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
};
});

View File

@@ -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 {

View File

@@ -485,6 +485,7 @@ body.course.unit,.view-unit {
.row {
margin-bottom: 0px;
overflow: hidden;
}
// Module Actions, also used for Pages

View File

@@ -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",

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
}
},

View File

@@ -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)
)

View File

@@ -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')
)

View File

@@ -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']

View File

@@ -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']

View File

@@ -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.

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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'

View 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))

View File

@@ -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)

View 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)

View File

@@ -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"""

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -5,7 +5,7 @@ setup(
version="0.2",
packages=["calc"],
install_requires=[
"pyparsing==1.5.6",
"pyparsing==2.0.1",
"numpy",
"scipy"
],

View File

@@ -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",

View File

@@ -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())

View File

@@ -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)

View File

@@ -19,7 +19,7 @@
}
.editor-tabs {
top: 11px !important;
top: 0 !important;
right: 10px;
z-index: 99;
}

View File

@@ -1,18 +0,0 @@
<section class="html-edit">
<textarea class="tiny-mce">dummy</textarea>
<!--
The text passed in is the escaped version of
&lt;problem>
&lt;p>&lt;/p>
&lt;multiplechoiceresponse>
<pre>&lt;problem>
&lt;p>&lt;/p></pre>
<div><foo>bar</foo></div>
-->
<textarea name="" class="edit-box">&amp;lt;problem&gt;
&amp;lt;p&gt;&amp;lt;/p&gt;
&amp;lt;multiplechoiceresponse&gt;
&lt;pre&gt;&amp;lt;problem&gt;
&amp;lt;p&gt;&amp;lt;/p&gt;</pre>
<div><foo>bar</foo></div></textarea>
</section>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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:

View File

@@ -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'

View File

@@ -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 &lt', ->
# 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("""&lt;problem>
&lt;p>&lt;/p>
&lt;multiplechoiceresponse>
<pre>&lt;problem>
&lt;p>&lt;/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')

View File

@@ -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();
});
});
});
});

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -277,7 +277,6 @@ function () {
.attr('title', text)
.text(text);
this.trigger('videoCaption.resize', null);
this.el.trigger('fullscreen', [this.isFullScreen]);
}

View File

@@ -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));

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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 ""

View File

@@ -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
"""

View File

@@ -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']

View File

@@ -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):
"""

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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'))

View 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);

View File

@@ -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

View File

@@ -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';
});

View 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.

View 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%;
}

View 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);
});
})();

View File

@@ -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

View File

@@ -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);

File diff suppressed because one or more lines are too long

View File

@@ -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;}

View File

@@ -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)})();

View File

@@ -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);
})();

View File

@@ -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);

View File

@@ -1 +0,0 @@
tinyMCE.addI18n('en.advhr_dlg',{size:"Height",noshade:"No Shadow",width:"Width",normal:"Normal",widthunits:"Units"});

View File

@@ -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>

View File

@@ -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;}

View File

@@ -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)})();

View File

@@ -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);
})();

View File

@@ -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">&nbsp;</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>&nbsp;</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">&nbsp;</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">&nbsp;</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">&nbsp;</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

View File

@@ -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);

View File

@@ -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"});

View File

@@ -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