Commit 1fc43bf6 by J. Cliff Dyer

Define custom completion for VideoModule

Update the VideoModule to publish a completion event when the player
reaches 95% complete, and submit a BlockCompletion when that event
occurs.

OC-3091
MCKIN-5897
parent 5a8579d3
...@@ -552,6 +552,15 @@ PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get( ...@@ -552,6 +552,15 @@ PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get(
# Allow extra middleware classes to be added to the app through configuration. # Allow extra middleware classes to be added to the app through configuration.
MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) 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 Any Derived Settings #######################
derive_settings(__name__) derive_settings(__name__)
...@@ -1493,3 +1493,10 @@ ZENDESK_USER = None ...@@ -1493,3 +1493,10 @@ ZENDESK_USER = None
ZENDESK_API_KEY = None ZENDESK_API_KEY = None
ZENDESK_OAUTH_ACCESS_TOKEN = None ZENDESK_OAUTH_ACCESS_TOKEN = None
ZENDESK_CUSTOM_FIELDS = {} 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
(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 @@ ...@@ -219,7 +219,7 @@
}).done(done); }).done(done);
}); });
it('set new inccorrect values', function() { it('set new incorrect values', function() {
var seek = state.videoPlayer.player.video.currentTime; var seek = state.videoPlayer.player.video.currentTime;
state.videoPlayer.player.seekTo(-50); state.videoPlayer.player.seekTo(-50);
expect(state.videoPlayer.player.getCurrentTime()).toBe(seek); expect(state.videoPlayer.player.getCurrentTime()).toBe(seek);
......
(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));
/* globals _ */
(function(require, $) { (function(require, $) {
'use strict'; 'use strict';
// In the case when the Video constructor will be called before RequireJS finishes loading all of the Video // In the case when the Video constructor will be called before RequireJS finishes loading all of the Video
...@@ -15,9 +16,9 @@ ...@@ -15,9 +16,9 @@
// If mock function was called with second parameter set to truthy value, we invoke the real `window.Video` // 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. // on all the stored elements so far.
if (processTempCallStack) { if (processTempCallStack) {
$.each(tempCallStack, function(index, element) { $.each(tempCallStack, function(index, el) {
// By now, `window.Video` is the real constructor. // By now, `window.Video` is the real constructor.
window.Video(element); window.Video(el);
}); });
return; return;
...@@ -54,6 +55,7 @@ ...@@ -54,6 +55,7 @@
'video/09_events_plugin.js', 'video/09_events_plugin.js',
'video/09_events_bumper_plugin.js', 'video/09_events_bumper_plugin.js',
'video/09_poster.js', 'video/09_poster.js',
'video/09_completion.js',
'video/10_commands.js', 'video/10_commands.js',
'video/095_video_context_menu.js' 'video/095_video_context_menu.js'
], ],
...@@ -61,8 +63,8 @@ ...@@ -61,8 +63,8 @@
VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen, VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen,
VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption, VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption,
VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper, VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper,
VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, VideoCommands, VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster,
VideoContextMenu VideoCompletionHandler, VideoCommands, VideoContextMenu
) { ) {
var youtubeXhr = null, var youtubeXhr = null,
oldVideo = window.Video; oldVideo = window.Video;
...@@ -75,9 +77,10 @@ ...@@ -75,9 +77,10 @@
mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder, mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder,
VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl, VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl,
VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu, VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu,
VideoSaveStatePlugin, VideoEventsPlugin], VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler],
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl, bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoEventsBumperPlugin], VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin,
VideoEventsBumperPlugin, VideoCompletionHandler],
state = { state = {
el: el, el: el,
id: id, id: id,
...@@ -104,10 +107,10 @@ ...@@ -104,10 +107,10 @@
return bumperState; return bumperState;
}; };
var player = function(state) { var player = function(innerState) {
return function() { return function() {
_.extend(state.metadata, {autoplay: true, focusFirstControl: true}); _.extend(innerState.metadata, {autoplay: true, focusFirstControl: true});
initialize(state, element); initialize(innerState, element);
}; };
}; };
...@@ -120,8 +123,8 @@ ...@@ -120,8 +123,8 @@
new VideoPoster(el, { new VideoPoster(el, {
poster: el.data('poster'), poster: el.data('poster'),
onClick: _.once(function() { onClick: _.once(function() {
var mainVideoPlayer = player(state), var mainVideoPlayer = player(state);
bumper, bumperState; var bumper, bumperState;
if (storage.getItem('isBumperShown')) { if (storage.getItem('isBumperShown')) {
mainVideoPlayer(); mainVideoPlayer();
} else { } else {
......
""" """
Utils for video bumper Utils for video bumper
""" """
from collections import OrderedDict
import copy import copy
import json import json
import pytz
import logging import logging
from collections import OrderedDict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
import pytz
from .video_utils import set_query_parameter from .video_utils import set_query_parameter
...@@ -137,6 +137,9 @@ def bumper_metadata(video, sources): ...@@ -137,6 +137,9 @@ def bumper_metadata(video, sources):
'transcriptAvailableTranslationsUrl': set_query_parameter( 'transcriptAvailableTranslationsUrl': set_query_parameter(
video.runtime.handler_url(video, 'transcript', 'available_translations').rstrip('/?'), 'is_bumper', 1 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 return metadata
...@@ -13,6 +13,7 @@ from datetime import datetime ...@@ -13,6 +13,7 @@ from datetime import datetime
from webob import Response from webob import Response
from xblock.core import XBlock from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xmodule.fields import RelativeTime from xmodule.fields import RelativeTime
...@@ -202,6 +203,33 @@ class VideoStudentViewHandlers(object): ...@@ -202,6 +203,33 @@ class VideoStudentViewHandlers(object):
) )
return response 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 @XBlock.handler
def transcript(self, request, dispatch): def transcript(self, request, dispatch):
""" """
...@@ -282,6 +310,8 @@ class VideoStudentViewHandlers(object): ...@@ -282,6 +310,8 @@ class VideoStudentViewHandlers(object):
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript( transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(
transcripts, transcript_format=self.transcript_download_format, lang=lang transcripts, transcript_format=self.transcript_download_format, lang=lang
) )
except (KeyError, UnicodeDecodeError):
return Response(status=404)
except (ValueError, NotFoundError): except (ValueError, NotFoundError):
response = Response(status=404) response = Response(status=404)
# Check for transcripts in edx-val as a last resort if corresponding feature is enabled. # Check for transcripts in edx-val as a last resort if corresponding feature is enabled.
...@@ -319,8 +349,6 @@ class VideoStudentViewHandlers(object): ...@@ -319,8 +349,6 @@ class VideoStudentViewHandlers(object):
response.content_type = Transcript.mime_types[self.transcript_download_format] response.content_type = Transcript.mime_types[self.transcript_download_format]
return response return response
except (KeyError, UnicodeDecodeError):
return Response(status=404)
else: else:
response = Response( response = Response(
transcript_content, transcript_content,
......
...@@ -27,6 +27,7 @@ from opaque_keys.edx.locator import AssetLocator ...@@ -27,6 +27,7 @@ from opaque_keys.edx.locator import AssetLocator
from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag
from openedx.core.lib.cache_utils import memoize_in_request_cache from openedx.core.lib.cache_utils import memoize_in_request_cache
from openedx.core.lib.license import LicenseMixin from openedx.core.lib.license import LicenseMixin
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
...@@ -97,7 +98,7 @@ log = logging.getLogger(__name__) ...@@ -97,7 +98,7 @@ log = logging.getLogger(__name__)
_ = lambda text: text _ = lambda text: text
@XBlock.wants('settings') @XBlock.wants('settings', 'completion')
class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule, LicenseMixin): class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, XModule, LicenseMixin):
""" """
XML source example: XML source example:
...@@ -110,6 +111,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -110,6 +111,9 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.ogv"/> <source src=".../mit-3091x/M-3091X-FA12-L21-3_100.ogv"/>
</video> </video>
""" """
has_custom_completion = True
completion_mode = XBlockCompletionMode.COMPLETABLE
video_time = 0 video_time = 0
icon_class = 'video' icon_class = 'video'
...@@ -150,9 +154,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -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_plugin.js'),
resource_string(module, 'js/src/video/09_events_bumper_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_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/095_video_context_menu.js'),
resource_string(module, 'js/src/video/10_commands.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': [ css = {'scss': [
...@@ -327,6 +332,12 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -327,6 +332,12 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
edx_video_id=self.edx_video_id.strip() 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 = { metadata = {
'saveStateUrl': self.system.ajax_url + '/save_user_state', 'saveStateUrl': self.system.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
...@@ -345,6 +356,8 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -345,6 +356,8 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'savedVideoPosition': self.saved_video_position.total_seconds(), 'savedVideoPosition': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(), 'start': self.start_time.total_seconds(),
'end': self.end_time.total_seconds(), 'end': self.end_time.total_seconds(),
'completionEnabled': completion_enabled,
'completionPercentage': settings.COMPLETION_VIDEO_COMPLETE_PERCENTAGE,
'transcriptLanguage': transcript_language, 'transcriptLanguage': transcript_language,
'transcriptLanguages': sorted_languages, 'transcriptLanguages': sorted_languages,
'ytTestTimeout': settings.YOUTUBE['TEST_TIMEOUT'], 'ytTestTimeout': settings.YOUTUBE['TEST_TIMEOUT'],
...@@ -358,18 +371,19 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -358,18 +371,19 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'transcriptAvailableTranslationsUrl': self.runtime.handler_url( 'transcriptAvailableTranslationsUrl': self.runtime.handler_url(
self, 'transcript', 'available_translations' self, 'transcript', 'available_translations'
).rstrip('/?'), ).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 # For now, the option "data-autohide-html5" is hard coded. This option
## inactivity. If set to true, controls and captions will autohide for # either enables or disables autohiding of controls and captions on mouse
## HTML5 sources (non-YouTube) after a period of mouse inactivity over the # inactivity. If set to true, controls and captions will autohide for
## whole video. When the mouse moves (or a key is pressed while any part of # HTML5 sources (non-YouTube) after a period of mouse inactivity over the
## the video player is focused), the captions and controls will be shown # whole video. When the mouse moves (or a key is pressed while any part of
## once again. # 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 # There is no option in the "Advanced Editor" to set this option. However,
## front-end exists. # this option will have an effect if changed to "True". The code on
# front-end exists.
'autohideHtml5': False, 'autohideHtml5': False,
# This is the server's guess at whether youtube is available for # This is the server's guess at whether youtube is available for
...@@ -399,8 +413,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -399,8 +413,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
return self.system.render_template('video.html', context) return self.system.render_template('video.html', context)
@XBlock.wants("request_cache") @XBlock.wants("request_cache", "settings", "completion")
@XBlock.wants("settings")
class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers,
TabsEditingDescriptor, EmptyDataRawDescriptor, LicenseMixin): TabsEditingDescriptor, EmptyDataRawDescriptor, LicenseMixin):
""" """
...@@ -408,6 +421,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -408,6 +421,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
""" """
module_class = VideoModule module_class = VideoModule
transcript = module_attr('transcript') transcript = module_attr('transcript')
publish_completion = module_attr('publish_completion')
show_in_read_only_mode = True show_in_read_only_mode = True
......
...@@ -183,6 +183,14 @@ class TestVideo(BaseTestXmodule): ...@@ -183,6 +183,14 @@ class TestVideo(BaseTestXmodule):
response = self.item_descriptor.handle_ajax('save_user_state', {u'demoo�': "sample"}) response = self.item_descriptor.handle_ajax('save_user_state', {u'demoo�': "sample"})
self.assertEqual(json.loads(response)['success'], True) 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): def tearDown(self):
_clear_assets(self.item_descriptor.location) _clear_assets(self.item_descriptor.location)
super(TestVideo, self).tearDown() super(TestVideo, self).tearDown()
......
...@@ -1088,6 +1088,15 @@ EDX_PLATFORM_REVISION = ENV_TOKENS.get('EDX_PLATFORM_REVISION', EDX_PLATFORM_REV ...@@ -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. # Allow extra middleware classes to be added to the app through configuration.
MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) 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 Any Derived Settings #######################
derive_settings(__name__) derive_settings(__name__)
...@@ -3456,3 +3456,9 @@ ACE_ROUTING_KEY = LOW_PRIORITY_QUEUE ...@@ -3456,3 +3456,9 @@ ACE_ROUTING_KEY = LOW_PRIORITY_QUEUE
# Initialize to 'unknown', but read from JSON in aws.py # Initialize to 'unknown', but read from JSON in aws.py
EDX_PLATFORM_REVISION = 'unknown' 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment