Merge branch 'master' into release
Conflicts: cms/envs/common.py lms/envs/common.py
This commit is contained in:
@@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Blades: Add .txt and .srt options to the "download transcript" button. BLD-844.
|
||||
|
||||
Blades: Fix bug when transcript cutting off view in full view mode. BLD-852.
|
||||
|
||||
Blades: Show start time or starting position on slider and VCR. BLD-823.
|
||||
|
||||
Common: Upgraded CodeMirror to 3.21.0 with an accessibility patch applied.
|
||||
|
||||
@@ -306,7 +306,7 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
locator = BlockUsageLocator(package_id=package_id, branch=branch, version_guid=version_guid, block_id=block)
|
||||
try:
|
||||
old_location, course, xblock, __ = _get_item_in_course(request, locator)
|
||||
__, course, xblock, __ = _get_item_in_course(request, locator)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ def login_page(request):
|
||||
# to course now that the user is authenticated via
|
||||
# the decorator.
|
||||
return redirect('/course')
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
# If CAS is enabled, redirect auth handling to there
|
||||
return redirect(reverse('cas-login'))
|
||||
|
||||
return render_to_response(
|
||||
'login.html',
|
||||
{
|
||||
|
||||
@@ -12,33 +12,42 @@ class HelpersTestCase(CourseTestCase):
|
||||
Unit tests for helpers.py.
|
||||
"""
|
||||
def test_xblock_studio_url(self):
|
||||
course = self.course
|
||||
|
||||
# Verify course URL
|
||||
self.assertEqual(xblock_studio_url(self.course),
|
||||
self.assertEqual(xblock_studio_url(course),
|
||||
u'/course/MITx.999.Robot_Super_Course/branch/published/block/Robot_Super_Course')
|
||||
|
||||
# Verify chapter URL
|
||||
chapter = ItemFactory.create(parent_location=self.course.location, category='chapter',
|
||||
display_name="Week 1")
|
||||
self.assertIsNone(xblock_studio_url(chapter))
|
||||
self.assertIsNone(xblock_studio_url(chapter, course))
|
||||
|
||||
# Verify lesson URL
|
||||
sequential = ItemFactory.create(parent_location=chapter.location, category='sequential',
|
||||
display_name="Lesson 1")
|
||||
self.assertIsNone(xblock_studio_url(sequential))
|
||||
self.assertIsNone(xblock_studio_url(sequential, course))
|
||||
|
||||
# Verify vertical URL
|
||||
vertical = ItemFactory.create(parent_location=sequential.location, category='vertical',
|
||||
display_name='Unit')
|
||||
self.assertEqual(xblock_studio_url(vertical),
|
||||
u'/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit')
|
||||
self.assertEqual(xblock_studio_url(vertical, course),
|
||||
u'/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit')
|
||||
|
||||
# Verify child vertical URL
|
||||
child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical',
|
||||
display_name='Child Vertical')
|
||||
self.assertEqual(xblock_studio_url(child_vertical),
|
||||
u'/container/MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical')
|
||||
self.assertEqual(xblock_studio_url(child_vertical, course),
|
||||
u'/container/MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical')
|
||||
|
||||
# Verify video URL
|
||||
video = ItemFactory.create(parent_location=child_vertical.location, category="video",
|
||||
display_name="My Video")
|
||||
self.assertIsNone(xblock_studio_url(video))
|
||||
self.assertIsNone(xblock_studio_url(video, course))
|
||||
|
||||
@@ -181,6 +181,16 @@ PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX')
|
||||
if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS:
|
||||
TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS")
|
||||
|
||||
# Django CAS external authentication settings
|
||||
CAS_EXTRA_LOGIN_PARAMS = ENV_TOKENS.get("CAS_EXTRA_LOGIN_PARAMS", None)
|
||||
if FEATURES.get('AUTH_USE_CAS'):
|
||||
CAS_SERVER_URL = ENV_TOKENS.get("CAS_SERVER_URL", None)
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'django_cas.backends.CASBackend',
|
||||
)
|
||||
INSTALLED_APPS += ('django_cas',)
|
||||
MIDDLEWARE_CLASSES += ('django_cas.middleware.CASMiddleware',)
|
||||
|
||||
################ SECURE AUTH ITEMS ###############################
|
||||
# Secret things: passwords, access keys, etc.
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
.video-controls .add-fullscreen {
|
||||
display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
|
||||
}
|
||||
|
||||
.video-tracks {
|
||||
.a11y-menu-container {
|
||||
.a11y-menu-list {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1399,7 +1399,7 @@ body.unit .xblock-type-container {
|
||||
// UI: special case discussion, HTML xmodule styling
|
||||
|
||||
body.unit .component {
|
||||
.xmodule_DiscussionModule, .xmodule_HtmlModule {
|
||||
.xmodule_DiscussionModule, .xmodule_HtmlModule, .xblock {
|
||||
margin-top: ($baseline*1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,12 @@ if settings.FEATURES.get('ENABLE_SERVICE_STATUS'):
|
||||
url(r'^status/', include('service_status.urls')),
|
||||
)
|
||||
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
urlpatterns += (
|
||||
url(r'^cas-auth/login/$', 'external_auth.views.cas_login', name="cas-login"),
|
||||
url(r'^cas-auth/logout/$', 'django_cas.views.logout', {'next_page': '/'}, name="cas-logout"),
|
||||
)
|
||||
|
||||
urlpatterns += patterns('', url(r'^admin/', include(admin.site.urls)),)
|
||||
|
||||
# enable automatic login
|
||||
|
||||
@@ -344,6 +344,9 @@ def signin_user(request):
|
||||
# branding and allow that to process the login if it
|
||||
# is enabled and the header is in the request.
|
||||
return redirect(reverse('root'))
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
# If CAS is enabled, redirect auth handling to there
|
||||
return redirect(reverse('cas-login'))
|
||||
if request.user.is_authenticated():
|
||||
return redirect(reverse('dashboard'))
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from user_api.models import UserCourseTag
|
||||
# global tags (e.g. using the existing UserPreferences table))
|
||||
COURSE_SCOPE = 'course'
|
||||
|
||||
|
||||
def get_course_tag(user, course_id, key):
|
||||
"""
|
||||
Gets the value of the user's course tag for the specified key in the specified
|
||||
|
||||
133
common/lib/xmodule/xmodule/css/video/accessible_menu.scss
Normal file
133
common/lib/xmodule/xmodule/css/video/accessible_menu.scss
Normal file
@@ -0,0 +1,133 @@
|
||||
$gray: rgb(127, 127, 127);
|
||||
$blue: rgb(0, 159, 230);
|
||||
$gray-d1: shade($gray,20%);
|
||||
$gray-l2: tint($gray,40%);
|
||||
$gray-l3: tint($gray,60%);
|
||||
$blue-s1: saturate($blue,15%);
|
||||
|
||||
%use-font-awesome {
|
||||
font-family: FontAwesome;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
speak: none;
|
||||
}
|
||||
|
||||
.a11y-menu-container {
|
||||
position: relative;
|
||||
|
||||
&.open {
|
||||
.a11y-menu-list {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.a11y-menu-list {
|
||||
top: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
list-style: none;
|
||||
background-color: $white;
|
||||
border: 1px solid #eee;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
color: $white;
|
||||
cursor: pointer;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: $gray-l2;
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
|
||||
&:hover {
|
||||
color: $gray-d1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active{
|
||||
a {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
box-shadow: none;
|
||||
border-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Video track button specific styles
|
||||
|
||||
.video-tracks {
|
||||
.a11y-menu-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
border-left: 1px solid #eee;
|
||||
|
||||
&.open {
|
||||
> a {
|
||||
background-color: $action-primary-active-bg;
|
||||
color: $very-light-text;
|
||||
|
||||
&:after {
|
||||
color: $very-light-text;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
> a {
|
||||
@include transition(all 0.25s ease-in-out 0s);
|
||||
@include font-size(12);
|
||||
display: block;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background-color: $very-light-text;
|
||||
padding: ($baseline*.75 $baseline*1.25 $baseline*.75 $baseline*.75);
|
||||
color: $gray-l2;
|
||||
min-width: 1.5em;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:after {
|
||||
@extend %use-font-awesome;
|
||||
content: "\f0d7";
|
||||
position: absolute;
|
||||
right: ($baseline*.5);
|
||||
top: 33%;
|
||||
color: $lighter-base-font-color;
|
||||
}
|
||||
}
|
||||
|
||||
.a11y-menu-list {
|
||||
right: 0;
|
||||
|
||||
li {
|
||||
font-size: em(14);
|
||||
|
||||
a {
|
||||
border: 0;
|
||||
display: block;
|
||||
padding: lh(.5);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,13 +46,15 @@ div.video {
|
||||
.video-sources,
|
||||
.video-tracks {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin: ($baseline*.75) ($baseline/2) 0 0;
|
||||
|
||||
a {
|
||||
> a {
|
||||
@include transition(all 0.25s ease-in-out 0s);
|
||||
@include font-size(14);
|
||||
display: inline-block;
|
||||
border-radius: 3px 3px 3px 3px;
|
||||
line-height : 14px;
|
||||
float: left;
|
||||
border-radius: 3px;
|
||||
background-color: $very-light-text;
|
||||
padding: ($baseline*.75);
|
||||
color: $lighter-base-font-color;
|
||||
@@ -62,7 +64,14 @@ div.video {
|
||||
color: $very-light-text;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.video-tracks {
|
||||
> a {
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
> a.external-track {
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +265,11 @@ div.video {
|
||||
margin: 0 lh() 0 0;
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
margin-right: lh(.5);
|
||||
font-size: em(14);
|
||||
}
|
||||
|
||||
li {
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
@@ -292,11 +306,13 @@ div.video {
|
||||
}
|
||||
|
||||
div.vidtime {
|
||||
padding-left: lh(.75);
|
||||
font-weight: bold;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
padding-left: lh(.75);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding-left: lh(.75);
|
||||
@media (max-width: 1120px) {
|
||||
padding-left: lh(.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,8 +405,8 @@ div.video {
|
||||
.menu{
|
||||
width: 131px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
width: 101px;
|
||||
@media (max-width: 1120px) {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,9 +419,9 @@ div.video {
|
||||
min-width: 116px;
|
||||
text-indent: 0;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 1120px) {
|
||||
min-width: 0;
|
||||
width: 86px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -418,7 +434,7 @@ div.video {
|
||||
text-transform: uppercase;
|
||||
color: #999;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 1120px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -429,7 +445,7 @@ div.video {
|
||||
margin-bottom: 0;
|
||||
padding: 0 lh(.5) 0 0;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (max-width: 1120px) {
|
||||
padding: 0 lh(.5) 0 lh(.5);
|
||||
}
|
||||
|
||||
@@ -676,9 +692,10 @@ div.video {
|
||||
vertical-align: middle;
|
||||
|
||||
&.closed {
|
||||
ol.subtitles {
|
||||
right: -(flex-grid(4));
|
||||
width: auto;
|
||||
div.tc-wrapper {
|
||||
article.video-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,17 +715,16 @@ div.video {
|
||||
|
||||
div.tc-wrapper {
|
||||
@include clearfix;
|
||||
display: table;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
position: static;
|
||||
|
||||
article.video-wrapper {
|
||||
width: 100%;
|
||||
display: table-cell;
|
||||
height: 100%;
|
||||
width: 75%;
|
||||
vertical-align: middle;
|
||||
float: none;
|
||||
margin-right: 0;
|
||||
|
||||
object, iframe, video{
|
||||
position: absolute;
|
||||
@@ -727,16 +743,12 @@ div.video {
|
||||
}
|
||||
|
||||
ol.subtitles {
|
||||
@include box-sizing(border-box);
|
||||
@include transition(none);
|
||||
background: rgba(#000, .8);
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
height: 100%;
|
||||
max-height: 460px;
|
||||
max-width: flex-grid(3);
|
||||
width: 25%;
|
||||
padding: lh();
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
visibility: visible;
|
||||
|
||||
li {
|
||||
|
||||
@@ -69,6 +69,23 @@
|
||||
</div>
|
||||
|
||||
<div class="focus_grabber last"></div>
|
||||
|
||||
<ul class="wrapper-downloads">
|
||||
<li class="video-tracks">
|
||||
<div class="a11y-menu-container">
|
||||
<a class="a11y-menu-button" href="#" title=".srt">.srt</a>
|
||||
<ol class="a11y-menu-list">
|
||||
<li class="a11y-menu-item">
|
||||
<a class="a11y-menu-item-link" href="#txt" title="Text (.txt) file" data-value="txt">Text (.txt) file</a>
|
||||
</li>
|
||||
<li class="a11y-menu-item active">
|
||||
<a class="a11y-menu-item-link" href="#srt" title="SubRip (.srt) file" data-value="srt">SubRip (.srt) file</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,12 +240,19 @@
|
||||
'setParams',
|
||||
'setMode'
|
||||
],
|
||||
obj = {};
|
||||
obj = {},
|
||||
delta = {
|
||||
add: jasmine.createSpy().andReturn(obj),
|
||||
substract: jasmine.createSpy().andReturn(obj),
|
||||
reset: jasmine.createSpy().andReturn(obj)
|
||||
};
|
||||
|
||||
$.each(methods, function (index, method) {
|
||||
obj[method] = jasmine.createSpy(method).andReturn(obj);
|
||||
});
|
||||
|
||||
obj.delta = delta;
|
||||
|
||||
return obj;
|
||||
}());
|
||||
|
||||
|
||||
@@ -75,32 +75,8 @@
|
||||
expect(state.el).toBe('#video_id');
|
||||
});
|
||||
|
||||
it('parse the videos if subtitles exist', function () {
|
||||
var sub = 'Z5KLxerq05Y';
|
||||
|
||||
expect(state.videos).toEqual({
|
||||
'0.75': sub,
|
||||
'1.0': sub,
|
||||
'1.25': sub,
|
||||
'1.50': sub
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
'parse the videos if subtitles do not exist',
|
||||
function ()
|
||||
{
|
||||
var sub = '';
|
||||
|
||||
$('#example').find('.video').data('sub', '');
|
||||
state = new window.Video('#example');
|
||||
|
||||
expect(state.videos).toEqual({
|
||||
'0.75': sub,
|
||||
'1.0': sub,
|
||||
'1.25': sub,
|
||||
'1.50': sub
|
||||
});
|
||||
it('doesn\'t have `videos` dictionary', function () {
|
||||
expect(state.videos).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parse Html5 sources', function () {
|
||||
|
||||
@@ -18,7 +18,7 @@ function (Resizer) {
|
||||
'</div>',
|
||||
'</div>'
|
||||
].join(''),
|
||||
config, container, element, originalConsoleLog;
|
||||
config, container, element;
|
||||
|
||||
beforeEach(function () {
|
||||
setFixtures(html);
|
||||
@@ -30,14 +30,9 @@ function (Resizer) {
|
||||
element: element
|
||||
};
|
||||
|
||||
originalConsoleLog = window.console.log;
|
||||
spyOn(console, 'log');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
window.console.log = originalConsoleLog;
|
||||
});
|
||||
|
||||
it('When Initialize without required parameters, log message is shown',
|
||||
function () {
|
||||
new Resizer({ });
|
||||
@@ -134,7 +129,7 @@ function (Resizer) {
|
||||
expect(spiesList[0].calls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('All callbacks are removed', function () {
|
||||
it('all callbacks are removed', function () {
|
||||
$.each(spiesList, function (index, spy) {
|
||||
resizer.callbacks.add(spy);
|
||||
});
|
||||
@@ -147,7 +142,7 @@ function (Resizer) {
|
||||
});
|
||||
});
|
||||
|
||||
it('Specific callback is removed', function () {
|
||||
it('specific callback is removed', function () {
|
||||
$.each(spiesList, function (index, spy) {
|
||||
resizer.callbacks.add(spy);
|
||||
});
|
||||
@@ -176,9 +171,86 @@ function (Resizer) {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Delta', function () {
|
||||
var resizer;
|
||||
|
||||
beforeEach(function () {
|
||||
resizer = new Resizer(config);
|
||||
});
|
||||
|
||||
it('adding delta align correctly by height', function () {
|
||||
var delta = 100,
|
||||
expectedHeight = container.height() + delta,
|
||||
realHeight;
|
||||
|
||||
resizer
|
||||
.delta.add(delta, 'height')
|
||||
.setMode('height');
|
||||
|
||||
realHeight = element.height();
|
||||
|
||||
expect(realHeight).toBe(expectedHeight);
|
||||
});
|
||||
|
||||
it('adding delta align correctly by width', function () {
|
||||
var delta = 100,
|
||||
expectedWidth = container.width() + delta,
|
||||
realWidth;
|
||||
|
||||
resizer
|
||||
.delta.add(delta, 'width')
|
||||
.setMode('width');
|
||||
|
||||
realWidth = element.width();
|
||||
|
||||
expect(realWidth).toBe(expectedWidth);
|
||||
});
|
||||
|
||||
it('substract delta align correctly by height', function () {
|
||||
var delta = 100,
|
||||
expectedHeight = container.height() - delta,
|
||||
realHeight;
|
||||
|
||||
resizer
|
||||
.delta.substract(delta, 'height')
|
||||
.setMode('height');
|
||||
|
||||
realHeight = element.height();
|
||||
|
||||
expect(realHeight).toBe(expectedHeight);
|
||||
});
|
||||
|
||||
it('substract delta align correctly by width', function () {
|
||||
var delta = 100,
|
||||
expectedWidth = container.width() - delta,
|
||||
realWidth;
|
||||
|
||||
resizer
|
||||
.delta.substract(delta, 'width')
|
||||
.setMode('width');
|
||||
|
||||
realWidth = element.width();
|
||||
|
||||
expect(realWidth).toBe(expectedWidth);
|
||||
});
|
||||
|
||||
it('reset delta', function () {
|
||||
var delta = 100,
|
||||
expectedWidth = container.width(),
|
||||
realWidth;
|
||||
|
||||
resizer
|
||||
.delta.substract(delta, 'width')
|
||||
.delta.reset()
|
||||
.setMode('width');
|
||||
|
||||
realWidth = element.width();
|
||||
|
||||
expect(realWidth).toBe(expectedWidth);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
(function (undefined) {
|
||||
describe('Video Accessible Menu', function () {
|
||||
var state;
|
||||
|
||||
afterEach(function () {
|
||||
$('source').remove();
|
||||
state.storage.clear();
|
||||
});
|
||||
|
||||
describe('constructor', function () {
|
||||
describe('always', function () {
|
||||
var videoTracks, container, button, menu, menuItems,
|
||||
menuItemsLinks;
|
||||
|
||||
beforeEach(function () {
|
||||
state = jasmine.initializePlayer();
|
||||
videoTracks = $('li.video-tracks');
|
||||
container = videoTracks.children('div.a11y-menu-container');
|
||||
button = container.children('a.a11y-menu-button');
|
||||
menuList = container.children('ol.a11y-menu-list');
|
||||
menuItems = menuList.children('li.a11y-menu-item');
|
||||
menuItemsLinks = menuItems.children('a.a11y-menu-item-link');
|
||||
});
|
||||
|
||||
it('add the accessible menu', function () {
|
||||
var activeMenuItem;
|
||||
// Make sure we have the expected HTML structure:
|
||||
// Menu container exists
|
||||
expect(container.length).toBe(1);
|
||||
// Only one button and one menu list per menu container.
|
||||
expect(button.length).toBe(1);
|
||||
expect(menuList.length).toBe(1);
|
||||
// At least one menu item and one menu link per menu
|
||||
// container. Exact length test?
|
||||
expect(menuItems.length).toBeGreaterThan(0);
|
||||
expect(menuItemsLinks.length).toBeGreaterThan(0);
|
||||
expect(menuItems.length).toBe(menuItemsLinks.length);
|
||||
// And one menu item is active
|
||||
activeMenuItem = menuItems.filter('.active');
|
||||
expect(activeMenuItem.length).toBe(1);
|
||||
|
||||
expect(activeMenuItem.children('a.a11y-menu-item-link'))
|
||||
.toHaveData('value', 'srt');
|
||||
|
||||
expect(activeMenuItem.children('a.a11y-menu-item-link'))
|
||||
.toHaveHtml('SubRip (.srt) file');
|
||||
|
||||
/* TO DO: Check that all the anchors contain correct text.
|
||||
$.each(li.toArray().reverse(), function (index, link) {
|
||||
expect($(link)).toHaveData(
|
||||
'speed', state.videoSpeedControl.speeds[index]
|
||||
);
|
||||
expect($(link).find('a').text()).toBe(
|
||||
state.videoSpeedControl.speeds[index] + 'x'
|
||||
);
|
||||
});
|
||||
*/
|
||||
});
|
||||
|
||||
it('add ARIA attributes to button, menu, and menu items links',
|
||||
function () {
|
||||
expect(button).toHaveAttrs({
|
||||
'role': 'button',
|
||||
'title': '.srt',
|
||||
'aria-disabled': 'false'
|
||||
});
|
||||
|
||||
expect(menuList).toHaveAttr('role', 'menu');
|
||||
|
||||
menuItemsLinks.each(function(){
|
||||
expect($(this)).toHaveAttrs({
|
||||
'role': 'menuitem',
|
||||
'aria-disabled': 'false'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when running', function () {
|
||||
var videoTracks, container, button, menu, menuItems,
|
||||
menuItemsLinks, KEY = $.ui.keyCode,
|
||||
|
||||
keyPressEvent = function(key) {
|
||||
return $.Event('keydown', {keyCode: key});
|
||||
},
|
||||
|
||||
tabBackPressEvent = function() {
|
||||
return $.Event('keydown',
|
||||
{keyCode: KEY.TAB, shiftKey: true});
|
||||
},
|
||||
|
||||
tabForwardPressEvent = function() {
|
||||
return $.Event('keydown',
|
||||
{keyCode: KEY.TAB, shiftKey: false});
|
||||
},
|
||||
|
||||
// Get previous element in array or cyles back to the last
|
||||
// if it is the first.
|
||||
previousSpeed = function(index) {
|
||||
return speedEntries.eq(index < 1 ?
|
||||
speedEntries.length - 1 :
|
||||
index - 1);
|
||||
},
|
||||
|
||||
// Get next element in array or cyles back to the first if
|
||||
// it is the last.
|
||||
nextSpeed = function(index) {
|
||||
return speedEntries.eq(index >= speedEntries.length-1 ?
|
||||
0 :
|
||||
index + 1);
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
state = jasmine.initializePlayer();
|
||||
videoTracks = $('li.video-tracks');
|
||||
container = videoTracks.children('div.a11y-menu-container');
|
||||
button = container.children('a.a11y-menu-button');
|
||||
menuList = container.children('ol.a11y-menu-list');
|
||||
menuItems = menuList.children('li.a11y-menu-item');
|
||||
menuItemsLinks = menuItems.children('a.a11y-menu-item-link');
|
||||
spyOn($.fn, 'focus').andCallThrough();
|
||||
});
|
||||
|
||||
it('open/close the menu on mouseenter/mouseleave', function () {
|
||||
container.mouseenter();
|
||||
expect(container).toHaveClass('open');
|
||||
container.mouseleave();
|
||||
expect(container).not.toHaveClass('open');
|
||||
});
|
||||
|
||||
it('do not close the menu on mouseleave if a menu item has ' +
|
||||
'focus', function () {
|
||||
// Open menu. Focus is on last menu item.
|
||||
container.trigger(keyPressEvent(KEY.ENTER));
|
||||
container.mouseenter().mouseleave();
|
||||
expect(container).toHaveClass('open');
|
||||
});
|
||||
|
||||
it('close the menu on click', function () {
|
||||
container.mouseenter().click();
|
||||
expect(container).not.toHaveClass('open');
|
||||
});
|
||||
|
||||
it('close the menu on outside click', function () {
|
||||
container.trigger(keyPressEvent(KEY.ENTER));
|
||||
$(window).click();
|
||||
expect(container).not.toHaveClass('open');
|
||||
});
|
||||
|
||||
it('open the menu on ENTER keydown', function () {
|
||||
container.trigger(keyPressEvent(KEY.ENTER));
|
||||
expect(container).toHaveClass('open');
|
||||
expect(menuItemsLinks.last().focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('open the menu on SPACE keydown', function () {
|
||||
container.trigger(keyPressEvent(KEY.SPACE));
|
||||
expect(container).toHaveClass('open');
|
||||
expect(menuItemsLinks.last().focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('open the menu on UP keydown', function () {
|
||||
container.trigger(keyPressEvent(KEY.UP));
|
||||
expect(container).toHaveClass('open');
|
||||
expect(menuItemsLinks.last().focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('close the menu on ESCAPE keydown', function () {
|
||||
container.trigger(keyPressEvent(KEY.ESCAPE));
|
||||
expect(container).not.toHaveClass('open');
|
||||
});
|
||||
|
||||
it('UP and DOWN keydown function as expected on menu items',
|
||||
function () {
|
||||
// Iterate through list in both directions and check if
|
||||
// things wrap up correctly.
|
||||
var lastEntry = menuItemsLinks.length-1, i;
|
||||
|
||||
// First open menu
|
||||
container.trigger(keyPressEvent(KEY.UP));
|
||||
|
||||
// Iterate with UP key until we have looped.
|
||||
for (i = lastEntry; i >= 0; i--) {
|
||||
menuItemsLinks.eq(i).trigger(keyPressEvent(KEY.UP));
|
||||
}
|
||||
|
||||
// Iterate with DOWN key until we have looped.
|
||||
for (i = 0; i <= lastEntry; i++) {
|
||||
menuItemsLinks.eq(i).trigger(keyPressEvent(KEY.DOWN));
|
||||
}
|
||||
|
||||
// Test if each element has been called twice.
|
||||
expect($.fn.focus.calls.length)
|
||||
.toEqual(2*menuItemsLinks.length+1);
|
||||
});
|
||||
|
||||
it('ESC keydown on menu item closes menu', function () {
|
||||
// First open menu. Focus is on last speed entry.
|
||||
container.trigger(keyPressEvent(KEY.UP));
|
||||
menuItemsLinks.last().trigger(keyPressEvent(KEY.ESCAPE));
|
||||
|
||||
// Menu is closed and focus has been returned to speed
|
||||
// control.
|
||||
expect(container).not.toHaveClass('open');
|
||||
expect(container.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ENTER keydown on menu item selects its data and closes menu',
|
||||
function () {
|
||||
// First open menu.
|
||||
container.trigger(keyPressEvent(KEY.UP));
|
||||
// Focus on '.txt'
|
||||
menuItemsLinks.eq(0).focus();
|
||||
menuItemsLinks.eq(0).trigger(keyPressEvent(KEY.ENTER));
|
||||
|
||||
// Menu is closed, focus has been returned to container
|
||||
// and file format is '.txt'.
|
||||
/* TO DO
|
||||
expect(container.focus).toHaveBeenCalled();
|
||||
expect($('.video_speeds li[data-speed="1.50"]'))
|
||||
.toHaveClass('active');
|
||||
expect($('.speeds p.active')).toHaveHtml('1.50x');
|
||||
*/
|
||||
});
|
||||
|
||||
it('SPACE keydown on menu item selects its data and closes menu',
|
||||
function () {
|
||||
// First open menu.
|
||||
container.trigger(keyPressEvent(KEY.UP));
|
||||
// Focus on '.txt'
|
||||
menuItemsLinks.eq(0).focus();
|
||||
menuItemsLinks.eq(0).trigger(keyPressEvent(KEY.SPACE));
|
||||
|
||||
// Menu is closed, focus has been returned to container
|
||||
// and file format is '.txt'.
|
||||
/* TO DO
|
||||
expect(speedControl.focus).toHaveBeenCalled();
|
||||
expect($('.video_speeds li[data-speed="1.50"]'))
|
||||
.toHaveClass('active');
|
||||
expect($('.speeds p.active')).toHaveHtml('1.50x');
|
||||
*/
|
||||
});
|
||||
|
||||
// TO DO? No such behavior implemented.
|
||||
xit('TAB + SHIFT keydown on speed entry closes menu and gives ' +
|
||||
'focus to Play/Pause control', function () {
|
||||
// First open menu. Focus is on last speed entry.
|
||||
speedControl.trigger(keyPressEvent(KEY.UP));
|
||||
speedEntries.last().trigger(tabBackPressEvent());
|
||||
|
||||
// Menu is closed and focus has been given to Play/Pause
|
||||
// control.
|
||||
expect(state.videoControl.playPauseEl.focus)
|
||||
.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// TO DO? No such behavior implemented.
|
||||
xit('TAB keydown on speed entry closes menu and gives focus ' +
|
||||
'to Volume control', function () {
|
||||
// First open menu. Focus is on last speed entry.
|
||||
speedControl.trigger(keyPressEvent(KEY.UP));
|
||||
speedEntries.last().trigger(tabForwardPressEvent());
|
||||
|
||||
// Menu is closed and focus has been given to Volume
|
||||
// control.
|
||||
expect(state.videoVolumeControl.buttonEl.focus)
|
||||
.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO
|
||||
xdescribe('change file format', function () {
|
||||
describe('when new file format is not the same', function () {
|
||||
beforeEach(function () {
|
||||
state = jasmine.initializePlayer();
|
||||
state.videoSpeedControl.setSpeed(1.0);
|
||||
spyOn(state.videoPlayer, 'onSpeedChange').andCallThrough();
|
||||
|
||||
$('li[data-speed="0.75"] a').click();
|
||||
});
|
||||
|
||||
it('trigger speedChange event', function () {
|
||||
expect(state.videoPlayer.onSpeedChange).toHaveBeenCalled();
|
||||
expect(state.videoSpeedControl.currentSpeed).toEqual(0.75);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO
|
||||
xdescribe('onSpeedChange', function () {
|
||||
beforeEach(function () {
|
||||
state = jasmine.initializePlayer();
|
||||
$('li[data-speed="1.0"] a').addClass('active');
|
||||
state.videoSpeedControl.setSpeed(0.75);
|
||||
});
|
||||
|
||||
it('set the new speed as active', function () {
|
||||
expect($('.video_speeds li[data-speed="1.0"]'))
|
||||
.not.toHaveClass('active');
|
||||
expect($('.video_speeds li[data-speed="0.75"]'))
|
||||
.toHaveClass('active');
|
||||
expect($('.speeds p.active')).toHaveHtml('0.75x');
|
||||
});
|
||||
});
|
||||
});
|
||||
}).call(this);
|
||||
@@ -7,8 +7,6 @@
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice')
|
||||
.andReturn(null);
|
||||
|
||||
state = jasmine.initializePlayer();
|
||||
videoControl = state.videoControl;
|
||||
$.fn.scrollTo.reset();
|
||||
});
|
||||
|
||||
@@ -29,18 +27,20 @@
|
||||
describe('always', function () {
|
||||
beforeEach(function () {
|
||||
spyOn($, 'ajaxWithPrefix').andCallThrough();
|
||||
state = jasmine.initializePlayer();
|
||||
});
|
||||
|
||||
it('create the caption element', function () {
|
||||
state = jasmine.initializePlayer();
|
||||
expect($('.video')).toContain('ol.subtitles');
|
||||
});
|
||||
|
||||
it('add caption control to video player', function () {
|
||||
state = jasmine.initializePlayer();
|
||||
expect($('.video')).toContain('a.hide-subtitles');
|
||||
});
|
||||
|
||||
it('add ARIA attributes to caption control', function () {
|
||||
state = jasmine.initializePlayer();
|
||||
var captionControl = $('a.hide-subtitles');
|
||||
expect(captionControl).toHaveAttrs({
|
||||
'role': 'button',
|
||||
@@ -49,7 +49,11 @@
|
||||
});
|
||||
});
|
||||
|
||||
it('fetch the caption', function () {
|
||||
it('fetch the caption in HTML5 mode', function () {
|
||||
runs(function () {
|
||||
state = jasmine.initializePlayer();
|
||||
});
|
||||
|
||||
waitsFor(function () {
|
||||
if (state.videoCaption.loaded === true) {
|
||||
return true;
|
||||
@@ -62,29 +66,55 @@
|
||||
expect($.ajaxWithPrefix).toHaveBeenCalledWith({
|
||||
url: '/transcript/translation',
|
||||
notifyOnError: false,
|
||||
data: {
|
||||
videoId: 'Z5KLxerq05Y',
|
||||
language: 'en'
|
||||
},
|
||||
data: jasmine.any(Object),
|
||||
success: jasmine.any(Function),
|
||||
error: jasmine.any(Function)
|
||||
});
|
||||
expect($.ajaxWithPrefix.mostRecentCall.args[0].data)
|
||||
.toEqual({
|
||||
language: 'en'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('bind window resize event', function () {
|
||||
expect($(window)).toHandleWith(
|
||||
'resize', state.videoCaption.resize
|
||||
);
|
||||
it('fetch the caption in Youtube mode', function () {
|
||||
runs(function () {
|
||||
state = jasmine.initializePlayerYouTube();
|
||||
});
|
||||
|
||||
waitsFor(function () {
|
||||
if (state.videoCaption.loaded === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, 'Expect captions to be loaded.', WAIT_TIMEOUT);
|
||||
|
||||
runs(function () {
|
||||
expect($.ajaxWithPrefix).toHaveBeenCalledWith({
|
||||
url: '/transcript/translation',
|
||||
notifyOnError: false,
|
||||
data: jasmine.any(Object),
|
||||
success: jasmine.any(Function),
|
||||
error: jasmine.any(Function)
|
||||
});
|
||||
expect($.ajaxWithPrefix.mostRecentCall.args[0].data)
|
||||
.toEqual({
|
||||
language: 'en',
|
||||
videoId: 'abcdefghijkl'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('bind the hide caption button', function () {
|
||||
state = jasmine.initializePlayer();
|
||||
expect($('.hide-subtitles')).toHandleWith(
|
||||
'click', state.videoCaption.toggle
|
||||
);
|
||||
});
|
||||
|
||||
it('bind the mouse movement', function () {
|
||||
state = jasmine.initializePlayer();
|
||||
expect($('.subtitles')).toHandleWith(
|
||||
'mouseover', state.videoCaption.onMouseEnter
|
||||
);
|
||||
@@ -103,8 +133,9 @@
|
||||
});
|
||||
|
||||
it('bind the scroll', function () {
|
||||
expect($('.subtitles'))
|
||||
.toHandleWith('scroll', state.videoControl.showControls);
|
||||
state = jasmine.initializePlayer();
|
||||
expect($('.subtitles'))
|
||||
.toHandleWith('scroll', state.videoControl.showControls);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -284,7 +315,8 @@
|
||||
describe('when no captions file was specified', function () {
|
||||
beforeEach(function () {
|
||||
state = jasmine.initializePlayer('video_all.html', {
|
||||
'sub': ''
|
||||
'sub': '',
|
||||
'transcriptLanguages': {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,6 +427,8 @@
|
||||
});
|
||||
|
||||
it('reRenderCaption', function () {
|
||||
state = jasmine.initializePlayer();
|
||||
|
||||
var Caption = state.videoCaption,
|
||||
li;
|
||||
|
||||
@@ -426,14 +460,6 @@
|
||||
spyOn(state, 'youtubeId').andReturn('Z5KLxerq05Y');
|
||||
});
|
||||
|
||||
it('do not fetch captions, if 1.0 speed is absent', function () {
|
||||
state.youtubeId.andReturn(void(0));
|
||||
Caption.fetchCaption();
|
||||
|
||||
expect($.ajaxWithPrefix).not.toHaveBeenCalled();
|
||||
expect(Caption.hideCaptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('show caption on language change', function () {
|
||||
Caption.loaded = true;
|
||||
Caption.fetchCaption();
|
||||
|
||||
@@ -549,6 +549,17 @@
|
||||
});
|
||||
});
|
||||
|
||||
it('Controls height is actual on switch to fullscreen', function () {
|
||||
spyOn($.fn, 'height').andCallFake(function (val) {
|
||||
return _.isUndefined(val) ? 100: this;
|
||||
});
|
||||
|
||||
state = jasmine.initializePlayer();
|
||||
$(state.el).trigger('fullscreen');
|
||||
|
||||
expect(state.videoControl.height).toBe(150);
|
||||
});
|
||||
|
||||
describe('play', function () {
|
||||
beforeEach(function () {
|
||||
state = jasmine.initializePlayer();
|
||||
|
||||
@@ -45,7 +45,6 @@ function (VideoPlayer) {
|
||||
|
||||
it('create video caption', function () {
|
||||
expect(state.videoCaption).toBeDefined();
|
||||
expect(state.youtubeId('1.0')).toEqual('Z5KLxerq05Y');
|
||||
expect(state.speed).toEqual('1.50');
|
||||
expect(state.config.transcriptTranslationUrl)
|
||||
.toEqual('/transcript/translation');
|
||||
@@ -712,6 +711,7 @@ function (VideoPlayer) {
|
||||
state.videoEl = $('video, iframe');
|
||||
|
||||
spyOn(state.videoCaption, 'resize').andCallThrough();
|
||||
spyOn($.fn, 'trigger').andCallThrough();
|
||||
state.videoControl.toggleFullScreen(jQuery.Event('click'));
|
||||
});
|
||||
|
||||
@@ -726,7 +726,8 @@ function (VideoPlayer) {
|
||||
|
||||
it('tell VideoCaption to resize', function () {
|
||||
expect(state.videoCaption.resize).toHaveBeenCalled();
|
||||
expect(state.resizer.setMode).toHaveBeenCalled();
|
||||
expect(state.resizer.setMode).toHaveBeenCalledWith('both');
|
||||
expect(state.resizer.delta.substract).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -759,6 +760,7 @@ function (VideoPlayer) {
|
||||
expect(state.videoCaption.resize).toHaveBeenCalled();
|
||||
expect(state.resizer.setMode)
|
||||
.toHaveBeenCalledWith('width');
|
||||
expect(state.resizer.delta.reset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,20 +13,24 @@ function () {
|
||||
elementRatio: null
|
||||
},
|
||||
callbacksList = [],
|
||||
delta = {
|
||||
height: 0,
|
||||
width: 0
|
||||
},
|
||||
module = {},
|
||||
mode = null,
|
||||
config;
|
||||
|
||||
var initialize = function (params) {
|
||||
if (config) {
|
||||
config = $.extend(true, config, params);
|
||||
} else {
|
||||
config = $.extend(true, {}, defaults, params);
|
||||
if (!config) {
|
||||
config = defaults;
|
||||
}
|
||||
|
||||
config = $.extend(true, {}, config, params);
|
||||
|
||||
if (!config.element) {
|
||||
console.log(
|
||||
'[Video info]: Required parameter `element` is not passed.'
|
||||
'Required parameter `element` is not passed.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,8 +39,8 @@ function () {
|
||||
|
||||
var getData = function () {
|
||||
var container = $(config.container),
|
||||
containerWidth = container.width(),
|
||||
containerHeight = container.height(),
|
||||
containerWidth = container.width() + delta.width,
|
||||
containerHeight = container.height() + delta.height,
|
||||
containerRatio = config.containerRatio,
|
||||
|
||||
element = $(config.element),
|
||||
@@ -74,7 +78,6 @@ function () {
|
||||
default:
|
||||
if (data.containerRatio >= data.elementRatio) {
|
||||
alignByHeightOnly();
|
||||
|
||||
} else {
|
||||
alignByWidthOnly();
|
||||
}
|
||||
@@ -142,7 +145,7 @@ function () {
|
||||
|
||||
addCallback(decorator);
|
||||
} else {
|
||||
console.error('[Video info]: TypeError: Argument is not a function.');
|
||||
console.error('TypeError: Argument is not a function.');
|
||||
}
|
||||
|
||||
return module;
|
||||
@@ -168,6 +171,29 @@ function () {
|
||||
}
|
||||
};
|
||||
|
||||
var cleanDelta = function () {
|
||||
delta['height'] = 0;
|
||||
delta['width'] = 0;
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var addDelta = function (value, side) {
|
||||
if (_.isNumber(value) && _.isNumber(delta[side])) {
|
||||
delta[side] += value;
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var substractDelta = function (value, side) {
|
||||
if (_.isNumber(value) && _.isNumber(delta[side])) {
|
||||
delta[side] -= value;
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
initialize.apply(module, arguments);
|
||||
|
||||
return $.extend(true, module, {
|
||||
@@ -181,6 +207,11 @@ function () {
|
||||
once: addOnceCallback,
|
||||
remove: removeCallback,
|
||||
removeAll: removeCallbacks
|
||||
},
|
||||
delta: {
|
||||
add: addDelta,
|
||||
substract: substractDelta,
|
||||
reset: cleanDelta
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -202,12 +202,6 @@ function (VideoPlayer, VideoStorage) {
|
||||
);
|
||||
|
||||
state.speeds = ['0.75', '1.0', '1.25', '1.50'];
|
||||
state.videos = {
|
||||
'0.75': state.config.sub,
|
||||
'1.0': state.config.sub,
|
||||
'1.25': state.config.sub,
|
||||
'1.50': state.config.sub
|
||||
};
|
||||
|
||||
// We must have at least one non-YouTube video source available.
|
||||
// Otherwise, return a negative.
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
(function (requirejs, require, define) {
|
||||
|
||||
// VideoAccessibleMenu module.
|
||||
define(
|
||||
'video/035_video_accessible_menu.js',
|
||||
[],
|
||||
function () {
|
||||
|
||||
// VideoAccessibleMenu() function - what this module "exports".
|
||||
return function (state) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
if (state.el.find('li.video-tracks') === 0) {
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
}
|
||||
|
||||
state.videoAccessibleMenu = {
|
||||
value: state.storage.getItem('transcript_download_format')
|
||||
};
|
||||
|
||||
_initialize(state);
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
};
|
||||
|
||||
// ***************************************************************
|
||||
// Private functions start here.
|
||||
// ***************************************************************
|
||||
|
||||
function _initialize(state) {
|
||||
_makeFunctionsPublic(state);
|
||||
_renderElements(state);
|
||||
_addAriaAttributes(state);
|
||||
_bindHandlers(state);
|
||||
}
|
||||
|
||||
// function _makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called,
|
||||
// these functions will get the 'state' object as a context.
|
||||
function _makeFunctionsPublic(state) {
|
||||
var methodsDict = {
|
||||
changeFileType: changeFileType,
|
||||
setValue: setValue
|
||||
};
|
||||
|
||||
state.bindTo(methodsDict, state.videoAccessibleMenu, state);
|
||||
}
|
||||
|
||||
// function _renderElements(state)
|
||||
//
|
||||
// Create any necessary DOM elements, attach them, and set their
|
||||
// initial configuration. Also make the created DOM elements available
|
||||
// via the 'state' object. Much easier to work this way - you don't
|
||||
// have to do repeated jQuery element selects.
|
||||
function _renderElements(state) {
|
||||
|
||||
// For the time being, we assume that the menu structure is present in
|
||||
// the template HTML. In the future accessible menu plugin, everything
|
||||
// inside <div class='menu-container'></div> will be generated in this
|
||||
// file.
|
||||
var container = state.el.find('li.video-tracks>div.a11y-menu-container'),
|
||||
button = container.children('a.a11y-menu-button'),
|
||||
menuList = container.children('ol.a11y-menu-list'),
|
||||
menuItems = menuList.children('li.a11y-menu-item'),
|
||||
menuItemsLinks = menuItems.children('a.a11y-menu-item-link'),
|
||||
value = (function (val, activeElement) {
|
||||
return val || activeElement.find('a').data('value') || 'srt';
|
||||
}(state.videoAccessibleMenu.value, menuItems.filter('.active'))),
|
||||
msg = '.' + value;
|
||||
|
||||
$.extend(state.videoAccessibleMenu, {
|
||||
container: container,
|
||||
button: button,
|
||||
menuList: menuList,
|
||||
menuItems: menuItems,
|
||||
menuItemsLinks: menuItemsLinks
|
||||
});
|
||||
|
||||
if (value) {
|
||||
state.videoAccessibleMenu.setValue(value);
|
||||
button.text(gettext(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function _addAriaAttributes(state) {
|
||||
var menu = state.videoAccessibleMenu;
|
||||
|
||||
menu.button.attr({
|
||||
'role': 'button',
|
||||
'aria-disabled': 'false'
|
||||
});
|
||||
|
||||
menu.menuList.attr('role', 'menu');
|
||||
|
||||
menu.menuItemsLinks.each(function(){
|
||||
$(this).attr({
|
||||
'role': 'menuitem',
|
||||
'aria-disabled': 'false'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get previous element in array or cyles back to the last if it is the
|
||||
// first.
|
||||
function _previousMenuItemLink(links, index) {
|
||||
return $(links.eq(index < 1 ? links.length - 1 : index - 1));
|
||||
}
|
||||
|
||||
// Get next element in array or cyles back to the first if it is the last.
|
||||
function _nextMenuItemLink(links, index) {
|
||||
return $(links.eq(index >= links.length - 1 ? 0 : index + 1));
|
||||
}
|
||||
|
||||
function _menuItemsLinksFocused(menu) {
|
||||
return menu.menuItemsLinks.is(':focus');
|
||||
}
|
||||
|
||||
function _openMenu(menu, without_handler) {
|
||||
// When menu items have focus, the menu stays open on
|
||||
// mouseleave. A _closeMenuHandler is added to the window
|
||||
// element to have clicks close the menu when they happen
|
||||
// outside of it. We namespace the click event to easily remove it (and
|
||||
// only it) in _closeMenu.
|
||||
menu.container.addClass('open');
|
||||
menu.button.text('...');
|
||||
if (!without_handler) {
|
||||
$(window).on('click.currentMenu', _closeMenuHandler.bind(menu));
|
||||
}
|
||||
|
||||
// @TODO: onOpen callback
|
||||
}
|
||||
|
||||
function _closeMenu(menu, without_handler) {
|
||||
// Remove the previously added clickHandler from window element.
|
||||
var msg = '.' + menu.value;
|
||||
|
||||
menu.container.removeClass('open');
|
||||
menu.button.text(gettext(msg));
|
||||
if (!without_handler) {
|
||||
$(window).off('click.currentMenu');
|
||||
}
|
||||
|
||||
// @TODO: onClose callback
|
||||
}
|
||||
|
||||
function _openMenuHandler(event) {
|
||||
_openMenu(this, true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function _closeMenuHandler(event) {
|
||||
// Only close the menu if no menu item link has focus or `click` event.
|
||||
if (!_menuItemsLinksFocused(this) || event.type == 'click') {
|
||||
_closeMenu(this, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function _toggleMenuHandler(event) {
|
||||
if (this.container.hasClass('open')) {
|
||||
_closeMenu(this, true);
|
||||
} else {
|
||||
_openMenu(this, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Various event handlers. They all return false to stop propagation and
|
||||
// prevent default behavior.
|
||||
function _clickHandler(event) {
|
||||
var target = $(event.currentTarget);
|
||||
|
||||
this.changeFileType.call(this, event);
|
||||
_closeMenu(this, true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function _keyDownHandler(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode,
|
||||
target = $(event.currentTarget),
|
||||
index;
|
||||
|
||||
if (target.is('a.a11y-menu-item-link')) {
|
||||
|
||||
index = target.parent().index();
|
||||
|
||||
switch (keyCode) {
|
||||
// Scroll up menu, wrapping at the top. Keep menu open.
|
||||
case KEY.UP:
|
||||
_previousMenuItemLink(this.menuItemsLinks, index).focus();
|
||||
break;
|
||||
// Scroll down menu, wrapping at the bottom. Keep menu
|
||||
// open.
|
||||
case KEY.DOWN:
|
||||
_nextMenuItemLink(this.menuItemsLinks, index).focus();
|
||||
break;
|
||||
// Close menu.
|
||||
case KEY.TAB:
|
||||
_closeMenu(this);
|
||||
// TODO
|
||||
// What has to happen here? In speed menu, tabbing backward
|
||||
// will give focus to Play/Pause button and tabbing
|
||||
// forward to Volume button.
|
||||
break;
|
||||
// Close menu, give focus to button and change
|
||||
// file type.
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
this.button.focus();
|
||||
this.changeFileType.call(this, event);
|
||||
_closeMenu(this);
|
||||
break;
|
||||
// Close menu and give focus to speed control.
|
||||
case KEY.ESCAPE:
|
||||
_closeMenu(this);
|
||||
this.button.focus();
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
switch(keyCode) {
|
||||
// Open menu and focus on last element of list above it.
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
case KEY.UP:
|
||||
_openMenu(this);
|
||||
this.menuItemsLinks.last().focus();
|
||||
break;
|
||||
// Close menu.
|
||||
case KEY.ESCAPE:
|
||||
_closeMenu(this);
|
||||
break;
|
||||
}
|
||||
// We do not stop propagation and default behavior on a TAB
|
||||
// keypress.
|
||||
return event.keyCode === KEY.TAB;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc Bind any necessary function callbacks to DOM events (click,
|
||||
* mousemove, etc.).
|
||||
*
|
||||
* @type {function}
|
||||
* @access private
|
||||
*
|
||||
* @param {object} state The object containg the state of the video player.
|
||||
* All other modules, their parameters, public variables, etc. are
|
||||
* available via this object.
|
||||
*
|
||||
* @this {object} The global window object.
|
||||
*
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function _bindHandlers(state) {
|
||||
var menu = state.videoAccessibleMenu;
|
||||
|
||||
// Attach various events handlers to menu container.
|
||||
menu.container.on({
|
||||
'mouseenter': _openMenuHandler.bind(menu),
|
||||
'mouseleave': _closeMenuHandler.bind(menu),
|
||||
'click': _toggleMenuHandler.bind(menu),
|
||||
'keydown': _keyDownHandler.bind(menu)
|
||||
});
|
||||
|
||||
// Attach click and keydown event handlers to individual menu items.
|
||||
menu.menuItems
|
||||
.on('click', 'a.a11y-menu-item-link', _clickHandler.bind(menu))
|
||||
.on('keydown', 'a.a11y-menu-item-link', _keyDownHandler.bind(menu));
|
||||
}
|
||||
|
||||
function setValue(value) {
|
||||
var menu = this.videoAccessibleMenu;
|
||||
|
||||
menu.value = value;
|
||||
menu.menuItems
|
||||
.removeClass('active')
|
||||
.find("a[data-value='" + value + "']")
|
||||
.parent()
|
||||
.addClass('active');
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// Public functions start here.
|
||||
// These are available via the 'state' object. Their context ('this'
|
||||
// keyword) is the 'state' object. The magic private function that makes
|
||||
// them available and sets up their context is makeFunctionsPublic().
|
||||
// ***************************************************************
|
||||
|
||||
function changeFileType(event) {
|
||||
var fileType = $(event.currentTarget).data('value');
|
||||
|
||||
this.videoAccessibleMenu.setValue(fileType);
|
||||
this.saveState(true, {'transcript_download_format': fileType});
|
||||
this.storage.setItem('transcript_download_format', fileType);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
@@ -221,7 +221,7 @@ function (HTML5Video, Resizer) {
|
||||
state.resizer = new Resizer({
|
||||
element: state.videoEl,
|
||||
elementRatio: videoWidth/videoHeight,
|
||||
container: state.videoEl.parent()
|
||||
container: state.container
|
||||
})
|
||||
.callbacks.once(function() {
|
||||
state.trigger('videoCaption.resize', null);
|
||||
@@ -235,7 +235,11 @@ function (HTML5Video, Resizer) {
|
||||
});
|
||||
}
|
||||
|
||||
$(window).bind('resize', _.debounce(state.resizer.align, 100));
|
||||
$(window).on('resize', _.debounce(function () {
|
||||
state.trigger('videoControl.updateControlsHeight', null);
|
||||
state.trigger('videoCaption.resize', null);
|
||||
state.resizer.align();
|
||||
}, 100));
|
||||
}
|
||||
|
||||
// function _restartUsingFlash(state)
|
||||
@@ -461,7 +465,7 @@ function (HTML5Video, Resizer) {
|
||||
this.videoPlayer.log(
|
||||
'pause_video',
|
||||
{
|
||||
'currentTime': this.videoPlayer.currentTime
|
||||
currentTime: this.videoPlayer.currentTime
|
||||
}
|
||||
);
|
||||
|
||||
@@ -482,7 +486,7 @@ function (HTML5Video, Resizer) {
|
||||
this.videoPlayer.log(
|
||||
'play_video',
|
||||
{
|
||||
'currentTime': this.videoPlayer.currentTime
|
||||
currentTime: this.videoPlayer.currentTime
|
||||
}
|
||||
);
|
||||
|
||||
@@ -863,8 +867,7 @@ function (HTML5Video, Resizer) {
|
||||
|
||||
// Default parameters that always get logged.
|
||||
logInfo = {
|
||||
'id': this.id,
|
||||
'code': this.youtubeId()
|
||||
id: this.id
|
||||
};
|
||||
|
||||
// If extra parameters were passed to the log.
|
||||
|
||||
@@ -40,6 +40,7 @@ function () {
|
||||
showPlayPlaceholder: showPlayPlaceholder,
|
||||
toggleFullScreen: toggleFullScreen,
|
||||
togglePlayback: togglePlayback,
|
||||
updateControlsHeight: updateControlsHeight,
|
||||
updateVcrVidTime: updateVcrVidTime
|
||||
};
|
||||
|
||||
@@ -83,6 +84,8 @@ function () {
|
||||
'role': 'slider',
|
||||
'title': gettext('Video slider')
|
||||
});
|
||||
|
||||
state.videoControl.updateControlsHeight();
|
||||
}
|
||||
|
||||
// function _bindHandlers(state)
|
||||
@@ -91,6 +94,23 @@ function () {
|
||||
function _bindHandlers(state) {
|
||||
state.videoControl.playPauseEl.on('click', state.videoControl.togglePlayback);
|
||||
state.videoControl.fullScreenEl.on('click', state.videoControl.toggleFullScreen);
|
||||
state.el.on('fullscreen', function (event, isFullScreen) {
|
||||
var height = state.videoControl.updateControlsHeight();
|
||||
|
||||
if (isFullScreen) {
|
||||
state.resizer
|
||||
.delta
|
||||
.substract(height, 'height')
|
||||
.setMode('both');
|
||||
|
||||
} else {
|
||||
state.resizer
|
||||
.delta
|
||||
.reset()
|
||||
.setMode('width');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('keyup', state.videoControl.exitFullScreen);
|
||||
|
||||
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
|
||||
@@ -110,12 +130,22 @@ function () {
|
||||
});
|
||||
}
|
||||
}
|
||||
function _getControlsHeight(control) {
|
||||
return control.el.height() + 0.5 * control.sliderEl.height();
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// Public functions start here.
|
||||
// These are available via the 'state' object. Their context ('this' keyword) is the 'state' object.
|
||||
// The magic private function that makes them available and sets up their context is makeFunctionsPublic().
|
||||
// ***************************************************************
|
||||
|
||||
function updateControlsHeight () {
|
||||
this.videoControl.height = _getControlsHeight(this.videoControl);
|
||||
|
||||
return this.videoControl.height;
|
||||
}
|
||||
|
||||
function show() {
|
||||
this.videoControl.el.removeClass('is-hidden');
|
||||
this.el.trigger('controls:show', arguments);
|
||||
@@ -234,13 +264,6 @@ function () {
|
||||
this.videoControl.fullScreenState = this.isFullScreen = false;
|
||||
fullScreenClassNameEl.removeClass('video-fullscreen');
|
||||
text = gettext('Fill browser');
|
||||
|
||||
this.resizer
|
||||
.setParams({
|
||||
container: this.videoEl.parent()
|
||||
})
|
||||
.setMode('width');
|
||||
|
||||
win.scrollTop(this.scrollPos);
|
||||
} else {
|
||||
this.scrollPos = win.scrollTop();
|
||||
@@ -248,13 +271,6 @@ function () {
|
||||
this.videoControl.fullScreenState = this.isFullScreen = true;
|
||||
fullScreenClassNameEl.addClass('video-fullscreen');
|
||||
text = gettext('Exit full browser');
|
||||
|
||||
this.resizer
|
||||
.setParams({
|
||||
container: window
|
||||
})
|
||||
.setMode('both');
|
||||
|
||||
}
|
||||
|
||||
this.videoControl.fullScreenEl
|
||||
@@ -262,6 +278,7 @@ function () {
|
||||
.text(text);
|
||||
|
||||
this.trigger('videoCaption.resize', null);
|
||||
this.el.trigger('fullscreen', [this.isFullScreen]);
|
||||
}
|
||||
|
||||
function exitFullScreen(event) {
|
||||
|
||||
@@ -135,7 +135,6 @@ function () {
|
||||
var self = this,
|
||||
Caption = this.videoCaption;
|
||||
|
||||
$(window).bind('resize', Caption.resize);
|
||||
Caption.hideSubtitlesEl.on({
|
||||
'click': Caption.toggle
|
||||
});
|
||||
@@ -226,14 +225,10 @@ function () {
|
||||
*/
|
||||
function fetchCaption() {
|
||||
var self = this,
|
||||
Caption = self.videoCaption;
|
||||
// Check whether the captions file was specified. This is the point
|
||||
// where we either stop with the caption panel (so that a white empty
|
||||
// panel to the right of the video will not be shown), or carry on
|
||||
// further.
|
||||
if (!this.youtubeId('1.0')) {
|
||||
return false;
|
||||
}
|
||||
Caption = self.videoCaption,
|
||||
data = {
|
||||
language: this.getCurrentLanguage()
|
||||
};
|
||||
|
||||
if (Caption.loaded) {
|
||||
Caption.hideCaptions(false);
|
||||
@@ -245,15 +240,16 @@ function () {
|
||||
Caption.fetchXHR.abort();
|
||||
}
|
||||
|
||||
if (this.videoType === 'youtube') {
|
||||
data.videoId = this.youtubeId();
|
||||
}
|
||||
|
||||
// Fetch the captions file. If no file was specified, or if an error
|
||||
// occurred, then we hide the captions panel, and the "CC" button
|
||||
Caption.fetchXHR = $.ajaxWithPrefix({
|
||||
url: self.config.transcriptTranslationUrl,
|
||||
notifyOnError: false,
|
||||
data: {
|
||||
videoId: this.youtubeId(),
|
||||
language: this.getCurrentLanguage()
|
||||
},
|
||||
data: data,
|
||||
success: function (captions) {
|
||||
Caption.captions = captions.text;
|
||||
Caption.start = captions.start;
|
||||
@@ -757,8 +753,12 @@ function () {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.resizer && !this.isFullScreen) {
|
||||
this.resizer.alignByWidthOnly();
|
||||
if (this.resizer) {
|
||||
if (this.isFullScreen) {
|
||||
this.resizer.setMode('both');
|
||||
} else {
|
||||
this.resizer.alignByWidthOnly();
|
||||
}
|
||||
}
|
||||
|
||||
this.videoCaption.setSubtitlesHeight();
|
||||
@@ -772,17 +772,8 @@ function () {
|
||||
}
|
||||
|
||||
function captionHeight() {
|
||||
var paddingTop;
|
||||
|
||||
if (this.isFullScreen) {
|
||||
paddingTop = parseInt(
|
||||
this.videoCaption.subtitlesEl.css('padding-top'), 10
|
||||
);
|
||||
|
||||
return $(window).height() -
|
||||
this.videoControl.el.height() -
|
||||
0.5 * this.videoControl.sliderEl.height() -
|
||||
2 * paddingTop;
|
||||
return this.container.height() - this.videoControl.height;
|
||||
} else {
|
||||
return this.container.height();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ require(
|
||||
[
|
||||
'video/01_initialize.js',
|
||||
'video/025_focus_grabber.js',
|
||||
'video/035_video_accessible_menu.js',
|
||||
'video/04_video_control.js',
|
||||
'video/05_video_quality_control.js',
|
||||
'video/06_video_progress_slider.js',
|
||||
@@ -52,6 +53,7 @@ require(
|
||||
function (
|
||||
Initialize,
|
||||
FocusGrabber,
|
||||
VideoAccessibleMenu,
|
||||
VideoControl,
|
||||
VideoQualityControl,
|
||||
VideoProgressSlider,
|
||||
@@ -87,6 +89,7 @@ function (
|
||||
|
||||
state.modules = [
|
||||
FocusGrabber,
|
||||
VideoAccessibleMenu,
|
||||
VideoControl,
|
||||
VideoQualityControl,
|
||||
VideoProgressSlider,
|
||||
|
||||
@@ -5,7 +5,7 @@ Test the partitions and partitions service
|
||||
|
||||
from collections import defaultdict
|
||||
from unittest import TestCase
|
||||
from mock import Mock, MagicMock
|
||||
from mock import Mock
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
|
||||
@@ -40,7 +40,7 @@ class SplitTestFields(object):
|
||||
)
|
||||
|
||||
|
||||
@XBlock.needs('user_tags')
|
||||
@XBlock.needs('user_tags') # pylint: disable=abstract-method
|
||||
@XBlock.needs('partitions')
|
||||
class SplitTestModule(SplitTestFields, XModule):
|
||||
"""
|
||||
@@ -196,7 +196,7 @@ class SplitTestModule(SplitTestFields, XModule):
|
||||
return progress
|
||||
|
||||
|
||||
@XBlock.needs('user_tags')
|
||||
@XBlock.needs('user_tags') # pylint: disable=abstract-method
|
||||
@XBlock.needs('partitions')
|
||||
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
# the editing interface can be the same as for sequences -- just a container
|
||||
@@ -223,4 +223,3 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
makes it use module.get_child_descriptors().
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
@@ -44,9 +44,10 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
self.module_system = get_test_system()
|
||||
|
||||
def get_module(descriptor):
|
||||
"""Mocks module_system get_module function"""
|
||||
module_system = get_test_system()
|
||||
module_system.get_module = get_module
|
||||
descriptor.bind_for_student(module_system, descriptor._field_data)
|
||||
descriptor.bind_for_student(module_system, descriptor._field_data) # pylint: disable=protected-access
|
||||
return descriptor
|
||||
|
||||
self.module_system.get_module = get_module
|
||||
@@ -67,8 +68,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
|
||||
|
||||
self.split_test_module = course_seq.get_children()[0]
|
||||
self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data)
|
||||
|
||||
self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data) # pylint: disable=protected-access
|
||||
|
||||
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
|
||||
@ddt.unpack
|
||||
@@ -83,7 +83,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@ddt.unpack
|
||||
def test_child_old_tag_value(self, user_tag):
|
||||
def test_child_old_tag_value(self, _user_tag):
|
||||
# If user_tag has a stale value, we should still get back a valid child url
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
@@ -109,13 +109,13 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@ddt.unpack
|
||||
def test_child_missing_tag_value(self, user_tag):
|
||||
def test_child_missing_tag_value(self, _user_tag):
|
||||
# If user_tag has a missing value, we should still get back a valid child url
|
||||
self.assertIn(self.split_test_module.child_descriptor.url_name, ['split_test_cond0', 'split_test_cond1'])
|
||||
|
||||
@ddt.data(('100',), ('200',), ('300',), ('400',), ('500',), ('600',), ('700',), ('800',), ('900',), ('1000',))
|
||||
@ddt.unpack
|
||||
def test_child_persist_new_tag_value_when_tag_missing(self, user_tag):
|
||||
def test_child_persist_new_tag_value_when_tag_missing(self, _user_tag):
|
||||
# If a user_tag has a missing value, a group should be saved/persisted for that user.
|
||||
# So, we check that we get the same url_name when we call on the url_name twice.
|
||||
# We run the test ten times so that, if our storage is failing, we'll be most likely to notice it.
|
||||
|
||||
@@ -146,6 +146,7 @@ class SequenceFactory(XmlImportFactory):
|
||||
"""Factory for <sequential> nodes"""
|
||||
tag = 'sequential'
|
||||
|
||||
|
||||
class VerticalFactory(XmlImportFactory):
|
||||
"""Factory for <vertical> nodes"""
|
||||
tag = 'vertical'
|
||||
|
||||
@@ -10,10 +10,10 @@ in-browser HTML5 video method (when in HTML5 mode).
|
||||
in XML.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
from HTMLParser import HTMLParser
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
@@ -156,6 +156,15 @@ class VideoFields(object):
|
||||
scope=Scope.preferences,
|
||||
default="en"
|
||||
)
|
||||
transcript_download_format = String(
|
||||
help="Transcript file format to download by user.",
|
||||
scope=Scope.preferences,
|
||||
values=[
|
||||
{"display_name": "SubRip (.srt) file", "value": "srt"},
|
||||
{"display_name": "Text (.txt) file", "value": "txt"}
|
||||
],
|
||||
default='srt',
|
||||
)
|
||||
speed = Float(
|
||||
help="The last speed that was explicitly set by user for the video.",
|
||||
scope=Scope.user_state,
|
||||
@@ -194,6 +203,7 @@ class VideoModule(VideoFields, XModule):
|
||||
resource_string(module, 'js/src/video/025_focus_grabber.js'),
|
||||
resource_string(module, 'js/src/video/02_html5_video.js'),
|
||||
resource_string(module, 'js/src/video/03_video_player.js'),
|
||||
resource_string(module, 'js/src/video/035_video_accessible_menu.js'),
|
||||
resource_string(module, 'js/src/video/04_video_control.js'),
|
||||
resource_string(module, 'js/src/video/05_video_quality_control.js'),
|
||||
resource_string(module, 'js/src/video/06_video_progress_slider.js'),
|
||||
@@ -203,20 +213,33 @@ class VideoModule(VideoFields, XModule):
|
||||
resource_string(module, 'js/src/video/10_main.js')
|
||||
]
|
||||
}
|
||||
css = {'scss': [resource_string(module, 'css/video/display.scss')]}
|
||||
css = {'scss': [
|
||||
resource_string(module, 'css/video/display.scss'),
|
||||
resource_string(module, 'css/video/accessible_menu.scss'),
|
||||
]}
|
||||
js_module_name = "Video"
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
accepted_keys = ['speed', 'saved_video_position', 'transcript_language']
|
||||
if dispatch == 'save_user_state':
|
||||
accepted_keys = [
|
||||
'speed', 'saved_video_position', 'transcript_language',
|
||||
'transcript_download_format',
|
||||
]
|
||||
|
||||
conversions = {
|
||||
'speed': json.loads,
|
||||
'saved_video_position': lambda v: RelativeTime.isotime_to_timedelta(v),
|
||||
}
|
||||
|
||||
if dispatch == 'save_user_state':
|
||||
for key in data:
|
||||
if hasattr(self, key) and key in accepted_keys:
|
||||
if key == 'saved_video_position':
|
||||
relative_position = RelativeTime.isotime_to_timedelta(data[key])
|
||||
self.saved_video_position = relative_position
|
||||
if key in conversions:
|
||||
value = conversions[key](data[key])
|
||||
else:
|
||||
setattr(self, key, json.loads(data[key]))
|
||||
value = data[key]
|
||||
|
||||
setattr(self, key, value)
|
||||
|
||||
if key == 'speed':
|
||||
self.global_speed = self.speed
|
||||
|
||||
@@ -229,6 +252,7 @@ class VideoModule(VideoFields, XModule):
|
||||
|
||||
def get_html(self):
|
||||
track_url = None
|
||||
transcript_download_format = self.transcript_download_format
|
||||
|
||||
get_ext = lambda filename: filename.rpartition('.')[-1]
|
||||
sources = {get_ext(src): src for src in self.html5_sources}
|
||||
@@ -242,7 +266,8 @@ class VideoModule(VideoFields, XModule):
|
||||
if self.download_track:
|
||||
if self.track:
|
||||
track_url = self.track
|
||||
elif self.sub:
|
||||
transcript_download_format = None
|
||||
elif self.sub or self.transcripts:
|
||||
track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download'
|
||||
|
||||
if not self.transcripts:
|
||||
@@ -290,13 +315,15 @@ class VideoModule(VideoFields, XModule):
|
||||
# configuration setting field.
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_test_url': settings.YOUTUBE_TEST_URL,
|
||||
'transcript_download_format': transcript_download_format,
|
||||
'transcript_download_formats_list': self.descriptor.fields['transcript_download_format'].values,
|
||||
'transcript_language': transcript_language,
|
||||
'transcript_languages': json.dumps(sorted_languages),
|
||||
'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation',
|
||||
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations',
|
||||
})
|
||||
|
||||
def get_transcript(self):
|
||||
def get_transcript(self, format='srt'):
|
||||
"""
|
||||
Returns transcript in *.srt format.
|
||||
|
||||
@@ -308,12 +335,18 @@ class VideoModule(VideoFields, XModule):
|
||||
lang = self.transcript_language
|
||||
subs_id = self.sub if lang == 'en' else self.youtube_id_1_0
|
||||
data = asset(self.location, subs_id, lang).data
|
||||
str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
|
||||
if format == 'txt':
|
||||
text = json.loads(data)['text']
|
||||
str_subs = HTMLParser().unescape("\n".join(text))
|
||||
mime_type = 'text/plain'
|
||||
else:
|
||||
str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
|
||||
mime_type = 'application/x-subrip'
|
||||
if not str_subs:
|
||||
log.debug('generate_srt_from_sjson produces no subtitles')
|
||||
raise ValueError
|
||||
|
||||
return str_subs
|
||||
return str_subs, format, mime_type
|
||||
|
||||
@XBlock.handler
|
||||
def transcript(self, request, dispatch):
|
||||
@@ -329,7 +362,7 @@ class VideoModule(VideoFields, XModule):
|
||||
`available_translations`: returns list of languages, for which SRT files exist. For 'en' check if SJSON exists.
|
||||
"""
|
||||
if dispatch == 'translation':
|
||||
if 'language' not in request.GET or 'videoId' not in request.GET:
|
||||
if 'language' not in request.GET:
|
||||
log.info("Invalid /transcript GET parameters.")
|
||||
return Response(status=400)
|
||||
|
||||
@@ -341,7 +374,7 @@ class VideoModule(VideoFields, XModule):
|
||||
self.transcript_language = lang
|
||||
|
||||
try:
|
||||
transcript = self.translation(request.GET.get('videoId'))
|
||||
transcript = self.translation(request.GET.get('videoId', None))
|
||||
except (TranscriptException, NotFoundError) as ex:
|
||||
log.info(ex.message)
|
||||
response = Response(status=404)
|
||||
@@ -351,7 +384,7 @@ class VideoModule(VideoFields, XModule):
|
||||
|
||||
elif dispatch == 'download':
|
||||
try:
|
||||
subs = self.get_transcript()
|
||||
subs, format, mime_type = self.get_transcript(format=self.transcript_download_format)
|
||||
except (NotFoundError, ValueError, KeyError):
|
||||
log.debug("Video@download exception")
|
||||
response = Response(status=404)
|
||||
@@ -359,10 +392,13 @@ class VideoModule(VideoFields, XModule):
|
||||
response = Response(
|
||||
subs,
|
||||
headerlist=[
|
||||
('Content-Disposition', 'attachment; filename="{0}.srt"'.format(self.transcript_language)),
|
||||
('Content-Disposition', 'attachment; filename="{filename}.{format}"'.format(
|
||||
filename=self.transcript_language,
|
||||
format=format,
|
||||
)),
|
||||
]
|
||||
)
|
||||
response.content_type = "application/x-subrip"
|
||||
response.content_type = mime_type
|
||||
|
||||
elif dispatch == 'available_translations':
|
||||
available_translations = []
|
||||
@@ -390,26 +426,30 @@ class VideoModule(VideoFields, XModule):
|
||||
|
||||
return response
|
||||
|
||||
def translation(self, subs_id):
|
||||
def translation(self, youtube_id):
|
||||
"""
|
||||
This is called to get transcript file for specific language.
|
||||
|
||||
subs_id: str: must be on of: self.sub or one of youtube_ids.
|
||||
youtube_id: str: must be one of youtube_ids or None if HTML video
|
||||
|
||||
Logic flow:
|
||||
|
||||
If english -> give back `sub` subtitles:
|
||||
Return what we have in contentstore for given subs_id,
|
||||
We should not regenerate needed transcripts, if, for example, they present for youtube 1.0 speed,
|
||||
and we need for other speeds. Such generation should be done in transcripts workflow.
|
||||
If non-english:
|
||||
a) extract subs_id from srt file name
|
||||
if non-youtube:
|
||||
b) try to find sjson by subs_id and return if sucessful
|
||||
c) otherwise generate sjson from srt and return it.
|
||||
if youtube:
|
||||
b) try to find sjson by subs_id and return if sucessful
|
||||
c) generate sjson from srt for all youtube speeds
|
||||
If youtube_id doesn't exist, we have a video in HTML5 mode. Otherwise,
|
||||
video video in Youtube or Flash modes.
|
||||
|
||||
if youtube:
|
||||
If english -> give back youtube_id subtitles:
|
||||
Return what we have in contentstore for given youtube_id.
|
||||
If non-english:
|
||||
a) extract youtube_id from srt file name.
|
||||
b) try to find sjson by youtube_id and return if successful.
|
||||
c) generate sjson from srt for all youtube speeds.
|
||||
if non-youtube:
|
||||
If english -> give back `sub` subtitles:
|
||||
Return what we have in contentstore for given subs_if that is stored in self.sub.
|
||||
If non-english:
|
||||
a) try to find previously generated sjson.
|
||||
b) otherwise generate sjson from srt and return it.
|
||||
|
||||
Filenames naming:
|
||||
en: subs_videoid.srt.sjson
|
||||
@@ -418,28 +458,36 @@ class VideoModule(VideoFields, XModule):
|
||||
Raises:
|
||||
NotFoundError if for 'en' subtitles no asset is uploaded.
|
||||
"""
|
||||
if self.transcript_language == 'en':
|
||||
return asset(self.location, subs_id).data
|
||||
|
||||
if not self.youtube_id_1_0: # Non-youtube (HTML5) case:
|
||||
return get_or_create_sjson(self)
|
||||
if youtube_id:
|
||||
# Youtube case:
|
||||
if self.transcript_language == 'en':
|
||||
return asset(self.location, youtube_id).data
|
||||
|
||||
youtube_ids = youtube_speed_dict(self)
|
||||
assert youtube_id in youtube_ids
|
||||
|
||||
try:
|
||||
sjson_transcript = asset(self.location, youtube_id, self.transcript_language).data
|
||||
except (NotFoundError):
|
||||
log.info("Can't find content in storage for %s transcript: generating.", youtube_id)
|
||||
generate_sjson_for_all_speeds(
|
||||
self,
|
||||
self.transcripts[self.transcript_language],
|
||||
{speed: youtube_id for youtube_id, speed in youtube_ids.iteritems()},
|
||||
self.transcript_language
|
||||
)
|
||||
sjson_transcript = asset(self.location, youtube_id, self.transcript_language).data
|
||||
|
||||
return sjson_transcript
|
||||
else:
|
||||
# HTML5 case
|
||||
if self.transcript_language == 'en':
|
||||
return asset(self.location, self.sub).data
|
||||
else:
|
||||
return get_or_create_sjson(self)
|
||||
|
||||
# Youtube case:
|
||||
youtube_ids = youtube_speed_dict(self)
|
||||
assert subs_id in youtube_ids
|
||||
|
||||
try:
|
||||
sjson_transcript = asset(self.location, subs_id, self.transcript_language).data
|
||||
except (NotFoundError):
|
||||
log.info("Can't find content in storage for %s transcript: generating.", subs_id)
|
||||
generate_sjson_for_all_speeds(
|
||||
self,
|
||||
self.transcripts[self.transcript_language],
|
||||
{speed: subs_id for subs_id, speed in youtube_ids.iteritems()},
|
||||
self.transcript_language
|
||||
)
|
||||
sjson_transcript = asset(self.location, subs_id, self.transcript_language).data
|
||||
return sjson_transcript
|
||||
|
||||
|
||||
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
|
||||
|
||||
@@ -34,6 +34,13 @@ class AcidView(PageObject):
|
||||
selector = '{} .acid-block {} .pass'.format(self.context_selector, test_selector)
|
||||
return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3))
|
||||
|
||||
def child_test_passed(self, test_selector):
|
||||
"""
|
||||
Return whether a particular :class:`.AcidParentBlock` test passed.
|
||||
"""
|
||||
selector = '{} .acid-parent-block {} .pass'.format(self.context_selector, test_selector)
|
||||
return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3))
|
||||
|
||||
@property
|
||||
def init_fn_passed(self):
|
||||
"""
|
||||
@@ -47,8 +54,8 @@ class AcidView(PageObject):
|
||||
Whether the tests of children passed
|
||||
"""
|
||||
return all([
|
||||
self.test_passed('.child-counts-match'),
|
||||
self.test_passed('.child-values-match')
|
||||
self.child_test_passed('.child-counts-match'),
|
||||
self.child_test_passed('.child-values-match')
|
||||
])
|
||||
|
||||
@property
|
||||
|
||||
@@ -359,6 +359,19 @@ class XBlockAcidBase(UniqueCourseTest):
|
||||
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
|
||||
|
||||
def validate_acid_block_view(self, acid_block):
|
||||
"""
|
||||
Verify that the LMS view for the Acid Block is correct
|
||||
"""
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
self.assertTrue(acid_block.scope_passed('user_state_summary'))
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
|
||||
|
||||
def test_acid_block(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in the lms.
|
||||
@@ -368,13 +381,7 @@ class XBlockAcidBase(UniqueCourseTest):
|
||||
self.tab_nav.go_to_tab('Courseware')
|
||||
|
||||
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
self.assertTrue(acid_block.scope_passed('user_state_summary'))
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
self.validate_acid_block_view(acid_block)
|
||||
|
||||
|
||||
class XBlockAcidNoChildTest(XBlockAcidBase):
|
||||
@@ -420,7 +427,7 @@ class XBlockAcidChildTest(XBlockAcidBase):
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block').add_children(
|
||||
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
|
||||
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
|
||||
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
|
||||
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
|
||||
@@ -430,6 +437,10 @@ class XBlockAcidChildTest(XBlockAcidBase):
|
||||
)
|
||||
).install()
|
||||
|
||||
def validate_acid_block_view(self, acid_block):
|
||||
super(XBlockAcidChildTest, self).validate_acid_block_view()
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
|
||||
# This will fail until we fix support of children in pure XBlocks
|
||||
@expectedFailure
|
||||
def test_acid_block(self):
|
||||
|
||||
@@ -147,6 +147,17 @@ class XBlockAcidBase(WebAppTest):
|
||||
|
||||
self.auth_page.visit()
|
||||
|
||||
def validate_acid_block_preview(self, acid_block):
|
||||
"""
|
||||
Validate the Acid Block's preview
|
||||
"""
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
self.assertTrue(acid_block.scope_passed('user_state_summary'))
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
|
||||
def test_acid_block_preview(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
@@ -155,22 +166,13 @@ class XBlockAcidBase(WebAppTest):
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.toggle_expand().unit('Test Unit').go_to()
|
||||
container = unit.components[0].go_to_container()
|
||||
|
||||
acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
self.assertTrue(acid_block.scope_passed('user_state_summary'))
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
acid_block = AcidView(self.browser, unit.components[0].preview_selector)
|
||||
self.validate_acid_block_preview(acid_block)
|
||||
|
||||
# This will fail until we support editing on the container page
|
||||
@expectedFailure
|
||||
def test_acid_block_editor(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
Verify that all expected acid block tests pass in studio editor
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
@@ -181,7 +183,6 @@ class XBlockAcidBase(WebAppTest):
|
||||
|
||||
acid_block = AcidView(self.browser, unit.components[0].edit().editor_selector)
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('content'))
|
||||
self.assertTrue(acid_block.scope_passed('settings'))
|
||||
@@ -213,7 +214,36 @@ class XBlockAcidNoChildTest(XBlockAcidBase):
|
||||
).install()
|
||||
|
||||
|
||||
class XBlockAcidChildTest(XBlockAcidBase):
|
||||
class XBlockAcidParentBase(XBlockAcidBase):
|
||||
"""
|
||||
Base class for tests that verify that parent XBlock integration is working correctly
|
||||
"""
|
||||
__test__ = False
|
||||
|
||||
def validate_acid_block_preview(self, acid_block):
|
||||
super(XBlockAcidParentBase, self).validate_acid_block_preview(acid_block)
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
|
||||
def test_acid_block_preview(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.toggle_expand().unit('Test Unit').go_to()
|
||||
container = unit.components[0].go_to_container()
|
||||
|
||||
acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
|
||||
self.validate_acid_block_preview(acid_block)
|
||||
|
||||
# This will fail until the container page supports editing
|
||||
@expectedFailure
|
||||
def test_acid_block_editor(self):
|
||||
super(XBlockAcidParentBase, self).test_acid_block_editor()
|
||||
|
||||
|
||||
class XBlockAcidEmptyParentTest(XBlockAcidParentBase):
|
||||
"""
|
||||
Tests of an AcidBlock with children
|
||||
"""
|
||||
@@ -232,7 +262,34 @@ class XBlockAcidChildTest(XBlockAcidBase):
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block').add_children(
|
||||
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
|
||||
class XBlockAcidChildTest(XBlockAcidParentBase):
|
||||
"""
|
||||
Tests of an AcidBlock with children
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
|
||||
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
|
||||
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
|
||||
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
114220
|
||||
],
|
||||
"text": [
|
||||
"LILA FISHER: Hi, welcome to Edx.",
|
||||
"Hi, welcome to Edx.",
|
||||
"I'm Lila Fisher, an Edx fellow helping to put",
|
||||
"together these courses.",
|
||||
"As you know, our courses are entirely online.",
|
||||
|
||||
@@ -100,11 +100,11 @@ Assign discussion administration roles
|
||||
|
||||
You can designate a team of people to help you run course discussions. Different options for working with discussion posts are available to discussion administrators with these roles:
|
||||
|
||||
* Forum moderators can edit and delete posts, review posts flagged for misuse, close and reopen threads, pin posts and endorse responses, and, if the course is cohorted, see posts from all cohorts. Responses and comments made by moderators are marked as "Staff".
|
||||
* Discussion moderators can edit and delete posts, review posts flagged for misuse, close and reopen threads, pin posts and endorse responses, and, if the course is cohorted, see posts from all cohorts. Responses and comments made by moderators are marked as "Staff".
|
||||
|
||||
* Forum community TAs have the same options for working with discussions as moderators. Responses and comments made by community TAs are marked as "Community TA".
|
||||
* Discussion community TAs have the same options for working with discussions as moderators. Responses and comments made by community TAs are marked as "Community TA".
|
||||
|
||||
* Forum admins have the same options for working with discussions as moderators. Admins can also assign these discussion management roles to more people while your course is running, or remove a role from a user whenever necessary. Responses and comments made by admins are marked as "Staff".
|
||||
* Discussion admins have the same options for working with discussions as moderators. Admins can also assign these discussion management roles to more people while your course is running, or remove a role from a user whenever necessary. Responses and comments made by admins are marked as "Staff".
|
||||
|
||||
**Note**: Discussion responses and comments made by course staff and instructors are also marked as "Staff".
|
||||
|
||||
@@ -123,7 +123,7 @@ To assign a role:
|
||||
|
||||
#. Click **Membership**.
|
||||
|
||||
#. In the Administration List Management section, use the drop-down list to select Forum Admins, Forum Moderators, or Forum Community TAs.
|
||||
#. In the Administration List Management section, use the drop-down list to select Discussion Admins, Discussion Moderators, or Discussion Community TAs.
|
||||
|
||||
#. Under the list of users who currently have that role, enter an email address and click **Add** for the role type.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Feature: LMS Video component
|
||||
As a student, I want to view course videos in LMS
|
||||
|
||||
# 0
|
||||
# 1
|
||||
Scenario: Video component stores position correctly when page is reloaded
|
||||
Given the course has a Video component in Youtube mode
|
||||
Then when I view the video it has rendered in Youtube mode
|
||||
@@ -13,51 +13,51 @@ Feature: LMS Video component
|
||||
And I click video button "play"
|
||||
Then I see video starts playing from "0:10" position
|
||||
|
||||
# 1
|
||||
# 2
|
||||
Scenario: Video component is fully rendered in the LMS in HTML5 mode
|
||||
Given the course has a Video component in HTML5 mode
|
||||
Then when I view the video it has rendered in HTML5 mode
|
||||
And all sources are correct
|
||||
|
||||
# 2
|
||||
# 3
|
||||
# Firefox doesn't have HTML5 (only mp4 - fix here)
|
||||
@skip_firefox
|
||||
Scenario: Autoplay is disabled in LMS for a Video component
|
||||
Given the course has a Video component in HTML5 mode
|
||||
Then when I view the video it does not have autoplay enabled
|
||||
|
||||
# 3
|
||||
# 4
|
||||
# Youtube testing
|
||||
Scenario: Video component is fully rendered in the LMS in Youtube mode with HTML5 sources
|
||||
Given youtube server is up and response time is 0.4 seconds
|
||||
And the course has a Video component in Youtube_HTML5 mode
|
||||
Then when I view the video it has rendered in Youtube mode
|
||||
|
||||
# 4
|
||||
# 5
|
||||
Scenario: Video component is not rendered in the LMS in Youtube mode with HTML5 sources
|
||||
Given youtube server is up and response time is 2 seconds
|
||||
And the course has a Video component in Youtube_HTML5 mode
|
||||
Then when I view the video it has rendered in HTML5 mode
|
||||
|
||||
# 5
|
||||
# 6
|
||||
Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources
|
||||
Given youtube server is up and response time is 2 seconds
|
||||
And the course has a Video component in Youtube mode
|
||||
Then when I view the video it has rendered in Youtube mode
|
||||
|
||||
# 6
|
||||
# 7
|
||||
Scenario: Video component is rendered in the LMS in Youtube mode with HTML5 sources that doesn't supported by browser
|
||||
Given youtube server is up and response time is 2 seconds
|
||||
And the course has a Video component in Youtube_HTML5_Unsupported_Video mode
|
||||
Then when I view the video it has rendered in Youtube mode
|
||||
|
||||
# 7
|
||||
# 8
|
||||
Scenario: Video component is rendered in the LMS in HTML5 mode with HTML5 sources that doesn't supported by browser
|
||||
Given the course has a Video component in HTML5_Unsupported_Video mode
|
||||
Then error message is shown
|
||||
And error message has correct text
|
||||
|
||||
# 8
|
||||
# 9
|
||||
Scenario: Video component stores speed correctly when each video is in separate sequence
|
||||
Given I am registered for the course "test_course"
|
||||
And it has a video "A" in "Youtube" mode in position "1" of sequential
|
||||
@@ -79,14 +79,100 @@ Feature: LMS Video component
|
||||
When I open video "C"
|
||||
Then video "C" should start playing at speed "1.0"
|
||||
|
||||
# 9
|
||||
Scenario: Language menu in Video component works correctly
|
||||
# 10
|
||||
Scenario: Language menu works correctly in Video component
|
||||
Given the course has a Video component in Youtube mode:
|
||||
| transcripts | sub |
|
||||
| {"zh": "OEoXaMPEzfM"} | OEoXaMPEzfM |
|
||||
| {"zh": "chinese_transcripts.srt"} | OEoXaMPEzfM |
|
||||
And I make sure captions are closed
|
||||
And I see video menu "language" with correct items
|
||||
And I select language with code "zh"
|
||||
Then I see "好 各位同学" text in the captions
|
||||
And I select language with code "en"
|
||||
And I see "Hi, welcome to Edx." text in the captions
|
||||
|
||||
# 11
|
||||
Scenario: CC button works correctly w/o english transcript in HTML5 mode of Video component
|
||||
Given the course has a Video component in HTML5 mode:
|
||||
| transcripts |
|
||||
| {"zh": "chinese_transcripts.srt"} |
|
||||
And I make sure captions are opened
|
||||
Then I see "好 各位同学" text in the captions
|
||||
|
||||
# 12
|
||||
Scenario: CC button works correctly only w/ english transcript in HTML5 mode of Video component
|
||||
Given I am registered for the course "test_course"
|
||||
And I have a "subs_OEoXaMPEzfM.srt.sjson" transcript file in assets
|
||||
And it has a video in "HTML5" mode:
|
||||
| sub |
|
||||
| OEoXaMPEzfM |
|
||||
And I make sure captions are opened
|
||||
Then I see "Hi, welcome to Edx." text in the captions
|
||||
|
||||
# 13
|
||||
Scenario: CC button works correctly w/o english transcript in Youtube mode of Video component
|
||||
Given the course has a Video component in Youtube mode:
|
||||
| transcripts |
|
||||
| {"zh": "chinese_transcripts.srt"} |
|
||||
And I make sure captions are opened
|
||||
Then I see "好 各位同学" text in the captions
|
||||
|
||||
# 14
|
||||
Scenario: CC button works correctly if transcripts and sub fields are empty, but transcript file exists is assets (Youtube mode of Video component)
|
||||
Given I am registered for the course "test_course"
|
||||
And I have a "subs_OEoXaMPEzfM.srt.sjson" transcript file in assets
|
||||
And it has a video in "Youtube" mode
|
||||
And I make sure captions are opened
|
||||
Then I see "Hi, welcome to Edx." text in the captions
|
||||
|
||||
# 15
|
||||
Scenario: CC button is hidden if no translations
|
||||
Given the course has a Video component in Youtube mode
|
||||
Then button "CC" is hidden
|
||||
|
||||
# 16
|
||||
Scenario: Video is aligned correctly if transcript is visible in fullscreen mode
|
||||
Given the course has a Video component in HTML5 mode:
|
||||
| sub |
|
||||
| OEoXaMPEzfM |
|
||||
And I make sure captions are opened
|
||||
And I click video button "fullscreen"
|
||||
Then I see video aligned correctly with enabled transcript
|
||||
|
||||
# 17
|
||||
Scenario: Video is aligned correctly if transcript is hidden in fullscreen mode
|
||||
Given the course has a Video component in Youtube mode
|
||||
And I click video button "fullscreen"
|
||||
Then I see video aligned correctly without enabled transcript
|
||||
|
||||
# 18
|
||||
Scenario: Video is aligned correctly on transcript toggle in fullscreen mode
|
||||
Given the course has a Video component in Youtube mode:
|
||||
| sub |
|
||||
| OEoXaMPEzfM |
|
||||
And I make sure captions are opened
|
||||
And I click video button "fullscreen"
|
||||
Then I see video aligned correctly with enabled transcript
|
||||
And I click video button "CC"
|
||||
Then I see video aligned correctly without enabled transcript
|
||||
|
||||
# 19
|
||||
Scenario: Download Transcript button works correctly in Video component
|
||||
Given I am registered for the course "test_course"
|
||||
And it has a video "A" in "Youtube" mode in position "1" of sequential:
|
||||
| sub | download_track |
|
||||
| OEoXaMPEzfM | true |
|
||||
And a video "B" in "Youtube" mode in position "2" of sequential:
|
||||
| sub | download_track |
|
||||
| OEoXaMPEzfM | true |
|
||||
And a video "C" in "Youtube" mode in position "3" of sequential:
|
||||
| track | download_track |
|
||||
| http://example.org/ | true |
|
||||
And I open the section with videos
|
||||
And I can download transcript in "srt" format
|
||||
And I select the transcript format "txt"
|
||||
And I can download transcript in "txt" format
|
||||
When I open video "B"
|
||||
Then I can download transcript in "txt" format
|
||||
When I open video "C"
|
||||
Then menu "download_transcript" doesn't exist
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
|
||||
from lettuce import world, step
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
from common import i_am_registered_for_the_course, section_location, visit_scenario_item
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.conf import settings
|
||||
from cache_toolbox.core import del_cached_content
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
import os
|
||||
from functools import partial
|
||||
from xmodule.contentstore.django import contentstore
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
LANGUAGES = settings.ALL_LANGUAGES
|
||||
@@ -22,72 +22,67 @@ HTML5_SOURCES = [
|
||||
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.webm',
|
||||
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.ogv',
|
||||
]
|
||||
|
||||
HTML5_SOURCES_INCORRECT = [
|
||||
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99',
|
||||
]
|
||||
VIDEO_BUTTONS = {
|
||||
'CC': '.hide-subtitles',
|
||||
'volume': '.volume',
|
||||
'play': '.video_control.play',
|
||||
'pause': '.video_control.pause',
|
||||
}
|
||||
VIDEO_MENUS = {
|
||||
'language': '.lang .menu',
|
||||
'speed': '.speed .menu',
|
||||
}
|
||||
|
||||
VIDEO_BUTTONS = {
|
||||
'CC': '.hide-subtitles',
|
||||
'volume': '.volume',
|
||||
'play': '.video_control.play',
|
||||
'pause': '.video_control.pause',
|
||||
'fullscreen': '.add-fullscreen',
|
||||
'download_transcript': '.video-tracks > a',
|
||||
}
|
||||
|
||||
VIDEO_MENUS = {
|
||||
'language': '.lang .menu',
|
||||
'speed': '.speed .menu',
|
||||
'download_transcript': '.video-tracks .a11y-menu-list',
|
||||
}
|
||||
|
||||
coursenum = 'test_course'
|
||||
sequence = {}
|
||||
|
||||
@step('when I view the (.*) it does not have autoplay enabled$')
|
||||
def does_not_autoplay(_step, video_type):
|
||||
assert(world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False')
|
||||
|
||||
class ReuqestHandlerWithSessionId(object):
|
||||
def get(self, url):
|
||||
"""
|
||||
Sends a request.
|
||||
"""
|
||||
kwargs = dict()
|
||||
|
||||
@step('the course has a Video component in (.*) mode(?:\:)?$')
|
||||
def view_video(_step, player_mode):
|
||||
session_id = [{i['name']:i['value']} for i in world.browser.cookies.all() if i['name']==u'sessionid']
|
||||
if session_id:
|
||||
kwargs.update({
|
||||
'cookies': session_id[0]
|
||||
})
|
||||
|
||||
i_am_registered_for_the_course(_step, coursenum)
|
||||
response = requests.get(url, **kwargs)
|
||||
self.response = response
|
||||
self.status_code = response.status_code
|
||||
self.headers = response.headers
|
||||
self.content = response.content
|
||||
|
||||
# Make sure we have a video
|
||||
add_video_to_course(coursenum, player_mode.lower(), _step.hashes)
|
||||
visit_scenario_item('SECTION')
|
||||
return self
|
||||
|
||||
def is_success(self):
|
||||
"""
|
||||
Returns `True` if the response was succeed, otherwise, returns `False`.
|
||||
"""
|
||||
if self.status_code < 400:
|
||||
return True
|
||||
return False
|
||||
|
||||
@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential(?:\:)?$')
|
||||
def add_video(_step, player_id, player_mode, position):
|
||||
sequence[player_id] = position
|
||||
add_video_to_course(coursenum, player_mode.lower(), _step.hashes, display_name=player_id)
|
||||
|
||||
|
||||
@step('I open the section with videos$')
|
||||
def visit_video_section(_step):
|
||||
visit_scenario_item('SECTION')
|
||||
|
||||
|
||||
@step('I select the "([^"]*)" speed on video "([^"]*)"$')
|
||||
def change_video_speed(_step, speed, player_id):
|
||||
_navigate_to_an_item_in_a_sequence(sequence[player_id])
|
||||
_change_video_speed(speed)
|
||||
|
||||
|
||||
@step('I open video "([^"]*)"$')
|
||||
def open_video(_step, player_id):
|
||||
_navigate_to_an_item_in_a_sequence(sequence[player_id])
|
||||
|
||||
|
||||
@step('video "([^"]*)" should start playing at speed "([^"]*)"$')
|
||||
def check_video_speed(_step, player_id, speed):
|
||||
speed_css = '.speeds p.active'
|
||||
assert world.css_has_text(speed_css, '{0}x'.format(speed))
|
||||
|
||||
def check_header(self, name, value):
|
||||
"""
|
||||
Returns `True` if the response header exist and has appropriate value,
|
||||
otherwise, returns `False`.
|
||||
"""
|
||||
if value in self.headers.get(name, ''):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_video_to_course(course, player_mode, hashes, display_name='Video'):
|
||||
category = 'video'
|
||||
@@ -126,19 +121,139 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'):
|
||||
|
||||
if hashes:
|
||||
kwargs['metadata'].update(hashes[0])
|
||||
course_location =world.scenario_dict['COURSE'].location
|
||||
|
||||
conversions = {
|
||||
'transcripts': json.loads,
|
||||
'download_track': json.loads,
|
||||
'download_video': json.loads,
|
||||
}
|
||||
|
||||
for key in kwargs['metadata']:
|
||||
if key in conversions:
|
||||
kwargs['metadata'][key] = conversions[key](kwargs['metadata'][key])
|
||||
|
||||
if 'sub' in kwargs['metadata']:
|
||||
filename = _get_sjson_filename(kwargs['metadata']['sub'], 'en')
|
||||
_upload_file(filename, course_location)
|
||||
|
||||
if 'transcripts' in kwargs['metadata']:
|
||||
kwargs['metadata']['transcripts'] = json.loads(kwargs['metadata']['transcripts'])
|
||||
|
||||
if 'sub' in kwargs['metadata']:
|
||||
_upload_file(kwargs['metadata']['sub'], 'en', world.scenario_dict['COURSE'].location)
|
||||
|
||||
for lang, videoId in kwargs['metadata']['transcripts'].items():
|
||||
_upload_file(videoId, lang, world.scenario_dict['COURSE'].location)
|
||||
for lang, filename in kwargs['metadata']['transcripts'].items():
|
||||
_upload_file(filename, course_location)
|
||||
|
||||
world.scenario_dict['VIDEO'] = world.ItemFactory.create(**kwargs)
|
||||
|
||||
|
||||
def _get_sjson_filename(videoId, lang):
|
||||
if lang == 'en':
|
||||
return 'subs_{0}.srt.sjson'.format(videoId)
|
||||
else:
|
||||
return '{0}_subs_{1}.srt.sjson'.format(lang, videoId)
|
||||
|
||||
|
||||
def _upload_file(filename, location):
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', filename)
|
||||
f = open(os.path.abspath(path))
|
||||
mime_type = "application/json"
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
location.org, location.course, filename
|
||||
)
|
||||
content = StaticContent(content_location, filename, mime_type, f.read())
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
|
||||
def _navigate_to_an_item_in_a_sequence(number):
|
||||
sequence_css = '#sequence-list a[data-element="{0}"]'.format(number)
|
||||
world.css_click(sequence_css)
|
||||
|
||||
|
||||
def _change_video_speed(speed):
|
||||
world.browser.execute_script("$('.speeds').addClass('open')")
|
||||
speed_css = 'li[data-speed="{0}"] a'.format(speed)
|
||||
world.css_click(speed_css)
|
||||
|
||||
|
||||
def _open_menu(menu):
|
||||
world.browser.execute_script("$('{selector}').parent().addClass('open')".format(
|
||||
selector=VIDEO_MENUS[menu]
|
||||
))
|
||||
|
||||
|
||||
def _get_all_dimensions():
|
||||
video = _get_dimensions('.video-player iframe, .video-player video')
|
||||
wrapper = _get_dimensions('.tc-wrapper')
|
||||
controls = _get_dimensions('.video-controls')
|
||||
progress_slider = _get_dimensions('.video-controls > .slider')
|
||||
|
||||
expected = dict(wrapper)
|
||||
expected['height'] -= controls['height'] + 0.5 * progress_slider['height']
|
||||
|
||||
return (video, expected)
|
||||
|
||||
|
||||
def _get_dimensions(selector):
|
||||
element = world.css_find(selector).first
|
||||
return element._element.size
|
||||
|
||||
|
||||
def _get_window_dimensions():
|
||||
return world.browser.driver.get_window_size()
|
||||
|
||||
|
||||
def _set_window_dimensions(width, height):
|
||||
world.browser.driver.set_window_size(width, height)
|
||||
# Wait 200 ms when JS finish resizing
|
||||
world.wait(0.2)
|
||||
|
||||
|
||||
@step('when I view the (.*) it does not have autoplay enabled$')
|
||||
def does_not_autoplay(_step, video_type):
|
||||
assert(world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False')
|
||||
|
||||
|
||||
@step('the course has a Video component in (.*) mode(?:\:)?$')
|
||||
def view_video(_step, player_mode):
|
||||
i_am_registered_for_the_course(_step, coursenum)
|
||||
add_video_to_course(coursenum, player_mode.lower(), _step.hashes)
|
||||
visit_scenario_item('SECTION')
|
||||
|
||||
|
||||
@step('a video in "([^"]*)" mode(?:\:)?$')
|
||||
def add_video(_step, player_mode):
|
||||
add_video_to_course(coursenum, player_mode.lower(), _step.hashes)
|
||||
visit_scenario_item('SECTION')
|
||||
|
||||
|
||||
@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential(?:\:)?$')
|
||||
def add_video_in_position(_step, player_id, player_mode, position):
|
||||
sequence[player_id] = position
|
||||
add_video_to_course(coursenum, player_mode.lower(), _step.hashes, display_name=player_id)
|
||||
|
||||
|
||||
@step('I open the section with videos$')
|
||||
def visit_video_section(_step):
|
||||
visit_scenario_item('SECTION')
|
||||
|
||||
|
||||
@step('I select the "([^"]*)" speed on video "([^"]*)"$')
|
||||
def change_video_speed(_step, speed, player_id):
|
||||
_navigate_to_an_item_in_a_sequence(sequence[player_id])
|
||||
_change_video_speed(speed)
|
||||
|
||||
|
||||
@step('I open video "([^"]*)"$')
|
||||
def open_video(_step, player_id):
|
||||
_navigate_to_an_item_in_a_sequence(sequence[player_id])
|
||||
|
||||
|
||||
@step('video "([^"]*)" should start playing at speed "([^"]*)"$')
|
||||
def check_video_speed(_step, player_id, speed):
|
||||
speed_css = '.speeds p.active'
|
||||
assert world.css_has_text(speed_css, '{0}x'.format(speed))
|
||||
|
||||
|
||||
@step('youtube server is up and response time is (.*) seconds$')
|
||||
def set_youtube_response_timeout(_step, time):
|
||||
world.youtube.config['time_to_response'] = float(time)
|
||||
@@ -157,7 +272,7 @@ def video_is_rendered(_step, mode):
|
||||
|
||||
@step('all sources are correct$')
|
||||
def all_sources_are_correct(_step):
|
||||
elements = world.css_find('.video video source')
|
||||
elements = world.css_find('.video-player video source')
|
||||
sources = [source['src'].split('?')[0] for source in elements]
|
||||
|
||||
assert set(sources) == set(HTML5_SOURCES)
|
||||
@@ -227,56 +342,8 @@ def select_language(_step, code):
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I click on video button "([^"]*)"$')
|
||||
def click_button(_step, button):
|
||||
world.css_find(VIDEO_BUTTONS[button]).click()
|
||||
|
||||
|
||||
def _upload_file(videoId, lang, location):
|
||||
if lang == 'en':
|
||||
filename = 'subs_{0}.srt.sjson'.format(videoId)
|
||||
else:
|
||||
filename = '{0}_subs_{1}.srt.sjson'.format(lang, videoId)
|
||||
|
||||
path = os.path.join(TEST_ROOT, 'uploads/', filename)
|
||||
f = open(os.path.abspath(path))
|
||||
mime_type = "application/json"
|
||||
|
||||
content_location = StaticContent.compute_location(
|
||||
location.org, location.course, filename
|
||||
)
|
||||
|
||||
sc_partial = partial(StaticContent, content_location, filename, mime_type)
|
||||
content = sc_partial(f.read())
|
||||
|
||||
(thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(
|
||||
content,
|
||||
tempfile_path=None
|
||||
)
|
||||
del_cached_content(thumbnail_location)
|
||||
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
contentstore().save(content)
|
||||
del_cached_content(content.location)
|
||||
|
||||
|
||||
def _navigate_to_an_item_in_a_sequence(number):
|
||||
sequence_css = 'a[data-element="{0}"]'.format(number)
|
||||
world.css_click(sequence_css)
|
||||
|
||||
|
||||
def _change_video_speed(speed):
|
||||
world.browser.execute_script("$('.speeds').addClass('open')")
|
||||
speed_css = 'li[data-speed="{0}"] a'.format(speed)
|
||||
world.css_click(speed_css)
|
||||
|
||||
|
||||
@step('I click video button "([^"]*)"$')
|
||||
def click_button_video(_step, button_type):
|
||||
world.wait_for_ajax_complete()
|
||||
button = button_type.strip()
|
||||
def click_button(_step, button):
|
||||
world.css_click(VIDEO_BUTTONS[button])
|
||||
|
||||
|
||||
@@ -295,7 +362,81 @@ def seek_video_to_n_seconds(_step, seconds):
|
||||
world.browser.execute_script(jsCode)
|
||||
|
||||
|
||||
def _open_menu(menu):
|
||||
world.browser.execute_script("$('{selector}').parent().addClass('open')".format(
|
||||
selector=VIDEO_MENUS[menu]
|
||||
))
|
||||
@step('I have a "([^"]*)" transcript file in assets$')
|
||||
def upload_to_assets(_step, filename):
|
||||
_upload_file(filename, world.scenario_dict['COURSE'].location)
|
||||
|
||||
|
||||
@step('button "([^"]*)" is hidden$')
|
||||
def is_hidden_button(_step, button):
|
||||
assert not world.css_visible(VIDEO_BUTTONS[button])
|
||||
|
||||
@step('menu "([^"]*)" doesn\'t exist$')
|
||||
def is_hidden_menu(_step, menu):
|
||||
assert world.is_css_not_present(VIDEO_MENUS[menu])
|
||||
|
||||
|
||||
@step('I see video aligned correctly (with(?:out)?) enabled transcript$')
|
||||
def video_alignment(_step, transcript_visibility):
|
||||
# Width of the video container in css equal 75% of window if transcript enabled
|
||||
wrapper_width = 75 if transcript_visibility == "with" else 100
|
||||
initial = _get_window_dimensions()
|
||||
|
||||
_set_window_dimensions(300, 600)
|
||||
real, expected = _get_all_dimensions()
|
||||
|
||||
width = round(100 * real['width']/expected['width']) == wrapper_width
|
||||
|
||||
_set_window_dimensions(600, 300)
|
||||
real, expected = _get_all_dimensions()
|
||||
|
||||
height = abs(expected['height'] - real['height']) <= 5
|
||||
|
||||
# Restore initial window size
|
||||
_set_window_dimensions(
|
||||
initial['width'], initial['height']
|
||||
)
|
||||
|
||||
assert all([width, height])
|
||||
|
||||
|
||||
@step('I can download transcript in "([^"]*)" format$')
|
||||
def i_can_download_transcript(_step, format):
|
||||
button = world.css_find('.video-tracks .a11y-menu-button').first
|
||||
assert button.text.strip() == '.' + format
|
||||
|
||||
formats = {
|
||||
'srt': {
|
||||
'content': '0\n00:00:00,270',
|
||||
'mime_type': 'application/x-subrip'
|
||||
},
|
||||
'txt': {
|
||||
'content': 'Hi, welcome to Edx.',
|
||||
'mime_type': 'text/plain'
|
||||
},
|
||||
}
|
||||
|
||||
url = world.css_find(VIDEO_BUTTONS['download_transcript'])[0]['href']
|
||||
request = ReuqestHandlerWithSessionId()
|
||||
assert request.get(url).is_success()
|
||||
assert request.check_header('content-type', formats[format]['mime_type'])
|
||||
assert request.content.startswith(formats[format]['content'])
|
||||
|
||||
|
||||
@step('I select the transcript format "([^"]*)"$')
|
||||
def select_transcript_format(_step, format):
|
||||
button = world.css_find('.video-tracks .a11y-menu-button').first
|
||||
button.mouse_over()
|
||||
assert button.text.strip() == '...'
|
||||
|
||||
menu_selector = VIDEO_MENUS['download_transcript']
|
||||
menu_items = world.css_find(menu_selector + ' a')
|
||||
|
||||
for item in menu_items:
|
||||
if item['data-value'] == format:
|
||||
item.click()
|
||||
world.wait_for_ajax_complete()
|
||||
break
|
||||
|
||||
assert world.css_find(menu_selector + ' .active a')[0]['data-value'] == format
|
||||
assert button.text.strip() == '.' + format
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
"""
|
||||
Test for split test XModule
|
||||
"""
|
||||
import ddt
|
||||
from mock import MagicMock, patch, Mock
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.test_partitions import StaticPartitionService
|
||||
from user_api.tests.factories import UserCourseTagFactory
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class SplitTestBase(ModuleStoreTestCase):
|
||||
"""
|
||||
Sets up a basic course and user for split test testing.
|
||||
Also provides tests of rendered HTML for two user_tag conditions, 0 and 1.
|
||||
"""
|
||||
__test__ = False
|
||||
COURSE_NUMBER = 'split-test-base'
|
||||
ICON_CLASSES = None
|
||||
TOOLTIPS = None
|
||||
HIDDEN_CONTENT = None
|
||||
VISIBLE_CONTENT = None
|
||||
|
||||
def setUp(self):
|
||||
self.partition = UserPartition(
|
||||
@@ -53,6 +58,10 @@ class SplitTestBase(ModuleStoreTestCase):
|
||||
self.client.login(username=self.student.username, password='test')
|
||||
|
||||
def _video(self, parent, group):
|
||||
"""
|
||||
Returns a video component with parent ``parent``
|
||||
that is intended to be displayed to group ``group``.
|
||||
"""
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location,
|
||||
category="video",
|
||||
@@ -60,6 +69,10 @@ class SplitTestBase(ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
def _problem(self, parent, group):
|
||||
"""
|
||||
Returns a problem component with parent ``parent``
|
||||
that is intended to be displayed to group ``group``.
|
||||
"""
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location,
|
||||
category="problem",
|
||||
@@ -68,6 +81,10 @@ class SplitTestBase(ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
def _html(self, parent, group):
|
||||
"""
|
||||
Returns an html component with parent ``parent``
|
||||
that is intended to be displayed to group ``group``.
|
||||
"""
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location,
|
||||
category="html",
|
||||
@@ -82,21 +99,23 @@ class SplitTestBase(ModuleStoreTestCase):
|
||||
self._check_split_test(1)
|
||||
|
||||
def _check_split_test(self, user_tag):
|
||||
tag_factory = UserCourseTagFactory(
|
||||
"""Checks that the right compentents are rendered for user with ``user_tag``"""
|
||||
# This explicitly sets the user_tag for self.student to ``user_tag``
|
||||
UserCourseTagFactory(
|
||||
user=self.student,
|
||||
course_id=self.course.id,
|
||||
key='xblock.partition_service.partition_{0}'.format(self.partition.id),
|
||||
value=str(user_tag)
|
||||
)
|
||||
|
||||
resp = self.client.get(reverse('courseware_section',
|
||||
kwargs={'course_id': self.course.id,
|
||||
'chapter': self.chapter.url_name,
|
||||
'section': self.sequential.url_name}
|
||||
resp = self.client.get(reverse(
|
||||
'courseware_section',
|
||||
kwargs={'course_id': self.course.id,
|
||||
'chapter': self.chapter.url_name,
|
||||
'section': self.sequential.url_name}
|
||||
))
|
||||
|
||||
content = resp.content
|
||||
print content
|
||||
|
||||
# Assert we see the proper icon in the top display
|
||||
self.assertIn('<a class="{} inactive progress-0"'.format(self.ICON_CLASSES[user_tag]), content)
|
||||
@@ -118,7 +137,7 @@ class TestVertSplitTestVert(SplitTestBase):
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
COURSE_NUMBER='vert-split-vert'
|
||||
COURSE_NUMBER = 'vert-split-vert'
|
||||
|
||||
ICON_CLASSES = [
|
||||
'seq_problem',
|
||||
@@ -141,6 +160,8 @@ class TestVertSplitTestVert(SplitTestBase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# We define problem compenents that we need but don't explicitly call elsewhere.
|
||||
# pylint: disable=unused-variable
|
||||
super(TestVertSplitTestVert, self).setUp()
|
||||
|
||||
# vert <- split_test
|
||||
@@ -151,6 +172,7 @@ class TestVertSplitTestVert(SplitTestBase):
|
||||
category="vertical",
|
||||
display_name="Split test vertical",
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
c0_url = self.course.location._replace(category="vertical", name="split_test_cond0")
|
||||
c1_url = self.course.location._replace(category="vertical", name="split_test_cond1")
|
||||
|
||||
@@ -210,10 +232,13 @@ class TestSplitTestVert(SplitTestBase):
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# We define problem compenents that we need but don't explicitly call elsewhere.
|
||||
# pylint: disable=unused-variable
|
||||
super(TestSplitTestVert, self).setUp()
|
||||
|
||||
# split_test cond 0 = vert <- {video, problem}
|
||||
# split_test cond 1 = vert <- {video, html}
|
||||
# pylint: disable=protected-access
|
||||
c0_url = self.course.location._replace(category="vertical", name="split_test_cond0")
|
||||
c1_url = self.course.location._replace(category="vertical", name="split_test_cond1")
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class TestVideo(BaseTestXmodule):
|
||||
data = [
|
||||
{'speed': 2.0},
|
||||
{'saved_video_position': "00:00:10"},
|
||||
{'transcript_language': json.dumps('uk')},
|
||||
{'transcript_language': 'uk'},
|
||||
]
|
||||
for sample in data:
|
||||
response = self.clients[self.users[0].username].post(
|
||||
@@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule):
|
||||
self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10))
|
||||
|
||||
self.assertEqual(self.item_descriptor.transcript_language, 'en')
|
||||
self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': json.dumps("uk")})
|
||||
self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': "uk"})
|
||||
self.assertEqual(self.item_descriptor.transcript_language, 'uk')
|
||||
|
||||
def tearDown(self):
|
||||
@@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo):
|
||||
response = self.item.transcript(request=request, dispatch='download')
|
||||
self.assertEqual(response.status, '404 Not Found')
|
||||
|
||||
@patch('xmodule.video_module.VideoModule.get_transcript', return_value='Subs!')
|
||||
def test_download_exist(self, __):
|
||||
@patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'srt', 'application/x-subrip'))
|
||||
def test_download_srt_exist(self, __):
|
||||
request = Request.blank('/download?language=en')
|
||||
response = self.item.transcript(request=request, dispatch='download')
|
||||
self.assertEqual(response.body, 'Subs!')
|
||||
self.assertEqual(response.headers['Content-Type'], 'application/x-subrip')
|
||||
|
||||
@patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'txt', 'text/plain'))
|
||||
def test_download_txt_exist(self, __):
|
||||
self.item.transcript_format = 'txt'
|
||||
request = Request.blank('/download?language=en')
|
||||
response = self.item.transcript(request=request, dispatch='download')
|
||||
self.assertEqual(response.body, 'Subs!')
|
||||
self.assertEqual(response.headers['Content-Type'], 'text/plain')
|
||||
|
||||
def test_download_en_no_sub(self):
|
||||
request = Request.blank('/download?language=en')
|
||||
@@ -189,17 +198,22 @@ class TestVideoTranscriptTranslation(TestVideo):
|
||||
# Tests for `translation` dispatch:
|
||||
|
||||
def test_translation_fails(self):
|
||||
# No videoId
|
||||
request = Request.blank('/translation?language=ru')
|
||||
# No language
|
||||
request = Request.blank('/translation')
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertEqual(response.status, '400 Bad Request')
|
||||
|
||||
# No videoId - HTML5 video with language that is not in available languages
|
||||
request = Request.blank('/translation?language=ru')
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertEqual(response.status, '404 Not Found')
|
||||
|
||||
# Language is not in available languages
|
||||
request = Request.blank('/translation?language=ru&videoId=12345')
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertEqual(response.status, '404 Not Found')
|
||||
|
||||
def test_translaton_en_success(self):
|
||||
def test_translaton_en_youtube_success(self):
|
||||
subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
|
||||
good_sjson = _create_file(json.dumps(subs))
|
||||
_upload_sjson_file(good_sjson, self.item_descriptor.location)
|
||||
@@ -210,25 +224,7 @@ class TestVideoTranscriptTranslation(TestVideo):
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertDictEqual(json.loads(response.body), subs)
|
||||
|
||||
def test_translaton_non_en_non_youtube_success(self):
|
||||
subs = {
|
||||
u'end': [100],
|
||||
u'start': [12],
|
||||
u'text': [
|
||||
u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.'
|
||||
]
|
||||
}
|
||||
self.non_en_file.seek(0)
|
||||
_upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1])
|
||||
subs_id = _get_subs_id(self.non_en_file.name)
|
||||
|
||||
# manually clean youtube_id_1_0, as it has default value
|
||||
self.item.youtube_id_1_0 = ""
|
||||
request = Request.blank('/translation?language=uk&videoId={}'.format(subs_id))
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertDictEqual(json.loads(response.body), subs)
|
||||
|
||||
def test_translation_non_en_youtube(self):
|
||||
def test_translation_non_en_youtube_success(self):
|
||||
subs = {
|
||||
u'end': [100],
|
||||
u'start': [12],
|
||||
@@ -270,6 +266,34 @@ class TestVideoTranscriptTranslation(TestVideo):
|
||||
}
|
||||
self.assertDictEqual(json.loads(response.body), calculated_1_5)
|
||||
|
||||
def test_translaton_en_html5_success(self):
|
||||
subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
|
||||
good_sjson = _create_file(json.dumps(subs))
|
||||
_upload_sjson_file(good_sjson, self.item_descriptor.location)
|
||||
subs_id = _get_subs_id(good_sjson.name)
|
||||
|
||||
self.item.sub = subs_id
|
||||
request = Request.blank('/translation?language=en')
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertDictEqual(json.loads(response.body), subs)
|
||||
|
||||
def test_translaton_non_en_html5_success(self):
|
||||
subs = {
|
||||
u'end': [100],
|
||||
u'start': [12],
|
||||
u'text': [
|
||||
u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.'
|
||||
]
|
||||
}
|
||||
self.non_en_file.seek(0)
|
||||
_upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1])
|
||||
|
||||
# manually clean youtube_id_1_0, as it has default value
|
||||
self.item.youtube_id_1_0 = ""
|
||||
request = Request.blank('/translation?language=uk')
|
||||
response = self.item.transcript(request=request, dispatch='translation')
|
||||
self.assertDictEqual(json.loads(response.body), subs)
|
||||
|
||||
|
||||
class TestVideoTranscriptsDownload(TestVideo):
|
||||
"""
|
||||
@@ -294,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo):
|
||||
self.item_descriptor.render('student_view')
|
||||
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
def test_good_transcript(self):
|
||||
def test_good_srt_transcript(self):
|
||||
good_sjson = _create_file(content=textwrap.dedent("""\
|
||||
{
|
||||
"start": [
|
||||
@@ -314,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo):
|
||||
|
||||
_upload_sjson_file(good_sjson, self.item.location)
|
||||
self.item.sub = _get_subs_id(good_sjson.name)
|
||||
text = self.item.get_transcript()
|
||||
text, format, download = self.item.get_transcript()
|
||||
expected_text = textwrap.dedent("""\
|
||||
0
|
||||
00:00:00,270 --> 00:00:02,720
|
||||
@@ -328,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo):
|
||||
|
||||
self.assertEqual(text, expected_text)
|
||||
|
||||
def test_good_txt_transcript(self):
|
||||
good_sjson = _create_file(content=textwrap.dedent("""\
|
||||
{
|
||||
"start": [
|
||||
270,
|
||||
2720
|
||||
],
|
||||
"end": [
|
||||
2720,
|
||||
5430
|
||||
],
|
||||
"text": [
|
||||
"Hi, welcome to Edx.",
|
||||
"Let's start with what is on your screen right now."
|
||||
]
|
||||
}
|
||||
"""))
|
||||
|
||||
_upload_sjson_file(good_sjson, self.item.location)
|
||||
self.item.sub = _get_subs_id(good_sjson.name)
|
||||
text, format, mime_type = self.item.get_transcript(format="txt")
|
||||
expected_text = textwrap.dedent("""\
|
||||
Hi, welcome to Edx.
|
||||
Let's start with what is on your screen right now.""")
|
||||
|
||||
self.assertEqual(text, expected_text)
|
||||
|
||||
def test_not_found_error(self):
|
||||
with self.assertRaises(NotFoundError):
|
||||
self.item.get_transcript()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Video xmodule tests in mongo."""
|
||||
from mock import patch, PropertyMock
|
||||
import json
|
||||
|
||||
from . import BaseTestXmodule
|
||||
from .test_video_xml import SOURCE_XML
|
||||
@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo):
|
||||
'youtube_streams': create_youtube_string(self.item_descriptor),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_format': 'srt',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
'transcript_language': 'en',
|
||||
'transcript_languages': '{"en": "English", "uk": "Ukrainian"}',
|
||||
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo):
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_format': 'srt',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
'transcript_language': 'en',
|
||||
'transcript_languages': '{"en": "English"}',
|
||||
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
}
|
||||
|
||||
for data in cases:
|
||||
@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
|
||||
expected_context.update({
|
||||
'transcript_download_format': None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt',
|
||||
'transcript_languages': '{"en": "English"}',
|
||||
'transcript_language': 'en',
|
||||
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
|
||||
@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
|
||||
'yt_test_timeout': 1500,
|
||||
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/',
|
||||
'transcript_download_format': 'srt',
|
||||
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
|
||||
'transcript_language': 'en',
|
||||
'transcript_languages': '{"en": "English"}',
|
||||
}
|
||||
|
||||
@@ -39,7 +39,15 @@ class GitImportError(Exception):
|
||||
CANNOT_PULL = _('git clone or pull failed!')
|
||||
XML_IMPORT_FAILED = _('Unable to run import command.')
|
||||
UNSUPPORTED_STORE = _('The underlying module store does not support import.')
|
||||
|
||||
# Translators: This is an error message when they ask for a
|
||||
# particular version of a git repository and that version isn't
|
||||
# available from the remote source they specified
|
||||
REMOTE_BRANCH_MISSING = _('The specified remote branch is not available.')
|
||||
# Translators: Error message shown when they have asked for a git
|
||||
# repository branch, a specific version within a repository, that
|
||||
# doesn't exist, or there is a problem changing to it.
|
||||
CANNOT_BRANCH = _('Unable to switch to specified branch. Please check '
|
||||
'your branch name.')
|
||||
|
||||
def cmd_log(cmd, cwd):
|
||||
"""
|
||||
@@ -54,8 +62,65 @@ def cmd_log(cmd, cwd):
|
||||
return output
|
||||
|
||||
|
||||
def add_repo(repo, rdir_in):
|
||||
"""This will add a git repo into the mongo modulestore"""
|
||||
def switch_branch(branch, rdir):
|
||||
"""
|
||||
This will determine how to change the branch of the repo, and then
|
||||
use the appropriate git commands to do so.
|
||||
|
||||
Raises an appropriate GitImportError exception if there is any issues with changing
|
||||
branches.
|
||||
"""
|
||||
# Get the latest remote
|
||||
try:
|
||||
cmd_log(['git', 'fetch', ], rdir)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Unable to fetch remote: %r', ex.output)
|
||||
raise GitImportError(GitImportError.CANNOT_BRANCH)
|
||||
|
||||
# Check if the branch is available from the remote.
|
||||
cmd = ['git', 'ls-remote', 'origin', '-h', 'refs/heads/{0}'.format(branch), ]
|
||||
try:
|
||||
output = cmd_log(cmd, rdir)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Getting a list of remote branches failed: %r', ex.output)
|
||||
raise GitImportError(GitImportError.CANNOT_BRANCH)
|
||||
if not branch in output:
|
||||
raise GitImportError(GitImportError.REMOTE_BRANCH_MISSING)
|
||||
# Check it the remote branch has already been made locally
|
||||
cmd = ['git', 'branch', '-a', ]
|
||||
try:
|
||||
output = cmd_log(cmd, rdir)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Getting a list of local branches failed: %r', ex.output)
|
||||
raise GitImportError(GitImportError.CANNOT_BRANCH)
|
||||
branches = []
|
||||
for line in output.split('\n'):
|
||||
branches.append(line.replace('*', '').strip())
|
||||
|
||||
if branch not in branches:
|
||||
# Checkout with -b since it is remote only
|
||||
cmd = ['git', 'checkout', '--force', '--track',
|
||||
'-b', branch, 'origin/{0}'.format(branch), ]
|
||||
try:
|
||||
cmd_log(cmd, rdir)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Unable to checkout remote branch: %r', ex.output)
|
||||
raise GitImportError(GitImportError.CANNOT_BRANCH)
|
||||
# Go ahead and reset hard to the newest version of the branch now that we know
|
||||
# it is local.
|
||||
try:
|
||||
cmd_log(['git', 'reset', '--hard', 'origin/{0}'.format(branch), ], rdir)
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Unable to reset to branch: %r', ex.output)
|
||||
raise GitImportError(GitImportError.CANNOT_BRANCH)
|
||||
|
||||
|
||||
def add_repo(repo, rdir_in, branch=None):
|
||||
"""
|
||||
This will add a git repo into the mongo modulestore.
|
||||
If branch is left as None, it will fetch the most recent
|
||||
version of the current branch.
|
||||
"""
|
||||
# pylint: disable=R0915
|
||||
|
||||
# Set defaults even if it isn't defined in settings
|
||||
@@ -102,6 +167,9 @@ def add_repo(repo, rdir_in):
|
||||
log.exception('Error running git pull: %r', ex.output)
|
||||
raise GitImportError(GitImportError.CANNOT_PULL)
|
||||
|
||||
if branch:
|
||||
switch_branch(branch, rdirp)
|
||||
|
||||
# get commit id
|
||||
cmd = ['git', 'log', '-1', '--format=%H', ]
|
||||
try:
|
||||
|
||||
@@ -25,8 +25,14 @@ class Command(BaseCommand):
|
||||
Pull a git repo and import into the mongo based content database.
|
||||
"""
|
||||
|
||||
help = _('Import the specified git repository into the '
|
||||
'modulestore and directory')
|
||||
# Translators: A git repository is a place to store a grouping of
|
||||
# versioned files. A branch is a sub grouping of a repository that
|
||||
# has a specific version of the repository. A modulestore is the database used
|
||||
# to store the courses for use on the Web site.
|
||||
help = ('Usage: '
|
||||
'git_add_course repository_url [directory to check out into] [repository_branch] '
|
||||
'\n{0}'.format(_('Import the specified git repository and optional branch into the '
|
||||
'modulestore and optionally specified directory.')))
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Check inputs and run the command"""
|
||||
@@ -38,16 +44,19 @@ class Command(BaseCommand):
|
||||
raise CommandError('This script requires at least one argument, '
|
||||
'the git URL')
|
||||
|
||||
if len(args) > 2:
|
||||
raise CommandError('This script requires no more than two '
|
||||
'arguments')
|
||||
if len(args) > 3:
|
||||
raise CommandError('Expected no more than three '
|
||||
'arguments; recieved {0}'.format(len(args)))
|
||||
|
||||
rdir_arg = None
|
||||
branch = None
|
||||
|
||||
if len(args) > 1:
|
||||
rdir_arg = args[1]
|
||||
if len(args) > 2:
|
||||
branch = args[2]
|
||||
|
||||
try:
|
||||
dashboard.git_import.add_repo(args[0], rdir_arg)
|
||||
dashboard.git_import.add_repo(args[0], rdir_arg, branch)
|
||||
except GitImportError as ex:
|
||||
raise CommandError(str(ex))
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
Provide tests for git_add_course management command.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import StringIO
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
@@ -14,6 +15,9 @@ from django.core.management.base import CommandError
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
import dashboard.git_import as git_import
|
||||
from dashboard.git_import import GitImportError
|
||||
@@ -39,6 +43,10 @@ class TestGitAddCourse(ModuleStoreTestCase):
|
||||
"""
|
||||
|
||||
TEST_REPO = 'https://github.com/mitocw/edx4edx_lite.git'
|
||||
TEST_COURSE = 'MITx/edx4edx/edx4edx'
|
||||
TEST_BRANCH = 'testing_do_not_delete'
|
||||
TEST_BRANCH_COURSE = 'MITx/edx4edx_branch/edx4edx'
|
||||
GIT_REPO_DIR = getattr(settings, 'GIT_REPO_DIR')
|
||||
|
||||
def assertCommandFailureRegexp(self, regex, *args):
|
||||
"""
|
||||
@@ -56,42 +64,45 @@ class TestGitAddCourse(ModuleStoreTestCase):
|
||||
self.assertCommandFailureRegexp(
|
||||
'This script requires at least one argument, the git URL')
|
||||
self.assertCommandFailureRegexp(
|
||||
'This script requires no more than two arguments',
|
||||
'blah', 'blah', 'blah')
|
||||
'Expected no more than three arguments; recieved 4',
|
||||
'blah', 'blah', 'blah', 'blah')
|
||||
self.assertCommandFailureRegexp(
|
||||
'Repo was not added, check log output for details',
|
||||
'blah')
|
||||
# Test successful import from command
|
||||
try:
|
||||
os.mkdir(getattr(settings, 'GIT_REPO_DIR'))
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.isdir(self.GIT_REPO_DIR):
|
||||
os.mkdir(self.GIT_REPO_DIR)
|
||||
self.addCleanup(shutil.rmtree, self.GIT_REPO_DIR)
|
||||
|
||||
# Make a course dir that will be replaced with a symlink
|
||||
# while we are at it.
|
||||
if not os.path.isdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx'):
|
||||
os.mkdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx')
|
||||
if not os.path.isdir(self.GIT_REPO_DIR / 'edx4edx'):
|
||||
os.mkdir(self.GIT_REPO_DIR / 'edx4edx')
|
||||
|
||||
call_command('git_add_course', self.TEST_REPO,
|
||||
getattr(settings, 'GIT_REPO_DIR') / 'edx4edx_lite')
|
||||
if os.path.isdir(getattr(settings, 'GIT_REPO_DIR')):
|
||||
shutil.rmtree(getattr(settings, 'GIT_REPO_DIR'))
|
||||
self.GIT_REPO_DIR / 'edx4edx_lite')
|
||||
|
||||
# Test with all three args (branch)
|
||||
call_command('git_add_course', self.TEST_REPO,
|
||||
self.GIT_REPO_DIR / 'edx4edx_lite',
|
||||
self.TEST_BRANCH)
|
||||
|
||||
|
||||
def test_add_repo(self):
|
||||
"""
|
||||
Various exit path tests for test_add_repo
|
||||
"""
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.NO_DIR):
|
||||
git_import.add_repo(self.TEST_REPO, None)
|
||||
git_import.add_repo(self.TEST_REPO, None, None)
|
||||
|
||||
os.mkdir(getattr(settings, 'GIT_REPO_DIR'))
|
||||
self.addCleanup(shutil.rmtree, getattr(settings, 'GIT_REPO_DIR'))
|
||||
os.mkdir(self.GIT_REPO_DIR)
|
||||
self.addCleanup(shutil.rmtree, self.GIT_REPO_DIR)
|
||||
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.URL_BAD):
|
||||
git_import.add_repo('foo', None)
|
||||
git_import.add_repo('foo', None, None)
|
||||
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.CANNOT_PULL):
|
||||
git_import.add_repo('file:///foobar.git', None)
|
||||
git_import.add_repo('file:///foobar.git', None, None)
|
||||
|
||||
# Test git repo that exists, but is "broken"
|
||||
bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git'))
|
||||
@@ -101,22 +112,107 @@ class TestGitAddCourse(ModuleStoreTestCase):
|
||||
cwd=bare_repo)
|
||||
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.BAD_REPO):
|
||||
git_import.add_repo('file://{0}'.format(bare_repo), None)
|
||||
git_import.add_repo('file://{0}'.format(bare_repo), None, None)
|
||||
|
||||
def test_detached_repo(self):
|
||||
"""
|
||||
Test repo that is in detached head state.
|
||||
"""
|
||||
repo_dir = getattr(settings, 'GIT_REPO_DIR')
|
||||
repo_dir = self.GIT_REPO_DIR
|
||||
# Test successful import from command
|
||||
try:
|
||||
os.mkdir(repo_dir)
|
||||
except OSError:
|
||||
pass
|
||||
self.addCleanup(shutil.rmtree, repo_dir)
|
||||
git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite')
|
||||
git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite', None)
|
||||
subprocess.check_output(['git', 'checkout', 'HEAD~2', ],
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=repo_dir / 'edx4edx_lite')
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.CANNOT_PULL):
|
||||
git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite')
|
||||
git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite', None)
|
||||
|
||||
def test_branching(self):
|
||||
"""
|
||||
Exercise branching code of import
|
||||
"""
|
||||
repo_dir = self.GIT_REPO_DIR
|
||||
# Test successful import from command
|
||||
if not os.path.isdir(repo_dir):
|
||||
os.mkdir(repo_dir)
|
||||
self.addCleanup(shutil.rmtree, repo_dir)
|
||||
|
||||
# Checkout non existent branch
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.REMOTE_BRANCH_MISSING):
|
||||
git_import.add_repo(self.TEST_REPO, repo_dir / 'edx4edx_lite', 'asdfasdfasdf')
|
||||
|
||||
# Checkout new branch
|
||||
git_import.add_repo(self.TEST_REPO,
|
||||
repo_dir / 'edx4edx_lite',
|
||||
self.TEST_BRANCH)
|
||||
def_ms = modulestore()
|
||||
# Validate that it is different than master
|
||||
self.assertIsNotNone(def_ms.get_course(self.TEST_BRANCH_COURSE))
|
||||
|
||||
# Attempt to check out the same branch again to validate branch choosing
|
||||
# works
|
||||
git_import.add_repo(self.TEST_REPO,
|
||||
repo_dir / 'edx4edx_lite',
|
||||
self.TEST_BRANCH)
|
||||
|
||||
# Delete to test branching back to master
|
||||
delete_course(def_ms, contentstore(),
|
||||
def_ms.get_course(self.TEST_BRANCH_COURSE).location,
|
||||
True)
|
||||
self.assertIsNone(def_ms.get_course(self.TEST_BRANCH_COURSE))
|
||||
git_import.add_repo(self.TEST_REPO,
|
||||
repo_dir / 'edx4edx_lite',
|
||||
'master')
|
||||
self.assertIsNone(def_ms.get_course(self.TEST_BRANCH_COURSE))
|
||||
self.assertIsNotNone(def_ms.get_course(self.TEST_COURSE))
|
||||
|
||||
def test_branch_exceptions(self):
|
||||
"""
|
||||
This wil create conditions to exercise bad paths in the switch_branch function.
|
||||
"""
|
||||
# create bare repo that we can mess with and attempt an import
|
||||
bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git'))
|
||||
os.mkdir(bare_repo)
|
||||
self.addCleanup(shutil.rmtree, bare_repo)
|
||||
subprocess.check_output(['git', '--bare', 'init', ], stderr=subprocess.STDOUT,
|
||||
cwd=bare_repo)
|
||||
|
||||
# Build repo dir
|
||||
repo_dir = self.GIT_REPO_DIR
|
||||
if not os.path.isdir(repo_dir):
|
||||
os.mkdir(repo_dir)
|
||||
self.addCleanup(shutil.rmtree, repo_dir)
|
||||
|
||||
rdir = '{0}/bare'.format(repo_dir)
|
||||
with self.assertRaisesRegexp(GitImportError, GitImportError.BAD_REPO):
|
||||
git_import.add_repo('file://{0}'.format(bare_repo), None, None)
|
||||
|
||||
# Get logger for checking strings in logs
|
||||
output = StringIO.StringIO()
|
||||
test_log_handler = logging.StreamHandler(output)
|
||||
test_log_handler.setLevel(logging.DEBUG)
|
||||
glog = git_import.log
|
||||
glog.addHandler(test_log_handler)
|
||||
|
||||
# Move remote so fetch fails
|
||||
shutil.move(bare_repo, '{0}/not_bare.git'.format(settings.TEST_ROOT))
|
||||
try:
|
||||
git_import.switch_branch('master', rdir)
|
||||
except GitImportError:
|
||||
self.assertIn('Unable to fetch remote', output.getvalue())
|
||||
shutil.move('{0}/not_bare.git'.format(settings.TEST_ROOT), bare_repo)
|
||||
output.truncate(0)
|
||||
|
||||
# Replace origin with a different remote
|
||||
subprocess.check_output(
|
||||
['git', 'remote', 'rename', 'origin', 'blah', ],
|
||||
stderr=subprocess.STDOUT, cwd=rdir
|
||||
)
|
||||
with self.assertRaises(GitImportError):
|
||||
git_import.switch_branch('master', rdir)
|
||||
self.assertIn('Getting a list of remote branches failed', output.getvalue())
|
||||
|
||||
@@ -272,7 +272,7 @@ class Users(SysadminDashboardView):
|
||||
'msg': self.msg,
|
||||
'djangopid': os.getpid(),
|
||||
'modeflag': {'users': 'active-section'},
|
||||
'mitx_version': getattr(settings, 'VERSION_STRING', ''),
|
||||
'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
|
||||
}
|
||||
return render_to_response(self.template_name, context)
|
||||
|
||||
@@ -316,7 +316,7 @@ class Users(SysadminDashboardView):
|
||||
'msg': self.msg,
|
||||
'djangopid': os.getpid(),
|
||||
'modeflag': {'users': 'active-section'},
|
||||
'mitx_version': getattr(settings, 'VERSION_STRING', ''),
|
||||
'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
|
||||
}
|
||||
return render_to_response(self.template_name, context)
|
||||
|
||||
@@ -348,7 +348,7 @@ class Courses(SysadminDashboardView):
|
||||
|
||||
return info
|
||||
|
||||
def get_course_from_git(self, gitloc, datatable):
|
||||
def get_course_from_git(self, gitloc, branch, datatable):
|
||||
"""This downloads and runs the checks for importing a course in git"""
|
||||
|
||||
if not (gitloc.endswith('.git') or gitloc.startswith('http:') or
|
||||
@@ -357,11 +357,11 @@ class Courses(SysadminDashboardView):
|
||||
"and be a valid url")
|
||||
|
||||
if self.is_using_mongo:
|
||||
return self.import_mongo_course(gitloc)
|
||||
return self.import_mongo_course(gitloc, branch)
|
||||
|
||||
return self.import_xml_course(gitloc, datatable)
|
||||
return self.import_xml_course(gitloc, branch, datatable)
|
||||
|
||||
def import_mongo_course(self, gitloc):
|
||||
def import_mongo_course(self, gitloc, branch):
|
||||
"""
|
||||
Imports course using management command and captures logging output
|
||||
at debug level for display in template
|
||||
@@ -390,7 +390,7 @@ class Courses(SysadminDashboardView):
|
||||
|
||||
error_msg = ''
|
||||
try:
|
||||
git_import.add_repo(gitloc, None)
|
||||
git_import.add_repo(gitloc, None, branch)
|
||||
except GitImportError as ex:
|
||||
error_msg = str(ex)
|
||||
ret = output.getvalue()
|
||||
@@ -411,7 +411,7 @@ class Courses(SysadminDashboardView):
|
||||
msg += "<pre>{0}</pre>".format(escape(ret))
|
||||
return msg
|
||||
|
||||
def import_xml_course(self, gitloc, datatable):
|
||||
def import_xml_course(self, gitloc, branch, datatable):
|
||||
"""Imports a git course into the XMLModuleStore"""
|
||||
|
||||
msg = u''
|
||||
@@ -436,13 +436,31 @@ class Courses(SysadminDashboardView):
|
||||
cmd_output = escape(
|
||||
subprocess.check_output(cmd, stderr=subprocess.STDOUT, cwd=cwd)
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
return _('Unable to clone or pull repository. Please check your url.')
|
||||
except subprocess.CalledProcessError as ex:
|
||||
log.exception('Git pull or clone output was: %r', ex.output)
|
||||
# Translators: unable to download the course content from
|
||||
# the source git repository. Clone occurs if this is brand
|
||||
# new, and pull is when it is being updated from the
|
||||
# source.
|
||||
return _('Unable to clone or pull repository. Please check '
|
||||
'your url. Output was: {0!r}'.format(ex.output))
|
||||
|
||||
msg += u'<pre>{0}</pre>'.format(cmd_output)
|
||||
if not os.path.exists(gdir):
|
||||
msg += _('Failed to clone repository to {0}').format(gdir)
|
||||
return msg
|
||||
# Change branch if specified
|
||||
if branch:
|
||||
try:
|
||||
git_import.switch_branch(branch, gdir)
|
||||
except GitImportError as ex:
|
||||
return str(ex)
|
||||
# Translators: This is a git repository branch, which is a
|
||||
# specific version of a courses content
|
||||
msg += u'<p>{0}</p>'.format(
|
||||
_('Successfully switched to branch: '
|
||||
'{branch_name}'.format(branch_name=branch)))
|
||||
|
||||
self.def_ms.try_load_course(os.path.abspath(gdir))
|
||||
errlog = self.def_ms.errored_courses.get(cdir, '')
|
||||
if errlog:
|
||||
@@ -494,7 +512,7 @@ class Courses(SysadminDashboardView):
|
||||
'msg': self.msg,
|
||||
'djangopid': os.getpid(),
|
||||
'modeflag': {'courses': 'active-section'},
|
||||
'mitx_version': getattr(settings, 'VERSION_STRING', ''),
|
||||
'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
|
||||
}
|
||||
return render_to_response(self.template_name, context)
|
||||
|
||||
@@ -511,8 +529,9 @@ class Courses(SysadminDashboardView):
|
||||
courses = self.get_courses()
|
||||
if action == 'add_course':
|
||||
gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '')
|
||||
branch = request.POST.get('repo_branch', '').strip().replace(' ', '').replace(';', '')
|
||||
datatable = self.make_datatable()
|
||||
self.msg += self.get_course_from_git(gitloc, datatable)
|
||||
self.msg += self.get_course_from_git(gitloc, branch, datatable)
|
||||
|
||||
elif action == 'del_course':
|
||||
course_id = request.POST.get('course_id', '').strip()
|
||||
@@ -563,7 +582,7 @@ class Courses(SysadminDashboardView):
|
||||
'msg': self.msg,
|
||||
'djangopid': os.getpid(),
|
||||
'modeflag': {'courses': 'active-section'},
|
||||
'mitx_version': getattr(settings, 'VERSION_STRING', ''),
|
||||
'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
|
||||
}
|
||||
return render_to_response(self.template_name, context)
|
||||
|
||||
@@ -602,7 +621,7 @@ class Staffing(SysadminDashboardView):
|
||||
'msg': self.msg,
|
||||
'djangopid': os.getpid(),
|
||||
'modeflag': {'staffing': 'active-section'},
|
||||
'mitx_version': getattr(settings, 'VERSION_STRING', ''),
|
||||
'edx_platform_version': getattr(settings, 'EDX_PLATFORM_VERSION_STRING', ''),
|
||||
}
|
||||
return render_to_response(self.template_name, context)
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ class SysadminBaseTestCase(ModuleStoreTestCase):
|
||||
Base class with common methods used in XML and Mongo tests
|
||||
"""
|
||||
|
||||
TEST_REPO = 'https://github.com/mitocw/edx4edx_lite.git'
|
||||
TEST_BRANCH = 'testing_do_not_delete'
|
||||
TEST_BRANCH_COURSE = 'MITx/edx4edx_branch/edx4edx'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup test case by adding primary user."""
|
||||
super(SysadminBaseTestCase, self).setUp()
|
||||
@@ -58,11 +62,12 @@ class SysadminBaseTestCase(ModuleStoreTestCase):
|
||||
GlobalStaff().add_users(self.user)
|
||||
self.client.login(username=self.user.username, password='foo')
|
||||
|
||||
def _add_edx4edx(self):
|
||||
def _add_edx4edx(self, branch=None):
|
||||
"""Adds the edx4edx sample course"""
|
||||
return self.client.post(reverse('sysadmin_courses'), {
|
||||
'repo_location': 'https://github.com/mitocw/edx4edx_lite.git',
|
||||
'action': 'add_course', })
|
||||
post_dict = {'repo_location': self.TEST_REPO, 'action': 'add_course', }
|
||||
if branch:
|
||||
post_dict['repo_branch'] = branch
|
||||
return self.client.post(reverse('sysadmin_courses'), post_dict)
|
||||
|
||||
def _rm_edx4edx(self):
|
||||
"""Deletes the sample course from the XML store"""
|
||||
@@ -301,11 +306,24 @@ class TestSysadmin(SysadminBaseTestCase):
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
# Delete a course
|
||||
response = self._rm_edx4edx()
|
||||
self._rm_edx4edx()
|
||||
course = def_ms.courses.get('{0}/edx4edx_lite'.format(
|
||||
os.path.abspath(settings.DATA_DIR)), None)
|
||||
self.assertIsNone(course)
|
||||
|
||||
# Load a bad git branch
|
||||
response = self._add_edx4edx('asdfasdfasdf')
|
||||
self.assertIn(GitImportError.REMOTE_BRANCH_MISSING,
|
||||
response.content.decode('utf-8'))
|
||||
|
||||
# Load a course from a git branch
|
||||
self._add_edx4edx(self.TEST_BRANCH)
|
||||
course = def_ms.courses.get('{0}/edx4edx_lite'.format(
|
||||
os.path.abspath(settings.DATA_DIR)), None)
|
||||
self.assertIsNotNone(course)
|
||||
self.assertIn(self.TEST_BRANCH_COURSE, course.location.course_id)
|
||||
self._rm_edx4edx()
|
||||
|
||||
# Try and delete a non-existent course
|
||||
response = self.client.post(reverse('sysadmin_courses'),
|
||||
{'course_id': 'foobar/foo/blah',
|
||||
|
||||
@@ -258,6 +258,17 @@ SSL_AUTH_EMAIL_DOMAIN = ENV_TOKENS.get("SSL_AUTH_EMAIL_DOMAIN", "MIT.EDU")
|
||||
SSL_AUTH_DN_FORMAT_STRING = ENV_TOKENS.get("SSL_AUTH_DN_FORMAT_STRING",
|
||||
"/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}")
|
||||
|
||||
# Django CAS external authentication settings
|
||||
CAS_EXTRA_LOGIN_PARAMS = ENV_TOKENS.get("CAS_EXTRA_LOGIN_PARAMS", None)
|
||||
if FEATURES.get('AUTH_USE_CAS'):
|
||||
CAS_SERVER_URL = ENV_TOKENS.get("CAS_SERVER_URL", None)
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'django_cas.backends.CASBackend',
|
||||
)
|
||||
INSTALLED_APPS += ('django_cas',)
|
||||
MIDDLEWARE_CLASSES += ('django_cas.middleware.CASMiddleware',)
|
||||
|
||||
HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS',{})
|
||||
|
||||
############################## SECURE AUTH ITEMS ###############
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<li class="field text is-not-editable" id="field-course-started">
|
||||
<label for="start-date">${_("Has the course started?")}</label>
|
||||
|
||||
<b>${_("Yes") if section_data['grade_cutoffs'] else _("No")}</b>
|
||||
<b>${_("Yes") if section_data['has_started'] else _("No")}</b>
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@
|
||||
%endif
|
||||
|
||||
<p><b>${_("Reports Available for Download")}</b></p>
|
||||
<p>${_("Unique, new file links for the CSV reports are generated on each visit to this page. These unique links expire within 5 minutes, due to the sensitive nature of student grade information. Please note that the report filename contains a timestamp that represents when your file was generated; this timestamp is UTC, not your local timezone.")}</p><br>
|
||||
<p>${_("A new CSV report is generated each time you click the <b>Generate Grade Report</b> button above. A link to each report remains available on this page, identified by the UTC date and time of generation. Reports are not deleted, so you will always be able to access previously generated reports from this page.")}</p>
|
||||
## Translators: a table of URL links to report files appears after this sentence.
|
||||
<p>${_("<b>Note</b>: To keep student data secure, you cannot save or email these links for direct access. Copies of links expire within 5 minutes.")}</p><br>
|
||||
|
||||
<div class="grade-downloads-table" id="grade-downloads-table" data-endpoint="${ section_data['list_grade_downloads_url'] }" ></div>
|
||||
</div>
|
||||
%endif
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<p>
|
||||
${_("Staff cannot modify staff or beta tester lists. To modify these lists, "
|
||||
"contact your instructor and ask them to add you as an instructor for staff "
|
||||
"and beta lists, or a forum admin for forum management.")}
|
||||
"and beta lists, or a discussion admin for discussion management.")}
|
||||
</p>
|
||||
%endif
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
data-display-name="${_("Instructors")}"
|
||||
data-info-text="
|
||||
${_("Instructors are the core administration of your course. Instructors can "
|
||||
"add and remove course staff, as well as administer forum access.")}"
|
||||
"add and remove course staff, as well as administer discussion access.")}"
|
||||
data-list-endpoint="${ section_data['list_course_role_members_url'] }"
|
||||
data-modify-endpoint="${ section_data['modify_access_url'] }"
|
||||
data-add-button-label="${_("Add Instructor")}"
|
||||
@@ -114,23 +114,23 @@
|
||||
|
||||
<div class="auth-list-container"
|
||||
data-rolename="Administrator"
|
||||
data-display-name="${_("Forum Admins")}"
|
||||
data-display-name="${_("Discussion Admins")}"
|
||||
data-info-text="
|
||||
${_("Forum admins can edit or delete any post, clear misuse flags, close "
|
||||
${_("Discussion admins can edit or delete any post, clear misuse flags, close "
|
||||
"and re-open threads, endorse responses, and see posts from all cohorts. "
|
||||
"They CAN add/delete other moderators and their posts are marked as 'staff'.")}"
|
||||
data-list-endpoint="${ section_data['list_forum_members_url'] }"
|
||||
data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }"
|
||||
data-add-button-label="Add ${_("Forum Admin")}"
|
||||
data-add-button-label="Add ${_("Discussion Admin")}"
|
||||
></div>
|
||||
%endif
|
||||
|
||||
%if section_data['access']['instructor'] or section_data['access']['forum_admin']:
|
||||
<div class="auth-list-container"
|
||||
data-rolename="Moderator"
|
||||
data-display-name="${_("Forum Moderators")}"
|
||||
data-display-name="${_("Discussion Moderators")}"
|
||||
data-info-text="
|
||||
${_("Forum moderators can edit or delete any post, clear misuse flags, close "
|
||||
${_("Discussion moderators can edit or delete any post, clear misuse flags, close "
|
||||
"and re-open threads, endorse responses, and see posts from all cohorts. "
|
||||
"They CANNOT add/delete other moderators and their posts are marked as 'staff'.")}"
|
||||
data-list-endpoint="${ section_data['list_forum_members_url'] }"
|
||||
@@ -140,10 +140,10 @@
|
||||
|
||||
<div class="auth-list-container"
|
||||
data-rolename="Community TA"
|
||||
data-display-name="${_("Forum Community TAs")}"
|
||||
data-display-name="${_("Discussion Community TAs")}"
|
||||
data-info-text="
|
||||
${_("Community TA's are members of the community whom you deem particularly "
|
||||
"helpful on the forums. They can edit or delete any post, clear misuse flags, "
|
||||
"helpful on the discussion boards. They can edit or delete any post, clear misuse flags, "
|
||||
"close and re-open threads, endorse responses, and see posts from all cohorts. "
|
||||
"Their posts are marked 'Community TA'.")}"
|
||||
data-list-endpoint="${ section_data['list_forum_members_url'] }"
|
||||
|
||||
@@ -126,10 +126,20 @@ textarea {
|
||||
<ul class="list-input">
|
||||
<li class="field text">
|
||||
<label for="repo_location">
|
||||
${_('Repo location')}:
|
||||
## Translators: Repo is short for git repository or source of
|
||||
## courseware
|
||||
${_('Repo Location')}:
|
||||
</label>
|
||||
<input type="text" name="repo_location" style="width:60%" />
|
||||
</li>
|
||||
<li class="field text">
|
||||
<label for="repo_location">
|
||||
## Translators: Repo is short for git repository or source of
|
||||
## courseware and branch is a specific version within that repository
|
||||
${_('Repo Branch (optional)')}:
|
||||
</label>
|
||||
<input type="text" name="repo_branch" style="width:60%" />
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-actions">
|
||||
<button type="submit" name="action" value="add_course">${_('Load new course from github')}</button>
|
||||
@@ -201,6 +211,7 @@ textarea {
|
||||
|
||||
</section>
|
||||
<div style="text-align:right; float: right"><span id="djangopid">${_('Django PID')}: ${djangopid}</span>
|
||||
| <span id="mitxver">${_('Platform Version')}: ${mitx_version}</span></div>
|
||||
## Translators: A version number appears after this string
|
||||
| <span id="edxver">${_('Platform Version')}: ${edx_platform_version}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -112,7 +112,29 @@
|
||||
% endif
|
||||
% if track:
|
||||
<li class="video-tracks">
|
||||
${('<a href="%s">' + _('Download timed transcript') + '</a>') % track}
|
||||
% if transcript_download_format:
|
||||
${('<a href="%s">' + _('Download transcript') + '</a>') % track
|
||||
}
|
||||
<div class="a11y-menu-container">
|
||||
<a class="a11y-menu-button" href="#" title="${'.' + transcript_download_format}">${'.' + transcript_download_format}</a>
|
||||
<ol class="a11y-menu-list">
|
||||
% for item in transcript_download_formats_list:
|
||||
% if item['value'] == transcript_download_format:
|
||||
<li class="a11y-menu-item active">
|
||||
% else:
|
||||
<li class="a11y-menu-item">
|
||||
% endif
|
||||
<a class="a11y-menu-item-link" href="#${item['value']}" title="${_('{file_format}'.format(file_format=item['display_name']))}" data-value="${item['value']}">
|
||||
${_('{file_format}'.format(file_format=item['display_name']))}
|
||||
</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ol>
|
||||
</div>
|
||||
% else:
|
||||
${('<a href="%s" class="external-track">' + _('Download transcript') + '</a>') % track
|
||||
}
|
||||
% endif
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
-e git+https://github.com/edx/event-tracking.git@f0211d702d#egg=event-tracking
|
||||
-e git+https://github.com/edx/bok-choy.git@62de7b576a08f36cde5b030c52bccb1a2f3f8df1#egg=bok_choy
|
||||
-e git+https://github.com/edx-solutions/django-splash.git@9965a53c269666a30bb4e2b3f6037c138aef2a55#egg=django-splash
|
||||
-e git+https://github.com/edx/acid-block.git@bf61f0fcd5916a9236bb5681c98374a48a13a74c#egg=acid-xblock
|
||||
-e git+https://github.com/edx/acid-block.git@459aff7b63db8f2c5decd1755706c1a64fb4ebb1#egg=acid-xblock
|
||||
|
||||
Reference in New Issue
Block a user