Merge branch 'master' into release-mergeback-to-master

This commit is contained in:
Harry Rein
2017-12-21 16:50:24 -05:00
committed by GitHub
46 changed files with 882 additions and 28 deletions

View File

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

View File

@@ -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.')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<!-- Based on video.html -->
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="video closed"
data-autoadvance-enabled="True"
data-metadata='{"autoAdvance": "true", "autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": [], "speed": "1.5", "startTime": "", "streams": "0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/"}'
>
<div class="focus_grabber first"></div>
<div class="tc-wrapper">
<article class="video-wrapper">
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
<span tabindex="-1" class="btn-play fa fa-youtube-play fa-2x is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
<div class="video-player-pre"></div>
<section class="video-player">
<iframe id="id"></iframe>
</section>
<div class="video-player-post"></div>
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
</div>
<div class="focus_grabber last"></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<!-- Based on video.html -->
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="video closed"
data-autoadvance-enabled="True"
data-metadata='{"autoAdvance": "false", "autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": [], "speed": "1.5", "startTime": "", "streams": "0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/"}'
>
<div class="focus_grabber first"></div>
<div class="tc-wrapper">
<article class="video-wrapper">
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
<span tabindex="-1" class="btn-play fa fa-youtube-play fa-2x is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
<div class="video-player-pre"></div>
<section class="video-player">
<iframe id="id"></iframe>
</section>
<div class="video-player-post"></div>
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
</div>
<div class="focus_grabber last"></div>
</div>
</div>
</div>
</div>

View File

@@ -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);

View File

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

View File

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

View File

@@ -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'),

View File

@@ -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'] || '';

View File

@@ -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);
});

View File

@@ -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([
'<button class="control auto-advance" aria-disabled="false" title="',
'{autoAdvanceText}',
'">',
'<span class="label" aria-hidden="true">',
'{autoAdvanceText}',
'</span>',
'</button>'].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));

View File

@@ -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');
},

View File

@@ -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);
},

View File

@@ -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],

View File

@@ -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=_(

View File

@@ -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,
}

View File

@@ -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)),

View File

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

View File

@@ -178,6 +178,7 @@ class AdvancedSettingsPage(CoursePage):
'course_image',
'banner_image',
'video_thumbnail_image',
'video_auto_advance',
'cosmetic_display_price',
'advertised_start',
'announcement',

View File

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

View File

@@ -104,6 +104,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas
self.assertEquals(resp.status_code, 200)
self.has_course_navigator(resp)
self.assertContains(resp, '<h3 class="entry-title">{}</h3>'.format(course.display_name_with_default))
def has_course_navigator(self, resp):
"""

View File

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

View File

@@ -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),
)

View File

@@ -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()
);

View File

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

View File

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

View File

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

View File

@@ -1024,7 +1024,7 @@
.verification-cta {
width: flex-grid(4, 12);
@include float(left);
@include float(right);
position: relative;

View File

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

View File

@@ -284,9 +284,9 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
<li class="actions-item" id="actions-item-email-settings-${dashboard_index}" role="menuitem">
% if show_email_settings:
% if not is_course_blocked:
<a href="#email-settings-modal" class="action action-email-settings" rel="leanModal" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a>
<a href="#email-settings-modal" class="action action-email-settings" rel="leanModal" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${course_overview.id in course_optouts}">${_('Email Settings')}</a>
% else:
<a class="action action-email-settings is-disabled" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a>
<a class="action action-email-settings is-disabled" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${course_overview.id in course_optouts}">${_('Email Settings')}</a>
% endif
% endif
</li>

View File

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

View File

@@ -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')

View File

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

View File

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