From aecc20af6be5c8a231fa10bc4901e96149da9074 Mon Sep 17 00:00:00 2001 From: Anton Stupak Date: Tue, 13 Aug 2013 18:10:44 +0300 Subject: [PATCH 1/3] Add Timed Transcripts Editor. --- CHANGELOG.rst | 2 + .../component_settings_editor_helpers.py | 22 +- .../contentstore/features/transcripts.feature | 654 ++++++++++ .../contentstore/features/transcripts.py | 246 ++++ .../features/video-editor.feature | 4 +- .../contentstore/features/video-editor.py | 16 +- cms/djangoapps/contentstore/features/video.py | 6 +- .../contentstore/features/youtube_setup.py | 45 + .../contentstore/tests/test_item.py | 12 +- .../contentstore/tests/test_transcripts.py | 698 +++++++++++ .../tests/test_transcripts_utils.py | 407 ++++++ .../contentstore/tests/test_utils.py | 14 +- cms/djangoapps/contentstore/tests/utils.py | 1 + .../contentstore/transcripts_utils.py | 341 +++++ cms/djangoapps/contentstore/utils.py | 17 +- cms/djangoapps/contentstore/views/__init__.py | 1 + cms/djangoapps/contentstore/views/item.py | 23 +- .../contentstore/views/transcripts_ajax.py | 555 +++++++++ cms/envs/acceptance.py | 13 + cms/envs/common.py | 6 + cms/static/coffee/spec/main.coffee | 13 + .../coffee/src/views/module_edit.coffee | 2 + cms/static/js/models/metadata.js | 1 + cms/static/js/views/abstract_editor.js | 92 ++ cms/static/js/views/metadata.js | 110 +- cms/static/js/views/transcripts/editor.js | 234 ++++ .../js/views/transcripts/file_uploader.js | 201 +++ .../js/views/transcripts/message_manager.js | 233 ++++ .../views/transcripts/metadata_videolist.js | 410 +++++++ cms/static/js/views/transcripts/utils.js | 365 ++++++ .../js_spec/helpers.js} | 0 cms/static/js_spec/transcripts/editor_spec.js | 300 +++++ .../js_spec/transcripts/file_uploader_spec.js | 241 ++++ .../transcripts/message_manager_spec.js | 284 +++++ cms/static/js_spec/transcripts/utils_spec.js | 264 ++++ .../js_spec/transcripts/videolist_spec.js | 554 +++++++++ cms/static/js_test.yml | 2 + cms/static/sass/elements/_xmodules.scss | 189 +++ cms/templates/base.html | 6 + .../js/transcripts/file-upload.underscore | 10 + .../messages/transcripts-choose.underscore | 36 + .../messages/transcripts-found.underscore | 16 + .../messages/transcripts-import.underscore | 16 + .../messages/transcripts-not-found.underscore | 16 + .../messages/transcripts-replace.underscore | 29 + .../messages/transcripts-uploaded.underscore | 16 + .../transcripts-use-existing.underscore | 39 + .../metadata-videolist-entry.underscore | 27 + cms/templates/widgets/video/transcripts.html | 66 + cms/urls.py | 9 + common/djangoapps/terrain/steps.py | 8 + common/djangoapps/terrain/ui_helpers.py | 5 + common/lib/xmodule/xmodule/js/src/.gitignore | 3 +- .../js/src/tabs/tabs-aggregator.coffee | 20 + .../lib/xmodule/xmodule/tests/test_video.py | 8 +- .../util}/mock_youtube_server/__init__.py | 0 .../mock_youtube_server.py | 113 ++ .../test_mock_youtube_server.py | 77 ++ common/lib/xmodule/xmodule/video_module.py | 60 +- common/static/js/vendor/jquery.ajaxQueue.js | 53 + .../test/data/uploads/chinese_transcripts.srt | 1092 +++++++++++++++++ .../data/uploads/subs_t__eq_exist.srt.sjson | 11 + .../data/uploads/subs_t_neq_exist.srt.sjson | 143 +++ .../data/uploads/subs_t_not_exist.srt.sjson | 143 +++ common/test/data/uploads/test_transcripts.srt | 43 + docs/developers/source/cms.rst | 4 + docs/developers/source/transcripts.rst | 247 ++++ .../source/transcripts_acceptance_tests.odt | Bin 0 -> 24815 bytes .../source/transcripts_workflow.odg | Bin 0 -> 31823 bytes .../source/transcripts_workflow.pdf | Bin 0 -> 58945 bytes .../courseware/features/youtube_setup.py | 5 +- .../mock_youtube_server.py | 80 -- .../test_mock_youtube_server.py | 53 - rakelib/docs.rake | 4 +- requirements/edx/base.txt | 1 + 75 files changed, 8760 insertions(+), 277 deletions(-) create mode 100644 cms/djangoapps/contentstore/features/transcripts.feature create mode 100644 cms/djangoapps/contentstore/features/transcripts.py create mode 100644 cms/djangoapps/contentstore/features/youtube_setup.py create mode 100644 cms/djangoapps/contentstore/tests/test_transcripts.py create mode 100644 cms/djangoapps/contentstore/tests/test_transcripts_utils.py create mode 100644 cms/djangoapps/contentstore/transcripts_utils.py create mode 100644 cms/djangoapps/contentstore/views/transcripts_ajax.py create mode 100644 cms/static/js/views/abstract_editor.js create mode 100644 cms/static/js/views/transcripts/editor.js create mode 100644 cms/static/js/views/transcripts/file_uploader.js create mode 100644 cms/static/js/views/transcripts/message_manager.js create mode 100644 cms/static/js/views/transcripts/metadata_videolist.js create mode 100644 cms/static/js/views/transcripts/utils.js rename cms/{templates/widgets/video/subtitles.html => static/js_spec/helpers.js} (100%) create mode 100644 cms/static/js_spec/transcripts/editor_spec.js create mode 100644 cms/static/js_spec/transcripts/file_uploader_spec.js create mode 100644 cms/static/js_spec/transcripts/message_manager_spec.js create mode 100644 cms/static/js_spec/transcripts/utils_spec.js create mode 100644 cms/static/js_spec/transcripts/videolist_spec.js create mode 100644 cms/templates/js/transcripts/file-upload.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-choose.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-found.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-import.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-not-found.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-replace.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-uploaded.underscore create mode 100644 cms/templates/js/transcripts/messages/transcripts-use-existing.underscore create mode 100644 cms/templates/js/transcripts/metadata-videolist-entry.underscore create mode 100644 cms/templates/widgets/video/transcripts.html rename {lms/djangoapps/courseware => common/lib/xmodule/xmodule/util}/mock_youtube_server/__init__.py (100%) create mode 100644 common/lib/xmodule/xmodule/util/mock_youtube_server/mock_youtube_server.py create mode 100644 common/lib/xmodule/xmodule/util/mock_youtube_server/test_mock_youtube_server.py create mode 100644 common/static/js/vendor/jquery.ajaxQueue.js create mode 100644 common/test/data/uploads/chinese_transcripts.srt create mode 100644 common/test/data/uploads/subs_t__eq_exist.srt.sjson create mode 100644 common/test/data/uploads/subs_t_neq_exist.srt.sjson create mode 100644 common/test/data/uploads/subs_t_not_exist.srt.sjson create mode 100644 common/test/data/uploads/test_transcripts.srt create mode 100644 docs/developers/source/transcripts.rst create mode 100644 docs/developers/source/transcripts_acceptance_tests.odt create mode 100644 docs/developers/source/transcripts_workflow.odg create mode 100644 docs/developers/source/transcripts_workflow.pdf delete mode 100644 lms/djangoapps/courseware/mock_youtube_server/mock_youtube_server.py delete mode 100644 lms/djangoapps/courseware/mock_youtube_server/test_mock_youtube_server.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e5436800a42a..d5daa92d0b5c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -64,6 +64,8 @@ LMS: Improved accessibility of parts of forum navigation sidebar. LMS: enhanced accessibility labeling and aria support for the discussion forum new post dropdown as well as response and comment area labeling. +Blades: Add Studio timed transcripts editor to video player. + LMS: enhanced shib support, including detection of linked shib account at login page and support for the ?next= GET parameter. diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 0c5565503e56..5473438571e1 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -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 diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature new file mode 100644 index 000000000000..c88d5da6ad86 --- /dev/null +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -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 + diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py new file mode 100644 index 000000000000..15413b52e5de --- /dev/null +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -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()) diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index d5b4a2a03b4e..07c298b0d214 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -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 diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 56b1610ce6f7..87fa1d7f54c4 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -5,14 +5,15 @@ 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], diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index a95191ec6daa..4f15bdc9b9cc 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -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$') diff --git a/cms/djangoapps/contentstore/features/youtube_setup.py b/cms/djangoapps/contentstore/features/youtube_setup.py new file mode 100644 index 000000000000..875b9f2286e6 --- /dev/null +++ b/cms/djangoapps/contentstore/features/youtube_setup.py @@ -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() diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index dcd94838c8b7..1317e76a10d1 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -1,14 +1,18 @@ +"""Tests for items views.""" + +import json +import datetime +from pytz import UTC +from django.core.urlresolvers import reverse + from contentstore.tests.utils import CourseTestCase from xmodule.modulestore.tests.factories import CourseFactory -from django.core.urlresolvers import reverse from xmodule.capa_module import CapaDescriptor -import json from xmodule.modulestore.django import modulestore -import datetime -from pytz import UTC class DeleteItem(CourseTestCase): + """Tests for '/delete_item' url.""" def setUp(self): """ Creates the test course with a static page in it. """ super(DeleteItem, self).setUp() diff --git a/cms/djangoapps/contentstore/tests/test_transcripts.py b/cms/djangoapps/contentstore/tests/test_transcripts.py new file mode 100644 index 000000000000..8ec510267bf8 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_transcripts.py @@ -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 = '