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 @@ + +
+
+
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
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 @@ + +
+
+
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
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