Commit f42d42d1 by Han Su Kim

Merge branch 'master' into release

Conflicts:
	cms/envs/common.py
	lms/envs/common.py
parents 68000559 9e88fb15
......@@ -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
......
$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);
});
});
});
});
......
......@@ -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.
......
......@@ -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'
......
......@@ -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,63 @@ 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
"""
__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(
)
)
)
)
).install()
class XBlockAcidChildTest(XBlockAcidParentBase):
"""
Tests of an AcidBlock with children
"""
......@@ -232,7 +289,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>"),
......
......@@ -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
"""
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&#39;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()
......
# -*- 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,
self.GIT_REPO_DIR / 'edx4edx_lite')
# Test with all three args (branch)
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',
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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment