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 = """