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)
|
||||
Reference in New Issue
Block a user