diff --git a/cms/envs/common.py b/cms/envs/common.py
index 2e7460006b..ec796329fc 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -170,6 +170,10 @@ FEATURES = {
# Don't autoplay videos for course authors
'AUTOPLAY_VIDEOS': False,
+ # Move the course author to next page when a video finishes. Set to True to
+ # show an auto-advance button in videos. If False, videos never auto-advance.
+ 'ENABLE_AUTOADVANCE_VIDEOS': False,
+
# If set to True, new Studio users won't be able to author courses unless
# an Open edX admin has added them to the course creator group.
'ENABLE_CREATOR_GROUP': True,
diff --git a/common/djangoapps/entitlements/management/__init__.py b/common/djangoapps/entitlements/management/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/entitlements/management/commands/__init__.py b/common/djangoapps/entitlements/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/entitlements/management/commands/expire_old_entitlements.py b/common/djangoapps/entitlements/management/commands/expire_old_entitlements.py
new file mode 100644
index 0000000000..a5a6e35ac0
--- /dev/null
+++ b/common/djangoapps/entitlements/management/commands/expire_old_entitlements.py
@@ -0,0 +1,69 @@
+"""
+Management command for expiring old entitlements.
+"""
+
+import logging
+
+from django.core.management import BaseCommand
+from django.core.paginator import Paginator
+
+from entitlements.models import CourseEntitlement
+from entitlements.tasks.v1.tasks import expire_old_entitlements
+
+logger = logging.getLogger(__name__) # pylint: disable=invalid-name
+
+
+class Command(BaseCommand):
+ """
+ Management command for expiring old entitlements.
+
+ Most entitlements get expired as the user interacts with the platform,
+ because the LMS checks as it goes. But if the learner has not logged in
+ for a while, we still want to reap these old entitlements. So this command
+ should be run every now and then (probably daily) to expire old
+ entitlements.
+
+ The command's goal is to pass a narrow subset of entitlements to an
+ idempotent Celery task for further (parallelized) processing.
+ """
+ help = 'Expire old entitlements.'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '-c', '--commit',
+ action='store_true',
+ default=False,
+ help='Submit tasks for processing'
+ )
+
+ parser.add_argument(
+ '--batch-size',
+ type=int,
+ default=10000, # arbitrary, should be adjusted if it is found to be inadequate
+ help='How many entitlements to give each celery task'
+ )
+
+ def handle(self, *args, **options):
+ logger.info('Looking for entitlements which may be expirable.')
+
+ # This query could be optimized to return a more narrow set, but at a
+ # complexity cost. See bug LEARNER-3451 about improving it.
+ entitlements = CourseEntitlement.objects.filter(expired_at__isnull=True).order_by('id')
+
+ batch_size = max(1, options.get('batch_size'))
+ entitlements = Paginator(entitlements, batch_size, allow_empty_first_page=False)
+
+ if options.get('commit'):
+ logger.info('Enqueuing entitlement expiration tasks for %d candidates.', entitlements.count)
+ else:
+ logger.info(
+ 'Found %d candidates. To enqueue entitlement expiration tasks, pass the -c or --commit flags.',
+ entitlements.count
+ )
+ return
+
+ for page_num in entitlements.page_range:
+ page = entitlements.page(page_num)
+ expire_old_entitlements.delay(page, logid=str(page_num))
+
+ logger.info('Done. Successfully enqueued tasks.')
diff --git a/common/djangoapps/entitlements/management/commands/tests/__init__.py b/common/djangoapps/entitlements/management/commands/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/entitlements/management/commands/tests/test_expire_old_entitlements.py b/common/djangoapps/entitlements/management/commands/tests/test_expire_old_entitlements.py
new file mode 100644
index 0000000000..46965d9c20
--- /dev/null
+++ b/common/djangoapps/entitlements/management/commands/tests/test_expire_old_entitlements.py
@@ -0,0 +1,85 @@
+"""Test Entitlements models"""
+
+from datetime import datetime, timedelta
+import mock
+import pytz
+
+from django.core.management import call_command
+from django.test import TestCase
+
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+from entitlements.models import CourseEntitlementPolicy
+from entitlements.tests.factories import CourseEntitlementFactory
+
+
+def make_entitlement(expired=False):
+ age = CourseEntitlementPolicy.DEFAULT_EXPIRATION_PERIOD_DAYS
+ past_datetime = datetime.now(tz=pytz.UTC) - timedelta(days=age)
+ expired_at = past_datetime if expired else None
+ return CourseEntitlementFactory.create(created=past_datetime, expired_at=expired_at)
+
+
+@skip_unless_lms
+@mock.patch('entitlements.tasks.v1.tasks.expire_old_entitlements.delay')
+class TestExpireOldEntitlementsCommand(TestCase):
+ """
+ Test expire_old_entitlement management command.
+ """
+
+ def test_no_commit(self, mock_task):
+ """
+ Verify that relevant tasks are only enqueued when the commit option is passed.
+ """
+ make_entitlement()
+
+ call_command('expire_old_entitlements')
+ self.assertEqual(mock_task.call_count, 0)
+
+ call_command('expire_old_entitlements', commit=True)
+ self.assertEqual(mock_task.call_count, 1)
+
+ def test_no_tasks_if_no_work(self, mock_task):
+ """
+ Verify that we never try to spin off a task if there are no matching database rows.
+ """
+ call_command('expire_old_entitlements', commit=True)
+ self.assertEqual(mock_task.call_count, 0)
+
+ # Now confirm that the above test wasn't a fluke and we will create a task if there is work
+ make_entitlement()
+ call_command('expire_old_entitlements', commit=True)
+ self.assertEqual(mock_task.call_count, 1)
+
+ def test_only_unexpired(self, mock_task):
+ """
+ Verify that only unexpired entitlements are included
+ """
+ # Create an old expired and an old unexpired entitlement
+ entitlement1 = make_entitlement(expired=True)
+ entitlement2 = make_entitlement()
+
+ # Sanity check
+ self.assertIsNotNone(entitlement1.expired_at)
+ self.assertIsNone(entitlement2.expired_at)
+
+ # Run expiration
+ call_command('expire_old_entitlements', commit=True)
+
+ # Make sure only the unexpired one gets used
+ self.assertEqual(mock_task.call_count, 1)
+ self.assertEqual(list(mock_task.call_args[0][0].object_list), [entitlement2])
+
+ def test_pagination(self, mock_task):
+ """
+ Verify that we chunk up our requests to celery.
+ """
+ for _ in range(5):
+ make_entitlement()
+
+ call_command('expire_old_entitlements', commit=True, batch_size=2)
+
+ args_list = mock_task.call_args_list
+ self.assertEqual(len(args_list), 3)
+ self.assertEqual(len(args_list[0][0][0].object_list), 2)
+ self.assertEqual(len(args_list[1][0][0].object_list), 2)
+ self.assertEqual(len(args_list[2][0][0].object_list), 1)
diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py
index 66ddec0231..cae62123ba 100644
--- a/common/djangoapps/entitlements/models.py
+++ b/common/djangoapps/entitlements/models.py
@@ -54,10 +54,11 @@ class CourseEntitlementPolicy(models.Model):
# Compute the days left for the regain
days_since_course_start = (now - course_overview.start).days
days_since_enrollment = (now - entitlement.enrollment_course_run.created).days
+ days_since_entitlement_created = (now - entitlement.created).days
# We want to return whichever days value is less since it is then the more recent one
days_until_regain_ends = (self.regain_period.days - # pylint: disable=no-member
- min(days_since_course_start, days_since_enrollment))
+ min(days_since_course_start, days_since_enrollment, days_since_entitlement_created))
# If the base days until expiration is less than the days until the regain period ends, use that instead
if days_until_expiry < days_until_regain_ends:
diff --git a/common/djangoapps/entitlements/tasks/__init__.py b/common/djangoapps/entitlements/tasks/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/entitlements/tasks/v1/__init__.py b/common/djangoapps/entitlements/tasks/v1/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/entitlements/tasks/v1/tasks.py b/common/djangoapps/entitlements/tasks/v1/tasks.py
new file mode 100644
index 0000000000..bfe849b235
--- /dev/null
+++ b/common/djangoapps/entitlements/tasks/v1/tasks.py
@@ -0,0 +1,56 @@
+"""
+This file contains celery tasks for entitlements-related functionality.
+"""
+
+from celery import task
+from celery.utils.log import get_task_logger
+from django.conf import settings
+
+
+LOGGER = get_task_logger(__name__)
+# Under cms the following setting is not defined, leading to errors during tests.
+ROUTING_KEY = getattr(settings, 'ENTITLEMENTS_EXPIRATION_ROUTING_KEY', None)
+# Maximum number of retries before giving up on awarding credentials.
+# For reference, 11 retries with exponential backoff yields a maximum waiting
+# time of 2047 seconds (about 30 minutes). Setting this to None could yield
+# unwanted behavior: infinite retries.
+MAX_RETRIES = 11
+
+
+@task(bind=True, ignore_result=True, routing_key=ROUTING_KEY)
+def expire_old_entitlements(self, entitlements, logid='...'):
+ """
+ This task is designed to be called to process a bundle of entitlements
+ that might be expired and confirm if we can do so. This is useful when
+ checking if an entitlement has just been abandoned by the learner and needs
+ to be expired. (In the normal course of a learner using the platform, the
+ entitlement will expire itself. But if a learner doesn't log in... So we
+ run this task every now and then to clear the backlog.)
+
+ Args:
+ entitlements (list): An iterable set of CourseEntitlements to check
+ logid (str): A string to identify this task in the logs
+
+ Returns:
+ None
+
+ """
+ LOGGER.info('Running task expire_old_entitlements [%s]', logid)
+
+ countdown = 2 ** self.request.retries
+
+ try:
+ for entitlement in entitlements:
+ # This property request will update the expiration if necessary as
+ # a side effect. We could manually call update_expired_at(), but
+ # let's use the same API the rest of the LMS does, to mimic normal
+ # usage and allow the update call to be an internal detail.
+ if entitlement.expired_at_datetime:
+ LOGGER.info('Expired entitlement with id %d [%s]', entitlement.id, logid)
+
+ except Exception as exc:
+ LOGGER.exception('Failed to expire entitlements [%s]', logid)
+ # The call above is idempotent, so retry at will
+ raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES)
+
+ LOGGER.info('Successfully completed the task expire_old_entitlements [%s]', logid)
diff --git a/common/djangoapps/entitlements/tasks/v1/tests/__init__.py b/common/djangoapps/entitlements/tasks/v1/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/entitlements/tasks/v1/tests/test_tasks.py b/common/djangoapps/entitlements/tasks/v1/tests/test_tasks.py
new file mode 100644
index 0000000000..8caababb8b
--- /dev/null
+++ b/common/djangoapps/entitlements/tasks/v1/tests/test_tasks.py
@@ -0,0 +1,74 @@
+"""
+Test entitlements tasks
+"""
+
+from datetime import datetime, timedelta
+import mock
+import pytz
+
+from django.test import TestCase
+
+from entitlements.models import CourseEntitlementPolicy
+from entitlements.tasks.v1 import tasks
+from entitlements.tests.factories import CourseEntitlementFactory
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+
+
+def make_entitlement(**kwargs):
+ m = mock.NonCallableMock()
+ p = mock.PropertyMock(**kwargs)
+ type(m).expired_at_datetime = p
+ return m, p
+
+
+def boom():
+ raise Exception('boom')
+
+
+@skip_unless_lms
+class TestExpireOldEntitlementsTask(TestCase):
+ """
+ Tests for the 'expire_old_entitlements' method.
+ """
+
+ def test_checks_expiration(self):
+ """
+ Test that we actually do check expiration on each entitlement (happy path)
+ """
+ entitlement1, prop1 = make_entitlement(return_value=None)
+ entitlement2, prop2 = make_entitlement(return_value='some date')
+ tasks.expire_old_entitlements.delay([entitlement1, entitlement2]).get()
+
+ # Test that the expired_at_datetime property was accessed
+ self.assertEqual(prop1.call_count, 1)
+ self.assertEqual(prop2.call_count, 1)
+
+ def test_retry(self):
+ """
+ Test that we retry when an exception occurs while checking old
+ entitlements.
+ """
+ entitlement, prop = make_entitlement(side_effect=boom)
+ task = tasks.expire_old_entitlements.delay([entitlement])
+
+ self.assertRaises(Exception, task.get)
+ self.assertEqual(prop.call_count, tasks.MAX_RETRIES + 1)
+
+ def test_actually_expired(self):
+ """
+ Integration test with CourseEntitlement to make sure we are calling the
+ correct API.
+ """
+ # Create an actual old entitlement
+ past_days = CourseEntitlementPolicy.DEFAULT_EXPIRATION_PERIOD_DAYS
+ past_datetime = datetime.now(tz=pytz.UTC) - timedelta(days=past_days)
+ entitlement = CourseEntitlementFactory.create(created=past_datetime)
+
+ # Sanity check
+ self.assertIsNone(entitlement.expired_at)
+
+ # Run enforcement
+ tasks.expire_old_entitlements.delay([entitlement]).get()
+ entitlement.refresh_from_db()
+
+ self.assertIsNotNone(entitlement.expired_at)
diff --git a/common/djangoapps/entitlements/tests/test_models.py b/common/djangoapps/entitlements/tests/test_models.py
index 6548939fbf..98f5b9a3e6 100644
--- a/common/djangoapps/entitlements/tests/test_models.py
+++ b/common/djangoapps/entitlements/tests/test_models.py
@@ -175,6 +175,21 @@ class TestModels(TestCase):
assert expired_at_datetime
assert entitlement.expired_at
+ # Verify that an entitlement that has just been created, but the user has been enrolled in the course for
+ # greater than 14 days, and the course started more than 14 days ago is not expired
+ entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
+ past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=20)
+ entitlement.created = datetime.utcnow().replace(tzinfo=pytz.UTC)
+ self.enrollment.created = past_datetime
+ self.course.start = past_datetime
+ entitlement.save()
+ self.enrollment.save()
+ self.course.save()
+ assert entitlement.enrollment_course_run
+ expired_at_datetime = entitlement.expired_at_datetime
+ assert expired_at_datetime is None
+ assert entitlement.expired_at is None
+
# Verify a date 451 days in the past (1 days after the policy expiration)
# That is enrolled and started in within the regain period is still expired
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss
index 1432447709..b99e4afd18 100644
--- a/common/lib/xmodule/xmodule/css/video/display.scss
+++ b/common/lib/xmodule/xmodule/css/video/display.scss
@@ -458,6 +458,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark
.volume,
.add-fullscreen,
.grouped-controls,
+ .auto-advance,
.quality-control {
@include border-left(1px dotted rgb(79, 89, 93)); // UXPL grayscale-cool x-dark
}
@@ -465,6 +466,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark
.speed-button,
.volume > .control,
.add-fullscreen,
+ .auto-advance,
.quality-control,
.toggle-transcript {
&:focus {
diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance.html b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance.html
new file mode 100644
index 0000000000..d6deeaf3d5
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0:00 / 0:00
+
+
+
+
+
+
+
+
+
+
diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance_disabled.html b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance_disabled.html
new file mode 100644
index 0000000000..0783804353
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance_disabled.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0:00 / 0:00
+
+
+
+
+
+
+
+
+
+
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_autoadvance_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_autoadvance_spec.js
new file mode 100644
index 0000000000..ee4c3a0713
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_autoadvance_spec.js
@@ -0,0 +1,109 @@
+(function() {
+ 'use strict';
+ describe('VideoAutoAdvance', function() {
+ var state, oldOTBD;
+ beforeEach(function() {
+ oldOTBD = window.onTouchBasedDevice;
+ window.onTouchBasedDevice = jasmine
+ .createSpy('onTouchBasedDevice').and.returnValue(null);
+ jasmine.clock().install();
+ });
+ afterEach(function() {
+ $('source').remove();
+ state.storage.clear();
+
+ if (state.videoPlayer) {
+ state.videoPlayer.destroy();
+ }
+ window.onTouchBasedDevice = oldOTBD;
+ jasmine.clock().uninstall();
+ });
+ describe('when auto-advance feature is unset (default behaviour)', function() {
+ beforeEach(function() {
+ state = jasmine.initializePlayer('video.html');
+ appendLoadFixtures('sequence.html');
+ });
+ it('no auto-advance button is shown', function() {
+ var $button = $('.control.auto-advance');
+ expect($button).not.toExist();
+ });
+ it('when video ends, it will not auto-advance to next unit', function() {
+ var $nextButton = $('.sequence-nav-button.button-next').first();
+ expect($nextButton).toExist();
+
+ // not auto-clicked yet
+ spyOnEvent($nextButton, 'click');
+ expect('click').not.toHaveBeenTriggeredOn($nextButton);
+
+ state.el.trigger('ended');
+ jasmine.clock().tick(2);
+
+ // still not auto-clicked
+ expect('click').not.toHaveBeenTriggeredOn($nextButton);
+ });
+ });
+ describe('when auto-advance feature is set', function() {
+ describe('and auto-advance is enabled', function() {
+ beforeEach(function() {
+ state = jasmine.initializePlayer('video_autoadvance.html');
+ appendLoadFixtures('sequence.html');
+ });
+ it('an active auto-advance button is shown', function() {
+ var $button = $('.control.auto-advance');
+ expect($button).toExist();
+ expect($button).toHaveClass('active');
+ });
+ it('when button is clicked, it will deactivate auto-advance', function() {
+ var $button = $('.control.auto-advance');
+ $button.click();
+ expect($button).not.toHaveClass('active');
+ });
+ it('when video ends, it will auto-advance to next unit', function() {
+ var $nextButton = $('.sequence-nav-button.button-next').first();
+ expect($nextButton).toExist();
+
+ // not auto-clicked yet
+ spyOnEvent($nextButton, 'click');
+ expect('click').not.toHaveBeenTriggeredOn($nextButton);
+
+ state.el.trigger('ended');
+ jasmine.clock().tick(2);
+
+ // now it was auto-clicked
+ expect('click').toHaveBeenTriggeredOn($nextButton);
+ });
+ });
+
+ describe('when auto-advance is disabled', function() {
+ beforeEach(function() {
+ state = jasmine.initializePlayer('video_autoadvance_disabled.html');
+ appendLoadFixtures('sequence.html');
+ });
+ it('an inactive auto-advance button is shown', function() {
+ var $button = $('.control.auto-advance');
+ expect($button).toExist();
+ expect($button).not.toHaveClass('active');
+ });
+ it('when the button is clicked, it will activate auto-advance', function() {
+ var $button = $('.control.auto-advance');
+ $button.click();
+ expect($button).toHaveClass('active');
+ });
+ it('when video ends, it will not auto-advance to next unit', function() {
+ var $nextButton = $('.sequence-nav-button.button-next').first();
+ expect($nextButton).toExist();
+
+ // not auto-clicked yet
+ spyOnEvent($nextButton, 'click');
+ expect('click').not.toHaveBeenTriggeredOn($nextButton);
+
+ state.el.trigger('ended');
+ jasmine.clock().tick(2);
+
+ // still not auto-clicked
+ expect('click').not.toHaveBeenTriggeredOn($nextButton);
+ });
+ });
+ });
+ });
+}).call(this);
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js
index e0120b165f..f6221117ec 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js
@@ -201,6 +201,7 @@
seek: plugin.onSeek,
skip: plugin.onSkip,
speedchange: plugin.onSpeedChange,
+ autoadvancechange: plugin.onAutoAdvanceChange,
'language_menu:show': plugin.onShowLanguageMenu,
'language_menu:hide': plugin.onHideLanguageMenu,
'transcript:show': plugin.onShowTranscript,
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js
index cc71901c6c..0d100a98db 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js
@@ -242,6 +242,7 @@
expect(state.videoSaveStatePlugin).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
speedchange: plugin.onSpeedChange,
+ autoadvancechange: plugin.onAutoAdvanceChange,
play: plugin.bindUnloadHandler,
'pause destroy': plugin.saveStateHandler,
'language_menu:change': plugin.onLanguageChange,
diff --git a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js
index 435c420565..f9fd9a162e 100644
--- a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js
+++ b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js
@@ -18,6 +18,7 @@ function() {
'Exit full browser': gettext('Exit full browser'),
'Fill browser': gettext('Fill browser'),
Speed: gettext('Speed'),
+ 'Auto-advance': gettext('Auto-advance'),
Volume: gettext('Volume'),
// Translators: Volume level equals 0%.
Muted: gettext('Muted'),
diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js
index 2f7d99a075..93a16f6631 100644
--- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js
+++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js
@@ -62,6 +62,7 @@ function(VideoPlayer, i18n, moment, _) {
});
},
+ /* eslint-disable no-use-before-define */
methodsDict = {
bindTo: bindTo,
fetchMetadata: fetchMetadata,
@@ -77,6 +78,7 @@ function(VideoPlayer, i18n, moment, _) {
parseYoutubeStreams: parseYoutubeStreams,
setPlayerMode: setPlayerMode,
setSpeed: setSpeed,
+ setAutoAdvance: setAutoAdvance,
speedToString: speedToString,
trigger: trigger,
youtubeId: youtubeId,
@@ -84,6 +86,7 @@ function(VideoPlayer, i18n, moment, _) {
loadYoutubePlayer: loadYoutubePlayer,
loadYouTubeIFrameAPI: loadYouTubeIFrameAPI
},
+ /* eslint-enable no-use-before-define */
_youtubeApiDeferred = null,
_oldOnYouTubeIframeAPIReady;
@@ -375,6 +378,14 @@ function(VideoPlayer, i18n, moment, _) {
showCaptions: isBoolean,
autoplay: isBoolean,
autohideHtml5: isBoolean,
+ autoAdvance: function(value) {
+ var shouldAutoAdvance = storage.getItem('auto_advance');
+ if (_.isUndefined(shouldAutoAdvance)) {
+ return isBoolean(value) || false;
+ } else {
+ return shouldAutoAdvance;
+ }
+ },
savedVideoPosition: function(value) {
return storage.getItem('savedVideoPosition', true) ||
Number(value) ||
@@ -568,6 +579,7 @@ function(VideoPlayer, i18n, moment, _) {
this.speed = this.speedToString(
this.config.speed || this.config.generalSpeed
);
+ this.auto_advance = this.config.autoAdvance;
this.htmlPlayerLoaded = false;
this.duration = this.metadata.duration;
@@ -704,6 +716,10 @@ function(VideoPlayer, i18n, moment, _) {
}
}
+ function setAutoAdvance(enabled) {
+ this.auto_advance = enabled;
+ }
+
function getVideoMetadata(url, callback) {
if (!(_.isString(url))) {
url = this.videos['1.0'] || '';
diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
index f9b327ea38..debff1da8b 100644
--- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
+++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js
@@ -14,6 +14,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
return dfd.promise();
},
+ /* eslint-disable no-use-before-define */
methodsDict = {
destroy: destroy,
duration: duration,
@@ -41,6 +42,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
onReady: onReady,
onSlideSeek: onSeek,
onSpeedChange: onSpeedChange,
+ onAutoAdvanceChange: onAutoAdvanceChange,
onStateChange: onStateChange,
onUnstarted: onUnstarted,
onVolumeChange: onVolumeChange,
@@ -53,6 +55,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
figureOutStartingTime: figureOutStartingTime,
updatePlayTime: updatePlayTime
};
+ /* eslint-enable no-use-before-define */
VideoPlayer.prototype = methodsDict;
@@ -427,6 +430,10 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
this.videoPlayer.setPlaybackRate(newSpeed);
}
+ function onAutoAdvanceChange(enabled) {
+ this.setAutoAdvance(enabled);
+ }
+
// Every 200 ms, if the video is playing, we call the function update, via
// clearInterval. This interval is called updateInterval.
// It is created on a onPlay event. Cleared on a onPause event.
@@ -564,6 +571,10 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
_this.videoPlayer.onSpeedChange(speed);
});
+ this.el.on('autoadvancechange', function(event, enabled) {
+ _this.videoPlayer.onAutoAdvanceChange(enabled);
+ });
+
this.el.on('volumechange volumechange:silent', function(event, volume) {
_this.videoPlayer.onVolumeChange(volume);
});
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_auto_advance_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_auto_advance_control.js
new file mode 100644
index 0000000000..e3935caed5
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_auto_advance_control.js
@@ -0,0 +1,129 @@
+(function(requirejs, require, define) {
+ 'use strict';
+ define(
+ 'video/08_video_auto_advance_control.js', [
+ 'edx-ui-toolkit/js/utils/html-utils',
+ 'underscore'
+ ], function(HtmlUtils, _) {
+ /**
+ * Auto advance control module.
+ * @exports video/08_video_auto_advance_control.js
+ * @constructor
+ * @param {object} state The object containing the state of the video player.
+ * @return {jquery Promise}
+ */
+ var AutoAdvanceControl = function(state) {
+ if (!(this instanceof AutoAdvanceControl)) {
+ return new AutoAdvanceControl(state);
+ }
+
+ _.bindAll(this, 'onClick', 'destroy', 'autoPlay', 'autoAdvance');
+ this.state = state;
+ this.state.videoAutoAdvanceControl = this;
+ this.initialize();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ AutoAdvanceControl.prototype = {
+ template: HtmlUtils.interpolateHtml(
+ HtmlUtils.HTML([
+ ''].join('')),
+ {
+ autoAdvanceText: gettext('Auto-advance')
+ }
+ ).toString(),
+
+ destroy: function() {
+ this.el.off({
+ click: this.onClick
+ });
+ this.el.remove();
+ this.state.el.off({
+ ready: this.autoPlay,
+ ended: this.autoAdvance,
+ destroy: this.destroy
+ });
+ delete this.state.videoAutoAdvanceControl;
+ },
+
+ /** Initializes the module. */
+ initialize: function() {
+ var state = this.state;
+
+ this.el = $(this.template);
+ this.render();
+ this.setAutoAdvance(state.auto_advance);
+ this.bindHandlers();
+
+ return true;
+ },
+
+ /**
+ * Creates any necessary DOM elements, attach them, and set their,
+ * initial configuration.
+ * @param {boolean} enabled Whether auto advance is enabled
+ */
+ render: function() {
+ this.state.el.find('.secondary-controls').prepend(this.el);
+ },
+
+ /**
+ * Bind any necessary function callbacks to DOM events (click,
+ * mousemove, etc.).
+ */
+ bindHandlers: function() {
+ this.el.on({
+ click: this.onClick
+ });
+ this.state.el.on({
+ ready: this.autoPlay,
+ ended: this.autoAdvance,
+ destroy: this.destroy
+ });
+ },
+
+ onClick: function(event) {
+ var enabled = !this.state.auto_advance;
+ event.preventDefault();
+ this.setAutoAdvance(enabled);
+ this.el.trigger('autoadvancechange', [enabled]);
+ },
+
+ /**
+ * Sets or unsets auto advance.
+ * @param {boolean} enabled Sets auto advance.
+ */
+ setAutoAdvance: function(enabled) {
+ if (enabled) {
+ this.el.addClass('active');
+ } else {
+ this.el.removeClass('active');
+ }
+ },
+
+ autoPlay: function() {
+ // Only autoplay the video if it's the first component of the unit.
+ // If a unit has more than one video, no more than one will autoplay.
+ var isFirstComponent = this.state.el.parents('.vert-0').length === 1;
+ if (this.state.auto_advance && isFirstComponent) {
+ this.state.videoCommands.execute('play');
+ }
+ },
+
+ autoAdvance: function() {
+ if (this.state.auto_advance) {
+ $('.sequence-nav-button.button-next').first().click();
+ }
+ }
+ };
+
+ return AutoAdvanceControl;
+ });
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js
index ab8fbfccbf..0fc90de6c2 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js
@@ -16,8 +16,8 @@
}
_.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onEnded', 'onSeek',
- 'onSpeedChange', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip',
- 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions',
+ 'onSpeedChange', 'onAutoAdvanceChange', 'onShowLanguageMenu', 'onHideLanguageMenu',
+ 'onSkip', 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions',
'destroy');
this.state = state;
@@ -45,6 +45,7 @@
seek: this.onSeek,
skip: this.onSkip,
speedchange: this.onSpeedChange,
+ autoadvancechange: this.onAutoAdvanceChange,
'language_menu:show': this.onShowLanguageMenu,
'language_menu:hide': this.onHideLanguageMenu,
'transcript:show': this.onShowTranscript,
@@ -105,6 +106,12 @@
});
},
+ onAutoAdvanceChange: function(event, enabled) {
+ this.log('auto_advance_change_video', {
+ enabled: enabled
+ });
+ },
+
onShowLanguageMenu: function() {
this.log('edx.video.language_menu.shown');
},
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js
index 8210c60ec7..7657d37a2a 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js
@@ -1,6 +1,6 @@
(function(define) {
'use strict';
- define('video/09_save_state_plugin.js', [], function() {
+ define('video/09_save_state_plugin.js', ['underscore'], function(_) {
/**
* Save state module.
* @exports video/09_save_state_plugin.js
@@ -15,8 +15,8 @@
return new SaveStatePlugin(state, i18n, options);
}
- _.bindAll(this, 'onSpeedChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload', 'onYoutubeAvailability',
- 'onLanguageChange', 'destroy');
+ _.bindAll(this, 'onSpeedChange', 'onAutoAdvanceChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload',
+ 'onYoutubeAvailability', 'onLanguageChange', 'destroy');
this.state = state;
this.options = _.extend({events: []}, options);
this.state.videoSaveStatePlugin = this;
@@ -38,6 +38,7 @@
initialize: function() {
this.events = {
speedchange: this.onSpeedChange,
+ autoadvancechange: this.onAutoAdvanceChange,
play: this.bindUnloadHandler,
'pause destroy': this.saveStateHandler,
'language_menu:change': this.onLanguageChange,
@@ -71,6 +72,11 @@
this.state.storage.setItem('general_speed', newSpeed);
},
+ onAutoAdvanceChange: function(event, enabled) {
+ this.saveState(true, {auto_advance: enabled});
+ this.state.storage.setItem('auto_advance', enabled);
+ },
+
saveStateHandler: function() {
this.saveState(true);
},
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 90fd45aea3..93b5890d9c 100644
--- a/common/lib/xmodule/xmodule/js/src/video/10_main.js
+++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js
@@ -45,6 +45,7 @@
'video/06_video_progress_slider.js',
'video/07_video_volume_control.js',
'video/08_video_speed_control.js',
+ 'video/08_video_auto_advance_control.js',
'video/09_video_caption.js',
'video/09_play_placeholder.js',
'video/09_play_pause_control.js',
@@ -61,9 +62,9 @@
],
function(
VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen,
- VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption,
- VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper,
- VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster,
+ VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoAutoAdvanceControl,
+ VideoCaption, VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl,
+ VideoBumper, VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster,
VideoCompletionHandler, VideoCommands, VideoContextMenu
) {
var youtubeXhr = null,
@@ -74,10 +75,13 @@
id = el.attr('id').replace(/video_/, ''),
storage = VideoStorage('VideoState', id),
bumperMetadata = el.data('bumper-metadata'),
- mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder,
- VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl,
- VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu,
- VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler],
+ autoAdvanceEnabled = el.data('autoadvance-enabled') === 'True',
+ mainVideoModules = [
+ FocusGrabber, VideoControl, VideoPlayPlaceholder,
+ VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl,
+ VideoVolumeControl, VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands,
+ VideoContextMenu, VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler
+ ].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []),
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin,
VideoEventsBumperPlugin, VideoCompletionHandler],
diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py
index 156fe89f0c..07ad649eef 100644
--- a/common/lib/xmodule/xmodule/modulestore/inheritance.py
+++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py
@@ -172,6 +172,14 @@ class InheritanceMixin(XBlockMixin):
default=True,
scope=Scope.settings
)
+ video_auto_advance = Boolean(
+ display_name=_("Enable video auto-advance"),
+ help=_(
+ "Specify whether to show an auto-advance button in videos. If the student clicks it, when the last video in a unit finishes it will automatically move to the next unit and autoplay the first video."
+ ),
+ scope=Scope.settings,
+ default=False
+ )
video_bumper = Dict(
display_name=_("Video Pre-Roll"),
help=_(
diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py
index d36729d4a1..e0a5e6af4a 100644
--- a/common/lib/xmodule/xmodule/video_module/video_handlers.py
+++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py
@@ -51,13 +51,14 @@ class VideoStudentViewHandlers(object):
Update values of xfields, that were changed by student.
"""
accepted_keys = [
- 'speed', 'saved_video_position', 'transcript_language',
+ 'speed', 'auto_advance', 'saved_video_position', 'transcript_language',
'transcript_download_format', 'youtube_is_available',
'bumper_last_view_date', 'bumper_do_not_show_again'
]
conversions = {
'speed': json.loads,
+ 'auto_advance': json.loads,
'saved_video_position': RelativeTime.isotime_to_timedelta,
'youtube_is_available': json.loads,
}
diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py
index 5646ff54dd..b4a34f9d98 100644
--- a/common/lib/xmodule/xmodule/video_module/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module/video_module.py
@@ -144,6 +144,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
resource_string(module, 'js/src/video/06_video_progress_slider.js'),
resource_string(module, 'js/src/video/07_video_volume_control.js'),
resource_string(module, 'js/src/video/08_video_speed_control.js'),
+ resource_string(module, 'js/src/video/08_video_auto_advance_control.js'),
resource_string(module, 'js/src/video/09_video_caption.js'),
resource_string(module, 'js/src/video/09_play_placeholder.js'),
resource_string(module, 'js/src/video/09_play_pause_control.js'),
@@ -338,6 +339,19 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
else:
completion_enabled = False
+ # This is the setting that controls whether the autoadvance button will be visible, not whether the
+ # video will autoadvance or not.
+ # For autoadvance controls to be shown, both the feature flag and the course setting must be true.
+ # This allows to enable the feature for certain courses only.
+ autoadvance_enabled = settings.FEATURES.get('ENABLE_AUTOADVANCE_VIDEOS', False) and \
+ getattr(self, 'video_auto_advance', False)
+
+ # This is the current status of auto-advance (not the control visibility).
+ # But when controls aren't visible we force it to off. The student might have once set the preference to
+ # true, but now staff or admin have hidden the autoadvance button and the student won't be able to disable
+ # it anymore; therefore we force-disable it in this case (when controls aren't visible).
+ autoadvance_this_video = self.auto_advance and autoadvance_enabled
+
metadata = {
'saveStateUrl': self.system.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
@@ -353,6 +367,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
'showCaptions': json.dumps(self.show_captions),
'generalSpeed': self.global_speed,
'speed': self.speed,
+ 'autoAdvance': autoadvance_this_video,
'savedVideoPosition': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(),
'end': self.end_time.total_seconds(),
@@ -395,6 +410,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
bumperize(self)
context = {
+ 'autoadvance_enabled': autoadvance_enabled,
'bumper_metadata': json.dumps(self.bumper['metadata']), # pylint: disable=E1101
'metadata': json.dumps(OrderedDict(metadata)),
'poster': json.dumps(get_poster(self)),
diff --git a/common/lib/xmodule/xmodule/video_module/video_xfields.py b/common/lib/xmodule/xmodule/video_module/video_xfields.py
index cec0036391..9dbc249871 100644
--- a/common/lib/xmodule/xmodule/video_module/video_xfields.py
+++ b/common/lib/xmodule/xmodule/video_module/video_xfields.py
@@ -148,6 +148,15 @@ class VideoFields(object):
scope=Scope.preferences,
default=1.0
)
+ auto_advance = Boolean(
+ help=_("Specify whether to advance automatically to the next unit when the video ends."),
+ scope=Scope.preferences,
+ # The default is True because this field only has an effect when auto-advance controls are enabled
+ # (globally enabled through feature flag and locally enabled through course setting); in that case
+ # it's good to start auto-advancing and let the student disable it, instead of the other way around
+ # (requiring the user to enable it). When auto-advance controls are hidden, this field won't be used.
+ default=True,
+ )
youtube_is_available = Boolean(
help=_("Specify whether YouTube is available for the user."),
scope=Scope.user_info,
diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py
index 769adae1bb..febd176360 100644
--- a/common/test/acceptance/pages/studio/settings_advanced.py
+++ b/common/test/acceptance/pages/studio/settings_advanced.py
@@ -178,6 +178,7 @@ class AdvancedSettingsPage(CoursePage):
'course_image',
'banner_image',
'video_thumbnail_image',
+ 'video_auto_advance',
'cosmetic_display_price',
'advertised_start',
'announcement',
diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py
index 548034e787..91bd202ee2 100644
--- a/common/test/acceptance/tests/lms/test_lms.py
+++ b/common/test/acceptance/tests/lms/test_lms.py
@@ -709,10 +709,8 @@ class HighLevelTabTest(UniqueCourseTest):
self.assertTrue(self.tab_nav.is_on_tab('Wiki'))
# Assert that a default wiki is created
- expected_article_name = "{org}.{course_number}.{course_run}".format(
- org=self.course_info['org'],
- course_number=self.course_info['number'],
- course_run=self.course_info['run']
+ expected_article_name = "{course_name}".format(
+ course_name=self.course_info['display_name']
)
self.assertEqual(expected_article_name, course_wiki.article_name)
diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py
index 78586cbe1d..87ba1fa69e 100644
--- a/lms/djangoapps/course_wiki/tests/tests.py
+++ b/lms/djangoapps/course_wiki/tests/tests.py
@@ -104,6 +104,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas
self.assertEquals(resp.status_code, 200)
self.has_course_navigator(resp)
+ self.assertContains(resp, '
{}
'.format(course.display_name_with_default))
def has_course_navigator(self, resp):
"""
diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py
index e6cd127870..2479d46862 100644
--- a/lms/djangoapps/course_wiki/views.py
+++ b/lms/djangoapps/course_wiki/views.py
@@ -83,7 +83,7 @@ def course_wiki_redirect(request, course_id, wiki_path=""): # pylint: disable=u
urlpath = URLPath.create_article(
root,
course_slug,
- title=course_slug,
+ title=course.display_name_with_default,
content=content,
user_message=_("Course page automatically created."),
user=None,
diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py
index 203350d7ef..f5789c52e3 100644
--- a/lms/djangoapps/courseware/tests/test_video_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_video_mongo.py
@@ -54,6 +54,7 @@ class TestVideoYouTube(TestVideo):
sources = [u'example.mp4', u'example.webm']
expected_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
@@ -64,6 +65,7 @@ class TestVideoYouTube(TestVideo):
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
+ 'autoAdvance': False,
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': False,
'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
@@ -134,6 +136,7 @@ class TestVideoNonYouTube(TestVideo):
sources = [u'example.mp4', u'example.webm']
expected_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
@@ -144,6 +147,7 @@ class TestVideoNonYouTube(TestVideo):
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
+ 'autoAdvance': False,
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': False,
'streams': '1.00:3_yD_cEKoCk',
@@ -201,6 +205,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
super(TestGetHtmlMethod, self).setUp()
self.setup_course()
self.default_metadata_dict = OrderedDict({
+ 'autoAdvance': False,
'saveStateUrl': '',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'streams': '1.00:3_yD_cEKoCk',
@@ -293,6 +298,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
sources = [u'example.mp4', u'example.webm']
expected_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
@@ -411,6 +417,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
]
initial_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
@@ -533,6 +540,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
metadata['autoplay'] = False
metadata['sources'] = ""
initial_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
@@ -704,6 +712,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
metadata = self.default_metadata_dict
metadata['sources'] = ""
initial_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
@@ -810,6 +819,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
]
initial_context = {
+ 'autoadvance_enabled': False,
'branding_info': {
'logo_src': 'http://www.xuetangx.com/static/images/logo.png',
'logo_tag': 'Video hosted by XuetangX.com',
@@ -1705,7 +1715,8 @@ class TestVideoWithBumper(TestVideo):
"""
CATEGORY = "video"
METADATA = {}
- FEATURES = settings.FEATURES
+ # Use temporary FEATURES in this test without affecting the original
+ FEATURES = dict(settings.FEATURES)
@patch('xmodule.video_module.bumper_utils.get_bumper_settings')
def test_is_bumper_enabled(self, get_bumper_settings):
@@ -1753,6 +1764,7 @@ class TestVideoWithBumper(TestVideo):
content = self.item_descriptor.render(STUDENT_VIEW).content
sources = [u'example.mp4', u'example.webm']
expected_context = {
+ 'autoadvance_enabled': False,
'branding_info': None,
'license': None,
'bumper_metadata': json.dumps(OrderedDict({
@@ -1779,6 +1791,7 @@ class TestVideoWithBumper(TestVideo):
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
+ 'autoAdvance': False,
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': False,
'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
@@ -1821,3 +1834,131 @@ class TestVideoWithBumper(TestVideo):
expected_content = self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
self.assertEqual(content, expected_content)
+
+
+@ddt.ddt
+class TestAutoAdvanceVideo(TestVideo):
+ """
+ Tests the server side of video auto-advance.
+ """
+ CATEGORY = "video"
+ METADATA = {}
+ # Use temporary FEATURES in this test without affecting the original
+ FEATURES = dict(settings.FEATURES)
+
+ def prepare_expected_context(self, autoadvanceenabled_flag, autoadvance_flag):
+ """
+ Build a dictionary with data expected by some operations in this test.
+ Only parameters related to auto-advance are variable, rest is fixed.
+ """
+ context = {
+ 'autoadvance_enabled': autoadvanceenabled_flag,
+ 'branding_info': None,
+ 'license': None,
+ 'cdn_eval': False,
+ 'cdn_exp_group': None,
+ 'display_name': u'A Name',
+ 'download_video_link': u'example.mp4',
+ 'handout': None,
+ 'id': self.item_descriptor.location.html_id(),
+ 'bumper_metadata': 'null',
+ 'metadata': json.dumps(OrderedDict({
+ 'autoAdvance': autoadvance_flag,
+ 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
+ 'autoplay': False,
+ 'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
+ 'sub': 'a_sub_file.srt.sjson',
+ 'sources': [u'example.mp4', u'example.webm'],
+ 'duration': None,
+ 'poster': None,
+ 'captionDataDir': None,
+ 'showCaptions': 'true',
+ 'generalSpeed': 1.0,
+ 'speed': None,
+ 'savedVideoPosition': 0.0,
+ 'start': 3603.0,
+ 'end': 3610.0,
+ 'transcriptLanguage': 'en',
+ 'transcriptLanguages': OrderedDict({'en': 'English', 'uk': u'Українська'}),
+ 'ytTestTimeout': 1500,
+ '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('/?'),
+ 'autohideHtml5': False,
+ 'recordedYoutubeIsAvailable': True,
+ 'completionEnabled': False,
+ 'completionPercentage': 0.95,
+ 'publishCompletionUrl': self.get_handler_url('publish_completion', ''),
+ })),
+ 'track': None,
+ 'transcript_download_format': u'srt',
+ 'transcript_download_formats_list': [
+ {'display_name': 'SubRip (.srt) file', 'value': 'srt'},
+ {'display_name': 'Text (.txt) file', 'value': 'txt'}
+ ],
+ 'poster': 'null'
+ }
+ return context
+
+ def assert_content_matches_expectations(self, autoadvanceenabled_must_be, autoadvance_must_be):
+ """
+ Check (assert) that loading video.html produces content that corresponds
+ to the passed context.
+ Helper function to avoid code repetition.
+ """
+
+ with override_settings(FEATURES=self.FEATURES):
+ content = self.item_descriptor.render(STUDENT_VIEW).content
+
+ expected_context = self.prepare_expected_context(
+ autoadvanceenabled_flag=autoadvanceenabled_must_be,
+ autoadvance_flag=autoadvance_must_be,
+ )
+
+ with override_settings(FEATURES=self.FEATURES):
+ expected_content = self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
+
+ self.assertEqual(content, expected_content)
+
+ def change_course_setting_autoadvance(self, new_value):
+ """
+ Change the .video_auto_advance course setting (a.k.a. advanced setting).
+ This avoids doing .save(), and instead modifies the instance directly.
+ Based on test code for video_bumper setting.
+ """
+ # This first render is done to initialize the instance
+ self.item_descriptor.render(STUDENT_VIEW)
+ item_instance = self.item_descriptor.xmodule_runtime.xmodule_instance
+ item_instance.video_auto_advance = new_value
+ # After this step, render() should see the new value
+ # e.g. use self.item_descriptor.render(STUDENT_VIEW).content
+
+ @ddt.data(
+ (False, False),
+ (False, True),
+ (True, False),
+ (True, True),
+ )
+ @ddt.unpack
+ def test_is_autoadvance_available_and_enabled(self, global_setting, course_setting):
+ """
+ Check that the autoadvance is not available when it is disabled via feature flag
+ (ENABLE_AUTOADVANCE_VIDEOS set to False) or by the course setting.
+ It checks that:
+ - only when the feature flag and the course setting are True (at the same time)
+ the controls are visible
+ - in that case (when the controls are visible) the video will autoadvance
+ (because that's the default), in other cases it won't
+ """
+ self.FEATURES.update({"ENABLE_AUTOADVANCE_VIDEOS": global_setting})
+ self.change_course_setting_autoadvance(new_value=course_setting)
+ self.assert_content_matches_expectations(
+ autoadvanceenabled_must_be=(global_setting and course_setting),
+ autoadvance_must_be=(global_setting and course_setting),
+ )
diff --git a/lms/djangoapps/discussion/static/discussion/js/views/discussion_board_view.js b/lms/djangoapps/discussion/static/discussion/js/views/discussion_board_view.js
index c2dafffd24..73cd76b665 100644
--- a/lms/djangoapps/discussion/static/discussion/js/views/discussion_board_view.js
+++ b/lms/djangoapps/discussion/static/discussion/js/views/discussion_board_view.js
@@ -279,12 +279,17 @@
crumbs = [],
subTopic = $('.forum-nav-browse-title', $item)
.first()
+ .contents()
+ .last()
.text()
.trim();
$parentSubMenus.each(function(i, el) {
- crumbs.push($(el).siblings('.forum-nav-browse-title')
+ crumbs.push(
+ $(el).siblings('.forum-nav-browse-title')
.first()
+ .contents()
+ .last()
.text()
.trim()
);
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 842454e08f..bf65051e9c 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -277,6 +277,9 @@ RECALCULATE_GRADES_ROUTING_KEY = ENV_TOKENS.get('RECALCULATE_GRADES_ROUTING_KEY'
# Queue to use for updating grades due to grading policy change
POLICY_CHANGE_GRADES_ROUTING_KEY = ENV_TOKENS.get('POLICY_CHANGE_GRADES_ROUTING_KEY', LOW_PRIORITY_QUEUE)
+# Queue to use for expiring old entitlements
+ENTITLEMENTS_EXPIRATION_ROUTING_KEY = ENV_TOKENS.get('ENTITLEMENTS_EXPIRATION_ROUTING_KEY', LOW_PRIORITY_QUEUE)
+
# Message expiry time in seconds
CELERY_EVENT_QUEUE_TTL = ENV_TOKENS.get('CELERY_EVENT_QUEUE_TTL', None)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index b9b03331eb..7fdf8c0ad0 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -163,6 +163,10 @@ FEATURES = {
# Don't autoplay videos for students
'AUTOPLAY_VIDEOS': False,
+ # Move the student to next page when a video finishes. Set to True to show
+ # an auto-advance button in videos. If False, videos never auto-advance.
+ 'ENABLE_AUTOADVANCE_VIDEOS': False,
+
# Enable instructor dash to submit background tasks
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
diff --git a/lms/static/js/learner_dashboard/views/course_entitlement_view.js b/lms/static/js/learner_dashboard/views/course_entitlement_view.js
index e5bee8555b..f0d19c3280 100644
--- a/lms/static/js/learner_dashboard/views/course_entitlement_view.js
+++ b/lms/static/js/learner_dashboard/views/course_entitlement_view.js
@@ -192,7 +192,9 @@
// Reset the card contents to the unenrolled state
this.$triggerOpenBtn.addClass('hidden');
this.$enterCourseBtn.addClass('hidden');
- this.$courseCardMessages.remove();
+ // Remove all message except for related programs, which should always be shown
+ // (Even other messages might need to be shown again in future: LEARNER-3523.)
+ this.$courseCardMessages.filter(':not(.message-related-programs)').remove();
this.$policyMsg.remove();
this.$('.enroll-btn-initial').focus();
HtmlUtils.setHtml(
diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss
index d66357ff5e..6a6a4ec4c0 100644
--- a/lms/static/sass/multicourse/_dashboard.scss
+++ b/lms/static/sass/multicourse/_dashboard.scss
@@ -1024,7 +1024,7 @@
.verification-cta {
width: flex-grid(4, 12);
- @include float(left);
+ @include float(right);
position: relative;
diff --git a/lms/static/sass/views/_course-entitlements.scss b/lms/static/sass/views/_course-entitlements.scss
index ea3a4b1cf2..f2eded1165 100644
--- a/lms/static/sass/views/_course-entitlements.scss
+++ b/lms/static/sass/views/_course-entitlements.scss
@@ -27,12 +27,13 @@
height: $baseline*1.5;
flex-grow: 1;
letter-spacing: 0;
+ white-space: nowrap;
background: theme-color("inverse");
border-color: theme-color("primary");
color: theme-color("primary");
text-shadow: none;
font-size: $font-size-base;
- padding: 0;
+ padding: 0 $baseline/4;
box-shadow: none;
border-radius: $border-radius-sm;
transition: all 0.4s ease-out;
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index fb65200ffb..1feb06cd65 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -284,9 +284,9 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
diff --git a/lms/templates/video.html b/lms/templates/video.html
index 4cebc37f9a..d475106cab 100644
--- a/lms/templates/video.html
+++ b/lms/templates/video.html
@@ -13,6 +13,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
class="video closed"
data-metadata='${metadata}'
data-bumper-metadata='${bumper_metadata}'
+ data-autoadvance-enabled="${autoadvance_enabled}"
data-poster='${poster}'
tabindex="-1"
>
diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py
index d168bef6a9..4575b931f0 100644
--- a/openedx/core/djangoapps/catalog/utils.py
+++ b/openedx/core/djangoapps/catalog/utils.py
@@ -262,11 +262,13 @@ def get_fulfillable_course_runs_for_entitlement(entitlement, course_runs):
These are the only sessions that can be selected for an entitlement.
"""
+
enrollable_sessions = []
# Only show published course runs that can still be enrolled and upgraded
now = datetime.datetime.now(UTC)
for course_run in course_runs:
+
# Only courses that have not ended will be displayed
run_start = course_run.get('start')
run_end = course_run.get('end')
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 9306fe821c..55bc792430 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -49,7 +49,7 @@ git+https://github.com/cpennington/pylint-django@fix-field-inference-during-monk
enum34==1.1.6
edx-django-oauth2-provider==1.2.5
edx-django-sites-extensions==2.3.0
-edx-enterprise==0.56.4
+edx-enterprise==0.56.5
edx-oauth2-provider==1.2.2
edx-organizations==0.4.9
edx-rest-api-client==1.7.1
diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt
index da97dab840..64f38babe0 100644
--- a/requirements/edx/paver.txt
+++ b/requirements/edx/paver.txt
@@ -1,5 +1,5 @@
# Requirements to run and test Paver
-Paver==1.2.4
+git+https://github.com/jzoldak/paver.git@b72ccd7b638c1e07105d04f670170b3a37095d10#egg=Paver==1.2.4a
libsass==0.10.0
markupsafe
-r base_common.txt