Add Timed Transcripts Editor.
This commit is contained in:
@@ -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
|
||||
|
||||
654
cms/djangoapps/contentstore/features/transcripts.feature
Normal file
654
cms/djangoapps/contentstore/features/transcripts.feature
Normal file
@@ -0,0 +1,654 @@
|
||||
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
|
||||
|
||||
246
cms/djangoapps/contentstore/features/transcripts.py
Normal file
246
cms/djangoapps/contentstore/features/transcripts.py
Normal file
@@ -0,0 +1,246 @@
|
||||
# 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$')
|
||||
|
||||
45
cms/djangoapps/contentstore/features/youtube_setup.py
Normal file
45
cms/djangoapps/contentstore/features/youtube_setup.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#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()
|
||||
@@ -1,14 +1,18 @@
|
||||
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
|
||||
"""Tests for items views."""
|
||||
|
||||
import json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
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 xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
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()
|
||||
|
||||
698
cms/djangoapps/contentstore/tests/test_transcripts.py
Normal file
698
cms/djangoapps/contentstore/tests/test_transcripts.py
Normal file
@@ -0,0 +1,698 @@
|
||||
"""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.')
|
||||
407
cms/djangoapps/contentstore/tests/test_transcripts_utils.py
Normal file
407
cms/djangoapps/contentstore/tests/test_transcripts_utils.py
Normal file
@@ -0,0 +1,407 @@
|
||||
""" 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)
|
||||
@@ -1,12 +1,22 @@
|
||||
""" 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):
|
||||
"""
|
||||
|
||||
341
cms/djangoapps/contentstore/transcripts_utils.py
Normal file
341
cms/djangoapps/contentstore/transcripts_utils.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
@@ -1,20 +1,23 @@
|
||||
#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:
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
"""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)
|
||||
|
||||
|
||||
555
cms/djangoapps/contentstore/views/transcripts_ajax.py
Normal file
555
cms/djangoapps/contentstore/views/transcripts_ajax.py
Normal file
@@ -0,0 +1,555 @@
|
||||
"""
|
||||
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;
|
||||
});
|
||||
|
||||
92
cms/static/js/views/abstract_editor.js
Normal file
92
cms/static/js/views/abstract_editor.js
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
|
||||
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');
|
||||
},
|
||||
|
||||
|
||||
234
cms/static/js/views/transcripts/editor.js
Normal file
234
cms/static/js/views/transcripts/editor.js
Normal file
@@ -0,0 +1,234 @@
|
||||
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;
|
||||
});
|
||||
201
cms/static/js/views/transcripts/file_uploader.js
Normal file
201
cms/static/js/views/transcripts/file_uploader.js
Normal file
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
});
|
||||
233
cms/static/js/views/transcripts/message_manager.js
Normal file
233
cms/static/js/views/transcripts/message_manager.js
Normal file
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
});
|
||||
410
cms/static/js/views/transcripts/metadata_videolist.js
Normal file
410
cms/static/js/views/transcripts/metadata_videolist.js
Normal file
@@ -0,0 +1,410 @@
|
||||
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;
|
||||
});
|
||||
365
cms/static/js/views/transcripts/utils.js
Normal file
365
cms/static/js/views/transcripts/utils.js
Normal file
@@ -0,0 +1,365 @@
|
||||
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&hl=en_US&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;
|
||||
});
|
||||
300
cms/static/js_spec/transcripts/editor_spec.js
Normal file
300
cms/static/js_spec/transcripts/editor_spec.js
Normal file
@@ -0,0 +1,300 @@
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
241
cms/static/js_spec/transcripts/file_uploader_spec.js
Normal file
241
cms/static/js_spec/transcripts/file_uploader_spec.js
Normal file
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
284
cms/static/js_spec/transcripts/message_manager_spec.js
Normal file
284
cms/static/js_spec/transcripts/message_manager_spec.js
Normal file
@@ -0,0 +1,284 @@
|
||||
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();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
264
cms/static/js_spec/transcripts/utils_spec.js
Normal file
264
cms/static/js_spec/transcripts/utils_spec.js
Normal file
@@ -0,0 +1,264 @@
|
||||
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&hl=en_US&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¶m=2#hash',
|
||||
'http://somelink.com/%s.%s#hash',
|
||||
'http://somelink.com/%s.%s?param=1¶m=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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
554
cms/static/js_spec/transcripts/videolist_spec.js
Normal file
554
cms/static/js_spec/transcripts/videolist_spec.js
Normal file
@@ -0,0 +1,554 @@
|
||||
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) {
|
||||
|
||||
10
cms/templates/js/transcripts/file-upload.underscore
Normal file
10
cms/templates/js/transcripts/file-upload.underscore
Normal file
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
@@ -0,0 +1,36 @@
|
||||
<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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
66
cms/templates/widgets/video/transcripts.html
Normal file
66
cms/templates/widgets/video/transcripts.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<%! 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'),
|
||||
|
||||
Reference in New Issue
Block a user