diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 0aa0e59951..d68d194d1a 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -552,6 +552,15 @@ PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get( # Allow extra middleware classes to be added to the app through configuration. MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) +########################## Settings for Completion API ##################### + +# Once a user has watched this percentage of a video, mark it as complete: +# (0.0 = 0%, 1.0 = 100%) +COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get( + 'COMPLETION_VIDEO_COMPLETE_PERCENTAGE', + COMPLETION_VIDEO_COMPLETE_PERCENTAGE, +) + ########################## Derive Any Derived Settings ####################### derive_settings(__name__) diff --git a/cms/envs/common.py b/cms/envs/common.py index cd035a3104..2e7460006b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1493,3 +1493,10 @@ ZENDESK_USER = None ZENDESK_API_KEY = None ZENDESK_OAUTH_ACCESS_TOKEN = None ZENDESK_CUSTOM_FIELDS = {} + + +############## Settings for Completion API ######################### + +# Once a user has watched this percentage of a video, mark it as complete: +# (0.0 = 0%, 1.0 = 100%) +COMPLETION_VIDEO_COMPLETE_PERCENTAGE = 0.95 diff --git a/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js b/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js new file mode 100644 index 0000000000..1b6c2fe727 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js @@ -0,0 +1,60 @@ +(function() { + 'use strict'; + describe('VideoPlayer completion', function() { + var state, oldOTBD; + + beforeEach(function() { + oldOTBD = window.onTouchBasedDevice; + window.onTouchBasedDevice = jasmine + .createSpy('onTouchBasedDevice') + .and.returnValue(null); + + state = jasmine.initializePlayer({ + recordedYoutubeIsAvailable: true, + completionEnabled: true, + publishCompletionUrl: 'https://example.com/publish_completion_url' + + }); + state.completionHandler.completeAfterTime = 20; + }); + + afterEach(function() { + $('source').remove(); + window.onTouchBasedDevice = oldOTBD; + state.storage.clear(); + if (state.videoPlayer) { + state.videoPlayer.destroy(); + } + }); + + it('calls the completion api when marking an object complete', function() { + state.completionHandler.markCompletion(Date.now()); + expect($.ajax).toHaveBeenCalledWith({ + url: state.config.publishCompletionUrl, + type: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({completion: 1.0}), + success: jasmine.any(Function), + error: jasmine.any(Function) + }); + expect(state.completionHandler.isComplete).toEqual(true); + }); + + it('calls the completion api on the LMS when the time updates', function() { + spyOn(state.completionHandler, 'markCompletion').and.callThrough(); + state.el.trigger('timeupdate', 24.0); + expect(state.completionHandler.markCompletion).toHaveBeenCalled(); + state.completionHandler.markCompletion.calls.reset(); + // But the handler is not called again after the block is completed. + state.el.trigger('timeupdate', 30.0); + expect(state.completionHandler.markCompletion).not.toHaveBeenCalled(); + }); + + it('calls the completion api on the LMS when the video ends', function() { + spyOn(state.completionHandler, 'markCompletion').and.callThrough(); + state.el.trigger('ended'); + expect(state.completionHandler.markCompletion).toHaveBeenCalled(); + }); + }); +}).call(this); diff --git a/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js b/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js index 28470d1477..157bf27572 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js @@ -219,7 +219,7 @@ }).done(done); }); - it('set new inccorrect values', function() { + it('set new incorrect values', function() { var seek = state.videoPlayer.player.video.currentTime; state.videoPlayer.player.seekTo(-50); expect(state.videoPlayer.player.getCurrentTime()).toBe(seek); diff --git a/common/lib/xmodule/xmodule/js/src/video/09_completion.js b/common/lib/xmodule/xmodule/js/src/video/09_completion.js new file mode 100644 index 0000000000..97378e92ef --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/09_completion.js @@ -0,0 +1,178 @@ +(function(define) { + 'use strict'; + /** + * Completion handler + * @exports video/09_completion.js + * @constructor + * @param {Object} state The object containing the state of the video + * @return {jquery Promise} + */ + define('video/09_completion.js', [], function() { + var VideoCompletionHandler = function(state) { + if (!(this instanceof VideoCompletionHandler)) { + return new VideoCompletionHandler(state); + } + this.state = state; + this.state.completionHandler = this; + this.initialize(); + return $.Deferred().resolve().promise(); + }; + + VideoCompletionHandler.prototype = { + + /** Tears down the VideoCompletionHandler. + * + * * Removes backreferences from this.state to this. + * * Turns off signal handlers. + */ + destroy: function() { + this.el.remove(); + this.el.off('timeupdate.completion'); + this.el.off('ended.completion'); + delete this.state.completionHandler; + }, + + /** Initializes the VideoCompletionHandler. + * + * This sets all the instance variables needed to perform + * completion calculations. + */ + initialize: function() { + // Attributes with "Time" in the name refer to the number of seconds since + // the beginning of the video, except for lastSentTime, which refers to a + // timestamp in seconds since the Unix epoch. + this.lastSentTime = undefined; + this.isComplete = false; + this.completionPercentage = this.state.config.completionPercentage; + this.startTime = this.state.config.startTime; + this.endTime = this.state.config.endTime; + this.isEnabled = this.state.config.completionEnabled; + if (this.endTime) { + this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, this.endTime); + } + if (this.isEnabled) { + this.bindHandlers(); + } + }, + + /** Bind event handler callbacks. + * + * When ended is triggered, mark the video complete + * unconditionally. + * + * When timeupdate is triggered, check to see if the user has + * passed the completeAfterTime in the video, and if so, mark the + * video complete. + * + * When destroy is triggered, clean up outstanding resources. + */ + bindHandlers: function() { + var self = this; + + /** Event handler to check if the video is complete, and submit + * a completion if it is. + * + * If the timeupdate handler doesn't fire after the required + * percentage, this will catch any fully complete videos. + */ + this.state.el.on('ended.completion', function() { + self.handleEnded(); + }); + + /** Event handler to check video progress, and mark complete if + * greater than completionPercentage + */ + this.state.el.on('timeupdate.completion', function(ev, currentTime) { + self.handleTimeUpdate(currentTime); + }); + + /** Event handler to clean up resources when the video player + * is destroyed. + */ + this.state.el.off('destroy', this.destroy); + }, + + /** Handler to call when the ended event is triggered */ + handleEnded: function() { + if (this.isComplete) { + return; + } + this.markCompletion(); + }, + + /** Handler to call when a timeupdate event is triggered */ + handleTimeUpdate: function(currentTime) { + var duration; + if (this.isComplete) { + return; + } + if (this.lastSentTime !== undefined && currentTime - this.lastSentTime < this.repostDelaySeconds()) { + // Throttle attempts to submit in case of network issues + return; + } + if (this.completeAfterTime === undefined) { + // Duration is not available at initialization time + duration = this.state.videoPlayer.duration(); + if (!duration) { + // duration is not yet set. Wait for another event, + // or fall back to 'ended' handler. + return; + } + this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, duration); + } + + if (currentTime > this.completeAfterTime) { + this.markCompletion(currentTime); + } + }, + + /** Submit completion to the LMS */ + markCompletion: function(currentTime) { + var self = this; + var errmsg; + this.isComplete = true; + this.lastSentTime = currentTime; + if (this.state.config.publishCompletionUrl) { + $.ajax({ + type: 'POST', + url: this.state.config.publishCompletionUrl, + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({completion: 1.0}), + success: function() { + self.state.el.off('timeupdate.completion'); + self.state.el.off('ended.completion'); + }, + error: function(xhr) { + /* eslint-disable no-console */ + self.complete = false; + errmsg = 'Failed to submit completion'; + if (xhr.responseJSON !== undefined) { + errmsg += ': ' + xhr.responseJSON.error; + } + console.warn(errmsg); + /* eslint-enable no-console */ + } + }); + } else { + /* eslint-disable no-console */ + console.warn('publishCompletionUrl not defined'); + /* eslint-enable no-console */ + } + }, + + /** Determine what point in the video (in seconds from the + * beginning) counts as complete. + */ + calculateCompleteAfterTime: function(startTime, endTime) { + return startTime + (endTime - startTime) * this.completionPercentage; + }, + + /** How many seconds to wait after a POST fails to try again. */ + repostDelaySeconds: function() { + return 3.0; + } + }; + return VideoCompletionHandler; + }); +}(RequireJS.define)); 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 c351390bad..90fd45aea3 100644 --- a/common/lib/xmodule/xmodule/js/src/video/10_main.js +++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js @@ -1,3 +1,4 @@ +/* globals _ */ (function(require, $) { 'use strict'; // In the case when the Video constructor will be called before RequireJS finishes loading all of the Video @@ -15,9 +16,9 @@ // If mock function was called with second parameter set to truthy value, we invoke the real `window.Video` // on all the stored elements so far. if (processTempCallStack) { - $.each(tempCallStack, function(index, element) { + $.each(tempCallStack, function(index, el) { // By now, `window.Video` is the real constructor. - window.Video(element); + window.Video(el); }); return; @@ -54,6 +55,7 @@ 'video/09_events_plugin.js', 'video/09_events_bumper_plugin.js', 'video/09_poster.js', + 'video/09_completion.js', 'video/10_commands.js', 'video/095_video_context_menu.js' ], @@ -61,8 +63,8 @@ VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen, VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption, VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper, - VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, VideoCommands, - VideoContextMenu + VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, + VideoCompletionHandler, VideoCommands, VideoContextMenu ) { var youtubeXhr = null, oldVideo = window.Video; @@ -75,9 +77,10 @@ mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder, VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl, VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu, - VideoSaveStatePlugin, VideoEventsPlugin], + VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler], bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl, - VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoEventsBumperPlugin], + VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, + VideoEventsBumperPlugin, VideoCompletionHandler], state = { el: el, id: id, @@ -104,10 +107,10 @@ return bumperState; }; - var player = function(state) { + var player = function(innerState) { return function() { - _.extend(state.metadata, {autoplay: true, focusFirstControl: true}); - initialize(state, element); + _.extend(innerState.metadata, {autoplay: true, focusFirstControl: true}); + initialize(innerState, element); }; }; @@ -120,8 +123,8 @@ new VideoPoster(el, { poster: el.data('poster'), onClick: _.once(function() { - var mainVideoPlayer = player(state), - bumper, bumperState; + var mainVideoPlayer = player(state); + var bumper, bumperState; if (storage.getItem('isBumperShown')) { mainVideoPlayer(); } else { diff --git a/common/lib/xmodule/xmodule/video_module/bumper_utils.py b/common/lib/xmodule/xmodule/video_module/bumper_utils.py index 6ef996e3b8..dce5b7d80b 100644 --- a/common/lib/xmodule/xmodule/video_module/bumper_utils.py +++ b/common/lib/xmodule/xmodule/video_module/bumper_utils.py @@ -1,14 +1,14 @@ """ Utils for video bumper """ +from collections import OrderedDict import copy import json -import pytz import logging -from collections import OrderedDict from datetime import datetime, timedelta from django.conf import settings +import pytz from .video_utils import set_query_parameter @@ -137,6 +137,9 @@ def bumper_metadata(video, sources): 'transcriptAvailableTranslationsUrl': set_query_parameter( video.runtime.handler_url(video, 'transcript', 'available_translations').rstrip('/?'), 'is_bumper', 1 ), + 'publishCompletionUrl': set_query_parameter( + video.runtime.handler_url(video, 'publish_completion', '').rstrip('?'), 'is_bumper', 1 + ), }) return metadata diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py index 9cde2c925e..d36729d4a1 100644 --- a/common/lib/xmodule/xmodule/video_module/video_handlers.py +++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py @@ -13,6 +13,7 @@ from datetime import datetime from webob import Response from xblock.core import XBlock +from xblock.exceptions import JsonHandlerError from xmodule.exceptions import NotFoundError from xmodule.fields import RelativeTime @@ -202,6 +203,33 @@ class VideoStudentViewHandlers(object): ) return response + @XBlock.json_handler + def publish_completion(self, data, dispatch): # pylint: disable=unused-argument + """ + Entry point for completion for student_view. + + Parameters: + data: JSON dict: + key: "completion" + value: float in range [0.0, 1.0] + + dispatch: Ignored. + Return value: JSON response (200 on success, 400 for malformed data) + """ + completion_service = self.runtime.service(self, 'completion') + if completion_service is None: + raise JsonHandlerError(500, u"No completion service found") + elif not completion_service.completion_tracking_enabled(): + raise JsonHandlerError(404, u"Completion tracking is not enabled and API calls are unexpected") + if not isinstance(data['completion'], (int, float)): + message = u"Invalid completion value {}. Must be a float in range [0.0, 1.0]" + raise JsonHandlerError(400, message.format(data['completion'])) + elif not 0.0 <= data['completion'] <= 1.0: + message = u"Invalid completion value {}. Must be in range [0.0, 1.0]" + raise JsonHandlerError(400, message.format(data['completion'])) + self.runtime.publish(self, "completion", data) + return {"result": "ok"} + @XBlock.handler def transcript(self, request, dispatch): """ @@ -282,6 +310,8 @@ class VideoStudentViewHandlers(object): transcript_content, transcript_filename, transcript_mime_type = self.get_transcript( transcripts, transcript_format=self.transcript_download_format, lang=lang ) + except (KeyError, UnicodeDecodeError): + return Response(status=404) except (ValueError, NotFoundError): response = Response(status=404) # Check for transcripts in edx-val as a last resort if corresponding feature is enabled. @@ -319,8 +349,6 @@ class VideoStudentViewHandlers(object): response.content_type = Transcript.mime_types[self.transcript_download_format] return response - except (KeyError, UnicodeDecodeError): - return Response(status=404) else: response = Response( transcript_content, diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 4e4d22006b..5646ff54dd 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -27,6 +27,7 @@ from opaque_keys.edx.locator import AssetLocator from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag from openedx.core.lib.cache_utils import memoize_in_request_cache from openedx.core.lib.license import LicenseMixin +from xblock.completable import XBlockCompletionMode from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.runtime import KvsFieldData @@ -97,7 +98,7 @@ log = logging.getLogger(__name__) _ = lambda text: text -@XBlock.wants('settings') +@XBlock.wants('settings', 'completion') class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule, LicenseMixin): """ XML source example: @@ -110,6 +111,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, """ + has_custom_completion = True + completion_mode = XBlockCompletionMode.COMPLETABLE + video_time = 0 icon_class = 'video' @@ -150,9 +154,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, resource_string(module, 'js/src/video/09_events_plugin.js'), resource_string(module, 'js/src/video/09_events_bumper_plugin.js'), resource_string(module, 'js/src/video/09_poster.js'), + resource_string(module, 'js/src/video/09_completion.js'), resource_string(module, 'js/src/video/095_video_context_menu.js'), resource_string(module, 'js/src/video/10_commands.js'), - resource_string(module, 'js/src/video/10_main.js') + resource_string(module, 'js/src/video/10_main.js'), ] } css = {'scss': [ @@ -327,6 +332,12 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, edx_video_id=self.edx_video_id.strip() ) + completion_service = self.runtime.service(self, 'completion') + if completion_service: + completion_enabled = completion_service.completion_tracking_enabled() + else: + completion_enabled = False + metadata = { 'saveStateUrl': self.system.ajax_url + '/save_user_state', 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), @@ -345,6 +356,8 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, 'savedVideoPosition': self.saved_video_position.total_seconds(), 'start': self.start_time.total_seconds(), 'end': self.end_time.total_seconds(), + 'completionEnabled': completion_enabled, + 'completionPercentage': settings.COMPLETION_VIDEO_COMPLETE_PERCENTAGE, 'transcriptLanguage': transcript_language, 'transcriptLanguages': sorted_languages, 'ytTestTimeout': settings.YOUTUBE['TEST_TIMEOUT'], @@ -358,18 +371,19 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, 'transcriptAvailableTranslationsUrl': self.runtime.handler_url( self, 'transcript', 'available_translations' ).rstrip('/?'), + 'publishCompletionUrl': self.runtime.handler_url(self, 'publish_completion', '').rstrip('?'), - ## For now, the option "data-autohide-html5" is hard coded. This option - ## either enables or disables autohiding of controls and captions on mouse - ## inactivity. If set to true, controls and captions will autohide for - ## HTML5 sources (non-YouTube) after a period of mouse inactivity over the - ## whole video. When the mouse moves (or a key is pressed while any part of - ## the video player is focused), the captions and controls will be shown - ## once again. - ## - ## There is no option in the "Advanced Editor" to set this option. However, - ## this option will have an effect if changed to "True". The code on - ## front-end exists. + # For now, the option "data-autohide-html5" is hard coded. This option + # either enables or disables autohiding of controls and captions on mouse + # inactivity. If set to true, controls and captions will autohide for + # HTML5 sources (non-YouTube) after a period of mouse inactivity over the + # whole video. When the mouse moves (or a key is pressed while any part of + # the video player is focused), the captions and controls will be shown + # once again. + # + # There is no option in the "Advanced Editor" to set this option. However, + # this option will have an effect if changed to "True". The code on + # front-end exists. 'autohideHtml5': False, # This is the server's guess at whether youtube is available for @@ -399,8 +413,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, return self.system.render_template('video.html', context) -@XBlock.wants("request_cache") -@XBlock.wants("settings") +@XBlock.wants("request_cache", "settings", "completion") class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, TabsEditingDescriptor, EmptyDataRawDescriptor, LicenseMixin): """ @@ -408,6 +421,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler """ module_class = VideoModule transcript = module_attr('transcript') + publish_completion = module_attr('publish_completion') show_in_read_only_mode = True diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py index fdead56789..6a721bbb62 100644 --- a/lms/djangoapps/courseware/tests/test_video_handlers.py +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -183,6 +183,14 @@ class TestVideo(BaseTestXmodule): response = self.item_descriptor.handle_ajax('save_user_state', {u'demoo�': "sample"}) self.assertEqual(json.loads(response)['success'], True) + def get_handler_url(self, handler, suffix): + """ + Return the URL for the specified handler on self.item_descriptor. + """ + return self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, handler, suffix + ).rstrip('/?') + def tearDown(self): _clear_assets(self.item_descriptor.location) super(TestVideo, self).tearDown() diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 9d62979616..203350d7ef 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -84,14 +84,13 @@ class TestVideoYouTube(TestVideo): 'ytApiUrl': 'https://www.youtube.com/iframe_api', 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/', 'ytKey': None, - 'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url( - self.item_descriptor, 'transcript', 'translation/__lang__' - ).rstrip('/?'), - 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url( - self.item_descriptor, 'transcript', 'available_translations' - ).rstrip('/?'), + 'transcriptTranslationUrl': self.get_handler_url('transcript', 'translation/__lang__'), + 'transcriptAvailableTranslationsUrl': self.get_handler_url('transcript', 'available_translations'), 'autohideHtml5': False, 'recordedYoutubeIsAvailable': True, + 'completionEnabled': False, + 'completionPercentage': 0.95, + 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), })), 'track': None, 'transcript_download_format': u'srt', @@ -165,14 +164,13 @@ class TestVideoNonYouTube(TestVideo): 'ytApiUrl': 'https://www.youtube.com/iframe_api', 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/', 'ytKey': None, - 'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url( - self.item_descriptor, 'transcript', 'translation/__lang__' - ).rstrip('/?'), - 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url( - self.item_descriptor, 'transcript', 'available_translations' - ).rstrip('/?'), + 'transcriptTranslationUrl': self.get_handler_url('transcript', 'translation/__lang__'), + 'transcriptAvailableTranslationsUrl': self.get_handler_url('transcript', 'available_translations'), 'autohideHtml5': False, 'recordedYoutubeIsAvailable': True, + 'completionEnabled': False, + 'completionPercentage': 0.95, + 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), })), 'track': None, 'transcript_download_format': u'srt', @@ -223,16 +221,24 @@ class TestGetHtmlMethod(BaseTestXmodule): 'ytApiUrl': 'https://www.youtube.com/iframe_api', 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/', 'ytKey': None, - 'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url( - self.item_descriptor, 'transcript', 'translation/__lang__' - ).rstrip('/?'), - 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url( - self.item_descriptor, 'transcript', 'available_translations' - ).rstrip('/?'), + 'transcriptTranslationUrl': self.get_handler_url('transcript', 'translation/__lang__'), + 'transcriptAvailableTranslationsUrl': self.get_handler_url('transcript', 'available_translations'), 'autohideHtml5': False, 'recordedYoutubeIsAvailable': True, + 'completionEnabled': False, + 'completionPercentage': 0.95, + 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), }) + def get_handler_url(self, handler, suffix): + """ + Return the URL for the specified handler on the block represented by + self.item_descriptor. + """ + return self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, handler, suffix + ).rstrip('/?') + def test_get_html_track(self): SOURCE_XML = """