Merge pull request #16569 from open-craft/cliff/video-completion
Define custom completion for VideoModule
This commit is contained in:
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
60
common/lib/xmodule/xmodule/js/spec/video/completion_spec.js
Normal file
60
common/lib/xmodule/xmodule/js/spec/video/completion_spec.js
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
178
common/lib/xmodule/xmodule/js/src/video/09_completion.js
Normal file
178
common/lib/xmodule/xmodule/js/src/video/09_completion.js
Normal file
@@ -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));
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.ogv"/>
|
||||
</video>
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -183,6 +183,14 @@ class TestVideo(BaseTestXmodule):
|
||||
response = self.item_descriptor.handle_ajax('save_user_state', {u'demoo<EFBFBD>': "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()
|
||||
|
||||
@@ -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 = """
|
||||
<video show_captions="true"
|
||||
@@ -318,20 +324,15 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
)
|
||||
|
||||
self.initialize_module(data=DATA)
|
||||
track_url = self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'download'
|
||||
).rstrip('/?')
|
||||
track_url = self.get_handler_url('transcript', 'download')
|
||||
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
metadata.update({
|
||||
'transcriptLanguages': {"en": "English"} if not data['transcripts'] else {"uk": u'Українська'},
|
||||
'transcriptLanguage': u'en' if not data['transcripts'] or data.get('sub') else u'uk',
|
||||
'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'),
|
||||
'publishCompletionUrl': self.get_handler_url('publish_completion', ''),
|
||||
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'sub': data['sub'],
|
||||
})
|
||||
@@ -441,12 +442,9 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
|
||||
expected_context = dict(initial_context)
|
||||
expected_context['metadata'].update({
|
||||
'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'),
|
||||
'publishCompletionUrl': self.get_handler_url('publish_completion', ''),
|
||||
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'sources': data['result'].get('sources', []),
|
||||
})
|
||||
@@ -581,12 +579,9 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
|
||||
expected_context = dict(initial_context)
|
||||
expected_context['metadata'].update({
|
||||
'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'),
|
||||
'publishCompletionUrl': self.get_handler_url('publish_completion', ''),
|
||||
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'sources': data['result']['sources'],
|
||||
})
|
||||
@@ -742,12 +737,9 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
# expected_context, expected context to be returned by get_html
|
||||
expected_context = dict(initial_context)
|
||||
expected_context['metadata'].update({
|
||||
'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'),
|
||||
'publishCompletionUrl': self.get_handler_url('publish_completion', ''),
|
||||
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'sources': data['result']['sources'],
|
||||
})
|
||||
@@ -854,12 +846,9 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
expected_context = dict(initial_context)
|
||||
expected_context['metadata'].update({
|
||||
'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'),
|
||||
'publishCompletionUrl': self.get_handler_url('publish_completion', ''),
|
||||
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
|
||||
'sources': data['result'].get('sources', []),
|
||||
})
|
||||
@@ -1774,14 +1763,13 @@ class TestVideoWithBumper(TestVideo):
|
||||
'transcriptLanguage': 'en',
|
||||
'transcriptLanguages': {'en': 'English'},
|
||||
'transcriptTranslationUrl': video_utils.set_query_parameter(
|
||||
self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'translation/__lang__'
|
||||
).rstrip('/?'), 'is_bumper', 1
|
||||
self.get_handler_url('transcript', 'translation/__lang__'), 'is_bumper', 1
|
||||
),
|
||||
'transcriptAvailableTranslationsUrl': video_utils.set_query_parameter(
|
||||
self.item_descriptor.xmodule_runtime.handler_url(
|
||||
self.item_descriptor, 'transcript', 'available_translations'
|
||||
).rstrip('/?'), 'is_bumper', 1
|
||||
self.get_handler_url('transcript', 'available_translations'), 'is_bumper', 1
|
||||
),
|
||||
"publishCompletionUrl": video_utils.set_query_parameter(
|
||||
self.get_handler_url('publish_completion', ''), 'is_bumper', 1
|
||||
),
|
||||
})),
|
||||
'cdn_eval': False,
|
||||
@@ -1811,14 +1799,13 @@ class TestVideoWithBumper(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',
|
||||
|
||||
@@ -1088,6 +1088,15 @@ EDX_PLATFORM_REVISION = ENV_TOKENS.get('EDX_PLATFORM_REVISION', EDX_PLATFORM_REV
|
||||
# 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__)
|
||||
|
||||
@@ -3456,3 +3456,9 @@ ACE_ROUTING_KEY = LOW_PRIORITY_QUEUE
|
||||
|
||||
# Initialize to 'unknown', but read from JSON in aws.py
|
||||
EDX_PLATFORM_REVISION = 'unknown'
|
||||
|
||||
############## 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
|
||||
|
||||
Reference in New Issue
Block a user