From 6738a37ec1531446590f49c5c1fd5ee2d50579d7 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 27 Aug 2013 11:08:22 -0400 Subject: [PATCH 1/4] Keep comments in capa XML from causing failures Comments (and processing instructions!) are handled oddly in lxml. This change will keep them from causing failures. They will be omitted from the HTML generated, which is fine, since they aren't needed there. --- common/lib/capa/capa/capa_problem.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index c2bdeadc21..08a223f609 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -555,6 +555,13 @@ class LoncapaProblem(object): Used by get_html. ''' + if not isinstance(problemtree.tag, basestring): + # Comment and ProcessingInstruction nodes are not Elements, + # and we're ok leaving those behind. + # BTW: etree gives us no good way to distinguish these things + # other than to examine .tag to see if it's a string. :( + return + if (problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type')): # leave javascript intact. From e88e04d3a6704ccdabe48f7b0fb6fe0b09ae8078 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 27 Aug 2013 11:44:41 -0400 Subject: [PATCH 2/4] A test that our XML-comments fix works. --- .../lib/capa/capa/tests/test_html_render.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 9bc326d7b9..8e343ee1cf 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -226,6 +226,26 @@ class CapaHtmlRenderTest(unittest.TestCase): span_element = rendered_html.find('span') self.assertEqual(span_element.get('attr'), "TEST") + def test_xml_comments_and_other_odd_things(self): + # Comments and processing instructions should be skipped. + xml_str = textwrap.dedent("""\ + + + ]> + + + + + """) + + # Create the problem + problem = new_loncapa_problem(xml_str) + + # Render the HTML + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
\s+
") + def _create_test_file(self, path, content_str): test_fp = self.system.filestore.open(path, "w") test_fp.write(content_str) From 6cb2e0b2064c023e884077bfdd8daab0d0801013 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 28 Aug 2013 11:13:20 -0400 Subject: [PATCH 3/4] Check extension rather than mimetype --- cms/templates/import.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/import.html b/cms/templates/import.html index a5c6b9f412..27337bf235 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -72,7 +72,7 @@ $('#fileupload').fileupload({ add: function(e, data) { submitBtn.unbind('click'); var file = data.files[0]; - if (file.type == "application/x-gzip") { + if (file.name.match(/tar\.gz$/)) { submitBtn.click(function(e){ e.preventDefault(); submitBtn.hide(); From a09e6104036d253c65a573c05c4ee6ca2ee4ffd3 Mon Sep 17 00:00:00 2001 From: Anton Stupak Date: Fri, 30 Aug 2013 09:51:48 +0300 Subject: [PATCH 4/4] Fix multiple video bug --- .../xmodule/xmodule/css/video/display.scss | 27 ++++ .../xmodule/xmodule/js/fixtures/video.html | 2 + .../xmodule/js/fixtures/video_all.html | 4 +- .../xmodule/js/fixtures/video_html5.html | 4 +- .../js/fixtures/video_no_captions.html | 2 + .../lib/xmodule/xmodule/js/spec/helper.coffee | 16 ++- .../xmodule/js/spec/video/general_spec.js | 60 +++------ .../xmodule/js/src/video/01_initialize.js | 116 ++++++++++++++---- .../js/src/video/08_video_speed_control.js | 18 ++- .../xmodule/xmodule/js/src/video/10_main.js | 11 +- common/lib/xmodule/xmodule/video_module.py | 12 +- .../courseware/features/video.feature | 33 ++++- lms/djangoapps/courseware/features/video.py | 45 +++++++ .../courseware/features/youtube_setup.py | 45 +++++++ .../mock_youtube_server/__init__.py | 0 .../mock_youtube_server.py | 81 ++++++++++++ .../test_mock_youtube_server.py | 53 ++++++++ .../courseware/tests/test_video_mongo.py | 8 +- .../courseware/tests/test_video_xml.py | 4 +- lms/envs/acceptance.py | 5 + lms/envs/acceptance_static.py | 4 + lms/templates/video.html | 3 + 22 files changed, 463 insertions(+), 90 deletions(-) create mode 100644 lms/djangoapps/courseware/features/youtube_setup.py create mode 100644 lms/djangoapps/courseware/mock_youtube_server/__init__.py create mode 100644 lms/djangoapps/courseware/mock_youtube_server/mock_youtube_server.py create mode 100644 lms/djangoapps/courseware/mock_youtube_server/test_mock_youtube_server.py diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 533ab2aec0..dc801be0f9 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -40,6 +40,12 @@ div.video { padding-bottom: 56.25%; position: relative; + div { + &.hidden { + display: none; + } + } + object, iframe { border: none; height: 100%; @@ -48,6 +54,15 @@ div.video { top: 0; width: 100%; } + + h3 { + text-align: center; + color: white; + + &.hidden { + display: none; + } + } } section.video-controls { @@ -516,6 +531,12 @@ div.video { height: 0px; } + article.video-wrapper section.video-player { + h3 { + color: black; + } + } + ol.subtitles { width: 0; height: 0; @@ -563,6 +584,12 @@ div.video { position: static; } + article.video-wrapper section.video-player { + h3 { + color: white; + } + } + div.tc-wrapper { @include clearfix; display: table; diff --git a/common/lib/xmodule/xmodule/js/fixtures/video.html b/common/lib/xmodule/xmodule/js/fixtures/video.html index 341e18ae9d..6e4df9ec9c 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video.html @@ -10,6 +10,8 @@ data-end="" data-caption-asset-path="/static/subs/" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" >
diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_all.html b/common/lib/xmodule/xmodule/js/fixtures/video_all.html index 25a3c2c0ab..85fd004976 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_all.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_all.html @@ -13,6 +13,8 @@ data-webm-source="test_files/test.webm" data-ogg-source="test_files/test.ogv" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" >
@@ -55,4 +57,4 @@
- \ No newline at end of file + diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html index 677ab9b247..f2c749ef27 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html @@ -13,6 +13,8 @@ data-webm-source="test_files/test.webm" data-ogg-source="test_files/test.ogv" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" >
@@ -27,4 +29,4 @@
- \ No newline at end of file + diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html index c611acfffd..69207230fa 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html @@ -10,6 +10,8 @@ data-end="" data-caption-asset-path="/static/subs/" data-autoplay="False" + data-yt-test-timeout="1500" + data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" >
diff --git a/common/lib/xmodule/xmodule/js/spec/helper.coffee b/common/lib/xmodule/xmodule/js/spec/helper.coffee index f3cecf71cb..7b5d3156e9 100644 --- a/common/lib/xmodule/xmodule/js/spec/helper.coffee +++ b/common/lib/xmodule/xmodule/js/spec/helper.coffee @@ -90,12 +90,24 @@ jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50'] jasmine.stubRequests = -> spyOn($, 'ajax').andCallFake (settings) -> if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/ - if settings.success + status = match[1].split('_') + if status and status[0] is 'status' + { + always: (callback) -> + callback.call(window, {}, status[1]) + error: (callback) -> + callback.call(window, {}, status[1]) + done: (callback) -> + callback.call(window, {}, status[1]) + } + else if settings.success # match[1] - it's video ID settings.success data: jasmine.stubbedMetadata[match[1]] else { always: (callback) -> - callback.call(window, {}, 'success'); + callback.call(window, {}, 'success') + done: (callback) -> + callback.call(window, {}, 'success') } else if match = settings.url.match /static(\/.*)?\/subs\/(.+)\.srt\.sjson/ settings.success jasmine.stubbedCaption diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js index 9194106fff..54f952bffb 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js @@ -55,46 +55,6 @@ expect(this.state.speed).toEqual('0.75'); }); }); - - describe('Check Youtube link existence', function () { - var statusList = { - error: 'html5', - timeout: 'html5', - abort: 'html5', - parsererror: 'html5', - success: 'youtube', - notmodified: 'youtube' - }; - - function stubDeffered(data, status) { - return { - always: function(callback) { - callback.call(window, data, status); - } - } - } - - function checkPlayer(videoType, data, status) { - this.state = new window.Video('#example'); - spyOn(this.state , 'getVideoMetadata') - .andReturn(stubDeffered(data, status)); - this.state.initialize('#example'); - - expect(this.state.videoType).toEqual(videoType); - } - - it('if video id is incorrect', function () { - checkPlayer('html5', { error: {} }, 'success'); - }); - - $.each(statusList, function(status, mode){ - it('Status:' + status + ', mode:' + mode, function () { - checkPlayer(mode, {}, status); - }); - }); - - }); - }); describe('HTML5', function () { @@ -154,10 +114,22 @@ it('parse Html5 sources', function () { var html5Sources = { - mp4: 'test_files/test.mp4', - webm: 'test_files/test.webm', - ogg: 'test_files/test.ogv' - }; + mp4: null, + webm: null, + ogg: null + }, v = document.createElement('video'); + + if (!!(v.canPlayType && v.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, ''))) { + html5Sources['webm'] = 'xmodule/include/fixtures/test.webm'; + } + + if (!!(v.canPlayType && v.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''))) { + html5Sources['mp4'] = 'xmodule/include/fixtures/test.mp4'; + } + + if (!!(v.canPlayType && v.canPlayType('video/ogg; codecs="theora"').replace(/no/, ''))) { + html5Sources['ogg'] = 'xmodule/include/fixtures/test.ogv'; + } expect(state.html5Sources).toEqual(html5Sources); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index 79bc16dbda..b41bdd6f1c 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -143,8 +143,6 @@ function (VideoPlayer) { if (state.parseYoutubeStreams(state.config.youtubeStreams)) { state.videoType = 'youtube'; - state.fetchMetadata(); - state.parseSpeed(); return true; } return false; @@ -153,9 +151,7 @@ function (VideoPlayer) { // function _prepareHTML5Video(state) // The function prepare HTML5 video, parse HTML5 // video sources etc. - function _prepareHTML5Video(state) { - state.videoType = 'html5'; - + function _prepareHTML5Video(state, html5Mode) { state.parseVideoSources( { mp4: state.config.mp4Source, @@ -164,20 +160,39 @@ function (VideoPlayer) { } ); + if (html5Mode) { + state.speeds = ['0.75', '1.0', '1.25', '1.50']; + state.videos = { + '0.75': state.config.sub, + '1.0': state.config.sub, + '1.25': state.config.sub, + '1.5': state.config.sub + }; + } + + // We must have at least one non-YouTube video source available. + // Otherwise, return a negative. + if ( + state.html5Sources.webm === null && + state.html5Sources.mp4 === null && + state.html5Sources.ogg === null + ) { + state.el.find('.video-player div').addClass('hidden'); + state.el.find('.video-player h3').removeClass('hidden'); + + return false; + } + + state.videoType = 'html5'; + if (!state.config.sub || !state.config.sub.length) { state.config.sub = ''; state.config.show_captions = false; } - state.speeds = ['0.75', '1.0', '1.25', '1.50']; - state.videos = { - '0.75': state.config.sub, - '1.0': state.config.sub, - '1.25': state.config.sub, - '1.5': state.config.sub - }; - state.setSpeed($.cookie('video_speed')); + + return true; } function _setConfigurations(state) { @@ -201,7 +216,7 @@ function (VideoPlayer) { // The function set initial configuration and preparation. function initialize(element) { - var _this = this; + var _this = this, tempYtTestTimeout; // This is used in places where we instead would have to check if an element has a CSS class 'fullscreen'. this.isFullScreen = false; @@ -227,28 +242,61 @@ function (VideoPlayer) { webmSource: this.el.data('webm-source'), oggSource: this.el.data('ogg-source'), + ytTestUrl: this.el.data('yt-test-url'), + fadeOutTimeout: 1400, availableQualities: ['hd720', 'hd1080', 'highres'] }; + // Check if the YT test timeout has been set. If not, or it is in + // improper format, then set to default value. + tempYtTestTimeout = parseInt(this.el.data('yt-test-timeout'), 10); + if (!isFinite(tempYtTestTimeout)) { + tempYtTestTimeout = 1500; + } + this.config.ytTestTimeout = tempYtTestTimeout; + if (!(_parseYouTubeIDs(this))) { // If we do not have YouTube ID's, try parsing HTML5 video sources. - _prepareHTML5Video(this); + if (!_prepareHTML5Video(this, true)) { + // Non-YouTube sources were not found either. + return; + } + _setConfigurations(this); _renderElements(this); } else { - this.getVideoMetadata() + if (!this.youtubeXhr) { + this.youtubeXhr = this.getVideoMetadata(); + } + + this.youtubeXhr .always(function(json, status) { var err = $.isPlainObject(json.error) || - (status !== "success" && status !== "notmodified"); - - if (err){ + (status !== 'success' && status !== 'notmodified'); + if (err) { // When the youtube link doesn't work for any reason // (for example, the great firewall in china) any // alternate sources should automatically play. - _prepareHTML5Video(_this); - _this.el.find('a.quality_control').hide(); + if (!_prepareHTML5Video(_this)) { + // Non-YouTube sources were not found either. + + _this.el.find('.video-player div').removeClass('hidden'); + _this.el.find('.video-player h3').addClass('hidden'); + + // If in reality the timeout was to short, try to + // continue loading the YouTube video anyways. + _this.fetchMetadata(); + _this.parseSpeed(); + } else { + // In-browser HTML5 player does not support quality + // control. + _this.el.find('a.quality_control').hide(); + } + } else { + _this.fetchMetadata(); + _this.parseSpeed(); } _setConfigurations(_this); @@ -294,7 +342,13 @@ function (VideoPlayer) { // Take the HTML5 sources (URLs of videos), and make them available explictly for each type // of video format (mp4, webm, ogg). function parseVideoSources(sources) { - var _this = this; + var _this = this, + v = document.createElement('video'), + sourceCodecs = { + mp4: 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + webm: 'video/webm; codecs="vp8, vorbis"', + ogg: 'video/ogg; codecs="theora"' + }; this.html5Sources = { mp4: null, @@ -304,7 +358,14 @@ function (VideoPlayer) { $.each(sources, function (name, source) { if (source && source.length) { - _this.html5Sources[name] = source; + if ( + Boolean( + v.canPlayType && + v.canPlayType(sourceCodecs[name]).replace(/no/, '') + ) + ) { + _this.html5Sources[name] = source; + } } }); } @@ -321,7 +382,9 @@ function (VideoPlayer) { $.each(this.videos, function (speed, url) { _this.getVideoMetadata(url, function(data) { - _this.metadata[data.data.id] = data.data; + if (data.data) { + _this.metadata[data.data.id] = data.data; + } }); }); } @@ -358,12 +421,11 @@ function (VideoPlayer) { if (typeof url !== 'string') { url = this.videos['1.0'] || ''; } - successHandler = ($.isFunction(callback)) ? callback : null; xhr = $.ajax({ - url: 'https://gdata.youtube.com/feeds/api/videos/' + url + '?v=2&alt=jsonc', - timeout: 500, + url: this.config.ytTestUrl + url + '?v=2&alt=jsonc', dataType: 'jsonp', + timeout: this.config.ytTestTimeout, success: successHandler }); diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js index c315e4afbc..91d2ba6fba 100644 --- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js @@ -10,21 +10,31 @@ function () { return function (state) { state.videoSpeedControl = {}; + if (state.videoType === 'html5') { + _initialize(state); + } else if (state.videoType === 'youtube' && state.youtubeXhr) { + state.youtubeXhr.done(function () { + _initialize(state); + }); + } + if (state.videoType === 'html5' && !(_checkPlaybackRates())) { _hideSpeedControl(state); return; } - - _makeFunctionsPublic(state); - _renderElements(state); - _bindHandlers(state); }; // *************************************************************** // Private functions start here. // *************************************************************** + function _initialize(state) { + _makeFunctionsPublic(state); + _renderElements(state); + _bindHandlers(state); + } + // function _makeFunctionsPublic(state) // // Functions which will be accessible via 'state' object. When called, diff --git a/common/lib/xmodule/xmodule/js/src/video/10_main.js b/common/lib/xmodule/xmodule/js/src/video/10_main.js index 70fdbc580d..457433592a 100644 --- a/common/lib/xmodule/xmodule/js/src/video/10_main.js +++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js @@ -20,7 +20,8 @@ function ( VideoSpeedControl, VideoCaption ) { - var previousState; + var previousState, + youtubeXhr = null; // Because this constructor can be called multiple times on a single page (when // the user switches verticals, the page doesn't reload, but the content changes), we must @@ -53,7 +54,11 @@ function ( state = {}; previousState = state; + state.youtubeXhr = youtubeXhr; Initialize(state, element); + if (!youtubeXhr) { + youtubeXhr = state.youtubeXhr; + } VideoControl(state); VideoQualityControl(state); @@ -67,6 +72,10 @@ function ( // Video with Jasmine. return state; }; + + window.Video.clearYoutubeXhr = function () { + youtubeXhr = null; + }; }); }(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index be77cd2684..8ea87b2d41 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -167,6 +167,12 @@ class VideoModule(VideoFields, XModule): sources = {get_ext(src): src for src in self.html5_sources} sources['main'] = self.source + # for testing Youtube timeout in acceptance tests + if getattr(settings, 'VIDEO_PORT', None): + yt_test_url = "http://127.0.0.1:" + str(settings.VIDEO_PORT) + '/test_youtube/' + else: + yt_test_url = 'https://gdata.youtube.com/feeds/api/videos/' + return self.system.render_template('video.html', { 'youtube_streams': _create_youtube_string(self), 'id': self.location.html_id(), @@ -181,7 +187,11 @@ class VideoModule(VideoFields, XModule): 'show_captions': json.dumps(self.show_captions), 'start': self.start_time, 'end': self.end_time, - 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True), + # TODO: Later on the value 1500 should be taken from some global + # configuration setting field. + 'yt_test_timeout': 1500, + 'yt_test_url': yt_test_url }) diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature index 6c8299f2c5..b741c8bee1 100644 --- a/lms/djangoapps/courseware/features/video.feature +++ b/lms/djangoapps/courseware/features/video.feature @@ -1,18 +1,39 @@ Feature: Video component As a student, I want to view course videos in LMS. - Scenario: Video component is fully rendered in the LMS in HTML5 mode Given the course has a Video component in HTML5 mode Then when I view the video it has rendered in HTML5 mode And all sources are correct - Scenario: Video component is fully rendered in the LMS in Youtube mode - Given the course has a Video component in Youtube mode - Then when I view the video it has rendered in Youtube mode - - # Firefox doesn't have HTML5 + # Firefox doesn't have HTML5 (only mp4 - fix here) @skip_firefox Scenario: Autoplay is enabled in LMS for a Video component Given the course has a Video component in HTML5 mode Then when I view the video it has autoplay enabled + +# Youtube testing +Scenario: Video component is fully rendered in the LMS in Youtube mode with HTML5 sources +Given youtube server is up and response time is 0.4 seconds +And the course has a Video component in Youtube_HTML5 mode +Then when I view the video it has rendered in Youtube mode + +Scenario: Video component is not rendered in the LMS in Youtube mode with HTML5 sources +Given youtube server is up and response time is 2 seconds +And the course has a Video component in Youtube_HTML5 mode +Then when I view the video it has rendered in HTML5 mode + +Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources +Given youtube server is up and response time is 2 seconds +And the course has a Video component in Youtube mode +Then when I view the video it has rendered in Youtube mode + +Scenario: Video component is rendered in the LMS in Youtube mode with HTML5 sources that doesn't supported by browser +Given youtube server is up and response time is 2 seconds +And the course has a Video component in Youtube_HTML5_Unsupported_Video mode +Then when I view the video it has rendered in Youtube mode + +Scenario: Video component is rendered in the LMS in HTML5 mode with HTML5 sources that doesn't supported by browser +Given the course has a Video component in HTML5_Unsupported_Video mode +Then error message is shown +And error message has correct text diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py index f597792019..e0a1461aea 100644 --- a/lms/djangoapps/courseware/features/video.py +++ b/lms/djangoapps/courseware/features/video.py @@ -3,6 +3,7 @@ from lettuce import world, step from lettuce.django import django_url from common import i_am_registered_for_the_course, section_location +from django.utils.translation import ugettext as _ ############### ACTIONS #################### @@ -11,6 +12,9 @@ HTML5_SOURCES = [ 'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.webm', 'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.ogv' ] +HTML5_SOURCES_INCORRECT = [ + 'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99' +] @step('when I view the (.*) it has autoplay enabled$') def does_autoplay_video(_step, video_type): @@ -51,10 +55,37 @@ def add_video_to_course(course, player_mode): 'html5_sources': HTML5_SOURCES } }) + if player_mode == 'youtube_html5': + kwargs.update({ + 'metadata': { + 'html5_sources': HTML5_SOURCES + } + }) + if player_mode == 'youtube_html5_unsupported_video': + kwargs.update({ + 'metadata': { + 'html5_sources': HTML5_SOURCES_INCORRECT + } + }) + if player_mode == 'html5_unsupported_video': + kwargs.update({ + 'metadata': { + 'youtube_id_1_0': '', + 'youtube_id_0_75': '', + 'youtube_id_1_25': '', + 'youtube_id_1_5': '', + 'html5_sources': HTML5_SOURCES_INCORRECT + } + }) world.ItemFactory.create(**kwargs) +@step('youtube server is up and response time is (.*) seconds$') +def set_youtube_response_timeout(_step, time): + world.youtube_server.time_to_response = time + + @step('when I view the video it has rendered in (.*) mode$') def video_is_rendered(_step, mode): modes = { @@ -64,9 +95,23 @@ def video_is_rendered(_step, mode): html_tag = modes[mode.lower()] assert world.css_find('.video {0}'.format(html_tag)).first + @step('all sources are correct$') def all_sources_are_correct(_step): sources = world.css_find('.video video source') assert set(source['src'] for source in sources) == set(HTML5_SOURCES) +@step('error message is shown$') +def error_message_is_shown(_step): + selector = '.video .video-player h3' + assert world.css_visible(selector) + + +@step('error message has correct text$') +def error_message_has_correct_text(_step): + selector = '.video .video-player h3' + text = _('ERROR: No playable video sources found!') + assert world.css_has_text(selector, text) + + diff --git a/lms/djangoapps/courseware/features/youtube_setup.py b/lms/djangoapps/courseware/features/youtube_setup.py new file mode 100644 index 0000000000..8233d1f458 --- /dev/null +++ b/lms/djangoapps/courseware/features/youtube_setup.py @@ -0,0 +1,45 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from courseware.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(): + # import ipdb; ipdb.set_trace() + 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 = 1 # seconds + + # 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/lms/djangoapps/courseware/mock_youtube_server/__init__.py b/lms/djangoapps/courseware/mock_youtube_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/courseware/mock_youtube_server/mock_youtube_server.py b/lms/djangoapps/courseware/mock_youtube_server/mock_youtube_server.py new file mode 100644 index 0000000000..46b269dda6 --- /dev/null +++ b/lms/djangoapps/courseware/mock_youtube_server/mock_youtube_server.py @@ -0,0 +1,81 @@ +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +import urlparse +from requests.packages.oauthlib.oauth1.rfc5849 import signature +import mock +import threading +import json +from logging import getLogger +logger = getLogger(__name__) +import time + +class MockYoutubeRequestHandler(BaseHTTPRequestHandler): + ''' + A handler for Youtube GET requests. + ''' + + protocol = "HTTP/1.0" + + def do_HEAD(self): + self._send_head() + + def do_GET(self): + ''' + Handle a GET request from the client and sends response back. + ''' + self._send_head() + + logger.debug("Youtube provider received GET request to path {}".format( + self.path) + ) # Log the request + + status_message = "I'm youtube." + response_timeout = float(self.server.time_to_response) + + # threading timer produces TypeError: 'NoneType' object is not callable here + # so we use time.sleep, as we already in separate thread. + time.sleep(response_timeout) + self._send_response(status_message) + + def _send_head(self): + ''' + Send the response code and MIME headers + ''' + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + def _send_response(self, message): + ''' + Send message back to the client + ''' + callback = urlparse.parse_qs(self.path)['callback'][0] + response = callback + '({})'.format(json.dumps({'message': message})) + # Log the response + logger.debug("Youtube: sent response {}".format(message)) + + self.wfile.write(response) + + +class MockYoutubeServer(HTTPServer): + ''' + A mock Youtube provider server that responds + to GET requests to localhost. + ''' + + def __init__(self, address): + ''' + Initialize the mock XQueue server instance. + + *address* is the (host, host's port to listen to) tuple. + ''' + handler = MockYoutubeRequestHandler + HTTPServer.__init__(self, address, handler) + + def shutdown(self): + ''' + Stop the server and free up the port + ''' + # First call superclass shutdown() + HTTPServer.shutdown(self) + # We also need to manually close the socket + self.socket.close() diff --git a/lms/djangoapps/courseware/mock_youtube_server/test_mock_youtube_server.py b/lms/djangoapps/courseware/mock_youtube_server/test_mock_youtube_server.py new file mode 100644 index 0000000000..4ccd7cdc58 --- /dev/null +++ b/lms/djangoapps/courseware/mock_youtube_server/test_mock_youtube_server.py @@ -0,0 +1,53 @@ +""" +Test for Mock_Youtube_Server +""" +import unittest +import threading +import urllib +from mock_youtube_server import MockYoutubeServer + +from nose.plugins.skip import SkipTest + + +class MockYoutubeServerTest(unittest.TestCase): + ''' + A mock version of the Youtube provider server that listens on a local + port and responds with jsonp. + + Used for lettuce BDD tests in lms/courseware/features/video.feature + ''' + + def setUp(self): + + # This is a test of the test setup, + # so it does not need to run as part of the unit test suite + # You can re-enable it by commenting out the line below + raise SkipTest + + # Create the server + server_port = 8034 + server_host = '127.0.0.1' + address = (server_host, server_port) + self.server = MockYoutubeServer(address, ) + self.server.time_to_response = 0.5 + # Start the server in a separate daemon thread + server_thread = threading.Thread(target=self.server.serve_forever) + server_thread.daemon = True + server_thread.start() + + def tearDown(self): + + # Stop the server, freeing up the port + self.server.shutdown() + + def test_request(self): + """ + Tests that Youtube server processes request with right program + path, and responses with incorrect signature. + """ + # GET request + response_handle = urllib.urlopen( + 'http://127.0.0.1:8034/feeds/api/videos/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func', + ) + response = response_handle.read() + self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response) diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 3436938cc0..b393b33da8 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -64,7 +64,9 @@ class TestVideo(BaseTestXmodule): 'sub': u'a_sub_file.srt.sjson', 'track': '', 'youtube_streams': _create_youtube_string(self.item_module), - 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True), + 'yt_test_timeout': 1500, + 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/' } self.maxDiff = None @@ -114,7 +116,9 @@ class TestVideoNonYouTube(TestVideo): 'sub': 'a_sub_file.srt.sjson', 'track': '', 'youtube_streams': '1.00:OEoXaMPEzfM', - 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True), + 'yt_test_timeout': 1500, + 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/' } self.assertEqual(context, expected_context) diff --git a/lms/djangoapps/courseware/tests/test_video_xml.py b/lms/djangoapps/courseware/tests/test_video_xml.py index 33df1432c0..d790173468 100644 --- a/lms/djangoapps/courseware/tests/test_video_xml.py +++ b/lms/djangoapps/courseware/tests/test_video_xml.py @@ -92,7 +92,9 @@ class VideoModuleUnitTest(unittest.TestCase): 'sources': sources, 'youtube_streams': _create_youtube_string(module), 'track': '', - 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True), + 'yt_test_timeout': 1500, + 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/' } self.assertEqual(module.get_html(), expected_context) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index e866a250d9..7924780f3a 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -82,6 +82,11 @@ XQUEUE_INTERFACE = { "basic_auth": ('anant', 'agarwal'), } + +# Set up Video information so that the lms will send +# requests to a mock Youtube server running locally +VIDEO_PORT = XQUEUE_PORT + 2 + # Forums are disabled in test.py to speed up unit tests, but we do not have # per-test control for acceptance tests MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True diff --git a/lms/envs/acceptance_static.py b/lms/envs/acceptance_static.py index 27efb6160d..c09c9e29e8 100644 --- a/lms/envs/acceptance_static.py +++ b/lms/envs/acceptance_static.py @@ -70,6 +70,10 @@ XQUEUE_INTERFACE = { "basic_auth": ('anant', 'agarwal'), } +# Set up Video information so that the lms will send +# requests to a mock Youtube server running locally +VIDEO_PORT = XQUEUE_PORT + 2 + # Include the lettuce app for acceptance testing, including the 'harvest' django-admin command INSTALLED_APPS += ('lettuce.django',) LETTUCE_APPS = ('courseware',) diff --git a/lms/templates/video.html b/lms/templates/video.html index 43f36915a0..3f06f00511 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -23,6 +23,8 @@ data-end="${end}" data-caption-asset-path="${caption_asset_path}" data-autoplay="${autoplay}" + data-yt-test-timeout="${yt_test_timeout}" + data-yt-test-url="${yt_test_url}" >
@@ -30,6 +32,7 @@
+