diff --git a/cms/templates/import.html b/cms/templates/import.html index a5c6b9f41261..27337bf235d3 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -72,7 +72,7 @@

${_("Course to import:")}

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(); diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index c2bdeadc2143..08a223f609b8 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -555,6 +555,13 @@ def _extract_html(self, problemtree): # private 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. diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 9bc326d7b924..8e343ee1cf8a 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 @@ def test_substitute_python_vars(self): 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) diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 533ab2aec0dc..dc801be0f99a 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 341e18ae9d66..6e4df9ec9c77 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 25a3c2c0ab4f..85fd004976b9 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 @@

Speed

- \ 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 677ab9b24738..f2c749ef2746 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 c611acfffdd9..69207230fa5d 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 f3cecf71cbe6..7b5d3156e909 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 9194106fff12..54f952bffbcd 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 79bc16dbdad7..b41bdd6f1cad 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 c315e4afbced..91d2ba6fba75 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 70fdbc580d2b..457433592a8c 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 be77cd268426..8ea87b2d41b8 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -167,6 +167,12 @@ def get_html(self): 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 @@ def get_html(self): '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 6c8299f2c531..b741c8bee11c 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 f5977920197d..e0a1461aea5e 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 @@ '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 000000000000..8233d1f4586e --- /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 000000000000..e69de29bb2d1 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 000000000000..46b269dda683 --- /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 000000000000..4ccd7cdc58db --- /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 3436938cc09b..b393b33da857 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -64,7 +64,9 @@ def test_video_constructor(self): '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 @@ def test_video_constructor(self): '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 33df1432c029..d79017346854 100644 --- a/lms/djangoapps/courseware/tests/test_video_xml.py +++ b/lms/djangoapps/courseware/tests/test_video_xml.py @@ -92,7 +92,9 @@ def test_video_get_html(self): '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 e866a250d91b..7924780f3abc 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -82,6 +82,11 @@ def seed(): "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 27efb6160d48..c09c9e29e816 100644 --- a/lms/envs/acceptance_static.py +++ b/lms/envs/acceptance_static.py @@ -70,6 +70,10 @@ "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 43f36915a0f7..3f06f0051181 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -23,6 +23,8 @@

${display_name}

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 @@

${display_name}

+