Commit aecc20af by Anton Stupak Committed by polesye

Add Timed Transcripts Editor.

parent 1f7bb112
......@@ -64,6 +64,8 @@ LMS: Improved accessibility of parts of forum navigation sidebar.
LMS: enhanced accessibility labeling and aria support for the discussion forum
new post dropdown as well as response and comment area labeling.
Blades: Add Studio timed transcripts editor to video player.
LMS: enhanced shib support, including detection of linked shib account
at login page and support for the ?next= GET parameter.
......
......@@ -51,6 +51,13 @@ def create_component_instance(step, category, component_type=None, is_advanced=F
module_count_before + 1))
@world.absorb
def click_new_component_button(step, component_button_css):
step.given('I have clicked the new unit button')
world.css_click(component_button_css)
def _click_advanced():
css = 'ul.problem-type-tabs a[href="#tab2"]'
world.css_click(css)
......@@ -122,24 +129,29 @@ def verify_setting_entry(setting, display_name, value, explicitly_set):
----------
setting: the WebDriverElement object found in the browser
display_name: the string expected as the label
value: the expected field value
html: the expected field value
explicitly_set: True if the value is expected to have been explicitly set
for the problem, rather than derived from the defaults. This is verified
by the existence of a "Clear" button next to the field value.
"""
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
assert_equal(display_name, setting.find_by_css('.setting-label')[0].html)
# Check if the web object is a list type
# If so, we use a slightly different mechanism for determining its value
if setting.has_class('metadata-list-enum'):
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
assert_equal(value, list_value)
elif setting.has_class('metadata-videolist-enum'):
list_value = ', '.join(ele.find_by_css('input')[0].value for ele in setting.find_by_css('.videolist-settings-item'))
assert_equal(value, list_value)
else:
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
settingClearButton = setting.find_by_css('.setting-clear')[0]
assert_equal(explicitly_set, settingClearButton.has_class('active'))
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
# VideoList doesn't have clear button
if not setting.has_class('metadata-videolist-enum'):
settingClearButton = setting.find_by_css('.setting-clear')[0]
assert_equal(explicitly_set, settingClearButton.has_class('active'))
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
@world.absorb
......
Feature: Video Component Editor
As a course author, I want to be able to create video components.
# For transcripts acceptance tests there are 3 available caption
# files. They can be used to test various transcripts features. Two of
# them can be imported from YouTube.
#
# The length of each file name is 11 characters. This is because the
# YouTube's ID length is 11 characters. If file name is not of length 11,
# front-end validation will not pass.
#
# t__eq_exist - this file exists on YouTube, and can be imported
# via the transcripts menu; after import, this file will
# be equal to the one stored locally
# t_neq_exist - same as above, except local file will differ from the
# one stored on YouTube
# t_not_exist - this file does not exist on YouTube; it exists locally
#1
Scenario: Check input error messages
Given I have created a Video component
And I edit the component
#User inputs html5 links with equal extension
And I enter a "123.webm" source to field number 1
And I enter a "456.webm" source to field number 2
Then I see error message "file_type"
# Currently we are working with 2nd field. It means, that if 2nd field
# contain incorrect value, 1st and 3rd fields should be disabled until
# 2nd field will be filled by correct correct value
And I expect 1, 3 inputs are disabled
When I clear fields
And I expect inputs are enabled
#User input URL with incorrect format
And I enter a "htt://link.c" source to field number 1
Then I see error message "url_format"
# Currently we are working with 1st field. It means, that if 1st field
# contain incorrect value, 2nd and 3rd fields should be disabled until
# 1st field will be filled by correct correct value
And I expect 2, 3 inputs are disabled
# We are not clearing fields here,
# Because we changing same field.
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I do not see error message
And I expect inputs are enabled
#2
Scenario: Testing interaction with test youtube server
Given I have created a Video component with subtitles
And I edit the component
# first part of url will be substituted by mock_youtube_server address
# for t__eq_exist id server will respond with transcripts
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
# t__eq_exist subs locally not presented at this moment
And I see button "import"
# for t_not_exist id server will respond with 404
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "not found"
And I do not see button "import"
And I see button "disabled_download_to_edit"
#3
Scenario: Youtube id only: check "not found" and "import" states
Given I have created a Video component with subtitles
And I edit the component
# Not found: w/o local or server subs
And I remove "t_not_exist" transcripts id from store
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "not found"
And I see value "" in the field "HTML5 Transcript"
# Import: w/o local but with server subs
And I remove "t__eq_exist" transcripts id from store
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I click button "import"
Then I see status message "found"
And I see button "upload_new_timed_transcripts"
And I see button "download_to_edit"
And I see value "t__eq_exist" in the field "HTML5 Transcript"
#4
Scenario: Youtube id only: check "Found" state
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "found"
And I see value "t_not_exist" in the field "HTML5 Transcript"
#5
Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are equal
Given I have created a Video component with subtitles "t__eq_exist"
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
And I see status message "found"
And I see value "t__eq_exist" in the field "HTML5 Transcript"
#6
Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are not equal
Given I have created a Video component with subtitles "t_neq_exist"
And I edit the component
And I enter a "http://youtu.be/t_neq_exist" source to field number 1
And I see status message "replace"
And I see button "replace"
And I click button "replace"
And I see status message "found"
And I see value "t_neq_exist" in the field "HTML5 Transcript"
#7
Scenario: html5 source only: check "Not Found" state
Given I have created a Video component
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "not found"
And I see value "" in the field "HTML5 Transcript"
#8
Scenario: html5 source only: check "Found" state
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "found"
And I see value "t_not_exist" in the field "HTML5 Transcript"
#9
Scenario: User sets youtube_id w/o server but with local subs and one html5 link w/o subs
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "found"
And I enter a "test_video_name.mp4" source to field number 2
Then I see status message "found"
And I see value "t_not_exist" in the field "HTML5 Transcript"
#10
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o subs
Given I have created a Video component
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I click button "import"
Then I see status message "found"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "found"
And I see value "t__eq_exist" in the field "HTML5 Transcript"
#11
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts
Given I have created a Video component
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.webm" source to field number 3
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
#12
Scenario: Entering youtube (no importing), and 2 html5 sources without transcripts - "Not Found"
Given I have created a Video component
And I edit the component
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "not found"
And I see button "disabled_download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "not found"
And I see button "upload_new_timed_transcripts"
And I see button "disabled_download_to_edit"
And I enter a "t_not_exist.webm" source to field number 3
Then I see status message "not found"
And I see button "disabled_download_to_edit"
And I see button "upload_new_timed_transcripts"
#13
Scenario: Entering youtube with imported transcripts, and 2 html5 sources without transcripts - "Found"
Given I have created a Video component
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I click button "import"
Then I see status message "found"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.webm" source to field number 3
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
#14
Scenario: Entering youtube w/o transcripts - html5 w/o transcripts - html5 with transcripts
Given I have created a Video component with subtitles "t_neq_exist"
And I edit the component
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "not found"
And I see button "disabled_download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "not found"
And I see button "disabled_download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "t_neq_exist.webm" source to field number 3
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
#15
Scenario: Entering youtube w/o imported transcripts - html5 w/o transcripts w/o import - html5 with transcripts
Given I have created a Video component with subtitles "t_neq_exist"
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
And I enter a "t_neq_exist.webm" source to field number 3
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
#16
Scenario: Entering youtube w/o imported transcripts - html5 with transcripts - html5 w/o transcripts w/o import
Given I have created a Video component with subtitles "t_neq_exist"
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
And I enter a "t_neq_exist.mp4" source to field number 2
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.webm" source to field number 3
Then I see status message "not found"
And I see button "import"
And I see button "upload_new_timed_transcripts"
#17
Scenario: Entering youtube with imported transcripts - html5 with transcripts - html5 w/o transcripts
Given I have created a Video component with subtitles "t_neq_exist"
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I click button "import"
Then I see status message "found"
And I see button "upload_new_timed_transcripts"
And I enter a "t_neq_exist.mp4" source to field number 2
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.webm" source to field number 3
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
#18
Scenario: Entering youtube with imported transcripts - html5 w/o transcripts - html5 with transcripts
Given I have created a Video component with subtitles "t_neq_exist"
And I edit the component
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found"
And I see button "import"
And I click button "import"
Then I see status message "found"
And I see button "upload_new_timed_transcripts"
And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "t_neq_exist.webm" source to field number 3
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
#19
Scenario: Entering html5 with transcripts - upload - youtube w/o transcripts
Given I have created a Video component with subtitles "t__eq_exist"
And I edit the component
And I enter a "t__eq_exist.mp4" source to field number 1
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I upload the transcripts file "test_transcripts.srt"
Then I see status message "uploaded_successfully"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I see value "t__eq_exist" in the field "HTML5 Transcript"
And I enter a "http://youtu.be/t_not_exist" source to field number 2
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I enter a "test_transcripts.webm" source to field number 3
Then I see status message "found"
#20
Scenario: Enter 2 HTML5 sources with transcripts, they are not the same, choose
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "test_transcripts.mp4" source to field number 1
Then I see status message "not found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I upload the transcripts file "test_transcripts.srt"
Then I see status message "uploaded_successfully"
And I see value "test_transcripts" in the field "HTML5 Transcript"
And I enter a "t_not_exist.webm" source to field number 2
Then I see status message "replace"
And I see choose button "test_transcripts.mp4" number 1
And I see choose button "t_not_exist.webm" number 2
And I click button "choose" number 2
And I see value "test_transcripts|t_not_exist" in the field "HTML5 Transcript"
#21
Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save - > change it to another one HTML5 source w/o transcripts - click on use existing - > change it to another one HTML5 source w/o transcripts - click on use existing
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I see value "t_not_exist" in the field "HTML5 Transcript"
And I save changes
And I edit the component
And I enter a "video_name_2.mp4" source to field number 1
Then I see status message "use existing"
And I see button "use_existing"
And I click button "use_existing"
And I see value "video_name_2" in the field "HTML5 Transcript"
And I enter a "video_name_3.mp4" source to field number 1
Then I see status message "use existing"
And I see button "use_existing"
And I click button "use_existing"
And I see value "video_name_3" in the field "HTML5 Transcript"
#22
Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - click on use existing -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> change it to another one HTML5 source w/o transcripts - click on use existing
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I see value "t_not_exist" in the field "HTML5 Transcript"
And I save changes
And I edit the component
And I enter a "video_name_2.mp4" source to field number 1
Then I see status message "use existing"
And I see button "use_existing"
And I click button "use_existing"
And I see value "video_name_2" in the field "HTML5 Transcript"
And I enter a "video_name_3.mp4" source to field number 1
Then I see status message "use existing"
And I see button "use_existing"
And I enter a "video_name_4.mp4" source to field number 1
Then I see status message "use existing"
And I see button "use_existing"
And I click button "use_existing"
And I see value "video_name_4" in the field "HTML5 Transcript"
#23
Scenario: Work with 2 fields: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> add another one HTML5 source w/o transcripts - click on use existing
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "found"
And I see button "download_to_edit"
And I see button "upload_new_timed_transcripts"
And I save changes
And I edit the component
And I enter a "video_name_2.mp4" source to field number 1
Then I see status message "use existing"
And I see button "use_existing"
And I enter a "video_name_3.webm" source to field number 2
Then I see status message "use existing"
And I see button "use_existing"
And I click button "use_existing"
And I see value "video_name_2|video_name_3" in the field "HTML5 Transcript"
#24 Uploading subtitles with different file name than file
Scenario: File name and name of subs are different
Given I have created a Video component
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
And I see status message "not found"
And I upload the transcripts file "test_transcripts.srt"
Then I see status message "uploaded_successfully"
And I see value "video_name_1" in the field "HTML5 Transcript"
And I save changes
Then when I view the video it does show the captions
And I edit the component
Then I see status message "found"
#25
# Video can have filled item.sub, but doesn't have subs file.
# In this case, after changing this video by another one without subs
# `Not found` message should appear ( not `use existing`).
Scenario: Video w/o subs - another video w/o subs - Not found message
Given I have created a Video component
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
Then I see status message "not found"
#26
Scenario: Subtitles are copied for every html5 video source
Given I have created a Video component
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
And I see status message "not found"
And I enter a "video_name_2.webm" source to field number 2
And I see status message "not found"
And I upload the transcripts file "test_transcripts.srt"
Then I see status message "uploaded_successfully"
And I see value "video_name_1|video_name_2" in the field "HTML5 Transcript"
And I clear field number 1
Then I see status message "found"
And I see value "video_name_2" in the field "HTML5 Transcript"
#27
Scenario: Upload button for single youtube id.
Given I have created a Video component
And I edit the component
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "not found"
And I see button "upload_new_timed_transcripts"
And I upload the transcripts file "test_transcripts.srt"
Then I see status message "uploaded_successfully"
And I save changes
Then when I view the video it does show the captions
And I edit the component
Then I see status message "found"
#28
Scenario: Upload button for youtube id with html5 ids.
Given I have created a Video component
And I edit the component
And I enter a "http://youtu.be/t_not_exist" source to field number 1
Then I see status message "not found"
And I see button "upload_new_timed_transcripts"
And I enter a "video_name_1.mp4" source to field number 2
Then I see status message "not found"
And I see button "upload_new_timed_transcripts"
And I upload the transcripts file "test_transcripts.srt"
Then I see status message "uploaded_successfully"
And I clear field number 1
Then I see status message "found"
And I see value "video_name_1" in the field "HTML5 Transcript"
And I save changes
Then when I view the video it does show the captions
And I edit the component
Then I see status message "found"
#29
Scenario: Change transcripts field in Advanced tab
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
Then I see status message "not found"
And I open tab "Advanced"
And I set value "t_not_exist" to the field "HTML5 Transcript"
And I save changes
Then when I view the video it does show the captions
And I edit the component
Then I see status message "found"
And I see value "video_name_1" in the field "HTML5 Transcript"
#30
Scenario: Check non-ascii (chinise) transcripts
Given I have created a Video component
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
Then I see status message "not found"
And I upload the transcripts file "chinese_transcripts.srt"
Then I see status message "uploaded_successfully"
And I save changes
Then when I view the video it does show the captions
#31
Scenario: Check saving module metadata on switching between tabs
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
Then I see status message "not found"
And I open tab "Advanced"
And I set value "t_not_exist" to the field "HTML5 Transcript"
And I open tab "Basic"
Then I see status message "found"
And I save changes
Then when I view the video it does show the captions
And I edit the component
Then I see status message "found"
And I see value "video_name_1" in the field "HTML5 Transcript"
#32
Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible w/o saving
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "found"
And I open tab "Advanced"
And I set value "" to the field "HTML5 Transcript"
And I open tab "Basic"
Then I see status message "not found"
And I save changes
Then when I view the video it does not show the captions
And I edit the component
Then I see status message "not found"
And I see value "" in the field "HTML5 Transcript"
#33
Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible with saving
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "t_not_exist.mp4" source to field number 1
Then I see status message "found"
And I save changes
And I edit the component
And I open tab "Advanced"
And I set value "" to the field "HTML5 Transcript"
And I open tab "Basic"
Then I see status message "not found"
And I save changes
Then when I view the video it does not show the captions
And I edit the component
Then I see status message "not found"
And I see value "" in the field "HTML5 Transcript"
#34
Scenario: Video with existing subs - Advanced tab - change to another one subs - Basic tab - Found message - Save - see correct subs
Given I have created a Video component with subtitles "t_not_exist"
And I edit the component
And I enter a "video_name_1.mp4" source to field number 1
Then I see status message "not found"
And I upload the transcripts file "chinese_transcripts.srt"
Then I see status message "uploaded_successfully"
And I save changes
Then when I view the video it does show the captions
And I edit the component
And I open tab "Advanced"
And I set value "t_not_exist" to the field "HTML5 Transcript"
And I open tab "Basic"
Then I see status message "found"
And I save changes
Then when I view the video it does show the captions
And I see "LILA FISHER: Hi, welcome to Edx." text in the captions
# disable missing docstring
# pylint: disable=C0111
import os
from lettuce import world, step
from django.conf import settings
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
# We should wait 300 ms for event handler invocation + 200ms for safety.
DELAY = 0.5
ERROR_MESSAGES = {
'url_format': u'Incorrect url format.',
'file_type': u'Link types should be unique.',
}
STATUSES = {
'found': u'Timed Transcript Found',
'not found': u'No Timed Transcript',
'replace': u'Timed Transcript Conflict',
'uploaded_successfully': u'Timed Transcript uploaded successfully',
'use existing': u'Timed Transcript Not Updated',
}
SELECTORS = {
'error_bar': '.transcripts-error-message',
'url_inputs': '.videolist-settings-item input.input',
'collapse_link': '.collapse-action.collapse-setting',
'collapse_bar': '.videolist-extra-videos',
'status_bar': '.transcripts-message-status',
}
# button type , button css selector, button message
BUTTONS = {
'import': ('.setting-import', 'Import from YouTube'),
'download_to_edit': ('.setting-download', 'Download to Edit'),
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download to Edit'),
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Timed Transcript'),
'replace': ('.setting-replace', 'Yes, Replace EdX Timed Transcript with YouTube Timed Transcript'),
'choose': ('.setting-choose', 'Timed Transcript from {}'),
'use_existing': ('.setting-use-existing', 'Use Existing Timed Transcript'),
}
@step('I clear fields$')
def clear_fields(_step):
js_str = '''
$('{selector}')
.eq({index})
.prop('disabled', false)
.removeClass('is-disabled');
'''
for index in range(1, 4):
js = js_str.format(selector=SELECTORS['url_inputs'], index=index - 1)
world.browser.execute_script(js)
_step.given('I clear field number {0}'.format(index))
@step('I clear field number (.+)$')
def clear_field(_step, index):
index = int(index) - 1
world.css_fill(SELECTORS['url_inputs'], '', index)
# In some reason chromeDriver doesn't trigger 'input' event after filling
# field by an empty value. That's why we trigger it manually via jQuery.
world.trigger_event(SELECTORS['url_inputs'], event='input', index=index)
@step('I expect (.+) inputs are disabled$')
def inputs_are_disabled(_step, indexes):
index_list = [int(i.strip()) - 1 for i in indexes.split(',')]
for index in index_list:
el = world.css_find(SELECTORS['url_inputs'])[index]
assert el['disabled']
@step('I expect inputs are enabled$')
def inputs_are_enabled(_step):
for index in range(3):
el = world.css_find(SELECTORS['url_inputs'])[index]
assert not el['disabled']
@step('I do not see error message$')
def i_do_not_see_error_message(_step):
world.wait(DELAY)
assert not world.css_visible(SELECTORS['error_bar'])
@step('I see error message "([^"]*)"$')
def i_see_error_message(_step, error):
world.wait(DELAY)
assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error.strip()])
@step('I do not see status message$')
def i_do_not_see_status_message(_step):
world.wait(DELAY)
world.wait_for_ajax_complete()
assert not world.css_visible(SELECTORS['status_bar'])
@step('I see status message "([^"]*)"$')
def i_see_status_message(_step, status):
world.wait(DELAY)
world.wait_for_ajax_complete()
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
@step('I (.*)see button "([^"]*)"$')
def i_see_button(_step, not_see, button_type):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
if not_see.strip():
assert world.is_css_not_present(BUTTONS[button][0])
else:
assert world.css_has_text(BUTTONS[button][0], BUTTONS[button][1])
@step('I (.*)see (.*)button "([^"]*)" number (\d+)$')
def i_see_button_with_custom_text(_step, not_see, button_type, custom_text, index):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
custom_text = custom_text.strip()
index = int(index.strip()) - 1
if not_see.strip():
assert world.is_css_not_present(BUTTONS[button][0])
else:
assert world.css_has_text(BUTTONS[button][0], BUTTONS[button][1].format(custom_text), index)
@step('I click button "([^"]*)"$')
def click_button(_step, button_type):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
world.css_click(BUTTONS[button][0])
@step('I click button "([^"]*)" number (\d+)$')
def click_button_index(_step, button_type, index):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
index = int(index.strip()) - 1
world.css_click(BUTTONS[button][0], index)
@step('I remove "([^"]+)" transcripts id from store')
def remove_transcripts_from_store(_step, subs_id):
"""Remove from store, if transcripts content exists."""
filename = 'subs_{0}.srt.sjson'.format(subs_id.strip())
content_location = StaticContent.compute_location(
world.scenario_dict['COURSE'].org,
world.scenario_dict['COURSE'].number,
filename
)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
print('Transcript file was removed from store.')
except NotFoundError:
print('Transcript file was NOT found and not removed.')
@step('I enter a "([^"]+)" source to field number (\d+)$')
def i_enter_a_source(_step, link, index):
world.wait(DELAY)
world.wait_for_ajax_complete()
index = int(index) - 1
if index is not 0 and not world.css_visible(SELECTORS['collapse_bar']):
world.css_click(SELECTORS['collapse_link'])
assert world.css_visible(SELECTORS['collapse_bar'])
world.css_fill(SELECTORS['url_inputs'], link, index)
@step('I upload the transcripts file "([^"]*)"$')
def upload_file(_step, file_name):
path = os.path.join(TEST_ROOT, 'uploads/', file_name.strip())
world.browser.execute_script("$('form.file-chooser').show()")
world.browser.attach_file('file', os.path.abspath(path))
@step('I see "([^"]*)" text in the captions')
def check_text_in_the_captions(_step, text):
assert world.browser.is_text_present(text.strip(), 5)
@step('I see value "([^"]*)" in the field "([^"]*)"$')
def check_transcripts_field(_step, values, field_name):
world.wait(DELAY)
world.wait_for_ajax_complete()
world.click_link_by_text('Advanced')
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')]
assert any(values_list)
world.click_link_by_text('Basic')
@step('I save changes$')
def save_changes(_step):
world.wait(DELAY)
world.wait_for_ajax_complete()
save_css = 'a.save-button'
world.css_click(save_css)
@step('I open tab "([^"]*)"$')
def open_tab(_step, tab_name):
world.click_link_by_text(tab_name.strip())
@step('I set value "([^"]*)" to the field "([^"]*)"$')
def set_value_transcripts_field(_step, value, field_name):
world.wait(DELAY)
world.wait_for_ajax_complete()
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
world.css_fill(field_id, value.strip())
......@@ -19,12 +19,12 @@ Feature: CMS.Video Component Editor
@skip_sauce
Scenario: Captions are hidden when "show captions" is false
Given I have created a Video component with subtitles
And I have set "show captions" to False
And I have set "show transcript" to False
Then when I view the video it does not show the captions
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are shown when "show captions" is true
Given I have created a Video component with subtitles
And I have set "show captions" to True
And I have set "show transcript" to True
Then when I view the video it does show the captions
......@@ -5,14 +5,15 @@ from lettuce import world, step
from terrain.steps import reload_the_page
@step('I have set "show captions" to (.*)$')
@step('I have set "show transcript" to (.*)$')
def set_show_captions(step, setting):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
world.css_click('a.edit-button')
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
world.browser.select('Show Captions', setting)
world.click_link_by_text('Advanced')
world.browser.select('Show Transcript', setting)
world.css_click('a.save-button')
......@@ -33,12 +34,17 @@ def shows_captions(_step, show_captions):
@step('I see the correct video settings and default values$')
def correct_video_settings(_step):
expected_entries = [
# basic
['Display Name', 'Video', False],
['Download Track', '', False],
['Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False],
# advanced
['Display Name', 'Video', False],
['Download Transcript', '', False],
['Download Video', '', False],
['End Time', '0', False],
['HTML5 Timed Transcript', '', False],
['Show Captions', 'True', False],
['HTML5 Transcript', '', False],
['Show Transcript', 'True', False],
['Start Time', '0', False],
['Video Sources', '', False],
['Youtube ID', 'OEoXaMPEzfM', False],
......
......@@ -43,11 +43,7 @@ def i_created_a_video_with_subs_with_name(_step, sub_id):
@step('I have uploaded subtitles "([^"]*)"$')
def i_have_uploaded_subtitles(_step, sub_id):
_step.given('I go to the files and uploads page')
sub_id = sub_id.strip()
if not sub_id:
sub_id = 'OEoXaMPEzfM'
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id))
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id.strip()))
@step('when I view the (.*) it does not have autoplay enabled$')
......
#pylint: disable=C0111
#pylint: disable=W0621
from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer
from lettuce import before, after, world
from django.conf import settings
import threading
from logging import getLogger
logger = getLogger(__name__)
@before.all
def setup_mock_youtube_server():
server_host = '127.0.0.1'
server_port = settings.VIDEO_PORT
address = (server_host, server_port)
# Create the mock server instance
server = MockYoutubeServer(address)
logger.debug("Youtube server started at {} port".format(str(server_port)))
server.time_to_response = 0.1 # seconds
server.address = address
# Start the server running in a separate daemon thread
# Because the thread is a daemon, it will terminate
# when the main thread terminates.
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
# Store the server instance in lettuce's world
# so that other steps can access it
# (and we can shut it down later)
world.youtube_server = server
@after.all
def teardown_mock_youtube_server(total):
# Stop the LTI server and free up the port
world.youtube_server.shutdown()
"""Tests for items views."""
import json
import datetime
from pytz import UTC
from django.core.urlresolvers import reverse
from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
from xmodule.capa_module import CapaDescriptor
import json
from xmodule.modulestore.django import modulestore
import datetime
from pytz import UTC
class DeleteItem(CourseTestCase):
"""Tests for '/delete_item' url."""
def setUp(self):
""" Creates the test course with a static page in it. """
super(DeleteItem, self).setUp()
......
"""Tests for items views."""
import os
import json
import tempfile
from uuid import uuid4
import copy
import textwrap
from pymongo import MongoClient
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from django.conf import settings
from contentstore import transcripts_utils
from contentstore.tests.utils import CourseTestCase
from cache_toolbox.core import del_cached_content
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
from contentstore.tests.modulestore_config import TEST_MODULESTORE
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
class Basetranscripts(CourseTestCase):
"""Base test class for transcripts tests."""
org = 'MITx'
number = '999'
def clear_subs_content(self):
"""Remove, if transcripts content exists."""
for youtube_id in self.get_youtube_ids().values():
filename = 'subs_{0}.srt.sjson'.format(youtube_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
except NotFoundError:
pass
def setUp(self):
"""Create initial data."""
super(Basetranscripts, self).setUp()
# Add video module
data = {
'parent_location': str(self.course_location),
'category': 'video',
'type': 'video'
}
resp = self.client.post(reverse('create_item'), data)
self.item_location = json.loads(resp.content).get('id')
self.assertEqual(resp.status_code, 200)
# hI10vDNYz4M - valid Youtube ID with transcripts.
# JMD_ifUUfsU, AKqURZnYqpk, DYpADpL7jAY - valid Youtube IDs without transcripts.
data = '<video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" />'
modulestore().update_item(self.item_location, data)
self.item = modulestore().get_item(self.item_location)
# Remove all transcripts for current module.
self.clear_subs_content()
def get_youtube_ids(self):
"""Return youtube speeds and ids."""
item = modulestore().get_item(self.item_location)
return {
0.75: item.youtube_id_0_75,
1: item.youtube_id_1_0,
1.25: item.youtube_id_1_25,
1.5: item.youtube_id_1_5
}
def tearDown(self):
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
_CONTENTSTORE.clear()
class TestUploadtranscripts(Basetranscripts):
"""Tests for '/transcripts/upload' url."""
def setUp(self):
"""Create initial data."""
super(TestUploadtranscripts, self).setUp()
self.good_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
self.good_srt_file.write(textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""))
self.good_srt_file.seek(0)
self.bad_data_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
self.bad_data_srt_file.write('Some BAD data')
self.bad_data_srt_file.seek(0)
self.bad_name_srt_file = tempfile.NamedTemporaryFile(suffix='.BAD')
self.bad_name_srt_file.write(textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""))
self.bad_name_srt_file.seek(0)
def test_success_video_module_source_subs_uploading(self):
data = textwrap.dedent("""
<video youtube="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item_location, data)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'id': self.item_location,
'file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(json.loads(resp.content).get('status'), 'Success')
item = modulestore().get_item(self.item_location)
self.assertEqual(item.sub, filename)
content_location = StaticContent.compute_location(
self.org, self.number, 'subs_{0}.srt.sjson'.format(filename))
self.assertTrue(contentstore().find(content_location))
def test_fail_data_without_id(self):
link = reverse('upload_transcripts')
resp = self.client.post(link, {'file': self.good_srt_file})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "id" form data.')
def test_fail_data_without_file(self):
link = reverse('upload_transcripts')
resp = self.client.post(link, {'id': self.item_location})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "file" form data.')
def test_fail_data_with_bad_location(self):
# Test for raising `InvalidLocationError` exception.
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'id': 'BAD_LOCATION',
'file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
# Test for raising `ItemNotFoundError` exception.
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
'file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
def test_fail_for_non_video_module(self):
# non_video module: setup
data = {
'parent_location': str(self.course_location),
'category': 'non_video',
'type': 'non_video'
}
resp = self.client.post(reverse('create_item'), data)
item_location = json.loads(resp.content).get('id')
data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />'
modulestore().update_item(item_location, data)
# non_video module: testing
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'id': item_location,
'file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
def test_fail_bad_xml(self):
data = '<<<video youtube="0.75:JMD_ifUUfsU,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" />'
modulestore().update_item(self.item_location, data)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'id': self.item_location,
'file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
# incorrect xml produces incorrect item category error
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
def test_fail_bad_data_srt_file(self):
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.bad_data_srt_file.name))[0]
resp = self.client.post(link, {
'id': self.item_location,
'file': self.bad_data_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Something wrong with SubRip transcripts file during parsing.')
def test_fail_bad_name_srt_file(self):
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.bad_name_srt_file.name))[0]
resp = self.client.post(link, {
'id': self.item_location,
'file': self.bad_name_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'We support only SubRip (*.srt) transcripts format.')
def test_undefined_file_extension(self):
srt_file = tempfile.NamedTemporaryFile(suffix='')
srt_file.write(textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""))
srt_file.seek(0)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(srt_file.name))[0]
resp = self.client.post(link, {
'id': self.item_location,
'file': srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Undefined file extension.')
def tearDown(self):
super(TestUploadtranscripts, self).tearDown()
self.good_srt_file.close()
self.bad_data_srt_file.close()
self.bad_name_srt_file.close()
class TestDownloadtranscripts(Basetranscripts):
"""Tests for '/transcripts/download' url."""
def save_subs_to_store(self, subs, subs_id):
"""Save transcripts into `StaticContent`."""
filedata = json.dumps(subs, indent=2)
mime_type = 'application/json'
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename)
content = StaticContent(content_location, filename, mime_type, filedata)
contentstore().save(content)
del_cached_content(content_location)
return content_location
def remove_subs_from_store(self, subs_id):
"""Remove from store, if transcripts content exists."""
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
except NotFoundError:
pass
def test_success_download_youtube(self):
data = '<video youtube="1:JMD_ifUUfsU" />'
modulestore().update_item(self.item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': self.item_location, 'subs_id': "JMD_ifUUfsU"})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, """0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> 00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n""")
def test_success_download_nonyoutube(self):
subs_id = str(uuid4())
data = textwrap.dedent("""
<video youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""".format(subs_id))
modulestore().update_item(self.item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, subs_id)
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': self.item_location, 'subs_id': subs_id})
self.assertEqual(resp.status_code, 200)
self.assertEqual(
resp.content,
'0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> '
'00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n'
)
transcripts_utils.remove_subs_from_store(subs_id, self.item)
def test_fail_data_without_file(self):
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': ''})
self.assertEqual(resp.status_code, 404)
resp = self.client.get(link, {})
self.assertEqual(resp.status_code, 404)
def test_fail_data_with_bad_location(self):
# Test for raising `InvalidLocationError` exception.
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': 'BAD_LOCATION'})
self.assertEqual(resp.status_code, 404)
# Test for raising `ItemNotFoundError` exception.
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION')})
self.assertEqual(resp.status_code, 404)
def test_fail_for_non_video_module(self):
# Video module: setup
data = {
'parent_location': str(self.course_location),
'category': 'videoalpha',
'type': 'videoalpha'
}
resp = self.client.post(reverse('create_item'), data)
item_location = json.loads(resp.content).get('id')
subs_id = str(uuid4())
data = textwrap.dedent("""
<videoalpha youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</videoalpha>
""".format(subs_id))
modulestore().update_item(item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, subs_id)
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': item_location})
self.assertEqual(resp.status_code, 404)
def test_fail_nonyoutube_subs_dont_exist(self):
data = textwrap.dedent("""
<video youtube="" sub="UNDEFINED">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item_location, data)
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': self.item_location})
self.assertEqual(resp.status_code, 404)
def test_empty_youtube_attr_and_sub_attr(self):
data = textwrap.dedent("""
<video youtube="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item_location, data)
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': self.item_location})
self.assertEqual(resp.status_code, 404)
def test_fail_bad_sjson_subs(self):
subs_id = str(uuid4())
data = textwrap.dedent("""
<video youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""".format(subs_id))
modulestore().update_item(self.item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1'
]
}
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
link = reverse('download_transcripts')
resp = self.client.get(link, {'id': self.item_location})
self.assertEqual(resp.status_code, 404)
class TestChecktranscripts(Basetranscripts):
"""Tests for '/transcripts/check' url."""
def save_subs_to_store(self, subs, subs_id):
"""Save transcripts into `StaticContent`."""
filedata = json.dumps(subs, indent=2)
mime_type = 'application/json'
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename)
content = StaticContent(content_location, filename, mime_type, filedata)
contentstore().save(content)
del_cached_content(content_location)
return content_location
def remove_subs_from_store(self, subs_id):
"""Remove from store, if transcripts content exists."""
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
except NotFoundError:
pass
def test_success_download_nonyoutube(self):
subs_id = str(uuid4())
data = textwrap.dedent("""
<video youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""".format(subs_id))
modulestore().update_item(self.item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, subs_id)
data = {
'id': self.item_location,
'videos': [{
'type': 'html5',
'video': subs_id,
'mode': 'mp4',
}]
}
link = reverse('check_transcripts')
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 200)
self.assertDictEqual(
json.loads(resp.content),
{
u'status': u'Success',
u'subs': unicode(subs_id),
u'youtube_local': False,
u'is_youtube_mode': False,
u'youtube_server': False,
u'command': u'found',
u'current_item_subs': unicode(subs_id),
u'youtube_diff': True,
u'html5_local': [unicode(subs_id)],
u'html5_equal': False,
}
)
transcripts_utils.remove_subs_from_store(subs_id, self.item)
def test_check_youtube(self):
data = '<video youtube="1:JMD_ifUUfsU" />'
modulestore().update_item(self.item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
link = reverse('check_transcripts')
data = {
'id': self.item_location,
'videos': [{
'type': 'youtube',
'video': 'JMD_ifUUfsU',
'mode': 'youtube',
}]
}
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 200)
self.assertDictEqual(
json.loads(resp.content),
{
u'status': u'Success',
u'subs': u'JMD_ifUUfsU',
u'youtube_local': True,
u'is_youtube_mode': True,
u'youtube_server': False,
u'command': u'found',
u'current_item_subs': None,
u'youtube_diff': True,
u'html5_local': [],
u'html5_equal': False,
}
)
def test_fail_data_without_id(self):
link = reverse('check_transcripts')
data = {
'id': '',
'videos': [{
'type': '',
'video': '',
'mode': '',
}]
}
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
def test_fail_data_with_bad_location(self):
# Test for raising `InvalidLocationError` exception.
link = reverse('check_transcripts')
data = {
'id': '',
'videos': [{
'type': '',
'video': '',
'mode': '',
}]
}
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
# Test for raising `ItemNotFoundError` exception.
data = {
'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
'videos': [{
'type': '',
'video': '',
'mode': '',
}]
}
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by location.")
def test_fail_for_non_video_module(self):
# Not video module: setup
data = {
'parent_location': str(self.course_location),
'category': 'not_video',
'type': 'not_video'
}
resp = self.client.post(reverse('create_item'), data)
item_location = json.loads(resp.content).get('id')
subs_id = str(uuid4())
data = textwrap.dedent("""
<not_video youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</videoalpha>
""".format(subs_id))
modulestore().update_item(item_location, data)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, subs_id)
data = {
'id': item_location,
'videos': [{
'type': '',
'video': '',
'mode': '',
}]
}
link = reverse('check_transcripts')
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
""" Tests for transcripts_utils. """
import unittest
from uuid import uuid4
import copy
import textwrap
from pymongo import MongoClient
from django.test.utils import override_settings
from django.conf import settings
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.exceptions import NotFoundError
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
from contentstore import transcripts_utils
from contentstore.tests.modulestore_config import TEST_MODULESTORE
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
class TestGenerateSubs(unittest.TestCase):
"""Tests for `generate_subs` function."""
def setUp(self):
self.source_subs = {
'start': [100, 200, 240, 390, 1000],
'end': [200, 240, 380, 1000, 1500],
'text': [
'subs #1',
'subs #2',
'subs #3',
'subs #4',
'subs #5'
]
}
def test_generate_subs_increase_speed(self):
subs = transcripts_utils.generate_subs(2, 1, self.source_subs)
self.assertDictEqual(
subs,
{
'start': [200, 400, 480, 780, 2000],
'end': [400, 480, 760, 2000, 3000],
'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
}
)
def test_generate_subs_decrease_speed_1(self):
subs = transcripts_utils.generate_subs(0.5, 1, self.source_subs)
self.assertDictEqual(
subs,
{
'start': [50, 100, 120, 195, 500],
'end': [100, 120, 190, 500, 750],
'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
}
)
def test_generate_subs_decrease_speed_2(self):
"""Test for correct devision during `generate_subs` process."""
subs = transcripts_utils.generate_subs(1, 2, self.source_subs)
self.assertDictEqual(
subs,
{
'start': [50, 100, 120, 195, 500],
'end': [100, 120, 190, 500, 750],
'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
}
)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
class TestSaveSubsToStore(ModuleStoreTestCase):
"""Tests for `save_subs_to_store` function."""
org = 'MITx'
number = '999'
display_name = 'Test course'
def clear_subs_content(self):
"""Remove, if subtitles content exists."""
try:
content = contentstore().find(self.content_location)
contentstore().delete(content.get_id())
except NotFoundError:
pass
def setUp(self):
self.course = CourseFactory.create(
org=self.org, number=self.number, display_name=self.display_name)
self.subs = {
'start': [100, 200, 240, 390, 1000],
'end': [200, 240, 380, 1000, 1500],
'text': [
'subs #1',
'subs #2',
'subs #3',
'subs #4',
'subs #5'
]
}
self.subs_id = str(uuid4())
filename = 'subs_{0}.srt.sjson'.format(self.subs_id)
self.content_location = StaticContent.compute_location(
self.org, self.number, filename
)
# incorrect subs
self.unjsonable_subs = set([1]) # set can't be serialized
self.unjsonable_subs_id = str(uuid4())
filename_unjsonable = 'subs_{0}.srt.sjson'.format(self.unjsonable_subs_id)
self.content_location_unjsonable = StaticContent.compute_location(
self.org, self.number, filename_unjsonable
)
self.clear_subs_content()
def test_save_subs_to_store(self):
with self.assertRaises(NotFoundError):
contentstore().find(self.content_location)
result_location = transcripts_utils.save_subs_to_store(
self.subs,
self.subs_id,
self.course)
self.assertTrue(contentstore().find(self.content_location))
self.assertEqual(result_location, self.content_location)
def test_save_unjsonable_subs_to_store(self):
"""
Assures that subs, that can't be dumped, can't be found later.
"""
with self.assertRaises(NotFoundError):
contentstore().find(self.content_location_unjsonable)
with self.assertRaises(TypeError):
transcripts_utils.save_subs_to_store(
self.unjsonable_subs,
self.unjsonable_subs_id,
self.course)
with self.assertRaises(NotFoundError):
contentstore().find(self.content_location_unjsonable)
def tearDown(self):
self.clear_subs_content()
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
_CONTENTSTORE.clear()
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
class TestDownloadYoutubeSubs(ModuleStoreTestCase):
"""Tests for `download_youtube_subs` function."""
org = 'MITx'
number = '999'
display_name = 'Test course'
def clear_subs_content(self, youtube_subs):
"""Remove, if subtitles content exists."""
for subs_id in youtube_subs.values():
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename
)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
except NotFoundError:
pass
def setUp(self):
self.course = CourseFactory.create(
org=self.org, number=self.number, display_name=self.display_name)
def tearDown(self):
MongoClient().drop_database(TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'])
_CONTENTSTORE.clear()
def test_success_downloading_subs(self):
good_youtube_subs = {
0.5: 'JMD_ifUUfsU',
1.0: 'hI10vDNYz4M',
2.0: 'AKqURZnYqpk'
}
self.clear_subs_content(good_youtube_subs)
# Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown
transcripts_utils.download_youtube_subs(good_youtube_subs, self.course)
# Check assets status after importing subtitles.
for subs_id in good_youtube_subs.values():
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename
)
self.assertTrue(contentstore().find(content_location))
self.clear_subs_content(good_youtube_subs)
def test_fail_downloading_subs(self):
bad_youtube_subs = {
0.5: 'BAD_YOUTUBE_ID1',
1.0: 'BAD_YOUTUBE_ID2',
2.0: 'BAD_YOUTUBE_ID3'
}
self.clear_subs_content(bad_youtube_subs)
with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException):
transcripts_utils.download_youtube_subs(bad_youtube_subs, self.course)
# Check assets status after importing subtitles.
for subs_id in bad_youtube_subs.values():
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename
)
with self.assertRaises(NotFoundError):
contentstore().find(content_location)
self.clear_subs_content(bad_youtube_subs)
def test_success_downloading_chinise_transcripts(self):
good_youtube_subs = {
1.0: 'j_jEn79vS3g', # Chinese, utf-8
}
self.clear_subs_content(good_youtube_subs)
# Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown
transcripts_utils.download_youtube_subs(good_youtube_subs, self.course)
# Check assets status after importing subtitles.
for subs_id in good_youtube_subs.values():
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename
)
self.assertTrue(contentstore().find(content_location))
self.clear_subs_content(good_youtube_subs)
class TestGenerateSubsFromSource(TestDownloadYoutubeSubs):
"""Tests for `generate_subs_from_source` function."""
def test_success_generating_subs(self):
youtube_subs = {
0.5: 'JMD_ifUUfsU',
1.0: 'hI10vDNYz4M',
2.0: 'AKqURZnYqpk'
}
srt_filedata = textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
""")
self.clear_subs_content(youtube_subs)
# Check transcripts_utils.TranscriptsGenerationException not thrown
transcripts_utils.generate_subs_from_source(youtube_subs, 'srt', srt_filedata, self.course)
# Check assets status after importing subtitles.
for subs_id in youtube_subs.values():
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
self.org, self.number, filename
)
self.assertTrue(contentstore().find(content_location))
self.clear_subs_content(youtube_subs)
def test_fail_bad_subs_type(self):
youtube_subs = {
0.5: 'JMD_ifUUfsU',
1.0: 'hI10vDNYz4M',
2.0: 'AKqURZnYqpk'
}
srt_filedata = textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
""")
with self.assertRaises(transcripts_utils.TranscriptsGenerationException) as cm:
transcripts_utils.generate_subs_from_source(youtube_subs, 'BAD_FORMAT', srt_filedata, self.course)
exception_message = cm.exception.message
self.assertEqual(exception_message, "We support only SubRip (*.srt) transcripts format.")
def test_fail_bad_subs_filedata(self):
youtube_subs = {
0.5: 'JMD_ifUUfsU',
1.0: 'hI10vDNYz4M',
2.0: 'AKqURZnYqpk'
}
srt_filedata = """BAD_DATA"""
with self.assertRaises(transcripts_utils.TranscriptsGenerationException) as cm:
transcripts_utils.generate_subs_from_source(youtube_subs, 'srt', srt_filedata, self.course)
exception_message = cm.exception.message
self.assertEqual(exception_message, "Something wrong with SubRip transcripts file during parsing.")
class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs):
"""Tests for `generate_srt_from_sjson` function."""
def test_success_generating_subs(self):
sjson_subs = {
'start': [100, 200, 240, 390, 54000],
'end': [200, 240, 380, 1000, 78400],
'text': [
'subs #1',
'subs #2',
'subs #3',
'subs #4',
'subs #5'
]
}
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1)
self.assertTrue(srt_subs)
expected_subs = [
'00:00:00,100 --> 00:00:00,200\nsubs #1',
'00:00:00,200 --> 00:00:00,240\nsubs #2',
'00:00:00,240 --> 00:00:00,380\nsubs #3',
'00:00:00,390 --> 00:00:01,000\nsubs #4',
'00:00:54,000 --> 00:01:18,400\nsubs #5',
]
for sub in expected_subs:
self.assertIn(sub, srt_subs)
def test_success_generating_subs_speed_up(self):
sjson_subs = {
'start': [100, 200, 240, 390, 54000],
'end': [200, 240, 380, 1000, 78400],
'text': [
'subs #1',
'subs #2',
'subs #3',
'subs #4',
'subs #5'
]
}
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 0.5)
self.assertTrue(srt_subs)
expected_subs = [
'00:00:00,050 --> 00:00:00,100\nsubs #1',
'00:00:00,100 --> 00:00:00,120\nsubs #2',
'00:00:00,120 --> 00:00:00,190\nsubs #3',
'00:00:00,195 --> 00:00:00,500\nsubs #4',
'00:00:27,000 --> 00:00:39,200\nsubs #5',
]
for sub in expected_subs:
self.assertIn(sub, srt_subs)
def test_success_generating_subs_speed_down(self):
sjson_subs = {
'start': [100, 200, 240, 390, 54000],
'end': [200, 240, 380, 1000, 78400],
'text': [
'subs #1',
'subs #2',
'subs #3',
'subs #4',
'subs #5'
]
}
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 2)
self.assertTrue(srt_subs)
expected_subs = [
'00:00:00,200 --> 00:00:00,400\nsubs #1',
'00:00:00,400 --> 00:00:00,480\nsubs #2',
'00:00:00,480 --> 00:00:00,760\nsubs #3',
'00:00:00,780 --> 00:00:02,000\nsubs #4',
'00:01:48,000 --> 00:02:36,800\nsubs #5',
]
for sub in expected_subs:
self.assertIn(sub, srt_subs)
def test_fail_generating_subs(self):
sjson_subs = {
'start': [100, 200],
'end': [100],
'text': [
'subs #1',
'subs #2'
]
}
srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1)
self.assertFalse(srt_subs)
""" Tests for utils. """
from contentstore import utils
import mock
import unittest
import collections
import copy
import json
from uuid import uuid4
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.exceptions import NotFoundError
class LMSLinksTestCase(TestCase):
""" Tests for LMS links. """
......@@ -88,8 +98,10 @@ class ExtraPanelTabTestCase(TestCase):
else:
return []
def get_course_with_tabs(self, tabs=[]):
def get_course_with_tabs(self, tabs=None):
""" Returns a mock course object with a tabs attribute. """
if tabs is None:
tabs = []
course = collections.namedtuple('MockCourse', ['tabs'])
if isinstance(tabs, basestring):
course.tabs = self.get_tab_type_dicts(tabs)
......
......@@ -61,6 +61,7 @@ class CourseTestCase(ModuleStoreTestCase):
number='999',
display_name='Robot Super Course',
)
self.course_location = self.course.location
def createNonStaffAuthedUserClient(self):
"""
......
"""
Utility functions for transcripts.
++++++++++++++++++++++++++++++++++
"""
import copy
import json
import requests
import logging
from pysrt import SubRipTime, SubRipItem, SubRipFile
from lxml import etree
from cache_toolbox.core import del_cached_content
from django.conf import settings
from xmodule.exceptions import NotFoundError
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from .utils import get_modulestore
log = logging.getLogger(__name__)
class TranscriptsGenerationException(Exception):
pass
class GetTranscriptsFromYouTubeException(Exception):
pass
class TranscriptsRequestValidationException(Exception):
pass
def generate_subs(speed, source_speed, source_subs):
"""
Generate transcripts from one speed to another speed.
Args:
`speed`: float, for this speed subtitles will be generated,
`source_speed`: float, speed of source_subs
`soource_subs`: dict, existing subtitles for speed `source_speed`.
Returns:
`subs`: dict, actual subtitles.
"""
if speed == source_speed:
return source_subs
coefficient = 1.0 * speed / source_speed
subs = {
'start': [
int(round(timestamp * coefficient)) for
timestamp in source_subs['start']
],
'end': [
int(round(timestamp * coefficient)) for
timestamp in source_subs['end']
],
'text': source_subs['text']}
return subs
def save_subs_to_store(subs, subs_id, item):
"""
Save transcripts into `StaticContent`.
Args:
`subs_id`: str, subtitles id
`item`: video module instance
Returns: location of saved subtitles.
"""
filedata = json.dumps(subs, indent=2)
mime_type = 'application/json'
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
content = StaticContent(content_location, filename, mime_type, filedata)
contentstore().save(content)
del_cached_content(content_location)
return content_location
def get_transcripts_from_youtube(youtube_id):
"""
Gets transcripts from youtube for youtube_id.
Parses only utf-8 encoded transcripts.
Other encodings are not supported at the moment.
Returns (status, transcripts): bool, dict.
"""
utf8_parser = etree.XMLParser(encoding='utf-8')
youtube_api = copy.deepcopy(settings.YOUTUBE_API)
youtube_api['params']['v'] = youtube_id
data = requests.get(youtube_api['url'], params=youtube_api['params'])
if data.status_code != 200 or not data.text:
msg = "Can't receive transcripts from Youtube for {}. Status code: {}.".format(
youtube_id, data.status_code)
raise GetTranscriptsFromYouTubeException(msg)
sub_starts, sub_ends, sub_texts = [], [], []
xmltree = etree.fromstring(data.content, parser=utf8_parser)
for element in xmltree:
if element.tag == "text":
start = float(element.get("start"))
duration = float(element.get("dur", 0)) # dur is not mandatory
text = element.text
end = start + duration
if text:
# Start and end should be ints representing the millisecond timestamp.
sub_starts.append(int(start * 1000))
sub_ends.append(int((end + 0.0001) * 1000))
sub_texts.append(text.replace('\n', ' '))
return {'start': sub_starts, 'end': sub_ends, 'text': sub_texts}
def download_youtube_subs(youtube_subs, item):
"""
Download transcripts from Youtube and save them to assets.
Args:
youtube_subs: dictionary of `speed: youtube_id` key:value pairs.
item: video module instance.
Returns: None, if transcripts were successfully downloaded and saved.
Otherwise raises GetTranscriptsFromYouTubeException.
"""
highest_speed = highest_speed_subs = None
missed_speeds = []
# Iterate from lowest to highest speed and try to do download transcripts
# from the Youtube service.
for speed, youtube_id in sorted(youtube_subs.iteritems()):
if not youtube_id:
continue
try:
subs = get_transcripts_from_youtube(youtube_id)
if not subs: # if empty subs are returned
raise GetTranscriptsFromYouTubeException
except GetTranscriptsFromYouTubeException:
missed_speeds.append(speed)
continue
save_subs_to_store(subs, youtube_id, item)
log.info(
"Transcripts for YouTube id %s (speed %s)"
"are downloaded and saved.", youtube_id, speed
)
highest_speed = speed
highest_speed_subs = subs
if not highest_speed:
raise GetTranscriptsFromYouTubeException("Can't find any transcripts on the Youtube service.")
# When we exit from the previous loop, `highest_speed` and `highest_speed_subs`
# are the transcripts data for the highest speed available on the
# Youtube service. We use the highest speed as main speed for the
# generation other transcripts, cause during calculation timestamps
# for lower speeds we just use multiplication instead of division.
for speed in missed_speeds: # Generate transcripts for missed speeds.
save_subs_to_store(
generate_subs(speed, highest_speed, highest_speed_subs),
youtube_subs[speed],
item
)
log.info(
"Transcripts for YouTube id %s (speed %s)"
"are generated from YouTube id %s (speed %s) and saved",
youtube_subs[speed], speed,
youtube_subs[highest_speed],
highest_speed
)
def remove_subs_from_store(subs_id, item):
"""
Remove from store, if transcripts content exists.
"""
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
log.info("Removed subs %s from store", subs_id)
except NotFoundError:
pass
def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item):
"""Generate transcripts from source files (like SubRip format, etc.)
and save them to assets for `item` module.
We expect, that speed of source subs equal to 1
:param speed_subs: dictionary {speed: sub_id, ...}
:param subs_type: type of source subs: "srt", ...
:param subs_filedata:unicode, content of source subs.
:param item: module object.
:returns: True, if all subs are generated and saved successfully.
"""
if subs_type != 'srt':
raise TranscriptsGenerationException("We support only SubRip (*.srt) transcripts format.")
try:
srt_subs_obj = SubRipFile.from_string(subs_filedata)
except Exception as e:
raise TranscriptsGenerationException(
"Something wrong with SubRip transcripts file during parsing. "
"Inner message is {}".format(e.message)
)
if not srt_subs_obj:
raise TranscriptsGenerationException("Something wrong with SubRip transcripts file during parsing.")
sub_starts = []
sub_ends = []
sub_texts = []
for sub in srt_subs_obj:
sub_starts.append(sub.start.ordinal)
sub_ends.append(sub.end.ordinal)
sub_texts.append(sub.text.replace('\n', ' '))
subs = {
'start': sub_starts,
'end': sub_ends,
'text': sub_texts}
for speed, subs_id in speed_subs.iteritems():
save_subs_to_store(
generate_subs(speed, 1, subs),
subs_id,
item
)
return subs
def generate_srt_from_sjson(sjson_subs, speed):
"""Generate transcripts with speed = 1.0 from sjson to SubRip (*.srt).
:param sjson_subs: "sjson" subs.
:param speed: speed of `sjson_subs`.
:returns: "srt" subs.
"""
output = ''
equal_len = len(sjson_subs['start']) == len(sjson_subs['end']) == len(sjson_subs['text'])
if not equal_len:
return output
sjson_speed_1 = generate_subs(speed, 1, sjson_subs)
for i in range(len(sjson_speed_1['start'])):
item = SubRipItem(
index=i,
start=SubRipTime(milliseconds=sjson_speed_1['start'][i]),
end=SubRipTime(milliseconds=sjson_speed_1['end'][i]),
text=sjson_speed_1['text'][i]
)
output += (unicode(item))
output += '\n'
return output
def save_module(item):
"""
Proceed with additional save operations.
"""
item.save()
store = get_modulestore(Location(item.id))
store.update_metadata(item.id, own_metadata(item))
def copy_or_rename_transcript(new_name, old_name, item, delete_old=False):
"""
Renames `old_name` transcript file in storage to `new_name`.
If `old_name` is not found in storage, raises `NotFoundError`.
If `delete_old` is True, removes `old_name` files from storage.
"""
filename = 'subs_{0}.srt.sjson'.format(old_name)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
transcripts = contentstore().find(content_location).data
save_subs_to_store(json.loads(transcripts), new_name, item)
item.sub = new_name
save_module(item)
if delete_old:
remove_subs_from_store(old_name, item)
def manage_video_subtitles_save(old_item, new_item):
"""
Does some specific things, that can be done only on save.
Video player item has some video fields: HTML5 ones and Youtube one.
1. If value of `sub` field of `new_item` is different from values of video fields of `new_item`,
and `new_item.sub` file is present, then code in this function creates copies of
`new_item.sub` file with new names. That names are equal to values of video fields of `new_item`
After that `sub` field of `new_item` is changed to one of values of video fields.
This whole action ensures that after user changes video fields, proper `sub` files, corresponding
to new values of video fields, will be presented in system.
old_item is not used here, but is added for future changes.
"""
# 1.
# assume '.' and '/' are not in filenames
html5_ids = [x.split('/')[-1].split('.')[0] for x in new_item.html5_sources]
possible_video_id_list = [new_item.youtube_id_1_0] + html5_ids
sub_name = new_item.sub
for video_id in possible_video_id_list:
if not video_id:
continue
# copy_or_rename_transcript changes item.sub of module
try:
# updates item.sub with `video_id`, if it is successful.
copy_or_rename_transcript(video_id, sub_name, new_item)
except NotFoundError:
# subtitles file `sub_name` is not presented in the system. Nothing to copy or rename.
log.debug(
"Copying %s file content to %s name is failed, "
"original file does not exist.",
sub_name, video_id
)
#pylint: disable=E1103, E1101
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
import copy
import logging
import re
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from django.conf import settings
from django.utils.translation import ugettext as _
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from django_comment_common.utils import unseed_permissions_roles
from auth.authz import _delete_course_group
from xmodule.modulestore.store_utilities import delete_course
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
log = logging.getLogger(__name__)
......
......@@ -16,6 +16,7 @@ from .preview import *
from .public import *
from .user import *
from .tabs import *
from .transcripts_ajax import *
try:
from .dev import *
except ImportError:
......
"""Views for items (modules)."""
import logging
from uuid import uuid4
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from util.json_request import expect_json, JsonResponse
from ..transcripts_utils import manage_video_subtitles_save
from ..utils import get_modulestore
from .access import has_access
from .helpers import _xmodule_recurse
from xmodule.x_module import XModuleDescriptor
__all__ = ['save_item', 'create_item', 'delete_item']
log = logging.getLogger(__name__)
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
log = logging.getLogger(__name__)
@login_required
@expect_json
......@@ -52,8 +59,13 @@ def save_item(request):
inspect.currentframe().f_back.f_code.co_name,
inspect.currentframe().f_back.f_code.co_filename
)
return HttpResponseBadRequest()
return JsonResponse({"error": "Request missing required attribute 'id'."}, 400)
try:
old_item = modulestore().get_item(item_location)
except (ItemNotFoundError, InvalidLocationError):
log.error("Can't find item by location.")
return JsonResponse({"error": "Can't find item by location"}, 404)
# check permissions for this user within this course
if not has_access(request.user, item_location):
......@@ -101,12 +113,16 @@ def save_item(request):
# commit to datastore
store.update_metadata(item_location, own_metadata(existing_item))
if existing_item.category == 'video':
manage_video_subtitles_save(old_item, existing_item)
return JsonResponse()
@login_required
@expect_json
def create_item(request):
"""View for create items."""
parent_location = Location(request.POST['parent_location'])
category = request.POST['category']
......@@ -149,6 +165,7 @@ def create_item(request):
@login_required
@expect_json
def delete_item(request):
"""View for removing items."""
item_location = request.POST['id']
item_location = Location(item_location)
......
"""
Actions manager for transcripts ajax calls.
+++++++++++++++++++++++++++++++++++++++++++
Module do not support rollback (pressing "Cancel" button in Studio)
All user changes are saved immediately.
"""
import copy
import os
import logging
import json
import requests
from django.http import HttpResponse, Http404
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.conf import settings
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from util.json_request import JsonResponse
from ..transcripts_utils import (
generate_subs_from_source,
generate_srt_from_sjson, remove_subs_from_store,
download_youtube_subs, get_transcripts_from_youtube,
copy_or_rename_transcript,
save_module,
manage_video_subtitles_save,
TranscriptsGenerationException,
GetTranscriptsFromYouTubeException,
TranscriptsRequestValidationException
)
from .access import has_access
__all__ = [
'upload_transcripts',
'download_transcripts',
'check_transcripts',
'choose_transcripts',
'replace_transcripts',
'rename_transcripts',
'save_transcripts'
]
log = logging.getLogger(__name__)
def error_response(response, message, status_code=400):
"""
Simplify similar actions: log message and return JsonResponse with message included in response.
By default return 400 (Bad Request) Response.
"""
log.debug(message)
response['status'] = message
return JsonResponse(response, status_code)
@login_required
def upload_transcripts(request):
"""
Upload transcripts for current module.
returns: response dict::
status: 'Success' and HTTP 200 or 'Error' and HTTP 400.
subs: Value of uploaded and saved html5 sub field in video item.
"""
response = {
'status': 'Unknown server error',
'subs': '',
}
item_location = request.POST.get('id')
if not item_location:
return error_response(response, 'POST data without "id" form data.')
# This is placed before has_access() to validate item_location,
# because has_access() raises InvalidLocationError if location is invalid.
try:
item = modulestore().get_item(item_location)
except (ItemNotFoundError, InvalidLocationError):
return error_response(response, "Can't find item by location.")
# Check permissions for this user within this course.
if not has_access(request.user, item_location):
raise PermissionDenied()
if 'file' not in request.FILES:
return error_response(response, 'POST data without "file" form data.')
video_list = request.POST.get('video_list')
if not video_list:
return error_response(response, 'POST data without video names.')
try:
video_list = json.loads(video_list)
except ValueError:
return error_response(response, 'Invalid video_list JSON.')
source_subs_filedata = request.FILES['file'].read().decode('utf8')
source_subs_filename = request.FILES['file'].name
if '.' not in source_subs_filename:
return error_response(response, "Undefined file extension.")
basename = os.path.basename(source_subs_filename)
source_subs_name = os.path.splitext(basename)[0]
source_subs_ext = os.path.splitext(basename)[1][1:]
if item.category != 'video':
return error_response(response, 'Transcripts are supported only for "video" modules.')
# Allow upload only if any video link is presented
if video_list:
sub_attr = source_subs_name
try: # Generate and save for 1.0 speed, will create subs_sub_attr.srt.sjson subtitles file in storage.
generate_subs_from_source({1: sub_attr}, source_subs_ext, source_subs_filedata, item)
except TranscriptsGenerationException as e:
return error_response(response, e.message)
statuses = {}
for video_dict in video_list:
video_name = video_dict['video']
# We are creating transcripts for every video source,
# for the case that in future, some of video sources can be deleted.
statuses[video_name] = copy_or_rename_transcript(video_name, sub_attr, item)
try:
# updates item.sub with `video_name` if it is successful.
copy_or_rename_transcript(video_name, sub_attr, item)
selected_name = video_name # name to write to item.sub field, chosen at random.
except NotFoundError:
# subtitles file `sub_attr` is not presented in the system. Nothing to copy or rename.
return error_response(response, "Can't find transcripts in storage for {}".format(sub_attr))
item.sub = selected_name # write one of new subtitles names to item.sub attribute.
save_module(item)
response['subs'] = item.sub
response['status'] = 'Success'
else:
return error_response(response, 'Empty video sources.')
return JsonResponse(response)
@login_required
def download_transcripts(request):
"""
Passes to user requested transcripts file.
Raises Http404 if unsuccessful.
"""
item_location = request.GET.get('id')
if not item_location:
log.debug('GET data without "id" property.')
raise Http404
# This is placed before has_access() to validate item_location,
# because has_access() raises InvalidLocationError if location is invalid.
try:
item = modulestore().get_item(item_location)
except (ItemNotFoundError, InvalidLocationError):
log.debug("Can't find item by location.")
raise Http404
# Check permissions for this user within this course.
if not has_access(request.user, item_location):
raise PermissionDenied()
subs_id = request.GET.get('subs_id')
if not subs_id:
log.debug('GET data without "subs_id" property.')
raise Http404
if item.category != 'video':
log.debug('transcripts are supported only for video" modules.')
raise Http404
filename = 'subs_{0}.srt.sjson'.format(subs_id)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
try:
sjson_transcripts = contentstore().find(content_location)
log.debug("Downloading subs for %s id", subs_id)
str_subs = generate_srt_from_sjson(json.loads(sjson_transcripts.data), speed=1.0)
if not str_subs:
log.debug('generate_srt_from_sjson produces no subtitles')
raise Http404
response = HttpResponse(str_subs, content_type='application/x-subrip')
response['Content-Disposition'] = 'attachment; filename="{0}.srt"'.format(subs_id)
return response
except NotFoundError:
log.debug("Can't find content in storage for %s subs", subs_id)
raise Http404
@login_required
def check_transcripts(request):
"""
Check state of transcripts availability.
request.GET['data'] has key `videos`, which can contain any of the following::
[
{u'type': u'youtube', u'video': u'OEoXaMPEzfM', u'mode': u'youtube'},
{u'type': u'html5', u'video': u'video1', u'mode': u'mp4'}
{u'type': u'html5', u'video': u'video2', u'mode': u'webm'}
]
`type` is youtube or html5
`video` is html5 or youtube video_id
`mode` is youtube, ,p4 or webm
Returns transcripts_presence dict::
html5_local: list of html5 ids, if subtitles exist locally for them;
is_youtube_mode: bool, if we have youtube_id, and as youtube mode is of higher priority, reflect this with flag;
youtube_local: bool, if youtube transcripts exist locally;
youtube_server: bool, if youtube transcripts exist on server;
youtube_diff: bool, if youtube transcripts exist on youtube server, and are different from local youtube ones;
current_item_subs: string, value of item.sub field;
status: string, 'Error' or 'Success';
subs: string, new value of item.sub field, that should be set in module;
command: string, action to front-end what to do and what to show to user.
"""
transcripts_presence = {
'html5_local': [],
'html5_equal': False,
'is_youtube_mode': False,
'youtube_local': False,
'youtube_server': False,
'youtube_diff': True,
'current_item_subs': None,
'status': 'Error',
}
try:
__, videos, item = validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(transcripts_presence, e.message)
transcripts_presence['status'] = 'Success'
filename = 'subs_{0}.srt.sjson'.format(item.sub)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
try:
local_transcripts = contentstore().find(content_location).data
transcripts_presence['current_item_subs'] = item.sub
except NotFoundError:
pass
# Check for youtube transcripts presence
youtube_id = videos.get('youtube', None)
if youtube_id:
transcripts_presence['is_youtube_mode'] = True
# youtube local
filename = 'subs_{0}.srt.sjson'.format(youtube_id)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
try:
local_transcripts = contentstore().find(content_location).data
transcripts_presence['youtube_local'] = True
except NotFoundError:
log.debug("Can't find transcripts in storage for youtube id: %s", youtube_id)
# youtube server
youtube_api = copy.deepcopy(settings.YOUTUBE_API)
youtube_api['params']['v'] = youtube_id
youtube_response = requests.get(youtube_api['url'], params=youtube_api['params'])
if youtube_response.status_code == 200 and youtube_response.text:
transcripts_presence['youtube_server'] = True
#check youtube local and server transcripts for equality
if transcripts_presence['youtube_server'] and transcripts_presence['youtube_local']:
try:
youtube_server_subs = get_transcripts_from_youtube(youtube_id)
if json.loads(local_transcripts) == youtube_server_subs: # check transcripts for equality
transcripts_presence['youtube_diff'] = False
except GetTranscriptsFromYouTubeException:
pass
# Check for html5 local transcripts presence
html5_subs = []
for html5_id in videos['html5']:
filename = 'subs_{0}.srt.sjson'.format(html5_id)
content_location = StaticContent.compute_location(
item.location.org, item.location.course, filename
)
try:
html5_subs.append(contentstore().find(content_location).data)
transcripts_presence['html5_local'].append(html5_id)
except NotFoundError:
log.debug("Can't find transcripts in storage for non-youtube video_id: %s", html5_id)
if len(html5_subs) == 2: # check html5 transcripts for equality
transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1])
command, subs_to_use = transcripts_logic(transcripts_presence, videos)
transcripts_presence.update({
'command': command,
'subs': subs_to_use,
})
return JsonResponse(transcripts_presence)
def transcripts_logic(transcripts_presence, videos):
"""
By `transcripts_presence` content, figure what show to user:
returns: `command` and `subs`.
`command`: string, action to front-end what to do and what show to user.
`subs`: string, new value of item.sub field, that should be set in module.
`command` is one of::
replace: replace local youtube subtitles with server one's
found: subtitles are found
import: import subtitles from youtube server
choose: choose one from two html5 subtitles
not found: subtitles are not found
"""
command = None
# new value of item.sub field, that should be set in module.
subs = ''
# youtube transcripts are of high priority than html5 by design
if (
transcripts_presence['youtube_diff'] and
transcripts_presence['youtube_local'] and
transcripts_presence['youtube_server']): # youtube server and local exist
command = 'replace'
subs = videos['youtube']
elif transcripts_presence['youtube_local']: # only youtube local exist
command = 'found'
subs = videos['youtube']
elif transcripts_presence['youtube_server']: # only youtube server exist
command = 'import'
else: # html5 part
if transcripts_presence['html5_local']: # can be 1 or 2 html5 videos
if len(transcripts_presence['html5_local']) == 1 or transcripts_presence['html5_equal']:
command = 'found'
subs = transcripts_presence['html5_local'][0]
else:
command = 'choose'
subs = transcripts_presence['html5_local'][0]
else: # html5 source have no subtitles
# check if item sub has subtitles
if transcripts_presence['current_item_subs'] and not transcripts_presence['is_youtube_mode']:
log.debug("Command is use existing %s subs", transcripts_presence['current_item_subs'])
command = 'use_existing'
else:
command = 'not_found'
log.debug(
"Resulted command: %s, current transcripts: %s, youtube mode: %s",
command,
transcripts_presence['current_item_subs'],
transcripts_presence['is_youtube_mode']
)
return command, subs
@login_required
def choose_transcripts(request):
"""
Replaces html5 subtitles, presented for both html5 sources, with chosen one.
Code removes rejected html5 subtitles and updates sub attribute with chosen html5_id.
It does nothing with youtube id's.
Returns: status `Success` and resulted item.sub value or status `Error` and HTTP 400.
"""
response = {
'status': 'Error',
'subs': '',
}
try:
data, videos, item = validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, e.message)
html5_id = data.get('html5_id') # html5_id chosen by user
# find rejected html5_id and remove appropriate subs from store
html5_id_to_remove = [x for x in videos['html5'] if x != html5_id]
if html5_id_to_remove:
remove_subs_from_store(html5_id_to_remove, item)
if item.sub != html5_id: # update sub value
item.sub = html5_id
save_module(item)
response = {'status': 'Success', 'subs': item.sub}
return JsonResponse(response)
@login_required
def replace_transcripts(request):
"""
Replaces all transcripts with youtube ones.
Downloads subtitles from youtube and replaces all transcripts with downloaded ones.
Returns: status `Success` and resulted item.sub value or status `Error` and HTTP 400.
"""
response = {'status': 'Error', 'subs': ''}
try:
__, videos, item = validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, e.message)
youtube_id = videos['youtube']
if not youtube_id:
return error_response(response, 'YouTube id {} is not presented in request data.'.format(youtube_id))
try:
download_youtube_subs({1.0: youtube_id}, item)
except GetTranscriptsFromYouTubeException as e:
return error_response(response, e.message)
item.sub = youtube_id
save_module(item)
response = {'status': 'Success', 'subs': item.sub}
return JsonResponse(response)
def validate_transcripts_data(request):
"""
Validates, that request contains all proper data for transcripts processing.
Returns tuple of 3 elements::
data: dict, loaded json from request,
videos: parsed `data` to useful format,
item: video item from storage
Raises `TranscriptsRequestValidationException` if validation is unsuccessful
or `PermissionDenied` if user has no access.
"""
data = json.loads(request.GET.get('data', '{}'))
if not data:
raise TranscriptsRequestValidationException('Incoming video data is empty.')
item_location = data.get('id')
# This is placed before has_access() to validate item_location,
# because has_access() raises InvalidLocationError if location is invalid.
try:
item = modulestore().get_item(item_location)
except (ItemNotFoundError, InvalidLocationError):
raise TranscriptsRequestValidationException("Can't find item by location.")
# Check permissions for this user within this course.
if not has_access(request.user, item_location):
raise PermissionDenied()
if item.category != 'video':
raise TranscriptsRequestValidationException('Transcripts are supported only for "video" modules.')
# parse data form request.GET.['data']['video'] to useful format
videos = {'youtube': '', 'html5': {}}
for video_data in data.get('videos'):
if video_data['type'] == 'youtube':
videos['youtube'] = video_data['video']
else: # do not add same html5 videos
if videos['html5'].get('video') != video_data['video']:
videos['html5'][video_data['video']] = video_data['mode']
return data, videos, item
@login_required
def rename_transcripts(request):
"""
Create copies of existing subtitles with new names of HTML5 sources.
Old subtitles are not deleted now, because we do not have rollback functionality.
If succeed, Item.sub will be chosen randomly from html5 video sources provided by front-end.
"""
response = {'status': 'Error', 'subs': ''}
try:
__, videos, item = validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, e.message)
old_name = item.sub
for new_name in videos['html5'].keys(): # copy subtitles for every HTML5 source
try:
# updates item.sub with new_name if it is successful.
copy_or_rename_transcript(new_name, old_name, item)
except NotFoundError:
# subtitles file `item.sub` is not presented in the system. Nothing to copy or rename.
error_response(response, "Can't find transcripts in storage for {}".format(old_name))
response['status'] = 'Success'
response['subs'] = item.sub # item.sub has been changed, it is not equal to old_name.
log.debug("Updated item.sub to %s", item.sub)
return JsonResponse(response)
@login_required
def save_transcripts(request):
"""
Saves video module with updated values of fields.
Returns: status `Success` or status `Error` and HTTP 400.
"""
response = {'status': 'Error'}
data = json.loads(request.GET.get('data', '{}'))
if not data:
return error_response(response, 'Incoming video data is empty.')
item_location = data.get('id')
try:
item = modulestore().get_item(item_location)
except (ItemNotFoundError, InvalidLocationError):
return error_response(response, "Can't find item by location.")
metadata = data.get('metadata')
if metadata is not None:
new_sub = metadata.get('sub')
for metadata_key, value in metadata.items():
setattr(item, metadata_key, value)
save_module(item) # item becomes updated with new values
if new_sub:
manage_video_subtitles_save(None, item)
else:
# If `new_sub` is empty, it means that user explicitly does not want to use
# transcripts for current video ids and we remove all transcripts from storage.
current_subs = data.get('current_subs')
if current_subs is not None:
for sub in current_subs:
remove_subs_from_store(sub, item)
response['status'] = 'Success'
return JsonResponse(response)
......@@ -114,3 +114,16 @@ if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
LETTUCE_SERVER_PORT = choice(PORTS)
else:
LETTUCE_SERVER_PORT = randint(1024, 65535)
# Set up Video information so that the cms will send
# requests to a mock Youtube server running locally
if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
VIDEO_PORT = choice(PORTS)
PORTS.remove(VIDEO_PORT)
else:
VIDEO_PORT = randint(1024, 65535)
# for testing Youtube
YOUTUBE_API['url'] = "http://127.0.0.1:" + str(VIDEO_PORT) + '/test_transcripts_youtube/'
......@@ -423,3 +423,9 @@ TRACKING_BACKENDS = {
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
TRACKING_ENABLED = True
# Current youtube api for requesting transcripts.
# for example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
YOUTUBE_API = {
'url': "http://video.google.com/timedtext",
'params': {'lang': 'en', 'v': 'set_youtube_id_of_11_symbols_here'}
}
......@@ -8,6 +8,7 @@ requirejs.config({
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
"jquery.ajaxQueue": "xmodule_js/common_static/js/vendor/jquery.ajaxQueue",
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
......@@ -30,6 +31,7 @@ requirejs.config({
"utility": "xmodule_js/common_static/js/src/utility",
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
"squire": "xmodule_js/common_static/js/vendor/Squire",
"jasmine-jquery": "xmodule_js/common_static/js/vendor/jasmine-jquery",
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
"draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd",
......@@ -68,6 +70,10 @@ requirejs.config({
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
},
"jquery.ajaxQueue": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxQueue"
},
"jquery.scrollTo": {
deps: ["jquery"],
exports: "jQuery.fn.scrollTo"
......@@ -136,6 +142,9 @@ requirejs.config({
"sinon": {
exports: "sinon"
},
"jasmine-jquery": {
deps: ["jasmine"]
},
"jasmine-stealth": {
deps: ["jasmine"]
},
......@@ -178,6 +187,10 @@ define([
"coffee/spec/views/overview_spec",
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
"js_spec/transcripts/utils_spec", "js_spec/transcripts/editor_spec",
"js_spec/transcripts/videolist_spec", "js_spec/transcripts/message_manager_spec",
"js_spec/transcripts/file_uploader_spec"
# these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js
# "coffee/spec/views/assets_spec"
......
......@@ -37,6 +37,8 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
collection: new MetadataCollection(models)
})
@module.setMetadataEditor(@metadataEditor) if @module.setMetadataEditor
# Need to update set "active" class on data editor if there is one.
# If we are only showing settings, hide the data editor controls and update settings accordingly.
if @hasDataEditor()
......
......@@ -107,6 +107,7 @@ define(["backbone"], function(Backbone) {
Metadata.FLOAT_TYPE = "Float";
Metadata.GENERIC_TYPE = "Generic";
Metadata.LIST_TYPE = "List";
Metadata.VIDEO_LIST_TYPE = "VideoList";
return Metadata;
});
define(["backbone", "underscore"], function(Backbone, _) {
var AbstractEditor = Backbone.View.extend({
// Model is MetadataModel
initialize : function() {
var self = this;
var templateName = _.result(this, 'templateName');
// Backbone model cid is only unique within the collection.
this.uniqueId = _.uniqueId(templateName + "_");
var tpl = document.getElementById(templateName).text;
if(!tpl) {
console.error("Couldn't load template: " + templateName);
}
this.template = _.template(tpl);
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
},
/**
* The ID/name of the template. Subclasses must override this.
*/
templateName: '',
/**
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
*/
getValueFromEditor : function () {},
/**
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
*/
setValueInEditor : function (value) {},
/**
* Sets the value in the model, using the value currently displayed in the view.
*/
updateModel: function () {
this.model.setValue(this.getValueFromEditor());
},
/**
* Clears the value currently set in the model (reverting to the default).
*/
clear: function () {
this.model.clear();
},
/**
* Shows the clear button, if it is not already showing.
*/
showClearButton: function() {
if (!this.$el.hasClass('is-set')) {
this.$el.addClass('is-set');
this.getClearButton().removeClass('inactive');
this.getClearButton().addClass('active');
}
},
/**
* Returns the clear button.
*/
getClearButton: function () {
return this.$el.find('.setting-clear');
},
/**
* Renders the editor, updating the value displayed in the view, as well as the state of
* the clear button.
*/
render: function () {
if (!this.template) return;
this.setValueInEditor(this.model.getDisplayValue());
if (this.model.isExplicitlySet()) {
this.showClearButton();
}
else {
this.$el.removeClass('is-set');
this.getClearButton().addClass('inactive');
this.getClearButton().removeClass('active');
}
return this;
}
});
return AbstractEditor;
});
define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, MetadataModel) {
define(
[
"backbone", "underscore", "js/models/metadata", "js/views/abstract_editor",
"js/views/transcripts/metadata_videolist"
],
function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
var Metadata = {};
Metadata.Editor = Backbone.View.extend({
......@@ -32,6 +37,9 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
else if(model.getType() === MetadataModel.LIST_TYPE) {
new Metadata.List(data);
}
else if(model.getType() === MetadataModel.VIDEO_LIST_TYPE) {
new VideoList(data);
}
else {
// Everything else is treated as GENERIC_TYPE, which uses String editor.
new Metadata.String(data);
......@@ -74,95 +82,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.AbstractEditor = Backbone.View.extend({
// Model is MetadataModel
initialize : function() {
var self = this;
var templateName = _.result(this, 'templateName');
// Backbone model cid is only unique within the collection.
this.uniqueId = _.uniqueId(templateName + "_");
var tpl = document.getElementById(templateName).text;
if(!tpl) {
console.error("Couldn't load template: " + templateName);
}
this.template = _.template(tpl);
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
},
/**
* The ID/name of the template. Subclasses must override this.
*/
templateName: '',
/**
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
*/
getValueFromEditor : function () {},
/**
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
*/
setValueInEditor : function (value) {},
/**
* Sets the value in the model, using the value currently displayed in the view.
*/
updateModel: function () {
this.model.setValue(this.getValueFromEditor());
},
/**
* Clears the value currently set in the model (reverting to the default).
*/
clear: function () {
this.model.clear();
},
/**
* Shows the clear button, if it is not already showing.
*/
showClearButton: function() {
if (!this.$el.hasClass('is-set')) {
this.$el.addClass('is-set');
this.getClearButton().removeClass('inactive');
this.getClearButton().addClass('active');
}
},
/**
* Returns the clear button.
*/
getClearButton: function () {
return this.$el.find('.setting-clear');
},
/**
* Renders the editor, updating the value displayed in the view, as well as the state of
* the clear button.
*/
render: function () {
if (!this.template) return;
this.setValueInEditor(this.model.getDisplayValue());
if (this.model.isExplicitlySet()) {
this.showClearButton();
}
else {
this.$el.removeClass('is-set');
this.getClearButton().addClass('inactive');
this.getClearButton().removeClass('active');
}
return this;
}
});
Metadata.String = Metadata.AbstractEditor.extend({
Metadata.String = AbstractEditor.extend({
events : {
"change input" : "updateModel",
......@@ -181,7 +101,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.Number = Metadata.AbstractEditor.extend({
Metadata.Number = AbstractEditor.extend({
events : {
"change input" : "updateModel",
......@@ -191,7 +111,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
},
render: function () {
Metadata.AbstractEditor.prototype.render.apply(this);
AbstractEditor.prototype.render.apply(this);
if (!this.initialized) {
var numToString = function (val) {
return val.toFixed(4);
......@@ -279,7 +199,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
});
Metadata.Option = Metadata.AbstractEditor.extend({
Metadata.Option = AbstractEditor.extend({
events : {
"change select" : "updateModel",
......@@ -316,7 +236,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.List = Metadata.AbstractEditor.extend({
Metadata.List = AbstractEditor.extend({
events : {
"click .setting-clear" : "clear",
......@@ -355,7 +275,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
// We don't call updateModel here since it's bound to the
// change event
var list = this.model.get('value') || [];
this.setValueInEditor(list.concat(['']))
this.setValueInEditor(list.concat(['']));
this.$el.find('.create-setting').addClass('is-disabled');
},
......
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils",
"js/views/metadata", "js/collections/metadata",
"js/views/transcripts/metadata_videolist"
],
function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
var Editor = Backbone.View.extend({
tagName: 'div',
initialize: function () {
// prepare data for MetadataView.Editor
var metadata = this.$el.data('metadata'),
models = this.toModels(metadata);
this.collection = new MetadataCollection(models);
// initialize MetadataView.Editor
this.metadataEditor = new MetadataView.Editor({
el: this.$el,
collection: this.collection
});
},
/**
* @function
*
* Convert JSON metadata to List of models
*
* @param {object|string} data Data containing information about metadata
* setting editors.
*
* @returns {array} Processed objects list.
*
* @example:
* var metadata = {
* field_1: {.1.},
* field_2: {.2.}
* };
*
* toModels(metadata) // => [{.1.}, {.2.}]
*
*/
toModels: function (data) {
var metadata = (_.isString(data)) ? JSON.parse(data) : data,
models = [];
for (var model in metadata) {
if (metadata.hasOwnProperty(model)) {
models.push(metadata[model]);
}
}
return models;
},
/**
* @function
*
* Synchronize data from `Advanced` tab of Video player with data in
* `Basic` tab. It is called when we go from `Advanced` to `Basic` tab.
*
* @param {object} metadataCollection Collection containing all models
* with information about metadata
* setting editors in `Advanced` tab.
*
*/
syncBasicTab: function (metadataCollection, metadataView) {
var result = [],
getField = Utils.getField,
component_id = this.$el.closest('.component').data('id'),
subs = getField(metadataCollection, 'sub'),
values = {},
videoUrl, metadata, modifiedValues;
// If metadataCollection is not passed, just exit.
if (!metadataCollection || !metadataView) {
return false;
}
// Get field that should be synchronized with `Advanced` tab fields.
videoUrl = getField(this.collection, 'video_url');
modifiedValues = metadataView.getModifiedMetadataValues();
var isSubsModified = (function (values) {
var isSubsChanged = subs.hasChanged("value");
return Boolean(isSubsChanged && _.isString(values.sub));
}(modifiedValues));
// When we change value of `sub` field in the `Advanced`,
// we update data on backend. That provides possibility to remove
// transcripts.
if (isSubsModified) {
metadata = $.extend(true, {}, modifiedValues);
// Save module state
Utils.command('save', component_id, null, {
metadata: metadata,
current_subs: _.pluck(
Utils.getVideoList(videoUrl.getDisplayValue()),
'video'
)
});
}
// Get values from `Advanced` tab fields (`html5_sources`,
// `youtube_id_1_0`) that should be synchronized.
values.html5Sources = getField(metadataCollection, 'html5_sources')
.getDisplayValue();
values.youtube = getField(metadataCollection, 'youtube_id_1_0')
.getDisplayValue();
// The length of youtube video_id should be 11 characters.
if (values.youtube.length === 11) {
// Just video id is retrieved from `Advanced` tab field and
// it should be transformed to appropriate format.
// OEoXaMPEzfM => http://youtu.be/OEoXaMPEzfM
values.youtube = Utils.getYoutubeLink(values.youtube);
} else {
values.youtube = '';
}
result.push(values.youtube);
result = result.concat(values.html5Sources);
videoUrl.setValue(result);
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(metadataCollection, this.collection);
if (isSubsModified){
// When `sub` field is changed, clean Storage to avoid overwriting.
Utils.Storage.remove('sub');
// Trigger `change` event manually if `video_url` model
// isn't changed.
if (!videoUrl.hasChanged()) {
videoUrl.trigger('change');
}
}
},
/**
* @function
*
* Synchronize data from `Basic` tab of Video player with data in
* `Advanced` tab. It is called when we go from `Basic` to `Advanced` tab.
*
* @param {object} metadataCollection Collection containing all models
* with information about metadata
* setting editors in `Advanced` tab.
*
*/
syncAdvancedTab: function (metadataCollection, metadataView) {
var getField = Utils.getField,
subsValue = Utils.Storage.get('sub'),
subs = getField(metadataCollection, 'sub'),
html5Sources, youtube, videoUrlValue, result;
// if metadataCollection is not passed, just exit.
if (!metadataCollection) {
return false;
}
// Get fields from `Advenced` tab (`html5_sources`, `youtube_id_1_0`)
// that should be synchronized.
html5Sources = getField(metadataCollection, 'html5_sources');
youtube = getField(metadataCollection, 'youtube_id_1_0');
// Get value from `Basic` tab `VideoUrl` field that should be
// synchronized.
videoUrlValue = getField(this.collection, 'video_url')
.getDisplayValue();
// Change list representation format to more convenient and group
// them by mode (`youtube`, `html5`).
// Before:
// [
// 'http://youtu.be/OEoXaMPEzfM',
// 'video_name.mp4',
// 'video_name.webm'
// ]
// After:
// {
// youtube: [{mode: `youtube`, type: `youtube`, ...}],
// html5: [
// {mode: `html5`, type: `mp4`, ...},
// {mode: `html5`, type: `webm`, ...}
// ]
// }
result = _.groupBy(
videoUrlValue,
function (value) {
return Utils.parseLink(value).mode;
}
);
if (html5Sources) {
html5Sources.setValue(result.html5 || []);
}
if (youtube) {
if (result.youtube) {
result = Utils.parseLink(result.youtube[0]).video;
} else {
result = '';
}
youtube.setValue(result);
}
// If Utils.Storage contain some subtitles, update them.
if (_.isString(subsValue)) {
subs.setValue(subsValue);
// After updating should be removed, because it might overwrite
// subtitles added by user manually.
Utils.Storage.remove('sub');
}
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(this.collection, metadataCollection);
}
});
return Editor;
});
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils"
],
function($, Backbone, _, Utils) {
var FileUploader = Backbone.View.extend({
invisibleClass: 'is-invisible',
// Pre-defined list of supported file formats.
validFileExtensions: ['srt'],
events: {
'change .file-input': 'changeHandler',
'click .setting-upload': 'clickHandler'
},
uploadTpl: '#file-upload',
initialize: function () {
_.bindAll(this);
this.file = false;
this.render();
},
render: function () {
var tpl = $(this.uploadTpl).text(),
tplContainer = this.$el.find('.transcripts-file-uploader'),
videoList = this.options.videoListObject.getVideoObjectsList();
if (tplContainer.length) {
if (!tpl) {
console.error('Couldn\'t load Transcripts File Upload template');
return;
}
this.template = _.template(tpl);
tplContainer.html(this.template({
ext: this.validFileExtensions,
component_id: this.options.component_id,
video_list: videoList
}));
this.$form = this.$el.find('.file-chooser');
this.$input = this.$form.find('.file-input');
this.$progress = this.$el.find('.progress-fill');
}
},
/**
* @function
*
* Uploads file to the server. Get file from the `file` property.
*
*/
upload: function () {
if (!this.file) {
return;
}
this.$form.ajaxSubmit({
beforeSend: this.xhrResetProgressBar,
uploadProgress: this.xhrProgressHandler,
complete: this.xhrCompleteHandler
});
},
/**
* @function
*
* Handle click event on `upload` button.
*
* @param {object} event Event object.
*
*/
clickHandler: function (event) {
event.preventDefault();
this.$input
.val(null)
// Show system upload window
.trigger('click');
},
/**
* @function
*
* Handle change event.
*
* @param {object} event Event object.
*
*/
changeHandler: function (event) {
event.preventDefault();
this.options.messenger.hideError();
this.file = this.$input.get(0).files[0];
// if file has valid file extension, than upload file.
// Otherwise, show error message.
if (this.checkExtValidity(this.file)) {
this.upload();
} else {
this.options.messenger
.showError('Please select a file in .srt format.');
}
},
/**
* @function
*
* Checks that file has supported extension.
*
* @param {object} file Object with information about file.
*
* @returns {boolean} Indicate that file has supported or unsupported
* extension.
*
*/
checkExtValidity: function (file) {
if (!file.name) {
return void(0);
}
var fileExtension = file.name
.split('.')
.pop()
.toLowerCase();
if ($.inArray(fileExtension, this.validFileExtensions) !== -1) {
return true;
}
return false;
},
/**
* @function
*
* Resets progress bar.
*
*/
xhrResetProgressBar: function () {
var percentVal = '0%';
this.$progress
.width(percentVal)
.html(percentVal)
.removeClass(this.invisibleClass);
},
/**
* @function
*
* Callback function to be invoked with upload progress information
* (if supported by the browser).
*
* @param {object} event Event object.
*
* @param {integer} position Amount of transmitted bytes.
* *
* @param {integer} total Total size of file.
* *
* @param {integer} percentComplete Object with information about file.
*
*/
xhrProgressHandler: function (event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
this.$progress
.width(percentVal)
.html(percentVal);
},
/**
* @function
*
* Handle complete uploading.
*
*/
xhrCompleteHandler: function (xhr) {
var resp = JSON.parse(xhr.responseText),
err = resp.status || 'Error: Uploading failed.',
sub = resp.subs;
this.$progress
.addClass(this.invisibleClass);
if (xhr.status === 200) {
this.options.messenger.render('uploaded', resp);
Utils.Storage.set('sub', sub);
} else {
this.options.messenger.showError(err);
}
}
});
return FileUploader;
});
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
"gettext"
],
function($, Backbone, _, Utils, FileUploader, gettext) {
var MessageManager = Backbone.View.extend({
tagName: 'div',
elClass: '.wrapper-transcripts-message',
invisibleClass: 'is-invisible',
events: {
'click .setting-import': 'importHandler',
'click .setting-replace': 'replaceHandler',
'click .setting-choose': 'chooseHandler',
'click .setting-use-existing': 'useExistingHandler'
},
// Pre-defined dict with anchors to status templates.
templates: {
not_found: '#transcripts-not-found',
found: '#transcripts-found',
import: '#transcripts-import',
replace: '#transcripts-replace',
uploaded: '#transcripts-uploaded',
use_existing: '#transcripts-use-existing',
choose: '#transcripts-choose'
},
initialize: function () {
_.bindAll(this);
this.component_id = this.$el.closest('.component').data('id');
this.fileUploader = new FileUploader({
el: this.$el,
messenger: this,
component_id: this.component_id,
videoListObject: this.options.parent
});
},
render: function (template_id, params) {
var tplHtml = $(this.templates[template_id]).text(),
videoList = this.options.parent.getVideoObjectsList(),
// Change list representation format to more convenient and group
// them by video property.
// Before:
// [
// {mode: `html5`, type: `mp4`, video: `video_name_1`},
// {mode: `html5`, type: `webm`, video: `video_name_2`}
// ]
// After:
// {
// `video_name_1`: [{mode: `html5`, type: `webm`, ...}],
// `video_name_2`: [{mode: `html5`, type: `mp4`, ...}]
// }
groupedList = _.groupBy(
videoList,
function (value) {
return value.video;
}
),
html5List = (params) ? params.html5_local : [],
template;
if (!tplHtml) {
console.error('Couldn\'t load Transcripts status template');
return;
}
template = _.template(tplHtml);
this.$el.find('.transcripts-status')
.removeClass('is-invisible')
.find(this.elClass).html(template({
component_id: encodeURIComponent(this.component_id),
html5_list: html5List,
grouped_list: groupedList,
subs_id: (params) ? params.subs: ''
}));
this.fileUploader.render();
return this;
},
/**
* @function
*
* Shows error message.
*
* @param {string} err Error message that will be shown
*
* @param {boolean} hideButtons Hide buttons
*
*/
showError: function (err, hideButtons) {
var $error = this.$el.find('.transcripts-error-message');
if (err) {
// Hide any other error messages.
this.hideError();
$error
.html(gettext(err))
.removeClass(this.invisibleClass);
if (hideButtons) {
this.$el.find('.wrapper-transcripts-buttons')
.addClass(this.invisibleClass);
}
}
},
/**
* @function
*
* Hides error message.
*
*/
hideError: function () {
this.$el.find('.transcripts-error-message')
.addClass(this.invisibleClass);
this.$el.find('.wrapper-transcripts-buttons')
.removeClass(this.invisibleClass);
},
/**
* @function
*
* Handle import button.
*
* @params {object} event Event object.
*
*/
importHandler: function (event) {
event.preventDefault();
this.processCommand('replace', 'Error: Import failed.');
},
/**
* @function
*
* Handle replace button.
*
* @params {object} event Event object.
*
*/
replaceHandler: function (event) {
event.preventDefault();
this.processCommand('replace', 'Error: Replacing failed.');
},
/**
* @function
*
* Handle choose buttons.
*
* @params {object} event Event object.
*
*/
chooseHandler: function (event) {
event.preventDefault();
var videoId = $(event.currentTarget).data('video-id');
this.processCommand('choose', 'Error: Choosing failed.', videoId);
},
/**
* @function
*
* Handle `use existing` button.
*
* @params {object} event Event object.
*
*/
useExistingHandler: function (event) {
event.preventDefault();
this.processCommand('rename', 'Error: Choosing failed.');
},
/**
* @function
*
* Decorator for `command` function in the Utils.
*
* @params {string} action Action that will be invoked on server. Is a part
* of url.
*
* @params {string} errorMessage Error massage that will be shown if any
* connection error occurs
*
* @params {string} videoId Extra parameter that sometimes should be sent
* to the server
*
*/
processCommand: function (action, errorMessage, videoId) {
var self = this,
component_id = this.component_id,
videoList = this.options.parent.getVideoObjectsList(),
extraParam, xhr;
if (videoId) {
extraParam = { html5_id: videoId };
}
xhr = Utils.command(action, component_id, videoList, extraParam)
.done(function (resp) {
var sub = resp.subs;
self.render('found', resp);
Utils.Storage.set('sub', sub);
})
.fail(function (resp) {
var message = resp.status || errorMessage;
self.showError(message);
});
return xhr;
}
});
return MessageManager;
});
define(
[
"jquery", "backbone", "underscore", "js/views/abstract_editor",
"js/views/transcripts/utils", "js/views/transcripts/message_manager",
"js/views/metadata"
],
function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
VideoList = AbstractEditor.extend({
// Time that we wait since the last time user typed.
inputDelay: 300,
events : {
'click .setting-clear' : 'clear',
'keypress .setting-input' : 'showClearButton',
'click .collapse-setting' : 'toggleExtraVideosBar'
},
templateName: 'metadata-videolist-entry',
// Pre-defined dict of placeholders: "videoType - placeholder" pairs.
placeholders: {
'webm': '.webm',
'mp4': 'http://somesite.com/video.mp4',
'youtube': 'http://youtube.com/'
},
initialize: function () {
// Initialize MessageManager that is responsible for
// status messages and errors.
this.messenger = new MessageManager({
el: this.$el,
parent: this
});
// Call it after MessageManager. This is because
// MessageManager is used in `render` method that
// is called in `AbstractEditor.prototype.initialize`.
AbstractEditor.prototype.initialize
.apply(this, arguments);
this.$el.on(
'input', 'input',
_.debounce(_.bind(this.inputHandler, this), this.inputDelay)
);
this.component_id = this.$el.closest('.component').data('id');
},
render: function () {
// Call inherited `render` method.
AbstractEditor.prototype.render
.apply(this, arguments);
var self = this,
component_id = this.$el.closest('.component').data('id'),
videoList = this.getVideoObjectsList(),
showServerError = function (response) {
var errorMessage = response.status || 'Error: Connection with server failed.';
self.messenger
.render('not_found')
.showError(
errorMessage,
true // hide buttons
);
};
this.$extraVideosBar = this.$el.find('.videolist-extra-videos');
if (videoList.length === 0) {
this.messenger
.render('not_found')
.showError(
'No sources',
true // hide buttons
);
return void(0);
}
// Check current state of Timed Transcripts.
Utils.command('check', component_id, videoList)
.done(function (resp) {
var params = resp,
len = videoList.length,
mode = (len === 1) ? videoList[0].mode : false;
// If there are more than 1 video or just html5 source is
// passed, video sources box should expand
if (len > 1 || mode === 'html5') {
self.openExtraVideosBar();
} else {
self.closeExtraVideosBar();
}
self.messenger.render(resp.command, params);
self.checkIsUniqVideoTypes();
// Synchronize transcripts field in the `Advanced` tab.
Utils.Storage.set('sub', resp.subs);
})
.fail(showServerError);
},
/**
* @function
*
* Returns the values currently displayed in the editor/view.
*
* @returns {array} List of non-empty values.
*
*/
getValueFromEditor: function () {
return _.map(
this.$el.find('.input'),
function (ele) {
return ele.value.trim();
}
).filter(_.identity);
},
/**
* @function
*
* Returns list of objects with information about the values currently
* displayed in the editor/view.
*
* @returns {array} List of objects.
*
* @examples
* this.getValueFromEditor(); // =>
* [
* 'http://youtu.be/OEoXaMPEzfM',
* 'video_name.mp4',
* 'video_name.webm'
* ]
*
* this.getVideoObjectsList(); // =>
* [
* {mode: `youtube`, type: `youtube`, ...},
* {mode: `html5`, type: `mp4`, ...},
* {mode: `html5`, type: `webm`, ...}
* ]
*
*/
getVideoObjectsList: function () {
var links = this.getValueFromEditor();
return Utils.getVideoList(links);
},
/**
* @function
*
* Sets the values currently displayed in the editor/view.
*
* @params {array} value List of values.
*
*/
setValueInEditor: function (value) {
var parseLink = Utils.parseLink,
list = this.$el.find('.input'),
val = value.filter(_.identity),
placeholders = this.getPlaceholders(val);
list.each(function (index) {
$(this)
.val(val[index] || null)
.attr('placeholder', placeholders[index]);
});
},
/**
* @function
*
* Returns the placeholders for the values currently displayed in the
* editor/view.
*
* @returns {array} List of placeholders.
*
*/
getPlaceholders: function (value) {
var parseLink = Utils.parseLink,
placeholders = _.clone(this.placeholders);
// Returned list should have the same size as a count of editors/views.
return _.map(
this.$el.find('.input'),
function (element, index) {
var linkInfo = parseLink(value[index]),
type = (linkInfo) ? linkInfo.type : null,
label;
// If placeholder for current video type exist, retrieve it
// and remove from cloned list.
// Otherwise, we use the remaining placeholders.
if (placeholders[type]) {
label = placeholders[type];
delete placeholders[type];
} else {
if ( !($.isArray(placeholders)) ) {
placeholders = _.values(placeholders);
}
label = placeholders.pop();
}
return label;
}
);
},
/**
* @function
*
* Opens video sources box.
*
* @params {object} event Event object.
*
*/
openExtraVideosBar: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
this.$extraVideosBar.addClass('is-visible');
},
/**
* @function
*
* Closes video sources box.
*
* @params {object} event Event object.
*
*/
closeExtraVideosBar: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
this.$extraVideosBar.removeClass('is-visible');
},
/**
* @function
*
* Toggles video sources box.
*
* @params {object} event Event object.
*
*/
toggleExtraVideosBar: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
if (this.$extraVideosBar.hasClass('is-visible')) {
this.closeExtraVideosBar.apply(this, arguments);
} else {
this.openExtraVideosBar.apply(this, arguments);
}
},
/**
* @function
*
* Handle `input` event.
*
* @params {object} event Event object.
*
*/
inputHandler: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
var $el = $(event.currentTarget),
$inputs = this.$el.find('.input'),
entry = $el.val(),
data = Utils.parseLink(entry),
isNotEmpty = Boolean(entry);
// Empty value should not be validated
if (this.checkValidity(data, isNotEmpty)) {
var fieldsValue = this.getValueFromEditor(),
modelValue = this.model.getValue();
if (modelValue) {
// Remove empty values
modelValue = modelValue.filter(_.identity);
}
// When some correct value is adjusted (model is changed),
// then field changes to incorrect value (no changes to model),
// then back to previous correct value (that value is already
// in model). In this case Backbone doesn't trigger 'change'
// event on model. That's why render method will not be invoked
// and we should hide error here.
if (_.isEqual(fieldsValue, modelValue)) {
this.messenger.hideError();
} else {
this.updateModel();
}
// Enable inputs.
$inputs
.prop('disabled', false)
.removeClass('is-disabled');
} else {
// If any error occurs, disable all inputs except the current.
// User cannot change other inputs before putting valid value in
// the current input.
$inputs
.not($el)
.prop('disabled', true)
.addClass('is-disabled');
// If error occurs in the main video input, just close video
// sources box.
if ($el.hasClass('videolist-url')) {
this.closeExtraVideosBar();
}
}
},
/**
* @function
*
* Checks the values currently displayed in the editor/view have unique
* types (mp4 | webm | youtube).
*
* @param {object} videoList List of objects with information about the
* values currently displayed in the editor/view
*
* @returns {boolean} Boolean value that indicate if video types are unique.
*
*/
isUniqVideoTypes: function (videoList) {
// Extract a list of "type" property values.
var arr = _.pluck(videoList, 'type'), // => ex: ['youtube', 'mp4', 'mp4']
// Produces a duplicate-free version of the array.
uniqArr = _.uniq(arr); // => ex: ['youtube', 'mp4']
return arr.length === uniqArr.length;
},
/**
* @function
*
* Shows error message if the values currently displayed in the
* editor/view have duplicate types.
*
* @param {object} list List of objects with information about the
* values currently displayed in the editor/view
*
* @returns {boolean} Boolean value that indicate if video types are unique.
*
*/
checkIsUniqVideoTypes: function (list) {
var videoList = list || this.getVideoObjectsList(),
isUnique = true;
if (!this.isUniqVideoTypes(videoList)) {
this.messenger
.showError('Link types should be unique.', true);
isUnique = false;
}
return isUnique;
},
/**
* @function
*
* Checks if the values currently displayed in the editor/view have
* valid values and show error messages.
*
* @param {object} data Objects with information about the value
* currently displayed in the editor/view
*
* @param {boolean} showErrorModeMessage Disable mode validation
*
* @returns {boolean} Boolean value that indicate if value is valid.
*
*/
checkValidity: function (data, showErrorModeMessage) {
var self = this,
videoList = this.getVideoObjectsList();
if (!this.checkIsUniqVideoTypes(videoList)) {
return false;
}
if (data.mode === 'incorrect' && showErrorModeMessage) {
this.messenger
.showError('Incorrect url format.', true);
return false;
}
return true;
}
});
return VideoList;
});
define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
var Utils = (function () {
var Storage = {};
/**
* @function
*
* Adds some data to the Storage object. If data with existent `data_id`
* is added, nothing happens.
*
* @param {string} data_id Unique identifier for the data.
* @param {any} data Data that should be stored.
*
* @returns {object} Object itself for chaining.
*/
Storage.set = function (data_id, data) {
Storage[data_id] = data;
return this;
};
/**
* @function
*
* Return data from the Storage object by identifier.
*
* @param {string} data_id Unique identifier of the data.
*
* @returns {any} Stored data.
*/
Storage.get= function (data_id) {
return Storage[data_id];
};
/**
* @function
*
* Deletes data from the Storage object by identifier.
*
* @param {string} data_id Unique identifier of the data.
*
* @returns {boolean} Boolean value that indicate if data is removed.
*/
Storage.remove = function (data_id) {
return (delete Storage[data_id]);
};
/**
* @function
*
* Returns model from collection by 'field_name' property.
*
* @param {object} collection The model (CMS.Models.Metadata) containing
* information about metadata setting editors.
* @param {string} field_name Name of field that should be found.
*
* @returns {
* object: when model exist,
* undefined: when model doesn't exist.
* }
*/
var _getField = function (collection, field_name) {
var model;
if (collection && field_name) {
model = collection.findWhere({
field_name: field_name
});
}
return model;
};
/**
* @function
*
* Parses Youtube link and return video id.
*
* These are the types of URLs supported:
* http://www.youtube.com/watch?v=OEoXaMPEzfM&feature=feedrec_grec_index
* http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/OEoXaMPEzfM
* http://www.youtube.com/v/OEoXaMPEzfM?fs=1&amp;hl=en_US&amp;rel=0
* http://www.youtube.com/watch?v=OEoXaMPEzfM#t=0m10s
* http://www.youtube.com/embed/OEoXaMPEzfM?rel=0
* http://www.youtube.com/watch?v=OEoXaMPEzfM
* http://youtu.be/OEoXaMPEzfM
*
* @param {string} url Url that should be parsed.
*
* @returns {
* string: Video Id,
* undefined: when url has incorrect format or argument is
* non-string, video id's length is not equal 11.
* }
*/
var _youtubeParser = (function () {
var cache = {};
return function (url) {
if (typeof url !== 'string') {
return void(0);
}
if (cache[url]) {
return cache[url];
}
var regExp = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/;
var match = url.match(regExp);
cache[url] = (match && match[1].length === 11) ? match[1] : void(0);
return cache[url];
};
}());
/**
* @function
*
* Parses links with html5 video sources in mp4 or webm formats.
*
* @param {string} url Url that should be parsed.
*
* @returns {
* object: Object with information about the video
* (file name, video type),
* undefined: when url has incorrect format or argument is
* non-string.
* }
*/
var _videoLinkParser = (function () {
var cache = {};
return function (url) {
if (typeof url !== 'string') {
return void(0);
}
if (cache[url]) {
return cache[url];
}
var link = document.createElement('a'),
match;
link.href = url;
match = link.pathname
.split('/')
.pop()
.match(/(.+)\.(mp4|webm)$/);
if (match) {
cache[url] = {
video: match[1],
type: match[2]
};
}
return cache[url];
};
}());
/**
* @function
*
* Facade function that parses html5 and youtube links.
*
* @param {string} url Url that should be parsed.
*
* @returns {
* object: Object with information about the video:
* {
* mode: "youtube|html5|incorrect",
* video: "file_name|youtube_id",
* type: "youtube|mp4|webm"
* },
* undefined: when argument is non-string.
* }
*/
var _linkParser = function (url) {
var result;
if (typeof url !== 'string') {
return void(0);
}
if (_youtubeParser(url)) {
result = {
mode: 'youtube',
video: _youtubeParser(url),
type: 'youtube'
};
} else if (_videoLinkParser(url)) {
result = $.extend({mode: 'html5'}, _videoLinkParser(url));
} else {
result = {
mode: 'incorrect'
};
}
return result;
};
/**
* @function
*
* Returns short-hand youtube url.
*
* @param {string} video_id Youtube Video Id that will be added to the link.
*
* @returns {string} Short-hand Youtube url.
*
* @example
* _getYoutubeLink('OEoXaMPEzfM'); => 'http://youtu.be/OEoXaMPEzfM'
*/
var _getYoutubeLink = function (video_id) {
return 'http://youtu.be/' + video_id;
};
/**
* @function
*
* Returns list of objects with information about the passed links.
*
* @param {array} links List of links that will be processed.
*
* @returns {array} List of objects.
*
* @examples
* var links = [
* 'http://youtu.be/OEoXaMPEzfM',
* 'video_name.mp4',
* 'video_name.webm'
* ]
*
* _getVideoList(links); // =>
* [
* {mode: `youtube`, type: `youtube`, ...},
* {mode: `html5`, type: `mp4`, ...},
* {mode: `html5`, type: `webm`, ...}
* ]
*
*/
var _getVideoList = function (links) {
if ($.isArray(links)) {
var arr = [],
data;
for (var i = 0, len = links.length; i < len; i += 1) {
data = _linkParser(links[i]);
if (data.mode !== 'incorrect') {
arr.push(data);
}
}
return arr;
}
};
/**
* @function
*
* Synchronizes 2 Backbone collections by 'field_name' property.
*
* @param {object} fromCollection Collection with which synchronization
* will happens.
* @param {object} toCollection Collection which will synchronized.
*
*/
var _syncCollections = function (fromCollection, toCollection) {
fromCollection.each(function (m) {
var model = toCollection.findWhere({
field_name: m.getFieldName()
});
if (model) {
model.setValue(m.getDisplayValue());
}
});
};
/**
* @function
*
* Sends Ajax requests in appropriate format.
*
* @param {string} action Action that will be invoked on server. Is a part
* of url.
* @param {string} component_id Id of component.
* @param {array} videoList List of object with information about inserted
* urls.
* @param {object} extraParams Extra parameters that can be send to the
* server
*
* @returns {object} XMLHttpRequest object. Using this object, we can attach
* callbacks to AJAX request events (for example on 'done', 'fail',
* etc.).
*/
var _command = (function () {
// We will store the XMLHttpRequest object that $.ajax() function
// returns, to abort an ongoing AJAX request (if necessary) upon
// subsequent invocations of _command() function.
//
// A new AJAX request will be made on each invocation of the
// _command() function.
var xhr = null;
return function (action, component_id, videoList, extraParams) {
var params, data;
console.log('[_command]: arguments = ', arguments);
if (extraParams) {
if ($.isPlainObject(extraParams)) {
params = extraParams;
} else {
params = {params: extraParams};
}
}
data = $.extend(
{ id: component_id },
{ videos: videoList },
params
);
xhr = $.ajaxQueue({
url: '/transcripts/' + action,
data: { data: JSON.stringify(data) },
notifyOnError: false,
type: 'get'
});
return xhr;
};
}());
return {
getField: _getField,
parseYoutubeLink: _youtubeParser,
parseHTML5Link: _videoLinkParser,
parseLink: _linkParser,
getYoutubeLink: _getYoutubeLink,
syncCollections: _syncCollections,
command: _command,
getVideoList: _getVideoList,
Storage: {
set: Storage.set,
get: Storage.get,
remove: Storage.remove
}
};
}());
return Utils;
});
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/editor",
"js/views/metadata", "js/models/metadata", "js/collections/metadata",
"underscore.string", "xmodule", "js/views/transcripts/metadata_videolist",
"jasmine-jquery"
],
function ($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCollection, _str) {
describe('Transcripts.Editor', function () {
var VideoListEntry = {
default_value: ['a thing', 'another thing'],
display_name: 'Video URL',
explicitly_set: true,
field_name: 'video_url',
help: 'A list of things.',
options: [],
type: MetadataModel.VIDEO_LIST_TYPE,
value: [
'http://youtu.be/12345678901',
'video.mp4',
'video.webm'
]
},
DisplayNameEntry = {
default_value: 'default value',
display_name: 'Dispaly Name',
explicitly_set: true,
field_name: 'display_name',
help: 'Specifies the name for this component.',
options: [],
type: MetadataModel.GENERIC_TYPE,
value: 'display value'
},
models = [DisplayNameEntry, VideoListEntry],
testData = {
'display_name': DisplayNameEntry,
'video_url': VideoListEntry
},
metadataDict = {
object: testData,
string: JSON.stringify(testData)
},
transcripts, container;
beforeEach(function () {
var tpl = sandbox({
'class': 'wrapper-comp-settings basic_metadata_edit',
'data-metadata': JSON.stringify(metadataDict['object'])
});
appendSetFixtures(tpl);
container = $('.basic_metadata_edit');
spyOn(Utils, 'command');
});
afterEach(function () {
Utils.Storage.remove('sub');
});
describe('Test initialization', function () {
beforeEach(function () {
spyOn(MetadataView, 'Editor');
transcripts = new Editor({
el: container
});
});
$.each(metadataDict, function(index, val) {
it('toModels with argument as ' + index, function () {
expect(transcripts.toModels(val)).toEqual(models);
});
});
it('MetadataView.Editor is initialized', function () {
expect(MetadataView.Editor).toHaveBeenCalledWith({
el: container,
collection: transcripts.collection
});
});
});
describe('Test synchronization', function () {
var nameEntry = {
default_value: 'default value',
display_name: 'Display Name',
explicitly_set: true,
field_name: 'display_name',
help: 'Specifies the name for this component.',
options: [],
type: MetadataModel.GENERIC_TYPE,
value: 'default'
},
subEntry = {
default_value: 'default value',
display_name: 'Timed Transcript',
explicitly_set: true,
field_name: 'sub',
help: 'Specifies the name for this component.',
options: [],
type: 'Generic',
value: 'default'
},
html5SourcesEntry = {
default_value: ['a thing', 'another thing'],
display_name: 'Video Sources',
explicitly_set: true,
field_name: 'html5_sources',
help: 'A list of html5 sources.',
options: [],
type: MetadataModel.LIST_TYPE,
value: ['default.mp4', 'default.webm']
},
youtubeEntry = {
default_value: 'OEoXaMPEzfM',
display_name: 'Youtube ID',
explicitly_set: true,
field_name: 'youtube_id_1_0',
help: 'Specifies the name for this component.',
options: [],
type: MetadataModel.GENERIC_TYPE,
value: 'OEoXaMPEzfM'
},
metadataCollection,
metadataView;
beforeEach(function () {
spyOn(MetadataView, 'Editor');
transcripts = new Editor({
el: container
});
metadataCollection = new MetadataCollection(
[
nameEntry,
subEntry,
html5SourcesEntry,
youtubeEntry
]
);
metadataView = jasmine.createSpyObj(
'MetadataView.Editor',
[
'getModifiedMetadataValues'
]
);
});
describe('Test Advanced to Basic synchronization', function () {
it('Correct data', function () {
transcripts.syncBasicTab(metadataCollection, metadataView);
var collection = transcripts.collection.models,
displayNameValue = collection[0].getValue(),
videoUrlValue = collection[1].getValue();
expect(displayNameValue).toBe('default');
expect(videoUrlValue).toEqual([
'http://youtu.be/OEoXaMPEzfM',
'default.mp4',
'default.webm'
]);
});
it('If metadataCollection is not defined', function () {
transcripts.syncBasicTab(null);
var collection = transcripts.collection.models,
videoUrlValue = collection[1].getValue();
expect(videoUrlValue).toEqual([
'http://youtu.be/12345678901',
'video.mp4',
'video.webm'
]);
});
it('Youtube Id has length not eqaul 11', function () {
var model = metadataCollection.findWhere({
field_name: 'youtube_id_1_0'
});
model.setValue([
'12345678',
'default.mp4',
'default.webm'
]);
transcripts.syncBasicTab(metadataCollection, metadataView);
var collection = transcripts.collection.models,
videoUrlValue = collection[1].getValue();
expect(videoUrlValue).toEqual([
'',
'default.mp4',
'default.webm'
]);
});
});
describe('Test Basic to Advanced synchronization', function () {
it('Correct data', function () {
transcripts.syncAdvancedTab(metadataCollection);
var collection = metadataCollection.models,
displayNameValue = collection[0].getValue(),
subValue = collection[1].getValue(),
html5SourcesValue = collection[2].getValue(),
youtubeValue = collection[3].getValue();
expect(displayNameValue).toBe('display value');
expect(subValue).toBe('default');
expect(html5SourcesValue).toEqual([
'video.mp4',
'video.webm'
]);
expect(youtubeValue).toBe('12345678901');
});
it('metadataCollection is not defined', function () {
transcripts.syncAdvancedTab(null);
var collection = metadataCollection.models,
displayNameValue = collection[0].getValue(),
subValue = collection[1].getValue(),
html5SourcesValue = collection[2].getValue(),
youtubeValue = collection[3].getValue();
expect(displayNameValue).toBe('default');
expect(subValue).toBe('default');
expect(html5SourcesValue).toEqual([
'default.mp4',
'default.webm'
]);
expect(youtubeValue).toBe('OEoXaMPEzfM');
});
it('Youtube Id is not adjusted', function () {
var model = transcripts.collection.models[1];
model.setValue([
'video.mp4',
'video.webm'
]);
transcripts.syncAdvancedTab(metadataCollection);
var collection = metadataCollection.models,
html5SourcesValue = collection[2].getValue(),
youtubeValue = collection[3].getValue();
expect(html5SourcesValue).toEqual([
'video.mp4',
'video.webm'
]);
expect(youtubeValue).toBe('');
});
it('Timed Transcript field is updated', function () {
Utils.Storage.set('sub', 'test_value');
transcripts.syncAdvancedTab(metadataCollection);
var collection = metadataCollection.models,
subValue = collection[1].getValue();
expect(subValue).toBe('test_value');
});
it('Timed Transcript field is updated just once', function () {
Utils.Storage.set('sub', 'test_value');
var collection = metadataCollection.models,
subModel = collection[1];
spyOn(subModel, 'setValue');
transcripts.syncAdvancedTab(metadataCollection);
transcripts.syncAdvancedTab(metadataCollection);
transcripts.syncAdvancedTab(metadataCollection);
expect(subModel.setValue.calls.length).toBe(1);
});
});
});
});
});
define(
[
"jquery", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
"xmodule", "jquery.form", "jasmine-jquery"
],
function ($, _, Utils, FileUploader) {
describe('Transcripts.FileUploader', function () {
var videoListEntryTemplate = readFixtures(
'transcripts/metadata-videolist-entry.underscore'
),
fileUploadTemplate = readFixtures(
'transcripts/file-upload.underscore'
),
view;
beforeEach(function () {
setFixtures(
$("<div>", {id: "metadata-videolist-entry"})
.html(videoListEntryTemplate)
);
appendSetFixtures(
$("<script>",
{
id: "file-upload",
type: "text/template"
}
).text(fileUploadTemplate)
);
var messenger = jasmine.createSpyObj(
'MessageManager',
['render', 'showError', 'hideError']
),
videoListObject = jasmine.createSpyObj(
'MetadataView.VideoList',
['render', 'getVideoObjectsList']
),
$container = $('.transcripts-status');
$container
.append('<div class="transcripts-file-uploader" />')
.append('<a class="setting-upload" href="#">Upload</a>');
spyOn(FileUploader.prototype, 'render').andCallThrough();
view = new FileUploader({
el: $container,
messenger: messenger,
videoListObject: videoListObject,
component_id: 'component_id'
});
});
it('Initialize', function () {
expect(view.file).toBe(false);
expect(FileUploader.prototype.render).toHaveBeenCalled();
});
describe('Render', function () {
beforeEach(function () {
spyOn(_, 'template').andCallThrough();
});
it('Template doesn\'t exist', function () {
spyOn(console, 'error');
view.uploadTpl = '';
view.render();
expect(console.error).toHaveBeenCalled();
expect(view.render).not.toThrow();
expect(_.template).not.toHaveBeenCalled();
});
it('Container where template will be inserted doesn\'t exist',
function () {
$('.transcripts-file-uploader').remove();
view.render();
expect(view.render).not.toThrow();
expect(_.template).not.toHaveBeenCalled();
}
);
it('All works okay if all data is okay', function () {
var elList = ['$form', '$input', '$progress'],
validFileExtensions = ['srt', 'sjson'],
result = $.map(validFileExtensions, function(item, index) {
return '.' + item;
}).join(', ');
view.validFileExtensions = validFileExtensions;
view.render();
expect(view.render).not.toThrow();
expect(_.template).toHaveBeenCalled();
$.each(elList, function(index, el) {
expect(view[el].length).not.toBe(0);
});
expect(view.$input.attr('accept')).toBe(result);
});
});
describe('Upload', function () {
it('File is not chosen', function () {
spyOn($.fn, 'ajaxSubmit');
view.upload();
expect(view.$form.ajaxSubmit).not.toHaveBeenCalled();
});
it('File is chosen', function () {
spyOn($.fn, 'ajaxSubmit');
view.file = {};
view.upload();
expect(view.$form.ajaxSubmit).toHaveBeenCalled();
});
});
it('clickHandler', function () {
spyOn($.fn, 'trigger');
$('.setting-upload').click();
expect($('.setting-upload').trigger).toHaveBeenCalledWith('click');
expect(view.$input).toHaveValue('');
});
describe('changeHadler', function () {
beforeEach(function () {
spyOn(view, 'upload');
});
it('Valid File Type - error should be hided', function () {
spyOn(view, 'checkExtValidity').andReturn(true);
view.$input.change();
expect(view.checkExtValidity).toHaveBeenCalled();
expect(view.upload).toHaveBeenCalled();
expect(view.options.messenger.hideError).toHaveBeenCalled();
});
it('Invalid File Type - error should be shown', function () {
spyOn(view, 'checkExtValidity').andReturn(false);
view.$input.change();
expect(view.checkExtValidity).toHaveBeenCalled();
expect(view.upload).not.toHaveBeenCalled();
expect(view.options.messenger.showError).toHaveBeenCalled();
});
});
describe('checkExtValidity', function () {
var data = {
Correct: {
name: 'file_name.srt',
isValid: true
},
Incorrect: {
name: 'file_name.mp4',
isValid: false
}
};
$.each(data, function(fileType, fileInfo) {
it(fileType + ' file type', function () {
var result = view.checkExtValidity(fileInfo);
expect(result).toBe(fileInfo.isValid);
});
});
});
it('xhrResetProgressBar', function () {
view.xhrResetProgressBar();
expect(view.$progress.width()).toBe(0);
expect(view.$progress.html()).toBe('0%');
expect(view.$progress).not.toHaveClass('is-invisible');
});
it('xhrProgressHandler', function () {
var percent = 26;
spyOn($.fn, 'width').andCallThrough();
view.xhrProgressHandler(null, null, null, percent);
expect(view.$progress.width).toHaveBeenCalledWith(percent + '%');
expect(view.$progress.html()).toBe(percent + '%');
});
describe('xhrCompleteHandler', function () {
it('Ajax Success', function () {
var xhr = {
status: 200,
responseText: JSON.stringify({
status: 'Success',
subs: 'test'
})
};
spyOn(Utils.Storage, 'set');
view.xhrCompleteHandler(xhr);
expect(view.$progress).toHaveClass('is-invisible');
expect(view.options.messenger.render.mostRecentCall.args[0])
.toEqual('uploaded');
expect(Utils.Storage.set)
.toHaveBeenCalledWith('sub', 'test');
});
var assertAjaxError = function (xhr) {
spyOn(Utils.Storage, 'set');
view.xhrCompleteHandler(xhr);
expect(view.options.messenger.showError).toHaveBeenCalled();
expect(view.$progress).toHaveClass('is-invisible');
expect(view.options.messenger.render)
.not
.toHaveBeenCalled();
expect(Utils.Storage.set)
.not
.toHaveBeenCalledWith('sub', 'test');
};
it('Ajax transport Error', function () {
var xhr = {
status: 400,
responseText: JSON.stringify({})
};
assertAjaxError(xhr);
});
});
});
});
define(
[
"jquery", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/message_manager",
"js/views/transcripts/file_uploader", "sinon", "jasmine-jquery",
"xmodule"
],
function ($, _, Utils, MessageManager, FileUploader, sinon) {
describe('Transcripts.MessageManager', function () {
var videoListEntryTemplate = readFixtures(
'transcripts/metadata-videolist-entry.underscore'
),
foundTemplate = readFixtures(
'transcripts/messages/transcripts-found.underscore'
),
handlers = {
importHandler: ['replace', 'Error: Import failed.'],
replaceHandler: ['replace', 'Error: Replacing failed.'],
chooseHandler: ['choose', 'Error: Choosing failed.', 'video_id']
},
view, fileUploader, sinonXhr;
beforeEach(function () {
var videoList, $container;
fileUploader = FileUploader.prototype;
setFixtures(
$("<div>", {id: "metadata-videolist-entry"})
.html(videoListEntryTemplate)
);
appendSetFixtures(
$("<script>",
{
id: "transcripts-found",
type: "text/template"
}
).text(foundTemplate)
);
videoList = jasmine.createSpyObj(
'MetadataView.VideoList',
['getVideoObjectsList']
);
$container = $('#metadata-videolist-entry');
spyOn(fileUploader, 'initialize');
spyOn(console, 'error');
spyOn(Utils.Storage, 'set');
view = new MessageManager({
el: $container,
parent: videoList,
component_id: 'component_id'
});
});
it('Initialize', function () {
expect(fileUploader.initialize).toHaveBeenCalledWith({
el: view.$el,
messenger: view,
component_id: view.component_id,
videoListObject: view.options.parent
});
});
describe('Render', function () {
beforeEach(function () {
spyOn(_,'template').andCallThrough();
spyOn(fileUploader, 'render');
});
it('Template doesn\'t exist', function () {
view.render('incorrect_template_name');
expect(console.error).toHaveBeenCalled();
expect(_.template).not.toHaveBeenCalled();
expect(view.$el.find('.transcripts-status'))
.toHaveClass('is-invisible');
expect(fileUploader.render).not.toHaveBeenCalled();
});
it('All works okay if correct data is passed', function () {
view.render('found');
expect(console.error).not.toHaveBeenCalled();
expect(_.template).toHaveBeenCalled();
expect(view.$el).not.toHaveClass('is-invisible');
expect(fileUploader.render).toHaveBeenCalled();
});
});
describe('showError', function () {
var errorMessage ='error',
$error, $buttons;
beforeEach(function () {
view.render('found');
spyOn(view, 'hideError');
spyOn($.fn, 'html').andCallThrough();
$error = view.$el.find('.transcripts-error-message');
$buttons = view.$el.find('.wrapper-transcripts-buttons');
});
it('Error message is not passed', function () {
view.showError(null);
expect(view.hideError).not.toHaveBeenCalled();
expect($error.html).not.toHaveBeenCalled();
expect($error).toHaveClass('is-invisible');
expect($buttons).not.toHaveClass('is-invisible');
});
it('Show message and buttons', function () {
view.showError(errorMessage);
expect(view.hideError).toHaveBeenCalled();
expect($error.html).toHaveBeenCalled();
expect($error).not.toHaveClass('is-invisible');
expect($buttons).not.toHaveClass('is-invisible');
});
it('Show message and hide buttons', function () {
view.showError(errorMessage, true);
expect(view.hideError).toHaveBeenCalled();
expect($error.html).toHaveBeenCalled();
expect($error).not.toHaveClass('is-invisible');
expect($buttons).toHaveClass('is-invisible');
});
});
it('hideError', function () {
view.render('found');
var $error = view.$el.find('.transcripts-error-message'),
$buttons = view.$el.find('.wrapper-transcripts-buttons');
expect($error).toHaveClass('is-invisible');
expect($buttons).not.toHaveClass('is-invisible');
});
$.each(handlers, function(key, value) {
it(key, function () {
var eventObj = jasmine.createSpyObj('event', ['preventDefault']);
spyOn($.fn, 'data').andReturn('video_id');
spyOn(view, 'processCommand');
view[key](eventObj);
expect(view.processCommand.mostRecentCall.args).toEqual(value);
});
});
describe('processCommand', function () {
var action = 'replace',
errorMessage = 'errorMessage',
videoList = void(0),
extraParamas = 'video_id';
beforeEach(function () {
view.render('found');
spyOn(Utils, 'command').andCallThrough();
spyOn(view, 'render');
spyOn(view, 'showError');
sinonXhr = sinon.fakeServer.create();
sinonXhr.autoRespond = true;
});
afterEach(function () {
sinonXhr.restore();
});
var assertCommand = function (config, expectFunc) {
var flag = false,
defaults = {
action: 'replace',
errorMessage: 'errorMessage',
extraParamas: void(0)
};
args = $.extend({}, defaults, config);
runs(function() {
view
.processCommand(
args.action,
args.errorMessage,
args.extraParamas
)
.always(function () { flag = true; });
});
waitsFor(function() {
return flag;
}, "Ajax Timeout", 750);
runs(expectFunc);
};
it('Invoke without extraParamas', function () {
sinonXhr.respondWith([
200,
{ "Content-Type": "application/json"},
JSON.stringify({
status: 'Success',
subs: 'video_id'
})
]);
assertCommand(
{ },
function() {
expect(Utils.command).toHaveBeenCalledWith(
action,
view.component_id,
videoList,
void(0)
);
expect(view.showError).not.toHaveBeenCalled();
expect(view.render.mostRecentCall.args[0])
.toEqual('found');
expect(Utils.Storage.set).toHaveBeenCalled();
}
);
});
it('Invoke with extraParamas', function () {
sinonXhr.respondWith([
200,
{ "Content-Type": "application/json"},
JSON.stringify({
status: 'Success',
subs: 'video_id'
})
]);
view.processCommand(action, errorMessage, extraParamas);
assertCommand(
{ extraParamas : extraParamas },
function () {
expect(Utils.command).toHaveBeenCalledWith(
action,
view.component_id,
videoList,
{
html5_id: extraParamas
}
);
expect(view.showError).not.toHaveBeenCalled();
expect(view.render.mostRecentCall.args[0])
.toEqual('found');
expect(Utils.Storage.set).toHaveBeenCalled();
}
);
});
it('Fail', function () {
sinonXhr.respondWith([400, {}, '']);
assertCommand(
{ },
function () {
expect(Utils.command).toHaveBeenCalledWith(
action,
view.component_id,
videoList,
void(0)
);
expect(view.showError).toHaveBeenCalled();
expect(view.render).not.toHaveBeenCalled();
expect(Utils.Storage.set).not.toHaveBeenCalled();
}
);
});
});
});
});
define(
[
"jquery", "underscore",
"js/views/transcripts/utils",
"underscore.string", "xmodule", "jasmine-jquery"
],
function ($, _, Utils, _str) {
describe('Transcripts.Utils', function () {
var videoId = 'OEoXaMPEzfM',
ytLinksList = (function (id) {
var links = [
'http://www.youtube.com/watch?v=%s&feature=feedrec_grec_index',
'http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/%s',
'http://www.youtube.com/v/%s?fs=1&amp;hl=en_US&amp;rel=0',
'http://www.youtube.com/watch?v=%s#t=0m10s',
'http://www.youtube.com/embed/%s?rel=0',
'http://www.youtube.com/watch?v=%s',
'http://youtu.be/%s'
];
return $.map(links, function (link) {
return _str.sprintf(link, id);
});
} (videoId)),
html5FileName = 'file_name',
html5LinksList = (function (videoName) {
var videoTypes = ['mp4', 'webm'],
links = [
'http://somelink.com/%s.%s?param=1&param=2#hash',
'http://somelink.com/%s.%s#hash',
'http://somelink.com/%s.%s?param=1&param=2',
'http://somelink.com/%s.%s',
'ftp://somelink.com/%s.%s',
'https://somelink.com/%s.%s',
'somelink.com/%s.%s',
'%s.%s'
],
data = {};
$.each(videoTypes, function (index, type) {
data[type] = $.map(links, function (link) {
return _str.sprintf(link, videoName, type);
});
});
return data;
} (html5FileName));
describe('Method: getField', function (){
var collection,
testFieldName = 'test_field';
beforeEach(function() {
collection = jasmine.createSpyObj(
'Collection',
[
'findWhere'
]
);
});
it('All works okay if all arguments are passed', function () {
Utils.getField(collection, testFieldName);
expect(collection.findWhere).toHaveBeenCalledWith({
field_name: testFieldName
});
});
var wrongArgumentLists = [
{
argName: 'collection',
list: [undefined, testFieldName]
},
{
argName: 'field name',
list: [collection, undefined]
},
{
argName: 'both',
list: [undefined, undefined]
}
];
$.each(wrongArgumentLists, function (index, element) {
it(element.argName + ' argument(s) is/are absent', function () {
var result = Utils.getField.apply(this, element.list);
expect(result).toBeUndefined();
});
});
});
describe('Method: parseYoutubeLink', function () {
describe('Supported urls', function () {
$.each(ytLinksList, function (index, link) {
it(link, function () {
var result = Utils.parseYoutubeLink(link);
expect(result).toBe(videoId);
});
});
});
describe('Wrong arguments ', function () {
beforeEach(function(){
spyOn(console, 'log');
});
it('no arguments', function () {
var result = Utils.parseYoutubeLink();
expect(result).toBeUndefined();
});
it('wrong data type', function () {
var result = Utils.parseYoutubeLink(1);
expect(result).toBeUndefined();
});
it('videoId is wrong', function () {
var videoId = 'wrong_id',
link = 'http://youtu.be/' + videoId,
result = Utils.parseYoutubeLink(link);
expect(result).toBeUndefined();
});
var wrongUrls = [
'http://youtu.bee/' + videoId,
'http://youtu.be/',
'example.com',
'http://google.com/somevideo.mp4'
];
$.each(wrongUrls, function (index, link) {
it(link, function () {
var result = Utils.parseYoutubeLink(link);
expect(result).toBeUndefined();
});
});
});
});
describe('Method: parseHTML5Link', function () {
describe('Supported urls', function () {
$.each(html5LinksList, function (format, linksList) {
$.each(linksList, function (index, link) {
it(link, function () {
var result = Utils.parseHTML5Link(link);
expect(result).toEqual({
video: html5FileName,
type: format
});
});
});
});
});
describe('Wrong arguments ', function () {
beforeEach(function(){
spyOn(console, 'log');
});
it('no arguments', function () {
var result = Utils.parseHTML5Link();
expect(result).toBeUndefined();
});
it('wrong data type', function () {
var result = Utils.parseHTML5Link(1);
expect(result).toBeUndefined();
});
var html5WrongUrls = [
'http://youtu.bee/' + videoId,
'http://youtu.be/',
'example.com',
'http://google.com/somevideo.mp1',
'http://google.com/somevideomp4',
'http://google.com/somevideo_mp4',
'http://google.com/somevideo:mp4',
'http://google.com/somevideo',
'http://google.com/somevideo.webm_'
];
$.each(html5WrongUrls, function (index, link) {
it(link, function () {
var result = Utils.parseHTML5Link(link);
expect(result).toBeUndefined();
});
});
});
});
it('Method: getYoutubeLink', function () {
var videoId = 'video_id',
result = Utils.getYoutubeLink(videoId),
expectedResult = 'http://youtu.be/' + videoId;
expect(result).toBe(expectedResult);
});
describe('Method: parseLink', function () {
var resultDataDict = {
'html5': {
link: html5LinksList['mp4'][0],
resp: {
mode: 'html5',
video: html5FileName,
type: 'mp4'
}
},
'youtube': {
link: ytLinksList[0],
resp: {
mode: 'youtube',
video: videoId,
type: 'youtube'
}
},
'incorrect': {
link: 'http://example.com',
resp: {
mode: 'incorrect'
}
}
};
$.each(resultDataDict, function (mode, data) {
it(mode, function () {
var result = Utils.parseLink(data.link);
expect(result).toEqual(data.resp);
});
});
describe('Wrong arguments ', function () {
it('no arguments', function () {
var result = Utils.parseLink();
expect(result).toBeUndefined();
});
it('wrong data type', function () {
var result = Utils.parseLink(1);
expect(result).toBeUndefined();
});
});
});
});
});
define(
[
"jquery", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/metadata_videolist",
"js/views/transcripts/message_manager",
"js/views/metadata", "js/models/metadata", "js/views/abstract_editor",
"sinon", "xmodule", "jasmine-jquery"
],
function ($, _, Utils, VideoList, MessageManager, MetadataView, MetadataModel, AbstractEditor, sinon) {
describe('CMS.Views.Metadata.VideoList', function () {
var videoListEntryTemplate = readFixtures(
'transcripts/metadata-videolist-entry.underscore'
),
correctMessanger = MessageManager,
messenger = correctMessanger.prototype,
abstractEditor = AbstractEditor.prototype,
component_id = 'component_id',
videoList = [
{
mode: "youtube",
type: "youtube",
video: "12345678901"
},
{
mode: "html5",
type: "mp4",
video: "video"
},
{
mode: "html5",
type: "webm",
video: "video"
}
],
modelStub = {
default_value: ['a thing', 'another thing'],
display_name: 'Video URL',
explicitly_set: true,
field_name: 'video_url',
help: 'A list of things.',
options: [],
type: MetadataModel.VIDEO_LIST_TYPE,
value: [
'http://youtu.be/12345678901',
'video.mp4',
'video.webm'
]
},
response = JSON.stringify({
command: 'found',
status: 'Success',
subs: 'video_id'
}),
view, sinonXhr;
beforeEach(function () {
sinonXhr = sinon.fakeServer.create();
sinonXhr.respondWith([
200,
{ "Content-Type": "application/json"},
response
]);
sinonXhr.autoRespond = true;
var tpl = sandbox({
'class': 'component',
'data-id': component_id
}),
model = new MetadataModel(modelStub),
videoList, $el;
setFixtures(tpl);
appendSetFixtures(
$("<script>",
{
id: "metadata-videolist-entry",
type: "text/template"
}
).text(videoListEntryTemplate)
);
spyOn(messenger, 'initialize');
spyOn(messenger, 'render').andReturn(messenger);
spyOn(messenger, 'showError');
spyOn(messenger, 'hideError');
spyOn(Utils, 'command').andCallThrough();
spyOn(abstractEditor, 'initialize').andCallThrough();
spyOn(abstractEditor, 'render').andCallThrough();
MessageManager = function () {
messenger.initialize();
return messenger;
};
$el = $('.component');
spyOn(console, 'error');
view = new VideoList({
el: $el,
model: model
});
this.addMatchers({
assertValueInView: function(expected) {
var actualValue = this.actual.getValueFromEditor();
return this.env.equals_(actualValue, expected);
},
assertCanUpdateView: function (expected) {
var actual = this.actual,
actualValue;
actual.setValueInEditor(expected);
actualValue = actual.getValueFromEditor();
return this.env.equals_(actualValue, expected);
},
assertIsCorrectVideoList: function (expected) {
var actualValue = this.actual.getVideoObjectsList();
return this.env.equals_(actualValue, expected);
}
});
});
afterEach(function () {
MessageManager = correctMessanger;
sinonXhr.restore();
});
var waitsForResponse = function (expectFunc, prep) {
var flag = false;
if (prep) {
runs(prep);
}
waitsFor(function() {
var req = sinonXhr.requests,
len = req.length;
if (len && req[0].readyState === 4) {
flag = true;
}
return flag;
}, "Ajax Timeout", 750);
runs(expectFunc);
};
it('Initialize', function () {
expect(abstractEditor.initialize).toHaveBeenCalled();
expect(messenger.initialize).toHaveBeenCalled();
expect(view.component_id).toBe(component_id);
expect(view.$el).toHandle('input');
});
describe('Render', function () {
var assertToHaveBeenRendered = function (videoList) {
expect(abstractEditor.render).toHaveBeenCalled();
expect(Utils.command).toHaveBeenCalledWith(
'check',
component_id,
videoList
);
expect(messenger.render).toHaveBeenCalled();
},
resetSpies = function() {
abstractEditor.render.reset();
Utils.command.reset();
messenger.render.reset();
sinonXhr.requests.length = 0;
};
it('is rendered in correct way', function () {
waitsForResponse(function () {
assertToHaveBeenRendered(videoList);
});
});
it('is rendered with opened extra videos bar', function () {
var videoListLength = [
{
mode: "youtube",
type: "youtube",
video: "12345678901"
},
{
mode: "html5",
type: "mp4",
video: "video"
}
],
videoListHtml5mode = [
{
mode: "html5",
type: "mp4",
video: "video"
}
];
spyOn(view, 'getVideoObjectsList').andReturn(videoListLength);
spyOn(view, 'openExtraVideosBar');
waitsForResponse(
function () {
assertToHaveBeenRendered(videoListLength);
view.getVideoObjectsList.andReturn(videoListLength);
expect(view.openExtraVideosBar).toHaveBeenCalled();
},
function () {
resetSpies();
view.render();
}
);
waitsForResponse(
function () {
assertToHaveBeenRendered(videoListHtml5mode);
expect(view.openExtraVideosBar).toHaveBeenCalled();
},
function () {
resetSpies();
view.openExtraVideosBar.reset();
view.getVideoObjectsList.andReturn(videoListHtml5mode);
view.render();
}
);
});
it('is rendered without opened extra videos bar', function () {
var videoList = [
{
mode: "youtube",
type: "youtube",
video: "12345678901"
}
];
spyOn(view, 'getVideoObjectsList').andReturn(videoList);
spyOn(view, 'closeExtraVideosBar');
waitsForResponse(
function () {
assertToHaveBeenRendered(videoList);
expect(view.closeExtraVideosBar).toHaveBeenCalled();
},
function () {
resetSpies();
view.render();
}
);
});
});
describe('isUniqVideoTypes', function () {
it('Unique data - return true', function () {
var data = videoList,
result = view.isUniqVideoTypes(data);
expect(result).toBe(true);
});
it('Not Unique data - return false', function () {
var data = [
{
mode: "html5",
type: "mp4",
video: "video"
},
{
mode: "html5",
type: "mp4",
video: "video"
},
{
mode: "youtube",
type: "youtube",
video: "12345678901"
}
],
result = view.isUniqVideoTypes(data);
expect(result).toBe(false);
});
});
describe('checkIsUniqVideoTypes', function () {
it('Error is shown', function () {
var data = [
{
mode: "html5",
type: "mp4",
video: "video"
},
{
mode: "html5",
type: "mp4",
video: "video"
},
{
mode: "youtube",
type: "youtube",
video: "12345678901"
}
],
result = view.checkIsUniqVideoTypes(data);
expect(messenger.showError).toHaveBeenCalled();
expect(result).toBe(false);
});
it('All works okay if arguments are not passed', function () {
spyOn(view, 'getVideoObjectsList').andReturn(videoList);
var result = view.checkIsUniqVideoTypes();
expect(view.getVideoObjectsList).toHaveBeenCalled();
expect(messenger.showError).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe('checkValidity', function () {
beforeEach(function () {
spyOn(view, 'checkIsUniqVideoTypes').andReturn(true);
});
it('Error message are shown', function () {
var data = { mode: 'incorrect' },
result = view.checkValidity(data, true);
expect(messenger.showError).toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(false);
});
it('Error message are shown when flag is not passed', function () {
var data = { mode: 'incorrect' },
result = view.checkValidity(data);
expect(messenger.showError).not.toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(true);
});
it('All works okay if correct data is passed', function () {
var data = videoList,
result = view.checkValidity(data);
expect(messenger.showError).not.toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(true);
});
});
it('openExtraVideosBar', function () {
view.$extraVideosBar.removeClass('is-visible');
view.openExtraVideosBar();
expect(view.$extraVideosBar).toHaveClass('is-visible');
});
it('closeExtraVideosBar', function () {
view.$extraVideosBar.addClass('is-visible');
view.closeExtraVideosBar();
expect(view.$extraVideosBar).not.toHaveClass('is-visible');
});
it('toggleExtraVideosBar', function () {
view.$extraVideosBar.addClass('is-visible');
view.toggleExtraVideosBar();
expect(view.$extraVideosBar).not.toHaveClass('is-visible');
view.toggleExtraVideosBar();
expect(view.$extraVideosBar).toHaveClass('is-visible');
});
it('getValueFromEditor', function () {
expect(view).assertValueInView(modelStub.value);
});
it('setValueInEditor', function () {
expect(view).assertCanUpdateView(['abc.mp4']);
});
it('getVideoObjectsList', function () {
var value = [
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
}
];
view.setValueInEditor([
'http://youtu.be/12345678901',
'video.mp4',
'video'
]);
expect(view).assertIsCorrectVideoList(value);
});
describe('getPlaceholders', function () {
var defaultPlaceholders;
beforeEach(function () {
defaultPlaceholders = view.placeholders;
});
it('All works okay if empty values are passed', function () {
var result = view.getPlaceholders([]),
expectedResult = _.values(defaultPlaceholders).reverse();
expect(result).toEqual(expectedResult);
});
it('On filling less than 3 fields, remaining fields should have ' +
'placeholders for video types that were not filled yet',
function () {
var dataDict = {
youtube: {
value: [modelStub.value[0]],
expectedResult: [
defaultPlaceholders.youtube,
defaultPlaceholders.mp4,
defaultPlaceholders.webm
]
},
mp4: {
value: [modelStub.value[1]],
expectedResult: [
defaultPlaceholders.mp4,
defaultPlaceholders.youtube,
defaultPlaceholders.webm
]
},
webm: {
value: [modelStub.value[2]],
expectedResult: [
defaultPlaceholders.webm,
defaultPlaceholders.youtube,
defaultPlaceholders.mp4
]
}
};
$.each(dataDict, function(index, val) {
var result = view.getPlaceholders(val.value);
expect(result).toEqual(val.expectedResult);
});
}
);
});
describe('inputHandler', function () {
var eventObject;
var resetSpies = function () {
messenger.hideError.reset();
view.updateModel.reset();
view.closeExtraVideosBar.reset();
};
beforeEach(function () {
eventObject = jQuery.Event('input');
spyOn(view, 'updateModel');
spyOn(view, 'closeExtraVideosBar');
spyOn(view, 'checkValidity');
spyOn($.fn, 'hasClass');
spyOn($.fn, 'addClass');
spyOn($.fn, 'removeClass');
spyOn($.fn, 'prop').andCallThrough();
spyOn(_, 'isEqual');
resetSpies();
});
it('Field has invalid value - nothing should happen',
function () {
$.fn.hasClass.andReturn(false);
view.checkValidity.andReturn(false);
view.inputHandler(eventObject);
expect(messenger.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
}
);
it('Main field has invalid value - extra Videos Bar should be closed',
function () {
$.fn.hasClass.andReturn(true);
view.checkValidity.andReturn(false);
view.inputHandler(eventObject);
expect(messenger.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
}
);
it('Model is updated if value is valid',
function () {
view.checkValidity.andReturn(true);
_.isEqual.andReturn(false);
view.inputHandler(eventObject);
expect(messenger.hideError).not.toHaveBeenCalled();
expect(view.updateModel).toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
}
);
it('Corner case: Error is hided',
function () {
view.checkValidity.andReturn(true);
_.isEqual.andReturn(true);
view.inputHandler(eventObject);
expect(messenger.hideError).toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
}
);
});
});
});
......@@ -40,6 +40,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/backbone-associations-min.js
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
- xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js
- xmodule_js/common_static/js/vendor/jquery.form.js
- xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill.js
- xmodule_js/common_static/js/vendor/sinon-1.7.1.js
......@@ -66,6 +67,7 @@ src_paths:
spec_paths:
- coffee/spec/main.js
- coffee/spec
- js_spec
# Paths to fixture files (optional)
# The fixture path will be set automatically when using jasmine-jquery.
......
......@@ -13,3 +13,192 @@
}
}
}
.xmodule_VideoDescriptor {
.wrapper-comp-settings.basic_metadata_edit{
.list-input.settings-list {
.field.comp-setting-entry {
.setting-label {
vertical-align: top;
margin-top: ($baseline/2);
}
.setting-help{
display: block;
width: 45%;
max-width: auto;
margin-left: 33%;
padding: 0 13px;
}
.collapse-setting {
@extend %t-action3;
display: block;
width: 100%;
padding: ($baseline/2);
font-weight: 600;
*[class^="icon-"] {
margin-right: ($baseline/4);
}
}
.videolist-url-tip.setting-help,
.videolist-extra-videos-tip.setting-help{
margin-left: 0;
width: 100%;
padding: 0 10px 10px;
}
.videolist-url-tip.setting-help{
padding: 0 0 10px;
}
.wrapper-comp-setting{
width: 100%;
display: block;
max-width: auto;
}
// inputs and labels
.wrapper-videolist-settings {
width: 45%;
display: inline-block;
min-width: ($baseline*5);
// inputs
.input {
width: 100%;
vertical-align: middle;
&.is-disabled,
&[disabled="disabled"]{
opacity: .5;
}
}
.wrapper-videolist-url{
margin-bottom: ($baseline/2);
}
.wrapper-videolist-urls{
background: $lightGrey;
padding: ($baseline/3);
// enumerated fields
.videolist-extra-videos {
display: none;
&.is-visible{
display: block;
}
.videolist-settings-item {
margin-bottom: ($baseline/2);
}
}
}
}
}
}
.transcripts-status{
margin-top: $baseline;
&.is-invisible{
display: none !important;
}
.wrapper-transcripts-message{
width: 60%;
display: inline-block;
vertical-align: top;
min-width: ($baseline*5);
margin-top: 10px;
.transcripts-message{
@include font-size(12);
}
.transcripts-message-status{
color: $green;
font-weight: 700;
&.status-error{
color: $red;
}
[class^="icon-"],
[class*=" icon-"]{
margin-right: 5px;
@include font-size(18);
}
}
.transcripts-error-message{
background: $red;
color: $white;
@include font-size(14);
padding: ($baseline/3);
&.is-invisible{
display: none;
}
}
.wrapper-transcripts-buttons{
&.is-invisible{
display: none;
}
}
}
.action{
@extend %btn-primary-blue;
@extend %t-action3;
margin-bottom: ($baseline/2);
}
}
// TYPE: enumerated video lists of metadata sets
.metadata-videolist-enum {
* {
@include box-sizing(border-box);
}
}
.file-chooser{
display: none;
}
.progress-bar{
display: block;
height: 30px;
margin: 10px 0;
border: 1px solid $blue;
text-align: center;
font-size: 1.14em;
&.is-invisible {
display: none;
}
&.loaded {
border-color: #66b93d;
.progress-fill {
background: #66b93d;
}
}
.progress-fill {
display: block;
width: 0%;
height: 30px;
background: $blue;
color: #fff;
line-height: 28px;
}
}
}
}
......@@ -45,6 +45,7 @@ var require = {
"jquery.form": "js/vendor/jquery.form",
"jquery.markitup": "js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "js/vendor/jquery.leanModal.min",
"jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue",
"jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min",
"jquery.timepicker": "js/vendor/timepicker/jquery.timepicker",
"jquery.cookie": "js/vendor/jquery.cookie",
......@@ -100,6 +101,10 @@ var require = {
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
},
"jquery.ajaxQueue": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxQueue"
},
"jquery.smoothScroll": {
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
......@@ -210,6 +215,7 @@ var require = {
<%static:include path="js/system-feedback.underscore" />
</script>
% if context_course:
<script type="text/javascript">
require(['js/models/course'], function(Course) {
......
<div class="progress-bar is-invisible">
<div class="progress-fill"></div>
</div>
<form class="file-chooser" action="/transcripts/upload"
method="post" enctype="multipart/form-data">
<input type="file" class="file-input" name="file"
accept="<%= _.map(ext, function(val){ return '.' + val; }).join(', ') %>">
<input type="hidden" name="id" value="<%= component_id %>">
<input type="hidden" name="video_list" value='<%= JSON.stringify(video_list) %>'>
</form>
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Conflict") %>
</div>
<p class="transcripts-message">
<%= gettext("The timed transcript for the first HTML5 source does not appear to be the same as the timed transcript for the second HTML5 source.") %>
<strong>
<%= gettext("Which one would you like to use?") %>
</strong>
</p>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<% _.each(html5_list, function(value, index) {
var type = grouped_list[value][0].type,
file_name = value + ((type) ? ('.' + type) : ''),
message = gettext("Timed Transcript from ") + file_name;
%>
<button
class="action setting-choose"
type="button"
name="setting-choose"
data-video-id="<%= value %>"
value="<%= message %>"
data-tooltip="<%= message %>"
>
<span>
<%= message %>
</span>
</button>
<% }) %>
</div>
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript Found") %></div>
<p class="transcripts-message">
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span>
</a>
</div>
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
<p class="transcripts-message">
<%= gettext("We don\'t have a timed transcript for this video on edX, but we found a transcript for this video on YouTube. Would you like to import it to edX?") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<button class="action setting-import" type="button" name="setting-import" value="<%= gettext("Import from YouTube") %>" data-tooltip="<%= gettext("Import from YouTube") %>">
<span><%= gettext("Import from YouTube") %></span>
</button>
</div>
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
<p class="transcripts-message">
<%= gettext("We don\'t have a timed transcript for this video. Please upload a .srt file:") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<%= gettext("Upload New Timed Transcript") %>
</button>
<a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download to Edit") %>">
<%= gettext("Download to Edit") %>
</a>
</div>
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Conflict") %>
</div>
<p class="transcripts-message">
<%= gettext("The timed transcript file on YouTube does not appear to be the same as the timed transcript file on edX.") %>
<strong>
<%= gettext("Would you like to replace the edX timed transcript with the ones from YouTube?") %>
</strong>
</p>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button
class="action setting-replace"
type="button"
name="setting-replace"
value="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
data-tooltip="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
>
<span>
<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>
</span>
</button>
</div>
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript uploaded successfully") %></div>
<p class="transcripts-message">
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span>
</a>
</div>
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Not Updated") %>
</div>
<p class="transcripts-message">
<%= gettext("You changed a video source, but did not update the timed transcript file. Do you want to upload new timed transcript?") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button
class="action setting-use-existing"
type="button"
name="setting-use-existing"
value="<%= gettext("Use Existing Timed Transcript") %>"
data-tooltip="<%= gettext("Use Existing Timed Transcript") %>"
>
<span>
<%= gettext("Use Existing Timed Transcript") %>
</span>
</button>
<button
class="action setting-upload"
type="button"
name="setting-upload"
value="<%= gettext("Upload New Timed Transcript") %>"
data-tooltip="<%= gettext("Upload New Timed Transcript") %>"
>
<span>
<%= gettext("Upload New Timed Transcript") %>
</span>
</button>
</div>
<div class="wrapper-comp-setting metadata-videolist-enum">
<label class="label setting-label" for="<%= uniqueId %>"><%= model.get('display_name')%></label>
<div class="wrapper-videolist-settings">
<div class="wrapper-videolist-url videolist-settings-item"><input type="text" id="<%= uniqueId %>" class="input videolist-url" value="<%= model.get('value')[0] %>"></div>
<div class="tip videolist-url-tip setting-help"><%= model.get('help') %></div>
<div class="wrapper-videolist-urls">
<a href="#" class="collapse-action collapse-setting">
<i class="icon-plus"></i><%= gettext("Add more video sources") %> <span class="sr"><%= model.get('display_name')%></span>
</a>
<div class="videolist-extra-videos">
<span class="tip videolist-extra-videos-tip setting-help"><%= gettext('To be sure all students can view the video, we recommend providing alternate versions of the same video: mp4, webm and youtube (if available).') %></span>
<ol class="videolist-settings">
<li class="videolist-settings-item">
<input type="text" class="input" value="<%= model.get('value')[1] %>">
</li>
<li class="videolist-settings-item">
<input type="text" class="input" value="<%= model.get('value')[2] %>">
</li>
</ol>
</div>
</div>
</div>
</div>
<div class="transcripts-status is-invisible">
<label class="label setting-label transcripts-label"><%= gettext("Timed Transcript") %></label>
<div class="wrapper-transcripts-message"></div>
</div>
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='../../static_content.html'/>
<%page args="tabName"/>
<%
import json
%>
## include js templates:
% for template_name in ["metadata-videolist-entry", "file-upload"]:
<script type="text/template" id="${template_name}">
<%static:include path="js/transcripts/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["transcripts-found", "transcripts-uploaded", "transcripts-use-existing", "transcripts-not-found", "transcripts-replace", "transcripts-import", "transcripts-choose"]:
<script type="text/template" id="${template_name}">
<%static:include path="js/transcripts/messages/${template_name}.underscore" />
</script>
% endfor
<div class="wrapper-comp-settings basic_metadata_edit" data-metadata='${json.dumps(transcripts_basic_tab_metadata) | h}'></div>
<script type="text/javascript">
require(
[
"domReady!",
"jquery",
"js/views/transcripts/editor"
],
function(doc, $, Editor) {
var transcripts = new Editor({
el: $('#editor-tab-${html_id}').find('.basic_metadata_edit')
}),
storage = TabsEditingDescriptor.getStorage();
TabsEditingDescriptor.Model.addModelUpdate(
'${html_id}',
'${tabName}',
function () {
// Advanced, Save
metadataEditor = storage.MetadataEditor;
if (metadataEditor) {
transcripts.syncAdvancedTab(metadataEditor.collection, metadataEditor);
}
}
);
TabsEditingDescriptor.Model.addOnSwitch(
'${html_id}',
'${tabName}',
function () {
// Basic
metadataEditor = storage.MetadataEditor;
if (metadataEditor) {
transcripts.syncBasicTab(metadataEditor.collection, metadataEditor);
}
}
);
}
);
</script>
......@@ -21,6 +21,15 @@ urlpatterns = patterns('', # nopep8
url(r'^save_item$', 'contentstore.views.save_item', name='save_item'),
url(r'^delete_item$', 'contentstore.views.delete_item', name='delete_item'),
url(r'^create_item$', 'contentstore.views.create_item', name='create_item'),
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
url(r'^transcripts/check$', 'contentstore.views.check_transcripts', name='check_transcripts'),
url(r'^transcripts/choose$', 'contentstore.views.choose_transcripts', name='choose_transcripts'),
url(r'^transcripts/replace$', 'contentstore.views.replace_transcripts', name='replace_transcripts'),
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'),
url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'),
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
......
......@@ -212,3 +212,11 @@ def i_answer_prompts_with(step, prompt):
In addition, this method changes the functionality of ONLY future alerts
"""
world.browser.execute_script('window.prompt = function(){return %s;}') % prompt
@step('I run ipdb')
def run_ipdb(_step):
"""Run ipdb as step for easy debugging"""
import ipdb
ipdb.set_trace()
assert True
......@@ -468,6 +468,11 @@ def click_link(partial_text, index=0):
@world.absorb
def click_link_by_text(text, index=0):
retry_on_exception(lambda: world.browser.find_link_by_text(text)[index].click())
@world.absorb
def css_text(css_selector, index=0, timeout=30):
# Wait for the css selector to appear
if is_css_present(css_selector):
......
......@@ -5,4 +5,5 @@
# Video are written in pure JavaScript.
!video/*.js
\ No newline at end of file
!video/*.js
!video/transcripts/*.js
\ No newline at end of file
......@@ -65,6 +65,15 @@ class @TabsEditingDescriptor
current_tab = @$tabs.filter('.current').html()
data: TabsEditingDescriptor.Model.getValue(@html_id, current_tab)
setMetadataEditor : (metadataEditor) ->
TabsEditingDescriptor.setMetadataEditor.apply(TabsEditingDescriptor, arguments)
getStorage : () ->
TabsEditingDescriptor.getStorage()
addToStorage : (id, data) ->
TabsEditingDescriptor.addToStorage.apply(TabsEditingDescriptor, arguments)
@Model :
addModelUpdate : (id, tabName, modelUpdateFunction) ->
###
......@@ -115,6 +124,7 @@ class @TabsEditingDescriptor
# html_id's of descriptors will be stored in modules variable as
# containers for callbacks.
modules: {}
Storage: {}
initialize : (id) ->
###
......@@ -123,3 +133,13 @@ class @TabsEditingDescriptor
@modules[id] = @modules[id] or {}
@modules[id].tabSwitch = @modules[id]['tabSwitch'] or {}
@modules[id].modelUpdate = @modules[id]['modelUpdate'] or {}
@setMetadataEditor : (metadataEditor) ->
TabsEditingDescriptor.Model.Storage['MetadataEditor'] = metadataEditor
@addToStorage : (id, data) ->
TabsEditingDescriptor.Model.Storage[id] = data
@getStorage : () ->
TabsEditingDescriptor.Model.Storage
......@@ -141,9 +141,13 @@ class VideoDescriptorTest(unittest.TestCase):
""""test get_context"""
correct_tabs = [
{
'name': "Settings",
'template': "tabs/metadata-edit-tab.html",
'name': "Basic",
'template': "video/transcripts.html",
'current': True
},
{
'name': 'Advanced',
'template': 'tabs/metadata-edit-tab.html'
}
]
rendered_context = self.descriptor.get_context()
......
......@@ -15,37 +15,70 @@ class MockYoutubeRequestHandler(BaseHTTPRequestHandler):
protocol = "HTTP/1.0"
def do_HEAD(self):
self._send_head()
code = 200
if 'test_transcripts_youtube' in self.path:
if not 'trans_exist' in self.path:
code = 404
self._send_head(code)
def do_GET(self):
'''
Handle a GET request from the client and sends response back.
'''
self._send_head()
logger.debug("Youtube provider received GET request to path {}".format(
self.path)
) # Log the request
status_message = "I'm youtube."
response_timeout = float(self.server.time_to_response)
if 'test_transcripts_youtube' in self.path:
if 't__eq_exist' in self.path:
status_message = """<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.0" dur="1.0">Equal transcripts</text></transcript>"""
self._send_head()
self._send_transcripts_response(status_message)
elif 't_neq_exist' in self.path:
status_message = """<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.1" dur="5.5">Transcripts sample, different that on server</text></transcript>"""
self._send_head()
self._send_transcripts_response(status_message)
else:
self._send_head(404)
elif 'test_youtube' in self.path:
self._send_head()
#testing videoplayers
status_message = "I'm youtube."
response_timeout = float(self.server.time_to_response)
# threading timer produces TypeError: 'NoneType' object is not callable here
# so we use time.sleep, as we already in separate thread.
time.sleep(response_timeout)
self._send_response(status_message)
# threading timer produces TypeError: 'NoneType' object is not callable here
# so we use time.sleep, as we already in separate thread.
time.sleep(response_timeout)
self._send_video_response(status_message)
else:
# unused url
self._send_head()
self._send_transcripts_response('Unused url')
logger.debug("Request to unused url.")
def _send_head(self):
def _send_head(self, code=200):
'''
Send the response code and MIME headers
'''
self.send_response(200)
self.send_response(code)
self.send_header('Content-type', 'text/html')
self.end_headers()
def _send_response(self, message):
def _send_transcripts_response(self, message):
'''
Send message back to the client for transcripts ajax requests.
'''
response = message
# Log the response
logger.debug("Youtube: sent response {}".format(message))
self.wfile.write(response)
def _send_video_response(self, message):
'''
Send message back to the client
Send message back to the client for video player requests.
Requires sending back callback id.
'''
callback = urlparse.parse_qs(self.path)['callback'][0]
response = callback + '({})'.format(json.dumps({'message': message}))
......
......@@ -3,15 +3,13 @@ Test for Mock_Youtube_Server
"""
import unittest
import threading
import urllib
import requests
from mock_youtube_server import MockYoutubeServer
from nose.plugins.skip import SkipTest
class MockYoutubeServerTest(unittest.TestCase):
'''
A mock version of the Youtube provider server that listens on a local
A mock version of the YouTube provider server that listens on a local
port and responds with jsonp.
Used for lettuce BDD tests in lms/courseware/features/video.feature
......@@ -19,11 +17,6 @@ class MockYoutubeServerTest(unittest.TestCase):
def setUp(self):
# This is a test of the test setup,
# so it does not need to run as part of the unit test suite
# You can re-enable it by commenting out the line below
raise SkipTest
# Create the server
server_port = 8034
server_host = '127.0.0.1'
......@@ -46,8 +39,39 @@ class MockYoutubeServerTest(unittest.TestCase):
path, and responses with incorrect signature.
"""
# GET request
response_handle = urllib.urlopen(
'http://127.0.0.1:8034/feeds/api/videos/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func',
# unused url
response = requests.get(
'http://127.0.0.1:8034/some url',
)
self.assertEqual("Unused url", response.content)
# video player test url, callback shoud be presented in url params
response = requests.get(
'http://127.0.0.1:8034/test_youtube/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func',
)
self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response.content)
# transcripts test url
response = requests.get(
'http://127.0.0.1:8034/test_transcripts_youtube/t__eq_exist',
)
self.assertEqual(
'<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.0" dur="1.0">Equal transcripts</text></transcript>',
response.content
)
# transcripts test url
response = requests.get(
'http://127.0.0.1:8034/test_transcripts_youtube/t_neq_exist',
)
self.assertEqual(
'<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.1" dur="5.5">Transcripts sample, different that on server</text></transcript>',
response.content
)
# transcripts test url, not trans_exist youtube_id, so 404 should be returned
response = requests.get(
'http://127.0.0.1:8034/test_transcripts_youtube/some_id',
)
response = response_handle.read()
self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response)
self.assertEqual(404, response.status_code)
......@@ -17,9 +17,11 @@ from lxml import etree
from pkg_resources import resource_string
import datetime
import time
import copy
from django.http import Http404
from django.conf import settings
from django.utils.translation import ugettext as _
from xmodule.x_module import XModule
from xmodule.editing_module import TabsEditingDescriptor
......@@ -30,7 +32,6 @@ from xblock.fields import Scope, String, Boolean, Float, List, Integer, ScopeIds
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
from xblock.runtime import DbModel
log = logging.getLogger(__name__)
......@@ -48,7 +49,7 @@ class VideoFields(object):
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
display_name="Show Captions",
display_name="Show Transcript",
scope=Scope.settings,
default=True
)
......@@ -103,13 +104,13 @@ class VideoFields(object):
)
track = String(
help="The external URL to download the timed transcript track. This appears as a link beneath the video.",
display_name="Download Track",
display_name="Download Transcript",
scope=Scope.settings,
default=""
)
sub = String(
help="The name of the timed transcript track (for non-Youtube videos).",
display_name="HTML5 Timed Transcript",
display_name="HTML5 Transcript",
scope=Scope.settings,
default=""
)
......@@ -196,14 +197,14 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
module_class = VideoModule
tabs = [
# {
# 'name': "Subtitles",
# 'template': "video/subtitles.html",
# },
{
'name': "Settings",
'template': "tabs/metadata-edit-tab.html",
'name': "Basic",
'template': "video/transcripts.html",
'current': True
},
{
'name': "Advanced",
'template': "tabs/metadata-edit-tab.html"
}
]
......@@ -286,6 +287,45 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
xml.append(ele)
return xml
def get_context(self):
"""
Extend context by data for transcripts basic tab.
"""
_context = super(VideoDescriptor, self).get_context()
metadata_fields = copy.deepcopy(self.editable_metadata_fields)
display_name = metadata_fields['display_name']
video_url = metadata_fields['html5_sources']
youtube_id_1_0 = metadata_fields['youtube_id_1_0']
def get_youtube_link(video_id):
if video_id:
return 'http://youtu.be/{0}'.format(video_id)
else:
return ''
video_url.update({
'help': _('A YouTube URL or a link to a file hosted anywhere on the web.'),
'display_name': 'Video URL',
'field_name': 'video_url',
'type': 'VideoList',
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]
})
youtube_id_1_0_value = get_youtube_link(youtube_id_1_0['value'])
if youtube_id_1_0_value:
video_url['value'].insert(0, youtube_id_1_0_value)
metadata = {
'display_name': display_name,
'video_url': video_url
}
_context.update({'transcripts_basic_tab_metadata': metadata})
return _context
@classmethod
def _parse_youtube(cls, data):
"""
......
/*
* jQuery.ajaxQueue - A queue for ajax requests
*
* @copyright: Copyright (c) 2013 Corey Frang
* @license: Licensed under the MIT license. See https://github.com/gnarf/jquery-ajaxQueue/blob/master/LICENSE-MIT.
* @website: https://github.com/gnarf/jquery-ajaxQueue
*/
(function($) {
// jQuery on an empty object, we are going to use this as our Queue
var ajaxQueue = $({});
$.ajaxQueue = function( ajaxOpts ) {
var jqXHR,
dfd = $.Deferred(),
promise = dfd.promise();
// run the actual query
function doRequest( next ) {
jqXHR = $.ajax( ajaxOpts );
jqXHR.done( dfd.resolve )
.fail( dfd.reject )
.then( next, next );
}
// queue our ajax request
ajaxQueue.queue( doRequest );
// add the abort method
promise.abort = function( statusText ) {
// proxy abort to the jqXHR if it is active
if ( jqXHR ) {
return jqXHR.abort( statusText );
}
// if there wasn't already a jqXHR we need to remove from queue
var queue = ajaxQueue.queue(),
index = $.inArray( doRequest, queue );
if ( index > -1 ) {
queue.splice( index, 1 );
}
// and then reject the deferred
dfd.rejectWith( ajaxOpts.context || ajaxOpts, [ promise, statusText, "" ] );
return promise;
};
return promise;
};
})(jQuery);
1
00:00:00,293 --> 00:00:01,245
好 各位同学
2
00:00:01,493 --> 00:00:03,821
我们今天要讲的题目是
3
00:00:04,037 --> 00:00:05,813
从算筹到ENIAC
4
00:00:06,181 --> 00:00:07,347
那么今天的主要内容
5
00:00:07,733 --> 00:00:10,904
我会从远古的手动的一些计算工具
6
00:00:11,197 --> 00:00:14,445
一直讲到我们现代的电子计算机ENIAC
7
00:00:15,165 --> 00:00:18,925
首先我们来介绍一下远古的计算工具
8
00:00:19,221 --> 00:00:21,125
我们看
9
00:00:21,491 --> 00:00:24,829
我们人类社会最早的计算工具是什么呢
10
00:00:25,126 --> 00:00:27,365
其实早先可以有结绳记事
11
00:00:27,749 --> 00:00:28,789
但是那个呢谈不上计算
12
00:00:29,221 --> 00:00:30,989
只能称得上是存储
13
00:00:31,405 --> 00:00:32,653
那么最早的计算工具
14
00:00:32,877 --> 00:00:36,149
是出现在中国的商周时期
15
00:00:36,357 --> 00:00:37,063
是什么呢
16
00:00:37,357 --> 00:00:39,805
就是这样的一些东西
17
00:00:40,117 --> 00:00:40,965
叫算筹
18
00:00:41,320 --> 00:00:42,557
那么我们古代成语
19
00:00:43,021 --> 00:00:44,445
“运筹帷幄”之中的“筹”
20
00:00:44,741 --> 00:00:46,141
就是这个算筹
21
00:00:47,729 --> 00:00:49,109
这是普通的算筹
22
00:00:49,373 --> 00:00:50,109
就是一些小木棍
23
00:00:50,557 --> 00:00:52,173
它的高端产品是什么呢
24
00:00:52,509 --> 00:00:53,869
就是一些小骨头棍
25
00:00:54,205 --> 00:00:57,237
这个算筹用来怎么计数呢
26
00:00:57,526 --> 00:01:00,741
我们古代有“横式”“纵式”两种计数方式
27
00:01:01,077 --> 00:01:04,477
其实就是不同的排放组合来代表不同的数字
28
00:01:05,109 --> 00:01:06,125
那么大家想
29
00:01:06,405 --> 00:01:09,509
这些小木棍用来作为计算工具
30
00:01:09,821 --> 00:01:13,253
能够完成什么样的工作呢
31
00:01:13,565 --> 00:01:18,013
事实上我们中国古代有一位老大爷
32
00:01:18,261 --> 00:01:21,389
他用算筹还是做了一些工作
33
00:01:21,702 --> 00:01:23,173
我们来认识一下这位老大爷
34
00:01:25,422 --> 00:01:26,149
他是谁呢
35
00:01:26,613 --> 00:01:27,691
他叫祖冲之
36
00:01:28,582 --> 00:01:30,501
他用算筹做出了什么呢
37
00:01:30,820 --> 00:01:36,453
他把圆周率从3.1415926到3.1415927之间估算出来
38
00:01:36,845 --> 00:01:41,333
所以算筹还是可以做一些很好的工作的
39
00:01:42,181 --> 00:01:44,533
同样的这个年代
40
00:01:44,845 --> 00:01:47,807
在欧洲也有类似的一些工具
41
00:01:48,253 --> 00:01:50,493
那就是欧洲的Napier算筹
42
00:01:51,157 --> 00:01:52,413
欧洲的这个Napier算筹
43
00:01:52,661 --> 00:01:54,989
它是依据一定的计算原理来做的
44
00:01:55,317 --> 00:01:56,030
什么原理呢
45
00:01:56,373 --> 00:01:57,101
我说是“格子原理”
46
00:01:57,501 --> 00:01:58,693
那什么是“格子原理”呢
47
00:01:58,965 --> 00:02:00,091
我们来看一下
48
00:02:00,462 --> 00:02:01,157
举个简单例子
49
00:02:01,541 --> 00:02:04,205
比如说我们要计算24乘以36
50
00:02:05,585 --> 00:02:07,765
这个时候我们用一个格子把它画出来
51
00:02:08,277 --> 00:02:10,933
把2、4、3、6分别对应横向的格和纵向的格
52
00:02:11,597 --> 00:02:14,837
这时候我们把每一个格子分成两个部分
53
00:02:15,197 --> 00:02:16,917
其中要填上数字
54
00:02:17,237 --> 00:02:17,775
填什么数字呢
55
00:02:18,037 --> 00:02:19,357
比如说2乘以6
56
00:02:21,234 --> 00:02:22,461
它的这个相应的位置
57
00:02:22,901 --> 00:02:23,862
2乘以6等于12
58
00:02:24,165 --> 00:02:26,021
相应的位置就填上1和2
59
00:02:27,117 --> 00:02:28,117
类似的方法
60
00:02:28,461 --> 00:02:30,013
我们把所有的格子的数字都填满
61
00:02:30,525 --> 00:02:32,949
那么好 24乘36到底等于多少
62
00:02:33,269 --> 00:02:36,533
我们看 个位数就是4
63
00:02:36,776 --> 00:02:39,741
十位数呢就是这样相加 就是6
64
00:02:40,037 --> 00:02:42,517
百位数就是这样相加 就是8
65
00:02:42,864 --> 00:02:45,981
864 大家去验算一下是不是对的
66
00:02:46,525 --> 00:02:47,638
是对的
67
00:02:48,581 --> 00:02:50,181
利用这样的一个“格子原理”
68
00:02:50,525 --> 00:02:53,829
欧洲的Napier发明了这种算筹
69
00:02:54,173 --> 00:02:55,277
就是这个样子的
70
00:02:55,603 --> 00:02:57,837
大家看 就是这样
71
00:03:00,402 --> 00:03:03,605
后来中国又出现了一种
72
00:03:03,893 --> 00:03:06,644
低碳 环保 便携
73
00:03:07,229 --> 00:03:09,540
同时解决问题又非常利索
74
00:03:09,797 --> 00:03:11,965
三下五除二就可以搞定的计算工具
75
00:03:12,557 --> 00:03:13,277
是什么呢
76
00:03:13,904 --> 00:03:15,079
就是算盘
77
00:03:16,205 --> 00:03:18,709
这个算盘应该说在中国古代
78
00:03:19,021 --> 00:03:21,709
社会发展中起到了重要的作用
79
00:03:22,060 --> 00:03:22,789
就不用多说了
80
00:03:25,157 --> 00:03:27,445
它出现在宋 元 大概这个年代
81
00:03:29,189 --> 00:03:31,900
还是大概同一时代
82
00:03:32,597 --> 00:03:34,061
在欧洲有一个数学家
83
00:03:34,853 --> 00:03:36,420
英国的数学家奥特雷德
84
00:03:36,842 --> 00:03:40,629
他发明了一种计算工具叫“计算尺”
85
00:03:41,701 --> 00:03:43,324
这个计算尺实际上
86
00:03:43,660 --> 00:03:45,772
尽管是在十五世纪发明的
87
00:03:46,029 --> 00:03:48,277
但是真正推广应用的
88
00:03:48,956 --> 00:03:49,941
让世人所知的
89
00:03:50,221 --> 00:03:51,060
是谁呢
90
00:03:51,364 --> 00:03:53,381
是到了十八世纪的时候
91
00:03:53,820 --> 00:03:54,412
一位人物
92
00:03:54,717 --> 00:03:55,495
叫做瓦特
93
00:03:56,509 --> 00:03:58,717
就是发明蒸汽机的瓦特
94
00:03:59,564 --> 00:04:01,565
他把计算尺做了一点点改进
95
00:04:01,893 --> 00:04:02,317
怎么改进呢
96
00:04:02,821 --> 00:04:03,573
大家看
97
00:04:03,941 --> 00:04:09,076
计算尺上增加了一个滑动的标
98
00:04:09,405 --> 00:04:11,645
这个标是用来作为
99
00:04:12,052 --> 00:04:14,901
记录中间的计算结果用的
100
00:04:15,652 --> 00:04:17,596
所以瓦特用它做了大量的计算
101
00:04:17,949 --> 00:04:20,789
也为后来的工业发展起到了重要作用
102
00:04:21,348 --> 00:04:22,740
这是计算尺
103
00:04:24,445 --> 00:04:25,973
这是远古的计算工具
104
00:04:26,261 --> 00:04:27,348
我们说远古的计算工具
105
00:04:27,724 --> 00:04:30,116
大体可以称为是手动计算工具
106
00:04:30,869 --> 00:04:32,772
后来又有一些先辈
107
00:04:33,196 --> 00:04:34,365
他们利用他们的智慧
108
00:04:34,748 --> 00:04:38,386
为我们发明了机械式的计算工具
109
00:04:38,893 --> 00:04:40,469
下面呢我们来看一下
110
00:04:40,837 --> 00:04:41,740
机械式计算工具
111
00:04:44,260 --> 00:04:47,052
在这一部分我要讲四个案例
112
00:04:47,716 --> 00:04:50,224
按照历史的顺序我要将四个案例
113
00:04:50,772 --> 00:04:52,564
其实我们还有一些别的计算工具
114
00:04:53,244 --> 00:04:54,724
我们首先来看
115
00:04:55,988 --> 00:04:59,332
在欧洲文艺复兴的期间
116
00:05:00,294 --> 00:05:05,103
有一位老人家设计了这样的一个装置
117
00:05:05,670 --> 00:05:09,102
由十三个齿轮来做加法
118
00:05:10,078 --> 00:05:10,998
这样一个装置
119
00:05:11,518 --> 00:05:13,238
那么这个装置实际的形状
120
00:05:13,886 --> 00:05:14,622
就是这样的形状
121
00:05:14,750 --> 00:05:17,084
那么是哪一位老人家设计的呢
122
00:05:17,924 --> 00:05:18,804
我们来认识一下
123
00:05:21,364 --> 00:05:22,356
哇 蒙娜丽莎
124
00:05:23,084 --> 00:05:24,012
的作者达芬奇
125
00:05:25,716 --> 00:05:27,524
他设计的这个装置
126
00:05:29,756 --> 00:05:32,908
一直是停留在手稿状态
127
00:05:33,692 --> 00:05:35,268
后来无意当中被人发现
128
00:05:35,837 --> 00:05:37,268
说达芬奇怎么还有这么一个发明
129
00:05:38,581 --> 00:05:39,852
但是一直到1968年
130
00:05:40,284 --> 00:05:41,748
才有人真正把他这个装置
131
00:05:42,148 --> 00:05:43,884
按照他的这个说明恢复了出来
132
00:05:44,348 --> 00:05:45,332
就是下面这个图
133
00:05:45,996 --> 00:05:47,516
这个图大家可以看到
134
00:05:48,004 --> 00:05:48,412
就是一个真实的装置
135
00:05:48,935 --> 00:05:51,380
你们在某些博物馆也许会看到这个装置
136
00:05:51,828 --> 00:05:56,204
这是最早达芬奇做了一个加法器的设计
137
00:05:56,852 --> 00:05:57,316
是一个机械式的
138
00:05:58,291 --> 00:06:00,772
这个呢大约是在十四世纪的时候
139
00:06:01,116 --> 00:06:04,180
又过了大约两百年
140
00:06:05,700 --> 00:06:07,933
一位德国的科学家叫契克卡德
141
00:06:08,836 --> 00:06:12,620
他又设计了一个机械式的计算装置
142
00:06:13,636 --> 00:06:14,616
这是契克卡德
143
00:06:15,021 --> 00:06:18,428
他发明的这个装置是这个样子的
144
00:06:19,268 --> 00:06:19,957
为什么这样呢?
145
00:06:20,164 --> 00:06:22,476
因为他最早发明这个装置是木头的
146
00:06:23,108 --> 00:06:24,005
放到他的家乡
147
00:06:25,445 --> 00:06:26,604
结果就因为是木头的
148
00:06:27,015 --> 00:06:28,452
有一次家乡不小心失火了
149
00:06:29,748 --> 00:06:30,452
一把火烧掉了
150
00:06:31,356 --> 00:06:32,284
所以留下的只有图纸
151
00:06:33,212 --> 00:06:35,228
后人又根据他的图纸
152
00:06:35,916 --> 00:06:37,277
真实地再现了
153
00:06:37,917 --> 00:06:39,278
他所设计的这个计算装置
154
00:06:40,284 --> 00:06:40,989
也还是木头的
155
00:06:41,572 --> 00:06:43,796
发现这个装置运行非常好
156
00:06:45,220 --> 00:06:46,692
这是德国的科学家当时
157
00:06:47,677 --> 00:06:50,004
用木头做的一个计算装置
158
00:06:51,101 --> 00:06:54,789
大体上和契克卡德在同一年代
159
00:06:55,573 --> 00:06:57,149
又有一位年轻人
160
00:06:57,565 --> 00:07:00,709
他在19岁的时候设计了一个
161
00:07:01,085 --> 00:07:03,381
也是机械式计算装置
162
00:07:03,925 --> 00:07:05,013
叫齿轮计算器
163
00:07:05,669 --> 00:07:06,317
就是这样的
164
00:07:06,661 --> 00:07:07,765
这个计算器的特点是什么
165
00:07:08,173 --> 00:07:10,325
十进制 带进位
166
00:07:10,989 --> 00:07:11,669
这样的一个特点
167
00:07:12,029 --> 00:07:14,189
这个年轻人为什么要设计这样一个装置呢
168
00:07:14,989 --> 00:07:18,365
是因为他的父亲是政府官员
169
00:07:18,901 --> 00:07:22,725
负责的工作是每天都要计算复杂的税率
170
00:07:23,293 --> 00:07:24,805
计算任务非常重
171
00:07:25,490 --> 00:07:28,309
他年纪轻轻的时候就觉得
172
00:07:28,685 --> 00:07:29,485
父亲很辛苦
173
00:07:29,869 --> 00:07:32,277
我希望能够给父亲做一点事情
174
00:07:32,613 --> 00:07:33,221
所以他就想
175
00:07:33,509 --> 00:07:34,661
我可不可以用一个机械的装置
176
00:07:35,013 --> 00:07:36,741
来代替父亲的这种繁琐的工作
177
00:07:37,069 --> 00:07:40,789
所以他就设计了这么一个装置
178
00:07:41,117 --> 00:07:42,629
叫齿轮计算器
179
00:07:44,803 --> 00:07:45,547
谁设计的呢
180
00:07:45,771 --> 00:07:46,569
就是这位年轻人
181
00:07:46,875 --> 00:07:47,880
大家可能就不太认识
182
00:07:48,253 --> 00:07:50,701
这位年轻人的名字叫做帕斯卡
183
00:07:51,952 --> 00:07:53,488
大家觉得耳熟啊
184
00:07:53,957 --> 00:07:55,149
帕斯卡是谁呢
185
00:07:55,525 --> 00:07:57,437
没错 就是你想的那个压强的单位
186
00:07:57,789 --> 00:07:58,269
帕斯卡
187
00:07:58,989 --> 00:08:02,229
他最早设计了这个齿轮式的计算器
188
00:08:03,725 --> 00:08:05,837
这个计算器的实物是这样的
189
00:08:06,637 --> 00:08:07,405
它最后生产没有呢
190
00:08:07,797 --> 00:08:08,677
生产了
191
00:08:08,965 --> 00:08:09,693
而且生产了很多
192
00:08:10,928 --> 00:08:12,949
生产了很多之后
193
00:08:13,341 --> 00:08:16,293
有几个样品当时还曾经送到了中国
194
00:08:17,077 --> 00:08:18,221
可惜中国当时也没有用
195
00:08:19,653 --> 00:08:22,086
这是打开后里面的装置
196
00:08:22,621 --> 00:08:26,725
通过齿轮的咬合来进行十进制的计算
197
00:08:27,565 --> 00:08:28,597
这是帕斯卡
198
00:08:29,131 --> 00:08:32,051
那么帕斯卡他所做的工作
199
00:08:32,763 --> 00:08:35,202
应该说在那个年代超前的
200
00:08:35,538 --> 00:08:36,538
也是一种非凡的
201
00:08:37,571 --> 00:08:39,702
那么 在帕斯卡生命的最后几年
202
00:08:40,131 --> 00:08:44,779
他专心地在写 总结自己的思想
203
00:08:45,251 --> 00:08:46,899
他在他的书中写道
204
00:08:47,251 --> 00:08:51,493
这种计算器所进行的工作比动物的行为
205
00:08:51,931 --> 00:08:54,179
更接近于人类的思维
206
00:08:55,747 --> 00:08:57,611
也就是说 他实际上
207
00:08:57,971 --> 00:09:00,499
提出了一种非凡的想法
208
00:09:01,099 --> 00:09:01,995
为了实现这样一个目的
209
00:09:02,371 --> 00:09:02,707
是什么
210
00:09:03,075 --> 00:09:08,475
就是利用纯粹的机械的装置
211
00:09:09,691 --> 00:09:14,771
来代替我们人类的思考和记忆
212
00:09:15,779 --> 00:09:17,867
那么这种想法在当时可以说
213
00:09:18,210 --> 00:09:21,194
是一种非凡的创新 非凡的创举
214
00:09:22,369 --> 00:09:25,449
但是非常可惜 他有这样非凡的创举
215
00:09:25,894 --> 00:09:30,705
但是帕斯卡呢 在39岁的时候就去世了
216
00:09:31,519 --> 00:09:33,082
英年早逝了 特别可惜
217
00:09:33,673 --> 00:09:34,929
那么帕斯卡其实自己呢
218
00:09:35,361 --> 00:09:36,538
也对自己有一个评价
219
00:09:36,945 --> 00:09:40,722
他说 人好比是脆弱的芦苇
220
00:09:41,218 --> 00:09:44,210
但是 他又是有思想的芦苇
221
00:09:45,227 --> 00:09:46,593
其实这句话很有意思
222
00:09:47,002 --> 00:09:47,793
我们现在回想
223
00:09:48,146 --> 00:09:51,490
我们现在很多人比一个强壮的芦苇
224
00:09:51,969 --> 00:09:53,529
不知道要强壮多少倍
225
00:09:54,073 --> 00:09:58,362
但是最终也是像芦苇一样悄无声息
226
00:09:59,209 --> 00:09:59,833
为什么呢
227
00:10:00,514 --> 00:10:02,905
大家可以想一想帕斯卡的这句话
228
00:10:05,661 --> 00:10:07,242
好 那么帕斯卡之后
229
00:10:07,977 --> 00:10:11,257
有一位比帕斯卡小二十多岁的年轻人
230
00:10:11,906 --> 00:10:15,682
那么 他被帕斯卡的想法深深的迷住了
231
00:10:16,130 --> 00:10:17,490
他后来设计了一个
232
00:10:17,842 --> 00:10:19,826
大约在帕斯卡计算机之后
233
00:10:20,137 --> 00:10:21,937
我们推算在大约在半个世纪之后
234
00:10:22,441 --> 00:10:24,881
他设计了一个乘法器
235
00:10:25,681 --> 00:10:26,905
大家看这个乘法器
236
00:10:27,450 --> 00:10:29,505
那么这个乘法器看这个样子就比较
237
00:10:29,834 --> 00:10:31,697
高端 大气 上档次
238
00:10:32,393 --> 00:10:33,337
比之前的要好看多了
239
00:10:33,889 --> 00:10:35,298
那么这个乘法器谁设计的呢
240
00:10:35,713 --> 00:10:37,225
我们认识一下这位年轻人
241
00:10:38,338 --> 00:10:40,441
他的名字叫莱布尼茨
242
00:10:42,233 --> 00:10:44,058
那么这个莱布尼茨 他这个乘法器
243
00:10:44,681 --> 00:10:47,297
和之前帕斯卡的加法器的区别在什么地方
244
00:10:47,834 --> 00:10:51,505
它是二进制的 所以这是它最大的特点
245
00:10:51,833 --> 00:10:52,593
二进制 乘法器
246
00:10:52,928 --> 00:10:53,673
机械式的
247
00:10:54,073 --> 00:10:55,258
那么说到这个二进制呢
248
00:10:55,505 --> 00:10:59,654
应该说还是和中国的文化还是相当有些渊源
249
00:11:00,050 --> 00:11:02,825
那么据说莱布尼茨的二进制想法
250
00:11:03,356 --> 00:11:05,418
来自于我们的伏羲八卦图
251
00:11:06,114 --> 00:11:07,241
那么怎么对应呢
252
00:11:07,697 --> 00:11:08,329
就是伏羲八卦图当中的
253
00:11:08,737 --> 00:11:11,657
乾 坤 坎 离 巽 艮 震 兑
254
00:11:11,938 --> 00:11:13,778
这八个卦呢 分别可以用
255
00:11:14,057 --> 00:11:18,113
二进制的000 001等等 这样表示出来
256
00:11:18,961 --> 00:11:20,540
所以呢 有一种说法呢 说
257
00:11:20,915 --> 00:11:23,543
莱布尼茨的二进制思想来源于中国的八卦
258
00:11:23,969 --> 00:11:25,393
当然 莱布尼茨本人是否认的
259
00:11:25,905 --> 00:11:27,730
但是又有中国学者又考证过
260
00:11:28,034 --> 00:11:29,090
说他否认不了这一点
261
00:11:29,298 --> 00:11:30,761
他肯定是之前见过这个八卦的
262
00:11:31,465 --> 00:11:33,906
当然这个事情 我们不去再考证
263
00:11:34,385 --> 00:11:38,217
它到底是起源于什么地方 二进制
264
00:11:38,489 --> 00:11:40,818
我觉得我们现在自强是更重要的
265
00:11:42,161 --> 00:11:43,673
好 那么我们这个小节呢
266
00:11:44,105 --> 00:11:47,161
主要就给大家介绍了最远古的手动计算工具
267
00:11:47,521 --> 00:11:48,969
和机械式的一些计算工具
268
00:11:49,489 --> 00:11:51,193
那么也体现了先辈的智慧
269
00:11:52,465 --> 00:11:57,954
但是真正对未来的 也就是现在的计算机科学
270
00:11:58,275 --> 00:11:59,841
产生重大影响
271
00:12:00,241 --> 00:12:01,773
我们有两位伟大的先驱
272
00:12:02,777 --> 00:12:03,657
那么 他们的故事
273
00:12:03,929 --> 00:12:05,988
我们在下一节将要给大家讲述
{
"start": [
1000
],
"end": [
2000
],
"text": [
"Equal transcripts"
]
}
\ No newline at end of file
{
"start": [
270,
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360
],
"end": [
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360,
114220
],
"text": [
"LILA FISHER: Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.",
"As you know, our courses are entirely online.",
"So before we start learning about the subjects that",
"brought you here, let's learn about the tools that you will",
"use to navigate through the course material.",
"Let's start with what is on your screen right now.",
"You are watching a video of me talking.",
"You have several tools associated with these videos.",
"Some of them are standard video buttons, like the play",
"Pause Button on the bottom left.",
"Like most video players, you can see how far you are into",
"this particular video segment and how long the entire video",
"segment is.",
"Something that you might not be used to",
"is the speed option.",
"While you are going through the videos, you can speed up",
"or slow down the video player with these buttons.",
"Go ahead and try that now.",
"Make me talk faster and slower.",
"If you ever get frustrated by the pace of speech, you can",
"adjust it this way.",
"Another great feature is the transcript on the side.",
"This will follow along with everything that I am saying as",
"I am saying it, so you can read along if you like.",
"You can also click on any of the words, and you will notice",
"that the video jumps to that word.",
"The video slider at the bottom of the video will let you",
"navigate through the video quickly.",
"If you ever find the transcript distracting, you",
"can toggle the captioning button in order to make it go",
"away or reappear.",
"Now that you know about the video player, I want to point",
"out the sequence navigator.",
"Right now you're in a lecture sequence, which interweaves",
"many videos and practice exercises.",
"You can see how far you are in a particular sequence by",
"observing which tab you're on.",
"You can navigate directly to any video or exercise by",
"clicking on the appropriate tab.",
"You can also progress to the next element by pressing the",
"Arrow button, or by clicking on the next tab.",
"Try that now.",
"The tutorial will continue in the next video."
]
}
\ No newline at end of file
{
"start": [
270,
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360
],
"end": [
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360,
114220
],
"text": [
"LILA FISHER: Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.",
"As you know, our courses are entirely online.",
"So before we start learning about the subjects that",
"brought you here, let's learn about the tools that you will",
"use to navigate through the course material.",
"Let's start with what is on your screen right now.",
"You are watching a video of me talking.",
"You have several tools associated with these videos.",
"Some of them are standard video buttons, like the play",
"Pause Button on the bottom left.",
"Like most video players, you can see how far you are into",
"this particular video segment and how long the entire video",
"segment is.",
"Something that you might not be used to",
"is the speed option.",
"While you are going through the videos, you can speed up",
"or slow down the video player with these buttons.",
"Go ahead and try that now.",
"Make me talk faster and slower.",
"If you ever get frustrated by the pace of speech, you can",
"adjust it this way.",
"Another great feature is the transcript on the side.",
"This will follow along with everything that I am saying as",
"I am saying it, so you can read along if you like.",
"You can also click on any of the words, and you will notice",
"that the video jumps to that word.",
"The video slider at the bottom of the video will let you",
"navigate through the video quickly.",
"If you ever find the transcript distracting, you",
"can toggle the captioning button in order to make it go",
"away or reappear.",
"Now that you know about the video player, I want to point",
"out the sequence navigator.",
"Right now you're in a lecture sequence, which interweaves",
"many videos and practice exercises.",
"You can see how far you are in a particular sequence by",
"observing which tab you're on.",
"You can navigate directly to any video or exercise by",
"clicking on the appropriate tab.",
"You can also progress to the next element by pressing the",
"Arrow button, or by clicking on the next tab.",
"Try that now.",
"The tutorial will continue in the next video."
]
}
\ No newline at end of file
0
00:00:00,270 --> 00:00:02,720
LILA FISHER: Hi, welcome to Edx.
1
00:00:02,720 --> 00:00:05,430
I'm Lila Fisher, an Edx fellow helping to put
2
00:00:05,430 --> 00:00:07,160
together these courses.
3
00:00:07,160 --> 00:00:10,830
As you know, our courses are entirely online.
4
00:00:10,830 --> 00:00:12,880
So before we start learning about the subjects that
5
00:00:12,880 --> 00:00:15,890
brought you here, let's learn about the tools that you will
6
00:00:15,890 --> 00:00:19,000
use to navigate through the course material.
7
00:00:19,000 --> 00:00:22,070
Let's start with what is on your screen right now.
8
00:00:22,070 --> 00:00:25,170
You are watching a video of me talking.
9
00:00:25,170 --> 00:00:27,890
You have several tools associated with these videos.
10
00:00:27,890 --> 00:00:30,590
Some of them are standard video buttons, like the play
......@@ -2,5 +2,9 @@
CMS module
*******************************************
.. module:: cms
.. toctree::
transcripts.rst
.. module:: transcripts
======================================================
Developer’s workflow for the timed transcripts in CMS.
======================================================
:download:`Multipage pdf version of Timed Transcripts workflow. <transcripts_workflow.pdf>`
:download:`Open office graph version (source for pdf). <transcripts_workflow.odg>`
:download:`List of implemented acceptance tests. <transcripts_acceptance_tests.odt>`
Description
===========
Timed Transcripts functionality is added in separate tab of Video module Editor, that is active by default. This tab is called `Basic`, another tab is called `Advanced` and contains default metadata fields.
`Basic` tab is a simple representation of `Advanced` tab that provides functionality to speed up adding Video module with transcripts to the course.
To make more accurate adjustments `Advanced` tab should be used.
Front-end part of `Basic` tab has 4 editors/views:
* Display name
* 3 editors for inserting Video URLs.
Video URL fields might contain 3 kinds of URLs:
* **YouTube** link. There are supported formats:
* http://www.youtube.com/watch?v=OEoXaMPEzfM&feature=feedrec_grec_index ;
* http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/OEoXaMPEzfM ;
* http://www.youtube.com/v/OEoXaMPEzfM?fs=1&amp;hl=en_US&amp;rel=0 ;
* http://www.youtube.com/watch?v=OEoXaMPEzfM#t=0m10s ;
* http://www.youtube.com/embed/OEoXaMPEzfM?rel=0 ;
* http://www.youtube.com/watch?v=OEoXaMPEzfM ;
* http://youtu.be/OEoXaMPEzfM ;
* **MP4** video source;
* **WEBM** video source.
Each of these kind of URLs can be specified just **ONCE**. Otherwise, error message occurs on front-end.
After filling editor **transcripts/check** method will be invoked with the parameters described below (see `API`_). Depending on conditions, that are also described below (see `Commands`_), this method responds with a *command* and front-end renders the appropriate View.
Each View can have specific actions. There is a list of supported actions:
* Download Timed Transcripts;
* Upload Timed Transcripts;
* Import Timed Transcripts from YouTube;
* Replace edX Timed Transcripts by Timed Transcripts from YouTube;
* Choose Timed Transcripts;
* Use existing Timed Transcripts.
All of these actions are handled by 7 API methods described below (see `API`_).
Because rollback functionality isn't implemented now, after invoking some of the actions user cannot revert changes by clicking button `Cancel`.
To remove timed transcripts file from the video just go to `Advanced` tab and clear field `sub` then Save changes.
Commands
========
Command from front-end point of view is just a reference to the needed View with possible actions that user can do depending on conditions described below (See edx-platform/cms/static/js/views/transcripts/message_manager.js:21-29).
So,
* **IF** YouTube transcripts present locally **AND** on YouTube server **AND** both of these transcripts files are **DIFFERENT**, we respond with `replace` command. Ask user to replace local transcripts file by YouTube's ones.
* **IF** YouTube transcripts present **ONLY** locally, we respond with `found` command.
* **IF** YouTube transcripts present **ONLY** on YouTube server, we respond with `import` command. Ask user to import transcripts file from YouTube server.
* **IF** player is in HTML5 video mode. It means that **ONLY** html5 sources are added:
* **IF** just 1 html5 source was added or both html5 sources have **EQUAL** transcripts files, then we respond with `found` command.
* **OTHERWISE**, when 2 html5 sources were added and founded transcripts files are **DIFFERENT**, we respond with `choose` command. In this case, user should choose which one transcripts file he wants to use.
* **IF** we are working with just 1 field **AND** item.sub field **HAS** a value **AND** user fills editor/view by the new value/video source without transcripts file, we respond with `use_existing` command. In this case, user will have possibility to use transcripts file from previous video.
* **OTHERWISE**, we will respond with `not_found` command.
Synchronization and Saving workflow
====================================
For now saving mechanism works as follows:
On click `Save` button **ModuleEdit** class (See edx-platform/cms/static/coffee/src/views/module_edit.coffee:83-101) grabs values from all modified metadata fields and sends all this data to the server.
Because of the fact that Timed Transcripts is module specific functionality, ModuleEdit class is not extended. Instead, to apply all changes that user did in the `Basic` tab, we use synchronization mechanism of TabsEditingDescriptor class. That mechanism provides us possibility to do needed actions on Tab switching and on Save (See edx-platform/cms/templates/widgets/video/transcripts.html).
On tab switching and when save action is invoked, JavaScript code synchronize collections (Metadata Collection and Transcripts Collection). You can see synchronization logic in the edx-platform/cms/static/js/views/transcripts/editor.js:72-219. In this case, Metadata fields always have the actual data.
API
===
We provide 7 API methods to work with timed transcripts
(edx-platform/cms/urls.py:23-29):
* transcripts/upload
* transcripts/download
* transcripts/check
* transcripts/choose
* transcripts/replace
* transcripts/rename
* transcripts/save
**"transcripts/upload"** method is used for uploading SRT transcripts for the
HTML5 and YouTube video modules.
*Method:*
POST
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
- file - BLOB file
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/download"** method is used for downloading SRT transcripts for the
HTML5 and YouTube video modules.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- subs_id - file name that is used to find transcripts file in the storage.
*Response:*
HTTP 404
or
HTTP 200 + BLOB of SRT file
**"transcripts/check"** method is used for checking availability of timed transcripts
for the video module.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
command: string with action to front-end what to do and what to show to user,
subs: file name of transcripts file that was found in the storage,
html5_local: [] or [True] or [True, True],
is_youtube_mode: True/False,
youtube_local: True/False,
youtube_server: True/False,
youtube_diff: True/False,
current_item_subs: string with value of item.sub field,
status: 'Error' or 'Success'
}
**"transcripts/choose"** method is used for choosing which transcripts file should be used.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
- html5_id - file name of chosen transcripts file.
*Response:*
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/replace"** method is used for handling `import` and `replace` commands.
Invoking this method starts downloading new transcripts file from YouTube server.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/rename"** method is used for handling `use_existing` command.
After invoking this method current transcripts file will be copied and renamed to another one with name of current video passed in the editor/view.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/save"** method is used for handling `save` command.
After invoking this method all changes will be saved that were done before this moment.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- metadata - new values for the metadata fields.
- currents_subs - list with the file names of videos passed in the editor/view.
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error'
}
Transcripts modules:
====================
.. automodule:: contentstore.views.transcripts_ajax
:members:
:show-inheritance:
.. automodule:: contentstore.transcripts_utils
:members:
:show-inheritance:
#pylint: disable=C0111
#pylint: disable=W0621
from courseware.mock_youtube_server.mock_youtube_server import MockYoutubeServer
from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer
from lettuce import before, after, world
from django.conf import settings
import threading
......@@ -25,6 +24,8 @@ def setup_mock_youtube_server():
server.time_to_response = 1 # seconds
server.address = address
# Start the server running in a separate daemon thread
# Because the thread is a daemon, it will terminate
# when the main thread terminates.
......
......@@ -5,7 +5,7 @@ desc "Invoke sphinx 'make build' to generate docs."
task :builddocs, [:type, :quiet] do |t, args|
args.with_defaults(:quiet => "quiet")
if args.type == 'dev'
path = "docs/developer"
path = "docs/developers"
elsif args.type == 'author'
path = "docs/course_authors"
elsif args.type == 'data'
......@@ -26,7 +26,7 @@ end
desc "Show docs in browser (mac and ubuntu)."
task :showdocs, [:options] do |t, args|
if args.options == 'dev'
path = "docs/developer"
path = "docs/developers"
elsif args.options == 'author'
path = "docs/course_authors"
elsif args.options == 'data'
......
......@@ -56,6 +56,7 @@ pyparsing==1.5.6
python-memcached==1.48
python-openid==2.2.5
pytz==2012h
pysrt==0.4.7
PyYAML==3.10
requests==1.2.3
scipy==0.11.0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment