Merge branch 'master' into release-mergeback-to-master
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.')
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
0
common/djangoapps/entitlements/tasks/__init__.py
Normal file
0
common/djangoapps/entitlements/tasks/__init__.py
Normal file
0
common/djangoapps/entitlements/tasks/v1/__init__.py
Normal file
0
common/djangoapps/entitlements/tasks/v1/__init__.py
Normal file
56
common/djangoapps/entitlements/tasks/v1/tasks.py
Normal file
56
common/djangoapps/entitlements/tasks/v1/tasks.py
Normal 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)
|
||||
74
common/djangoapps/entitlements/tasks/v1/tests/test_tasks.py
Normal file
74
common/djangoapps/entitlements/tasks/v1/tests/test_tasks.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'] || '';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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=_(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -178,6 +178,7 @@ class AdvancedSettingsPage(CoursePage):
|
||||
'course_image',
|
||||
'banner_image',
|
||||
'video_thumbnail_image',
|
||||
'video_auto_advance',
|
||||
'cosmetic_display_price',
|
||||
'advertised_start',
|
||||
'announcement',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1024,7 +1024,7 @@
|
||||
.verification-cta {
|
||||
width: flex-grid(4, 12);
|
||||
|
||||
@include float(left);
|
||||
@include float(right);
|
||||
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user