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, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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. 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. 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 ...@@ -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'): 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) locator = BlockUsageLocator(package_id=package_id, branch=branch, version_guid=version_guid, block_id=block)
try: try:
old_location, course, xblock, __ = _get_item_in_course(request, locator) __, course, xblock, __ = _get_item_in_course(request, locator)
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
......
...@@ -44,6 +44,10 @@ def login_page(request): ...@@ -44,6 +44,10 @@ def login_page(request):
# to course now that the user is authenticated via # to course now that the user is authenticated via
# the decorator. # the decorator.
return redirect('/course') 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( return render_to_response(
'login.html', 'login.html',
{ {
......
...@@ -12,33 +12,42 @@ class HelpersTestCase(CourseTestCase): ...@@ -12,33 +12,42 @@ class HelpersTestCase(CourseTestCase):
Unit tests for helpers.py. Unit tests for helpers.py.
""" """
def test_xblock_studio_url(self): def test_xblock_studio_url(self):
course = self.course
# Verify course URL # 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') u'/course/MITx.999.Robot_Super_Course/branch/published/block/Robot_Super_Course')
# Verify chapter URL # Verify chapter URL
chapter = ItemFactory.create(parent_location=self.course.location, category='chapter', chapter = ItemFactory.create(parent_location=self.course.location, category='chapter',
display_name="Week 1") display_name="Week 1")
self.assertIsNone(xblock_studio_url(chapter)) self.assertIsNone(xblock_studio_url(chapter))
self.assertIsNone(xblock_studio_url(chapter, course))
# Verify lesson URL # Verify lesson URL
sequential = ItemFactory.create(parent_location=chapter.location, category='sequential', sequential = ItemFactory.create(parent_location=chapter.location, category='sequential',
display_name="Lesson 1") display_name="Lesson 1")
self.assertIsNone(xblock_studio_url(sequential)) self.assertIsNone(xblock_studio_url(sequential))
self.assertIsNone(xblock_studio_url(sequential, course))
# Verify vertical URL # Verify vertical URL
vertical = ItemFactory.create(parent_location=sequential.location, category='vertical', vertical = ItemFactory.create(parent_location=sequential.location, category='vertical',
display_name='Unit') display_name='Unit')
self.assertEqual(xblock_studio_url(vertical), self.assertEqual(xblock_studio_url(vertical),
u'/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit') 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 # Verify child vertical URL
child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical', child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical',
display_name='Child Vertical') display_name='Child Vertical')
self.assertEqual(xblock_studio_url(child_vertical), self.assertEqual(xblock_studio_url(child_vertical),
u'/container/MITx.999.Robot_Super_Course/branch/published/block/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 # Verify video URL
video = ItemFactory.create(parent_location=child_vertical.location, category="video", video = ItemFactory.create(parent_location=child_vertical.location, category="video",
display_name="My Video") display_name="My Video")
self.assertIsNone(xblock_studio_url(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') ...@@ -181,6 +181,16 @@ PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX')
if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS: if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS:
TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS") 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 ############################### ################ SECURE AUTH ITEMS ###############################
# Secret things: passwords, access keys, etc. # Secret things: passwords, access keys, etc.
......
...@@ -22,6 +22,15 @@ ...@@ -22,6 +22,15 @@
.video-controls .add-fullscreen { .video-controls .add-fullscreen {
display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors 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 { ...@@ -1399,7 +1399,7 @@ body.unit .xblock-type-container {
// UI: special case discussion, HTML xmodule styling // UI: special case discussion, HTML xmodule styling
body.unit .component { body.unit .component {
.xmodule_DiscussionModule, .xmodule_HtmlModule { .xmodule_DiscussionModule, .xmodule_HtmlModule, .xblock {
margin-top: ($baseline*1.5); margin-top: ($baseline*1.5);
} }
} }
...@@ -115,6 +115,12 @@ if settings.FEATURES.get('ENABLE_SERVICE_STATUS'): ...@@ -115,6 +115,12 @@ if settings.FEATURES.get('ENABLE_SERVICE_STATUS'):
url(r'^status/', include('service_status.urls')), 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)),) urlpatterns += patterns('', url(r'^admin/', include(admin.site.urls)),)
# enable automatic login # enable automatic login
......
...@@ -344,6 +344,9 @@ def signin_user(request): ...@@ -344,6 +344,9 @@ def signin_user(request):
# branding and allow that to process the login if it # branding and allow that to process the login if it
# is enabled and the header is in the request. # is enabled and the header is in the request.
return redirect(reverse('root')) 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(): if request.user.is_authenticated():
return redirect(reverse('dashboard')) return redirect(reverse('dashboard'))
......
...@@ -14,6 +14,7 @@ from user_api.models import UserCourseTag ...@@ -14,6 +14,7 @@ from user_api.models import UserCourseTag
# global tags (e.g. using the existing UserPreferences table)) # global tags (e.g. using the existing UserPreferences table))
COURSE_SCOPE = 'course' COURSE_SCOPE = 'course'
def get_course_tag(user, course_id, key): def get_course_tag(user, course_id, key):
""" """
Gets the value of the user's course tag for the specified key in the specified 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 { ...@@ -46,13 +46,15 @@ div.video {
.video-sources, .video-sources,
.video-tracks { .video-tracks {
display: inline-block; display: inline-block;
vertical-align: top;
margin: ($baseline*.75) ($baseline/2) 0 0; margin: ($baseline*.75) ($baseline/2) 0 0;
a { > a {
@include transition(all 0.25s ease-in-out 0s); @include transition(all 0.25s ease-in-out 0s);
@include font-size(14); @include font-size(14);
display: inline-block; line-height : 14px;
border-radius: 3px 3px 3px 3px; float: left;
border-radius: 3px;
background-color: $very-light-text; background-color: $very-light-text;
padding: ($baseline*.75); padding: ($baseline*.75);
color: $lighter-base-font-color; color: $lighter-base-font-color;
...@@ -62,7 +64,14 @@ div.video { ...@@ -62,7 +64,14 @@ div.video {
color: $very-light-text; 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 { ...@@ -256,6 +265,11 @@ div.video {
margin: 0 lh() 0 0; margin: 0 lh() 0 0;
padding: 0; padding: 0;
@media (max-width: 1120px) {
margin-right: lh(.5);
font-size: em(14);
}
li { li {
float: left; float: left;
margin-bottom: 0; margin-bottom: 0;
...@@ -292,11 +306,13 @@ div.video { ...@@ -292,11 +306,13 @@ div.video {
} }
div.vidtime { div.vidtime {
padding-left: lh(.75);
font-weight: bold; font-weight: bold;
line-height: 46px; //height of play pause buttons line-height: 46px; //height of play pause buttons
padding-left: lh(.75);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
padding-left: lh(.75);
@media (max-width: 1120px) {
padding-left: lh(.5);
}
} }
} }
} }
...@@ -389,8 +405,8 @@ div.video { ...@@ -389,8 +405,8 @@ div.video {
.menu{ .menu{
width: 131px; width: 131px;
@media (max-width: 1024px) { @media (max-width: 1120px) {
width: 101px; width: 80px;
} }
} }
...@@ -403,9 +419,9 @@ div.video { ...@@ -403,9 +419,9 @@ div.video {
min-width: 116px; min-width: 116px;
text-indent: 0; text-indent: 0;
@media (max-width: 1024px) { @media (max-width: 1120px) {
min-width: 0; min-width: 0;
width: 86px; width: 60px;
} }
h3 { h3 {
...@@ -418,7 +434,7 @@ div.video { ...@@ -418,7 +434,7 @@ div.video {
text-transform: uppercase; text-transform: uppercase;
color: #999; color: #999;
@media (max-width: 1024px) { @media (max-width: 1120px) {
display: none; display: none;
} }
} }
...@@ -429,7 +445,7 @@ div.video { ...@@ -429,7 +445,7 @@ div.video {
margin-bottom: 0; margin-bottom: 0;
padding: 0 lh(.5) 0 0; padding: 0 lh(.5) 0 0;
@media (max-width: 1024px) { @media (max-width: 1120px) {
padding: 0 lh(.5) 0 lh(.5); padding: 0 lh(.5) 0 lh(.5);
} }
...@@ -676,9 +692,10 @@ div.video { ...@@ -676,9 +692,10 @@ div.video {
vertical-align: middle; vertical-align: middle;
&.closed { &.closed {
ol.subtitles { div.tc-wrapper {
right: -(flex-grid(4)); article.video-wrapper {
width: auto; width: 100%;
}
} }
} }
...@@ -698,17 +715,16 @@ div.video { ...@@ -698,17 +715,16 @@ div.video {
div.tc-wrapper { div.tc-wrapper {
@include clearfix; @include clearfix;
display: table;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: static; position: static;
article.video-wrapper { article.video-wrapper {
width: 100%; height: 100%;
display: table-cell; width: 75%;
vertical-align: middle; vertical-align: middle;
float: none; margin-right: 0;
object, iframe, video{ object, iframe, video{
position: absolute; position: absolute;
...@@ -727,16 +743,12 @@ div.video { ...@@ -727,16 +743,12 @@ div.video {
} }
ol.subtitles { ol.subtitles {
@include box-sizing(border-box);
@include transition(none); @include transition(none);
background: rgba(#000, .8); background: #000;
bottom: 0;
height: 100%; height: 100%;
max-height: 460px; width: 25%;
max-width: flex-grid(3);
padding: lh(); padding: lh();
position: fixed;
right: 0;
top: 0;
visibility: visible; visibility: visible;
li { li {
......
...@@ -69,6 +69,23 @@ ...@@ -69,6 +69,23 @@
</div> </div>
<div class="focus_grabber last"></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> </div>
</div> </div>
......
...@@ -240,12 +240,19 @@ ...@@ -240,12 +240,19 @@
'setParams', 'setParams',
'setMode' 'setMode'
], ],
obj = {}; obj = {},
delta = {
add: jasmine.createSpy().andReturn(obj),
substract: jasmine.createSpy().andReturn(obj),
reset: jasmine.createSpy().andReturn(obj)
};
$.each(methods, function (index, method) { $.each(methods, function (index, method) {
obj[method] = jasmine.createSpy(method).andReturn(obj); obj[method] = jasmine.createSpy(method).andReturn(obj);
}); });
obj.delta = delta;
return obj; return obj;
}()); }());
......
...@@ -75,32 +75,8 @@ ...@@ -75,32 +75,8 @@
expect(state.el).toBe('#video_id'); expect(state.el).toBe('#video_id');
}); });
it('parse the videos if subtitles exist', function () { it('doesn\'t have `videos` dictionary', function () {
var sub = 'Z5KLxerq05Y'; expect(state.videos).toBeUndefined();
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('parse Html5 sources', function () { it('parse Html5 sources', function () {
......
...@@ -18,7 +18,7 @@ function (Resizer) { ...@@ -18,7 +18,7 @@ function (Resizer) {
'</div>', '</div>',
'</div>' '</div>'
].join(''), ].join(''),
config, container, element, originalConsoleLog; config, container, element;
beforeEach(function () { beforeEach(function () {
setFixtures(html); setFixtures(html);
...@@ -30,14 +30,9 @@ function (Resizer) { ...@@ -30,14 +30,9 @@ function (Resizer) {
element: element element: element
}; };
originalConsoleLog = window.console.log;
spyOn(console, 'log'); spyOn(console, 'log');
}); });
afterEach(function () {
window.console.log = originalConsoleLog;
});
it('When Initialize without required parameters, log message is shown', it('When Initialize without required parameters, log message is shown',
function () { function () {
new Resizer({ }); new Resizer({ });
...@@ -134,7 +129,7 @@ function (Resizer) { ...@@ -134,7 +129,7 @@ function (Resizer) {
expect(spiesList[0].calls.length).toEqual(1); expect(spiesList[0].calls.length).toEqual(1);
}); });
it('All callbacks are removed', function () { it('all callbacks are removed', function () {
$.each(spiesList, function (index, spy) { $.each(spiesList, function (index, spy) {
resizer.callbacks.add(spy); resizer.callbacks.add(spy);
}); });
...@@ -147,7 +142,7 @@ function (Resizer) { ...@@ -147,7 +142,7 @@ function (Resizer) {
}); });
}); });
it('Specific callback is removed', function () { it('specific callback is removed', function () {
$.each(spiesList, function (index, spy) { $.each(spiesList, function (index, spy) {
resizer.callbacks.add(spy); resizer.callbacks.add(spy);
}); });
...@@ -176,9 +171,86 @@ function (Resizer) { ...@@ -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 @@ ...@@ -7,8 +7,6 @@
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice') window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice')
.andReturn(null); .andReturn(null);
state = jasmine.initializePlayer();
videoControl = state.videoControl;
$.fn.scrollTo.reset(); $.fn.scrollTo.reset();
}); });
...@@ -29,18 +27,20 @@ ...@@ -29,18 +27,20 @@
describe('always', function () { describe('always', function () {
beforeEach(function () { beforeEach(function () {
spyOn($, 'ajaxWithPrefix').andCallThrough(); spyOn($, 'ajaxWithPrefix').andCallThrough();
state = jasmine.initializePlayer();
}); });
it('create the caption element', function () { it('create the caption element', function () {
state = jasmine.initializePlayer();
expect($('.video')).toContain('ol.subtitles'); expect($('.video')).toContain('ol.subtitles');
}); });
it('add caption control to video player', function () { it('add caption control to video player', function () {
state = jasmine.initializePlayer();
expect($('.video')).toContain('a.hide-subtitles'); expect($('.video')).toContain('a.hide-subtitles');
}); });
it('add ARIA attributes to caption control', function () { it('add ARIA attributes to caption control', function () {
state = jasmine.initializePlayer();
var captionControl = $('a.hide-subtitles'); var captionControl = $('a.hide-subtitles');
expect(captionControl).toHaveAttrs({ expect(captionControl).toHaveAttrs({
'role': 'button', 'role': 'button',
...@@ -49,7 +49,11 @@ ...@@ -49,7 +49,11 @@
}); });
}); });
it('fetch the caption', function () { it('fetch the caption in HTML5 mode', function () {
runs(function () {
state = jasmine.initializePlayer();
});
waitsFor(function () { waitsFor(function () {
if (state.videoCaption.loaded === true) { if (state.videoCaption.loaded === true) {
return true; return true;
...@@ -62,29 +66,55 @@ ...@@ -62,29 +66,55 @@
expect($.ajaxWithPrefix).toHaveBeenCalledWith({ expect($.ajaxWithPrefix).toHaveBeenCalledWith({
url: '/transcript/translation', url: '/transcript/translation',
notifyOnError: false, notifyOnError: false,
data: { data: jasmine.any(Object),
videoId: 'Z5KLxerq05Y',
language: 'en'
},
success: jasmine.any(Function), success: jasmine.any(Function),
error: jasmine.any(Function) error: jasmine.any(Function)
}); });
expect($.ajaxWithPrefix.mostRecentCall.args[0].data)
.toEqual({
language: 'en'
});
}); });
}); });
it('bind window resize event', function () { it('fetch the caption in Youtube mode', function () {
expect($(window)).toHandleWith( runs(function () {
'resize', state.videoCaption.resize 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 () { it('bind the hide caption button', function () {
state = jasmine.initializePlayer();
expect($('.hide-subtitles')).toHandleWith( expect($('.hide-subtitles')).toHandleWith(
'click', state.videoCaption.toggle 'click', state.videoCaption.toggle
); );
}); });
it('bind the mouse movement', function () { it('bind the mouse movement', function () {
state = jasmine.initializePlayer();
expect($('.subtitles')).toHandleWith( expect($('.subtitles')).toHandleWith(
'mouseover', state.videoCaption.onMouseEnter 'mouseover', state.videoCaption.onMouseEnter
); );
...@@ -103,8 +133,9 @@ ...@@ -103,8 +133,9 @@
}); });
it('bind the scroll', function () { it('bind the scroll', function () {
expect($('.subtitles')) state = jasmine.initializePlayer();
.toHandleWith('scroll', state.videoControl.showControls); expect($('.subtitles'))
.toHandleWith('scroll', state.videoControl.showControls);
}); });
}); });
...@@ -284,7 +315,8 @@ ...@@ -284,7 +315,8 @@
describe('when no captions file was specified', function () { describe('when no captions file was specified', function () {
beforeEach(function () { beforeEach(function () {
state = jasmine.initializePlayer('video_all.html', { state = jasmine.initializePlayer('video_all.html', {
'sub': '' 'sub': '',
'transcriptLanguages': {},
}); });
}); });
...@@ -395,6 +427,8 @@ ...@@ -395,6 +427,8 @@
}); });
it('reRenderCaption', function () { it('reRenderCaption', function () {
state = jasmine.initializePlayer();
var Caption = state.videoCaption, var Caption = state.videoCaption,
li; li;
...@@ -426,14 +460,6 @@ ...@@ -426,14 +460,6 @@
spyOn(state, 'youtubeId').andReturn('Z5KLxerq05Y'); 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 () { it('show caption on language change', function () {
Caption.loaded = true; Caption.loaded = true;
Caption.fetchCaption(); Caption.fetchCaption();
......
...@@ -549,6 +549,17 @@ ...@@ -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 () { describe('play', function () {
beforeEach(function () { beforeEach(function () {
state = jasmine.initializePlayer(); state = jasmine.initializePlayer();
......
...@@ -45,7 +45,6 @@ function (VideoPlayer) { ...@@ -45,7 +45,6 @@ function (VideoPlayer) {
it('create video caption', function () { it('create video caption', function () {
expect(state.videoCaption).toBeDefined(); expect(state.videoCaption).toBeDefined();
expect(state.youtubeId('1.0')).toEqual('Z5KLxerq05Y');
expect(state.speed).toEqual('1.50'); expect(state.speed).toEqual('1.50');
expect(state.config.transcriptTranslationUrl) expect(state.config.transcriptTranslationUrl)
.toEqual('/transcript/translation'); .toEqual('/transcript/translation');
...@@ -712,6 +711,7 @@ function (VideoPlayer) { ...@@ -712,6 +711,7 @@ function (VideoPlayer) {
state.videoEl = $('video, iframe'); state.videoEl = $('video, iframe');
spyOn(state.videoCaption, 'resize').andCallThrough(); spyOn(state.videoCaption, 'resize').andCallThrough();
spyOn($.fn, 'trigger').andCallThrough();
state.videoControl.toggleFullScreen(jQuery.Event('click')); state.videoControl.toggleFullScreen(jQuery.Event('click'));
}); });
...@@ -726,7 +726,8 @@ function (VideoPlayer) { ...@@ -726,7 +726,8 @@ function (VideoPlayer) {
it('tell VideoCaption to resize', function () { it('tell VideoCaption to resize', function () {
expect(state.videoCaption.resize).toHaveBeenCalled(); 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) { ...@@ -759,6 +760,7 @@ function (VideoPlayer) {
expect(state.videoCaption.resize).toHaveBeenCalled(); expect(state.videoCaption.resize).toHaveBeenCalled();
expect(state.resizer.setMode) expect(state.resizer.setMode)
.toHaveBeenCalledWith('width'); .toHaveBeenCalledWith('width');
expect(state.resizer.delta.reset).toHaveBeenCalled();
}); });
}); });
}); });
......
...@@ -13,20 +13,24 @@ function () { ...@@ -13,20 +13,24 @@ function () {
elementRatio: null elementRatio: null
}, },
callbacksList = [], callbacksList = [],
delta = {
height: 0,
width: 0
},
module = {}, module = {},
mode = null, mode = null,
config; config;
var initialize = function (params) { var initialize = function (params) {
if (config) { if (!config) {
config = $.extend(true, config, params); config = defaults;
} else {
config = $.extend(true, {}, defaults, params);
} }
config = $.extend(true, {}, config, params);
if (!config.element) { if (!config.element) {
console.log( console.log(
'[Video info]: Required parameter `element` is not passed.' 'Required parameter `element` is not passed.'
); );
} }
...@@ -35,8 +39,8 @@ function () { ...@@ -35,8 +39,8 @@ function () {
var getData = function () { var getData = function () {
var container = $(config.container), var container = $(config.container),
containerWidth = container.width(), containerWidth = container.width() + delta.width,
containerHeight = container.height(), containerHeight = container.height() + delta.height,
containerRatio = config.containerRatio, containerRatio = config.containerRatio,
element = $(config.element), element = $(config.element),
...@@ -74,7 +78,6 @@ function () { ...@@ -74,7 +78,6 @@ function () {
default: default:
if (data.containerRatio >= data.elementRatio) { if (data.containerRatio >= data.elementRatio) {
alignByHeightOnly(); alignByHeightOnly();
} else { } else {
alignByWidthOnly(); alignByWidthOnly();
} }
...@@ -142,7 +145,7 @@ function () { ...@@ -142,7 +145,7 @@ function () {
addCallback(decorator); addCallback(decorator);
} else { } else {
console.error('[Video info]: TypeError: Argument is not a function.'); console.error('TypeError: Argument is not a function.');
} }
return module; return module;
...@@ -168,6 +171,29 @@ function () { ...@@ -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); initialize.apply(module, arguments);
return $.extend(true, module, { return $.extend(true, module, {
...@@ -181,6 +207,11 @@ function () { ...@@ -181,6 +207,11 @@ function () {
once: addOnceCallback, once: addOnceCallback,
remove: removeCallback, remove: removeCallback,
removeAll: removeCallbacks removeAll: removeCallbacks
},
delta: {
add: addDelta,
substract: substractDelta,
reset: cleanDelta
} }
}); });
}; };
......
...@@ -202,12 +202,6 @@ function (VideoPlayer, VideoStorage) { ...@@ -202,12 +202,6 @@ function (VideoPlayer, VideoStorage) {
); );
state.speeds = ['0.75', '1.0', '1.25', '1.50']; 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. // We must have at least one non-YouTube video source available.
// Otherwise, return a negative. // Otherwise, return a negative.
......
...@@ -221,7 +221,7 @@ function (HTML5Video, Resizer) { ...@@ -221,7 +221,7 @@ function (HTML5Video, Resizer) {
state.resizer = new Resizer({ state.resizer = new Resizer({
element: state.videoEl, element: state.videoEl,
elementRatio: videoWidth/videoHeight, elementRatio: videoWidth/videoHeight,
container: state.videoEl.parent() container: state.container
}) })
.callbacks.once(function() { .callbacks.once(function() {
state.trigger('videoCaption.resize', null); state.trigger('videoCaption.resize', null);
...@@ -235,7 +235,11 @@ function (HTML5Video, Resizer) { ...@@ -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) // function _restartUsingFlash(state)
...@@ -461,7 +465,7 @@ function (HTML5Video, Resizer) { ...@@ -461,7 +465,7 @@ function (HTML5Video, Resizer) {
this.videoPlayer.log( this.videoPlayer.log(
'pause_video', 'pause_video',
{ {
'currentTime': this.videoPlayer.currentTime currentTime: this.videoPlayer.currentTime
} }
); );
...@@ -482,7 +486,7 @@ function (HTML5Video, Resizer) { ...@@ -482,7 +486,7 @@ function (HTML5Video, Resizer) {
this.videoPlayer.log( this.videoPlayer.log(
'play_video', 'play_video',
{ {
'currentTime': this.videoPlayer.currentTime currentTime: this.videoPlayer.currentTime
} }
); );
...@@ -863,8 +867,7 @@ function (HTML5Video, Resizer) { ...@@ -863,8 +867,7 @@ function (HTML5Video, Resizer) {
// Default parameters that always get logged. // Default parameters that always get logged.
logInfo = { logInfo = {
'id': this.id, id: this.id
'code': this.youtubeId()
}; };
// If extra parameters were passed to the log. // If extra parameters were passed to the log.
......
...@@ -40,6 +40,7 @@ function () { ...@@ -40,6 +40,7 @@ function () {
showPlayPlaceholder: showPlayPlaceholder, showPlayPlaceholder: showPlayPlaceholder,
toggleFullScreen: toggleFullScreen, toggleFullScreen: toggleFullScreen,
togglePlayback: togglePlayback, togglePlayback: togglePlayback,
updateControlsHeight: updateControlsHeight,
updateVcrVidTime: updateVcrVidTime updateVcrVidTime: updateVcrVidTime
}; };
...@@ -83,6 +84,8 @@ function () { ...@@ -83,6 +84,8 @@ function () {
'role': 'slider', 'role': 'slider',
'title': gettext('Video slider') 'title': gettext('Video slider')
}); });
state.videoControl.updateControlsHeight();
} }
// function _bindHandlers(state) // function _bindHandlers(state)
...@@ -91,6 +94,23 @@ function () { ...@@ -91,6 +94,23 @@ function () {
function _bindHandlers(state) { function _bindHandlers(state) {
state.videoControl.playPauseEl.on('click', state.videoControl.togglePlayback); state.videoControl.playPauseEl.on('click', state.videoControl.togglePlayback);
state.videoControl.fullScreenEl.on('click', state.videoControl.toggleFullScreen); 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); $(document).on('keyup', state.videoControl.exitFullScreen);
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) { if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
...@@ -110,12 +130,22 @@ function () { ...@@ -110,12 +130,22 @@ function () {
}); });
} }
} }
function _getControlsHeight(control) {
return control.el.height() + 0.5 * control.sliderEl.height();
}
// *************************************************************** // ***************************************************************
// Public functions start here. // Public functions start here.
// These are available via the 'state' object. Their context ('this' keyword) is the 'state' object. // 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(). // 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() { function show() {
this.videoControl.el.removeClass('is-hidden'); this.videoControl.el.removeClass('is-hidden');
this.el.trigger('controls:show', arguments); this.el.trigger('controls:show', arguments);
...@@ -234,13 +264,6 @@ function () { ...@@ -234,13 +264,6 @@ function () {
this.videoControl.fullScreenState = this.isFullScreen = false; this.videoControl.fullScreenState = this.isFullScreen = false;
fullScreenClassNameEl.removeClass('video-fullscreen'); fullScreenClassNameEl.removeClass('video-fullscreen');
text = gettext('Fill browser'); text = gettext('Fill browser');
this.resizer
.setParams({
container: this.videoEl.parent()
})
.setMode('width');
win.scrollTop(this.scrollPos); win.scrollTop(this.scrollPos);
} else { } else {
this.scrollPos = win.scrollTop(); this.scrollPos = win.scrollTop();
...@@ -248,13 +271,6 @@ function () { ...@@ -248,13 +271,6 @@ function () {
this.videoControl.fullScreenState = this.isFullScreen = true; this.videoControl.fullScreenState = this.isFullScreen = true;
fullScreenClassNameEl.addClass('video-fullscreen'); fullScreenClassNameEl.addClass('video-fullscreen');
text = gettext('Exit full browser'); text = gettext('Exit full browser');
this.resizer
.setParams({
container: window
})
.setMode('both');
} }
this.videoControl.fullScreenEl this.videoControl.fullScreenEl
...@@ -262,6 +278,7 @@ function () { ...@@ -262,6 +278,7 @@ function () {
.text(text); .text(text);
this.trigger('videoCaption.resize', null); this.trigger('videoCaption.resize', null);
this.el.trigger('fullscreen', [this.isFullScreen]);
} }
function exitFullScreen(event) { function exitFullScreen(event) {
......
...@@ -135,7 +135,6 @@ function () { ...@@ -135,7 +135,6 @@ function () {
var self = this, var self = this,
Caption = this.videoCaption; Caption = this.videoCaption;
$(window).bind('resize', Caption.resize);
Caption.hideSubtitlesEl.on({ Caption.hideSubtitlesEl.on({
'click': Caption.toggle 'click': Caption.toggle
}); });
...@@ -226,14 +225,10 @@ function () { ...@@ -226,14 +225,10 @@ function () {
*/ */
function fetchCaption() { function fetchCaption() {
var self = this, var self = this,
Caption = self.videoCaption; Caption = self.videoCaption,
// Check whether the captions file was specified. This is the point data = {
// where we either stop with the caption panel (so that a white empty language: this.getCurrentLanguage()
// panel to the right of the video will not be shown), or carry on };
// further.
if (!this.youtubeId('1.0')) {
return false;
}
if (Caption.loaded) { if (Caption.loaded) {
Caption.hideCaptions(false); Caption.hideCaptions(false);
...@@ -245,15 +240,16 @@ function () { ...@@ -245,15 +240,16 @@ function () {
Caption.fetchXHR.abort(); Caption.fetchXHR.abort();
} }
if (this.videoType === 'youtube') {
data.videoId = this.youtubeId();
}
// Fetch the captions file. If no file was specified, or if an error // Fetch the captions file. If no file was specified, or if an error
// occurred, then we hide the captions panel, and the "CC" button // occurred, then we hide the captions panel, and the "CC" button
Caption.fetchXHR = $.ajaxWithPrefix({ Caption.fetchXHR = $.ajaxWithPrefix({
url: self.config.transcriptTranslationUrl, url: self.config.transcriptTranslationUrl,
notifyOnError: false, notifyOnError: false,
data: { data: data,
videoId: this.youtubeId(),
language: this.getCurrentLanguage()
},
success: function (captions) { success: function (captions) {
Caption.captions = captions.text; Caption.captions = captions.text;
Caption.start = captions.start; Caption.start = captions.start;
...@@ -757,8 +753,12 @@ function () { ...@@ -757,8 +753,12 @@ function () {
}); });
} }
if (this.resizer && !this.isFullScreen) { if (this.resizer) {
this.resizer.alignByWidthOnly(); if (this.isFullScreen) {
this.resizer.setMode('both');
} else {
this.resizer.alignByWidthOnly();
}
} }
this.videoCaption.setSubtitlesHeight(); this.videoCaption.setSubtitlesHeight();
...@@ -772,17 +772,8 @@ function () { ...@@ -772,17 +772,8 @@ function () {
} }
function captionHeight() { function captionHeight() {
var paddingTop;
if (this.isFullScreen) { if (this.isFullScreen) {
paddingTop = parseInt( return this.container.height() - this.videoControl.height;
this.videoCaption.subtitlesEl.css('padding-top'), 10
);
return $(window).height() -
this.videoControl.el.height() -
0.5 * this.videoControl.sliderEl.height() -
2 * paddingTop;
} else { } else {
return this.container.height(); return this.container.height();
} }
......
...@@ -42,6 +42,7 @@ require( ...@@ -42,6 +42,7 @@ require(
[ [
'video/01_initialize.js', 'video/01_initialize.js',
'video/025_focus_grabber.js', 'video/025_focus_grabber.js',
'video/035_video_accessible_menu.js',
'video/04_video_control.js', 'video/04_video_control.js',
'video/05_video_quality_control.js', 'video/05_video_quality_control.js',
'video/06_video_progress_slider.js', 'video/06_video_progress_slider.js',
...@@ -52,6 +53,7 @@ require( ...@@ -52,6 +53,7 @@ require(
function ( function (
Initialize, Initialize,
FocusGrabber, FocusGrabber,
VideoAccessibleMenu,
VideoControl, VideoControl,
VideoQualityControl, VideoQualityControl,
VideoProgressSlider, VideoProgressSlider,
...@@ -87,6 +89,7 @@ function ( ...@@ -87,6 +89,7 @@ function (
state.modules = [ state.modules = [
FocusGrabber, FocusGrabber,
VideoAccessibleMenu,
VideoControl, VideoControl,
VideoQualityControl, VideoQualityControl,
VideoProgressSlider, VideoProgressSlider,
......
...@@ -5,7 +5,7 @@ Test the partitions and partitions service ...@@ -5,7 +5,7 @@ Test the partitions and partitions service
from collections import defaultdict from collections import defaultdict
from unittest import TestCase from unittest import TestCase
from mock import Mock, MagicMock from mock import Mock
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.partitions_service import PartitionService from xmodule.partitions.partitions_service import PartitionService
......
...@@ -40,7 +40,7 @@ class SplitTestFields(object): ...@@ -40,7 +40,7 @@ class SplitTestFields(object):
) )
@XBlock.needs('user_tags') @XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.needs('partitions') @XBlock.needs('partitions')
class SplitTestModule(SplitTestFields, XModule): class SplitTestModule(SplitTestFields, XModule):
""" """
...@@ -196,7 +196,7 @@ class SplitTestModule(SplitTestFields, XModule): ...@@ -196,7 +196,7 @@ class SplitTestModule(SplitTestFields, XModule):
return progress return progress
@XBlock.needs('user_tags') @XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.needs('partitions') @XBlock.needs('partitions')
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
# the editing interface can be the same as for sequences -- just a container # the editing interface can be the same as for sequences -- just a container
...@@ -223,4 +223,3 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -223,4 +223,3 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
makes it use module.get_child_descriptors(). makes it use module.get_child_descriptors().
""" """
return True return True
...@@ -44,9 +44,10 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -44,9 +44,10 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.module_system = get_test_system() self.module_system = get_test_system()
def get_module(descriptor): def get_module(descriptor):
"""Mocks module_system get_module function"""
module_system = get_test_system() module_system = get_test_system()
module_system.get_module = get_module 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 return descriptor
self.module_system.get_module = get_module self.module_system.get_module = get_module
...@@ -67,8 +68,7 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -67,8 +68,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access 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 = 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.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
@ddt.unpack @ddt.unpack
...@@ -83,7 +83,7 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -83,7 +83,7 @@ class SplitTestModuleTest(XModuleXmlImportTest):
@ddt.data(('0',), ('1',)) @ddt.data(('0',), ('1',))
@ddt.unpack @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 # If user_tag has a stale value, we should still get back a valid child url
self.tags_service.set_tag( self.tags_service.set_tag(
self.tags_service.COURSE_SCOPE, self.tags_service.COURSE_SCOPE,
...@@ -109,13 +109,13 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -109,13 +109,13 @@ class SplitTestModuleTest(XModuleXmlImportTest):
@ddt.data(('0',), ('1',)) @ddt.data(('0',), ('1',))
@ddt.unpack @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 # 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']) 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.data(('100',), ('200',), ('300',), ('400',), ('500',), ('600',), ('700',), ('800',), ('900',), ('1000',))
@ddt.unpack @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. # 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. # 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. # 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): ...@@ -146,6 +146,7 @@ class SequenceFactory(XmlImportFactory):
"""Factory for <sequential> nodes""" """Factory for <sequential> nodes"""
tag = 'sequential' tag = 'sequential'
class VerticalFactory(XmlImportFactory): class VerticalFactory(XmlImportFactory):
"""Factory for <vertical> nodes""" """Factory for <vertical> nodes"""
tag = 'vertical' tag = 'vertical'
......
...@@ -34,6 +34,13 @@ class AcidView(PageObject): ...@@ -34,6 +34,13 @@ class AcidView(PageObject):
selector = '{} .acid-block {} .pass'.format(self.context_selector, test_selector) selector = '{} .acid-block {} .pass'.format(self.context_selector, test_selector)
return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3)) 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 @property
def init_fn_passed(self): def init_fn_passed(self):
""" """
...@@ -47,8 +54,8 @@ class AcidView(PageObject): ...@@ -47,8 +54,8 @@ class AcidView(PageObject):
Whether the tests of children passed Whether the tests of children passed
""" """
return all([ return all([
self.test_passed('.child-counts-match'), self.child_test_passed('.child-counts-match'),
self.test_passed('.child-values-match') self.child_test_passed('.child-values-match')
]) ])
@property @property
......
...@@ -359,6 +359,19 @@ class XBlockAcidBase(UniqueCourseTest): ...@@ -359,6 +359,19 @@ class XBlockAcidBase(UniqueCourseTest):
self.course_info_page = CourseInfoPage(self.browser, self.course_id) self.course_info_page = CourseInfoPage(self.browser, self.course_id)
self.tab_nav = TabNavPage(self.browser) 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): def test_acid_block(self):
""" """
Verify that all expected acid block tests pass in the lms. Verify that all expected acid block tests pass in the lms.
...@@ -368,13 +381,7 @@ class XBlockAcidBase(UniqueCourseTest): ...@@ -368,13 +381,7 @@ class XBlockAcidBase(UniqueCourseTest):
self.tab_nav.go_to_tab('Courseware') self.tab_nav.go_to_tab('Courseware')
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]') acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
self.assertTrue(acid_block.init_fn_passed) self.validate_acid_block_view(acid_block)
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'))
class XBlockAcidNoChildTest(XBlockAcidBase): class XBlockAcidNoChildTest(XBlockAcidBase):
...@@ -420,7 +427,7 @@ class XBlockAcidChildTest(XBlockAcidBase): ...@@ -420,7 +427,7 @@ class XBlockAcidChildTest(XBlockAcidBase):
XBlockFixtureDesc('chapter', 'Test Section').add_children( XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children( XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').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', 'First Acid Child', metadata={'name': 'first'}),
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}), XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"), XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
...@@ -430,6 +437,10 @@ class XBlockAcidChildTest(XBlockAcidBase): ...@@ -430,6 +437,10 @@ class XBlockAcidChildTest(XBlockAcidBase):
) )
).install() ).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 # This will fail until we fix support of children in pure XBlocks
@expectedFailure @expectedFailure
def test_acid_block(self): def test_acid_block(self):
......
...@@ -147,6 +147,17 @@ class XBlockAcidBase(WebAppTest): ...@@ -147,6 +147,17 @@ class XBlockAcidBase(WebAppTest):
self.auth_page.visit() 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): def test_acid_block_preview(self):
""" """
Verify that all expected acid block tests pass in studio preview Verify that all expected acid block tests pass in studio preview
...@@ -155,22 +166,13 @@ class XBlockAcidBase(WebAppTest): ...@@ -155,22 +166,13 @@ class XBlockAcidBase(WebAppTest):
self.outline.visit() self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection') subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to() 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) acid_block = AcidView(self.browser, unit.components[0].preview_selector)
self.assertTrue(acid_block.init_fn_passed) self.validate_acid_block_preview(acid_block)
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'))
# This will fail until we support editing on the container page
@expectedFailure
def test_acid_block_editor(self): 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() self.outline.visit()
...@@ -181,7 +183,6 @@ class XBlockAcidBase(WebAppTest): ...@@ -181,7 +183,6 @@ class XBlockAcidBase(WebAppTest):
acid_block = AcidView(self.browser, unit.components[0].edit().editor_selector) acid_block = AcidView(self.browser, unit.components[0].edit().editor_selector)
self.assertTrue(acid_block.init_fn_passed) 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.resource_url_passed)
self.assertTrue(acid_block.scope_passed('content')) self.assertTrue(acid_block.scope_passed('content'))
self.assertTrue(acid_block.scope_passed('settings')) self.assertTrue(acid_block.scope_passed('settings'))
...@@ -213,7 +214,63 @@ class XBlockAcidNoChildTest(XBlockAcidBase): ...@@ -213,7 +214,63 @@ class XBlockAcidNoChildTest(XBlockAcidBase):
).install() ).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 Tests of an AcidBlock with children
""" """
...@@ -232,7 +289,7 @@ class XBlockAcidChildTest(XBlockAcidBase): ...@@ -232,7 +289,7 @@ class XBlockAcidChildTest(XBlockAcidBase):
XBlockFixtureDesc('chapter', 'Test Section').add_children( XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children( XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').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', 'First Acid Child', metadata={'name': 'first'}),
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}), XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"), XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
......
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
114220 114220
], ],
"text": [ "text": [
"LILA FISHER: Hi, welcome to Edx.", "Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put", "I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.", "together these courses.",
"As you know, our courses are entirely online.", "As you know, our courses are entirely online.",
......
...@@ -100,11 +100,11 @@ Assign discussion administration roles ...@@ -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: 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". **Note**: Discussion responses and comments made by course staff and instructors are also marked as "Staff".
...@@ -123,7 +123,7 @@ To assign a role: ...@@ -123,7 +123,7 @@ To assign a role:
#. Click **Membership**. #. 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. #. 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 @@ ...@@ -2,7 +2,7 @@
Feature: LMS Video component Feature: LMS Video component
As a student, I want to view course videos in LMS As a student, I want to view course videos in LMS
# 0 # 1
Scenario: Video component stores position correctly when page is reloaded Scenario: Video component stores position correctly when page is reloaded
Given the course has a Video component in Youtube mode Given the course has a Video component in Youtube mode
Then when I view the video it has rendered in Youtube mode Then when I view the video it has rendered in Youtube mode
...@@ -13,51 +13,51 @@ Feature: LMS Video component ...@@ -13,51 +13,51 @@ Feature: LMS Video component
And I click video button "play" And I click video button "play"
Then I see video starts playing from "0:10" position Then I see video starts playing from "0:10" position
# 1 # 2
Scenario: Video component is fully rendered in the LMS in HTML5 mode Scenario: Video component is fully rendered in the LMS in HTML5 mode
Given the course has a Video component 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 Then when I view the video it has rendered in HTML5 mode
And all sources are correct And all sources are correct
# 2 # 3
# Firefox doesn't have HTML5 (only mp4 - fix here) # Firefox doesn't have HTML5 (only mp4 - fix here)
@skip_firefox @skip_firefox
Scenario: Autoplay is disabled in LMS for a Video component Scenario: Autoplay is disabled in LMS for a Video component
Given the course has a Video component in HTML5 mode Given the course has a Video component in HTML5 mode
Then when I view the video it does not have autoplay enabled Then when I view the video it does not have autoplay enabled
# 3 # 4
# Youtube testing # Youtube testing
Scenario: Video component is fully rendered in the LMS in Youtube mode with HTML5 sources 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 Given youtube server is up and response time is 0.4 seconds
And the course has a Video component in Youtube_HTML5 mode And the course has a Video component in Youtube_HTML5 mode
Then when I view the video it has rendered in Youtube 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 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 Given youtube server is up and response time is 2 seconds
And the course has a Video component in Youtube_HTML5 mode And the course has a Video component in Youtube_HTML5 mode
Then when I view the video it has rendered in 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 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 Given youtube server is up and response time is 2 seconds
And the course has a Video component in Youtube mode And the course has a Video component in Youtube mode
Then when I view the video it has rendered 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 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 Given youtube server is up and response time is 2 seconds
And the course has a Video component in Youtube_HTML5_Unsupported_Video mode 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 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 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 Given the course has a Video component in HTML5_Unsupported_Video mode
Then error message is shown Then error message is shown
And error message has correct text And error message has correct text
# 8 # 9
Scenario: Video component stores speed correctly when each video is in separate sequence Scenario: Video component stores speed correctly when each video is in separate sequence
Given I am registered for the course "test_course" Given I am registered for the course "test_course"
And it has a video "A" in "Youtube" mode in position "1" of sequential And it has a video "A" in "Youtube" mode in position "1" of sequential
...@@ -79,14 +79,100 @@ Feature: LMS Video component ...@@ -79,14 +79,100 @@ Feature: LMS Video component
When I open video "C" When I open video "C"
Then video "C" should start playing at speed "1.0" Then video "C" should start playing at speed "1.0"
# 9 # 10
Scenario: Language menu in Video component works correctly Scenario: Language menu works correctly in Video component
Given the course has a Video component in Youtube mode: Given the course has a Video component in Youtube mode:
| transcripts | sub | | transcripts | sub |
| {"zh": "OEoXaMPEzfM"} | OEoXaMPEzfM | | {"zh": "chinese_transcripts.srt"} | OEoXaMPEzfM |
And I make sure captions are closed And I make sure captions are closed
And I see video menu "language" with correct items And I see video menu "language" with correct items
And I select language with code "zh" And I select language with code "zh"
Then I see "好 各位同学" text in the captions Then I see "好 各位同学" text in the captions
And I select language with code "en" And I select language with code "en"
And I see "Hi, welcome to Edx." text in the captions 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 Test for split test XModule
""" """
import ddt
from mock import MagicMock, patch, Mock
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings 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 courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.test_partitions import StaticPartitionService
from user_api.tests.factories import UserCourseTagFactory from user_api.tests.factories import UserCourseTagFactory
from xmodule.partitions.partitions import Group, UserPartition
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class SplitTestBase(ModuleStoreTestCase): 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 __test__ = False
COURSE_NUMBER = 'split-test-base'
ICON_CLASSES = None
TOOLTIPS = None
HIDDEN_CONTENT = None
VISIBLE_CONTENT = None
def setUp(self): def setUp(self):
self.partition = UserPartition( self.partition = UserPartition(
...@@ -53,6 +58,10 @@ class SplitTestBase(ModuleStoreTestCase): ...@@ -53,6 +58,10 @@ class SplitTestBase(ModuleStoreTestCase):
self.client.login(username=self.student.username, password='test') self.client.login(username=self.student.username, password='test')
def _video(self, parent, group): def _video(self, parent, group):
"""
Returns a video component with parent ``parent``
that is intended to be displayed to group ``group``.
"""
return ItemFactory.create( return ItemFactory.create(
parent_location=parent.location, parent_location=parent.location,
category="video", category="video",
...@@ -60,6 +69,10 @@ class SplitTestBase(ModuleStoreTestCase): ...@@ -60,6 +69,10 @@ class SplitTestBase(ModuleStoreTestCase):
) )
def _problem(self, parent, group): def _problem(self, parent, group):
"""
Returns a problem component with parent ``parent``
that is intended to be displayed to group ``group``.
"""
return ItemFactory.create( return ItemFactory.create(
parent_location=parent.location, parent_location=parent.location,
category="problem", category="problem",
...@@ -68,6 +81,10 @@ class SplitTestBase(ModuleStoreTestCase): ...@@ -68,6 +81,10 @@ class SplitTestBase(ModuleStoreTestCase):
) )
def _html(self, parent, group): def _html(self, parent, group):
"""
Returns an html component with parent ``parent``
that is intended to be displayed to group ``group``.
"""
return ItemFactory.create( return ItemFactory.create(
parent_location=parent.location, parent_location=parent.location,
category="html", category="html",
...@@ -82,21 +99,23 @@ class SplitTestBase(ModuleStoreTestCase): ...@@ -82,21 +99,23 @@ class SplitTestBase(ModuleStoreTestCase):
self._check_split_test(1) self._check_split_test(1)
def _check_split_test(self, user_tag): 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, user=self.student,
course_id=self.course.id, course_id=self.course.id,
key='xblock.partition_service.partition_{0}'.format(self.partition.id), key='xblock.partition_service.partition_{0}'.format(self.partition.id),
value=str(user_tag) value=str(user_tag)
) )
resp = self.client.get(reverse('courseware_section', resp = self.client.get(reverse(
kwargs={'course_id': self.course.id, 'courseware_section',
'chapter': self.chapter.url_name, kwargs={'course_id': self.course.id,
'section': self.sequential.url_name} 'chapter': self.chapter.url_name,
'section': self.sequential.url_name}
)) ))
content = resp.content content = resp.content
print content
# Assert we see the proper icon in the top display # Assert we see the proper icon in the top display
self.assertIn('<a class="{} inactive progress-0"'.format(self.ICON_CLASSES[user_tag]), content) self.assertIn('<a class="{} inactive progress-0"'.format(self.ICON_CLASSES[user_tag]), content)
...@@ -118,7 +137,7 @@ class TestVertSplitTestVert(SplitTestBase): ...@@ -118,7 +137,7 @@ class TestVertSplitTestVert(SplitTestBase):
""" """
__test__ = True __test__ = True
COURSE_NUMBER='vert-split-vert' COURSE_NUMBER = 'vert-split-vert'
ICON_CLASSES = [ ICON_CLASSES = [
'seq_problem', 'seq_problem',
...@@ -141,6 +160,8 @@ class TestVertSplitTestVert(SplitTestBase): ...@@ -141,6 +160,8 @@ class TestVertSplitTestVert(SplitTestBase):
] ]
def setUp(self): def setUp(self):
# We define problem compenents that we need but don't explicitly call elsewhere.
# pylint: disable=unused-variable
super(TestVertSplitTestVert, self).setUp() super(TestVertSplitTestVert, self).setUp()
# vert <- split_test # vert <- split_test
...@@ -151,6 +172,7 @@ class TestVertSplitTestVert(SplitTestBase): ...@@ -151,6 +172,7 @@ class TestVertSplitTestVert(SplitTestBase):
category="vertical", category="vertical",
display_name="Split test vertical", display_name="Split test vertical",
) )
# pylint: disable=protected-access
c0_url = self.course.location._replace(category="vertical", name="split_test_cond0") c0_url = self.course.location._replace(category="vertical", name="split_test_cond0")
c1_url = self.course.location._replace(category="vertical", name="split_test_cond1") c1_url = self.course.location._replace(category="vertical", name="split_test_cond1")
...@@ -210,10 +232,13 @@ class TestSplitTestVert(SplitTestBase): ...@@ -210,10 +232,13 @@ class TestSplitTestVert(SplitTestBase):
] ]
def setUp(self): def setUp(self):
# We define problem compenents that we need but don't explicitly call elsewhere.
# pylint: disable=unused-variable
super(TestSplitTestVert, self).setUp() super(TestSplitTestVert, self).setUp()
# split_test cond 0 = vert <- {video, problem} # split_test cond 0 = vert <- {video, problem}
# split_test cond 1 = vert <- {video, html} # split_test cond 1 = vert <- {video, html}
# pylint: disable=protected-access
c0_url = self.course.location._replace(category="vertical", name="split_test_cond0") c0_url = self.course.location._replace(category="vertical", name="split_test_cond0")
c1_url = self.course.location._replace(category="vertical", name="split_test_cond1") c1_url = self.course.location._replace(category="vertical", name="split_test_cond1")
......
...@@ -110,7 +110,7 @@ class TestVideo(BaseTestXmodule): ...@@ -110,7 +110,7 @@ class TestVideo(BaseTestXmodule):
data = [ data = [
{'speed': 2.0}, {'speed': 2.0},
{'saved_video_position': "00:00:10"}, {'saved_video_position': "00:00:10"},
{'transcript_language': json.dumps('uk')}, {'transcript_language': 'uk'},
] ]
for sample in data: for sample in data:
response = self.clients[self.users[0].username].post( response = self.clients[self.users[0].username].post(
...@@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule): ...@@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule):
self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10)) self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10))
self.assertEqual(self.item_descriptor.transcript_language, 'en') 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') self.assertEqual(self.item_descriptor.transcript_language, 'uk')
def tearDown(self): def tearDown(self):
...@@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo): ...@@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo):
response = self.item.transcript(request=request, dispatch='download') response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.status, '404 Not Found') self.assertEqual(response.status, '404 Not Found')
@patch('xmodule.video_module.VideoModule.get_transcript', return_value='Subs!') @patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'srt', 'application/x-subrip'))
def test_download_exist(self, __): def test_download_srt_exist(self, __):
request = Request.blank('/download?language=en') request = Request.blank('/download?language=en')
response = self.item.transcript(request=request, dispatch='download') response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.body, 'Subs!') 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): def test_download_en_no_sub(self):
request = Request.blank('/download?language=en') request = Request.blank('/download?language=en')
...@@ -189,17 +198,22 @@ class TestVideoTranscriptTranslation(TestVideo): ...@@ -189,17 +198,22 @@ class TestVideoTranscriptTranslation(TestVideo):
# Tests for `translation` dispatch: # Tests for `translation` dispatch:
def test_translation_fails(self): def test_translation_fails(self):
# No videoId # No language
request = Request.blank('/translation?language=ru') request = Request.blank('/translation')
response = self.item.transcript(request=request, dispatch='translation') response = self.item.transcript(request=request, dispatch='translation')
self.assertEqual(response.status, '400 Bad Request') 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 # Language is not in available languages
request = Request.blank('/translation?language=ru&videoId=12345') request = Request.blank('/translation?language=ru&videoId=12345')
response = self.item.transcript(request=request, dispatch='translation') response = self.item.transcript(request=request, dispatch='translation')
self.assertEqual(response.status, '404 Not Found') 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."]} subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
good_sjson = _create_file(json.dumps(subs)) good_sjson = _create_file(json.dumps(subs))
_upload_sjson_file(good_sjson, self.item_descriptor.location) _upload_sjson_file(good_sjson, self.item_descriptor.location)
...@@ -210,25 +224,7 @@ class TestVideoTranscriptTranslation(TestVideo): ...@@ -210,25 +224,7 @@ class TestVideoTranscriptTranslation(TestVideo):
response = self.item.transcript(request=request, dispatch='translation') response = self.item.transcript(request=request, dispatch='translation')
self.assertDictEqual(json.loads(response.body), subs) self.assertDictEqual(json.loads(response.body), subs)
def test_translaton_non_en_non_youtube_success(self): def test_translation_non_en_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):
subs = { subs = {
u'end': [100], u'end': [100],
u'start': [12], u'start': [12],
...@@ -270,6 +266,34 @@ class TestVideoTranscriptTranslation(TestVideo): ...@@ -270,6 +266,34 @@ class TestVideoTranscriptTranslation(TestVideo):
} }
self.assertDictEqual(json.loads(response.body), calculated_1_5) 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): class TestVideoTranscriptsDownload(TestVideo):
""" """
...@@ -294,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo): ...@@ -294,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo):
self.item_descriptor.render('student_view') self.item_descriptor.render('student_view')
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance 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("""\ good_sjson = _create_file(content=textwrap.dedent("""\
{ {
"start": [ "start": [
...@@ -314,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo): ...@@ -314,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo):
_upload_sjson_file(good_sjson, self.item.location) _upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name) 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("""\ expected_text = textwrap.dedent("""\
0 0
00:00:00,270 --> 00:00:02,720 00:00:00,270 --> 00:00:02,720
...@@ -328,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo): ...@@ -328,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo):
self.assertEqual(text, expected_text) 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): def test_not_found_error(self):
with self.assertRaises(NotFoundError): with self.assertRaises(NotFoundError):
self.item.get_transcript() self.item.get_transcript()
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Video xmodule tests in mongo.""" """Video xmodule tests in mongo."""
from mock import patch, PropertyMock from mock import patch, PropertyMock
import json
from . import BaseTestXmodule from . import BaseTestXmodule
from .test_video_xml import SOURCE_XML from .test_video_xml import SOURCE_XML
...@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo): ...@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo):
'youtube_streams': create_youtube_string(self.item_descriptor), 'youtube_streams': create_youtube_string(self.item_descriptor),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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_language': 'en',
'transcript_languages': '{"en": "English", "uk": "Ukrainian"}', 'transcript_languages': '{"en": "English", "uk": "Ukrainian"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
...@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo): ...@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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_language': 'en',
'transcript_languages': '{"en": "English"}', 'transcript_languages': '{"en": "English"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
...@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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: for data in cases:
...@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render('student_view').content context = self.item_descriptor.render('student_view').content
expected_context.update({ 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_languages': '{"en": "English"}',
'transcript_language': 'en', 'transcript_language': 'en',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
...@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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_language': 'en',
'transcript_languages': '{"en": "English"}', 'transcript_languages': '{"en": "English"}',
} }
......
...@@ -39,7 +39,15 @@ class GitImportError(Exception): ...@@ -39,7 +39,15 @@ class GitImportError(Exception):
CANNOT_PULL = _('git clone or pull failed!') CANNOT_PULL = _('git clone or pull failed!')
XML_IMPORT_FAILED = _('Unable to run import command.') XML_IMPORT_FAILED = _('Unable to run import command.')
UNSUPPORTED_STORE = _('The underlying module store does not support import.') 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): def cmd_log(cmd, cwd):
""" """
...@@ -54,8 +62,65 @@ def cmd_log(cmd, cwd): ...@@ -54,8 +62,65 @@ def cmd_log(cmd, cwd):
return output return output
def add_repo(repo, rdir_in): def switch_branch(branch, rdir):
"""This will add a git repo into the mongo modulestore""" """
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 # pylint: disable=R0915
# Set defaults even if it isn't defined in settings # Set defaults even if it isn't defined in settings
...@@ -102,6 +167,9 @@ def add_repo(repo, rdir_in): ...@@ -102,6 +167,9 @@ def add_repo(repo, rdir_in):
log.exception('Error running git pull: %r', ex.output) log.exception('Error running git pull: %r', ex.output)
raise GitImportError(GitImportError.CANNOT_PULL) raise GitImportError(GitImportError.CANNOT_PULL)
if branch:
switch_branch(branch, rdirp)
# get commit id # get commit id
cmd = ['git', 'log', '-1', '--format=%H', ] cmd = ['git', 'log', '-1', '--format=%H', ]
try: try:
......
...@@ -25,8 +25,14 @@ class Command(BaseCommand): ...@@ -25,8 +25,14 @@ class Command(BaseCommand):
Pull a git repo and import into the mongo based content database. Pull a git repo and import into the mongo based content database.
""" """
help = _('Import the specified git repository into the ' # Translators: A git repository is a place to store a grouping of
'modulestore and directory') # 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): def handle(self, *args, **options):
"""Check inputs and run the command""" """Check inputs and run the command"""
...@@ -38,16 +44,19 @@ class Command(BaseCommand): ...@@ -38,16 +44,19 @@ class Command(BaseCommand):
raise CommandError('This script requires at least one argument, ' raise CommandError('This script requires at least one argument, '
'the git URL') 'the git URL')
if len(args) > 2: if len(args) > 3:
raise CommandError('This script requires no more than two ' raise CommandError('Expected no more than three '
'arguments') 'arguments; recieved {0}'.format(len(args)))
rdir_arg = None rdir_arg = None
branch = None
if len(args) > 1: if len(args) > 1:
rdir_arg = args[1] rdir_arg = args[1]
if len(args) > 2:
branch = args[2]
try: try:
dashboard.git_import.add_repo(args[0], rdir_arg) dashboard.git_import.add_repo(args[0], rdir_arg, branch)
except GitImportError as ex: except GitImportError as ex:
raise CommandError(str(ex)) raise CommandError(str(ex))
...@@ -2,11 +2,12 @@ ...@@ -2,11 +2,12 @@
Provide tests for git_add_course management command. Provide tests for git_add_course management command.
""" """
import unittest import logging
import os import os
import shutil import shutil
import StringIO import StringIO
import subprocess import subprocess
import unittest
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
...@@ -14,6 +15,9 @@ from django.core.management.base import CommandError ...@@ -14,6 +15,9 @@ from django.core.management.base import CommandError
from django.test.utils import override_settings from django.test.utils import override_settings
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE 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 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
import dashboard.git_import as git_import import dashboard.git_import as git_import
from dashboard.git_import import GitImportError from dashboard.git_import import GitImportError
...@@ -39,6 +43,10 @@ class TestGitAddCourse(ModuleStoreTestCase): ...@@ -39,6 +43,10 @@ class TestGitAddCourse(ModuleStoreTestCase):
""" """
TEST_REPO = 'https://github.com/mitocw/edx4edx_lite.git' 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): def assertCommandFailureRegexp(self, regex, *args):
""" """
...@@ -56,42 +64,45 @@ class TestGitAddCourse(ModuleStoreTestCase): ...@@ -56,42 +64,45 @@ class TestGitAddCourse(ModuleStoreTestCase):
self.assertCommandFailureRegexp( self.assertCommandFailureRegexp(
'This script requires at least one argument, the git URL') 'This script requires at least one argument, the git URL')
self.assertCommandFailureRegexp( self.assertCommandFailureRegexp(
'This script requires no more than two arguments', 'Expected no more than three arguments; recieved 4',
'blah', 'blah', 'blah') 'blah', 'blah', 'blah', 'blah')
self.assertCommandFailureRegexp( self.assertCommandFailureRegexp(
'Repo was not added, check log output for details', 'Repo was not added, check log output for details',
'blah') 'blah')
# Test successful import from command # Test successful import from command
try: if not os.path.isdir(self.GIT_REPO_DIR):
os.mkdir(getattr(settings, 'GIT_REPO_DIR')) os.mkdir(self.GIT_REPO_DIR)
except OSError: self.addCleanup(shutil.rmtree, self.GIT_REPO_DIR)
pass
# Make a course dir that will be replaced with a symlink # Make a course dir that will be replaced with a symlink
# while we are at it. # while we are at it.
if not os.path.isdir(getattr(settings, 'GIT_REPO_DIR') / 'edx4edx'): if not os.path.isdir(self.GIT_REPO_DIR / 'edx4edx'):
os.mkdir(getattr(settings, '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, call_command('git_add_course', self.TEST_REPO,
getattr(settings, 'GIT_REPO_DIR') / 'edx4edx_lite') self.GIT_REPO_DIR / 'edx4edx_lite',
if os.path.isdir(getattr(settings, 'GIT_REPO_DIR')): self.TEST_BRANCH)
shutil.rmtree(getattr(settings, 'GIT_REPO_DIR'))
def test_add_repo(self): def test_add_repo(self):
""" """
Various exit path tests for test_add_repo Various exit path tests for test_add_repo
""" """
with self.assertRaisesRegexp(GitImportError, GitImportError.NO_DIR): 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')) os.mkdir(self.GIT_REPO_DIR)
self.addCleanup(shutil.rmtree, getattr(settings, 'GIT_REPO_DIR')) self.addCleanup(shutil.rmtree, self.GIT_REPO_DIR)
with self.assertRaisesRegexp(GitImportError, GitImportError.URL_BAD): 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): 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" # Test git repo that exists, but is "broken"
bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git')) bare_repo = os.path.abspath('{0}/{1}'.format(settings.TEST_ROOT, 'bare.git'))
...@@ -101,22 +112,107 @@ class TestGitAddCourse(ModuleStoreTestCase): ...@@ -101,22 +112,107 @@ class TestGitAddCourse(ModuleStoreTestCase):
cwd=bare_repo) cwd=bare_repo)
with self.assertRaisesRegexp(GitImportError, GitImportError.BAD_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): def test_detached_repo(self):
""" """
Test repo that is in detached head state. 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 # Test successful import from command
try: try:
os.mkdir(repo_dir) os.mkdir(repo_dir)
except OSError: except OSError:
pass pass
self.addCleanup(shutil.rmtree, repo_dir) 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', ], subprocess.check_output(['git', 'checkout', 'HEAD~2', ],
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
cwd=repo_dir / 'edx4edx_lite') cwd=repo_dir / 'edx4edx_lite')
with self.assertRaisesRegexp(GitImportError, GitImportError.CANNOT_PULL): 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): ...@@ -272,7 +272,7 @@ class Users(SysadminDashboardView):
'msg': self.msg, 'msg': self.msg,
'djangopid': os.getpid(), 'djangopid': os.getpid(),
'modeflag': {'users': 'active-section'}, '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) return render_to_response(self.template_name, context)
...@@ -316,7 +316,7 @@ class Users(SysadminDashboardView): ...@@ -316,7 +316,7 @@ class Users(SysadminDashboardView):
'msg': self.msg, 'msg': self.msg,
'djangopid': os.getpid(), 'djangopid': os.getpid(),
'modeflag': {'users': 'active-section'}, '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) return render_to_response(self.template_name, context)
...@@ -348,7 +348,7 @@ class Courses(SysadminDashboardView): ...@@ -348,7 +348,7 @@ class Courses(SysadminDashboardView):
return info 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""" """This downloads and runs the checks for importing a course in git"""
if not (gitloc.endswith('.git') or gitloc.startswith('http:') or if not (gitloc.endswith('.git') or gitloc.startswith('http:') or
...@@ -357,11 +357,11 @@ class Courses(SysadminDashboardView): ...@@ -357,11 +357,11 @@ class Courses(SysadminDashboardView):
"and be a valid url") "and be a valid url")
if self.is_using_mongo: 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 Imports course using management command and captures logging output
at debug level for display in template at debug level for display in template
...@@ -390,7 +390,7 @@ class Courses(SysadminDashboardView): ...@@ -390,7 +390,7 @@ class Courses(SysadminDashboardView):
error_msg = '' error_msg = ''
try: try:
git_import.add_repo(gitloc, None) git_import.add_repo(gitloc, None, branch)
except GitImportError as ex: except GitImportError as ex:
error_msg = str(ex) error_msg = str(ex)
ret = output.getvalue() ret = output.getvalue()
...@@ -411,7 +411,7 @@ class Courses(SysadminDashboardView): ...@@ -411,7 +411,7 @@ class Courses(SysadminDashboardView):
msg += "<pre>{0}</pre>".format(escape(ret)) msg += "<pre>{0}</pre>".format(escape(ret))
return msg return msg
def import_xml_course(self, gitloc, datatable): def import_xml_course(self, gitloc, branch, datatable):
"""Imports a git course into the XMLModuleStore""" """Imports a git course into the XMLModuleStore"""
msg = u'' msg = u''
...@@ -436,13 +436,31 @@ class Courses(SysadminDashboardView): ...@@ -436,13 +436,31 @@ class Courses(SysadminDashboardView):
cmd_output = escape( cmd_output = escape(
subprocess.check_output(cmd, stderr=subprocess.STDOUT, cwd=cwd) subprocess.check_output(cmd, stderr=subprocess.STDOUT, cwd=cwd)
) )
except subprocess.CalledProcessError: except subprocess.CalledProcessError as ex:
return _('Unable to clone or pull repository. Please check your url.') 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) msg += u'<pre>{0}</pre>'.format(cmd_output)
if not os.path.exists(gdir): if not os.path.exists(gdir):
msg += _('Failed to clone repository to {0}').format(gdir) msg += _('Failed to clone repository to {0}').format(gdir)
return msg 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)) self.def_ms.try_load_course(os.path.abspath(gdir))
errlog = self.def_ms.errored_courses.get(cdir, '') errlog = self.def_ms.errored_courses.get(cdir, '')
if errlog: if errlog:
...@@ -494,7 +512,7 @@ class Courses(SysadminDashboardView): ...@@ -494,7 +512,7 @@ class Courses(SysadminDashboardView):
'msg': self.msg, 'msg': self.msg,
'djangopid': os.getpid(), 'djangopid': os.getpid(),
'modeflag': {'courses': 'active-section'}, '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) return render_to_response(self.template_name, context)
...@@ -511,8 +529,9 @@ class Courses(SysadminDashboardView): ...@@ -511,8 +529,9 @@ class Courses(SysadminDashboardView):
courses = self.get_courses() courses = self.get_courses()
if action == 'add_course': if action == 'add_course':
gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '') gitloc = request.POST.get('repo_location', '').strip().replace(' ', '').replace(';', '')
branch = request.POST.get('repo_branch', '').strip().replace(' ', '').replace(';', '')
datatable = self.make_datatable() 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': elif action == 'del_course':
course_id = request.POST.get('course_id', '').strip() course_id = request.POST.get('course_id', '').strip()
...@@ -563,7 +582,7 @@ class Courses(SysadminDashboardView): ...@@ -563,7 +582,7 @@ class Courses(SysadminDashboardView):
'msg': self.msg, 'msg': self.msg,
'djangopid': os.getpid(), 'djangopid': os.getpid(),
'modeflag': {'courses': 'active-section'}, '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) return render_to_response(self.template_name, context)
...@@ -602,7 +621,7 @@ class Staffing(SysadminDashboardView): ...@@ -602,7 +621,7 @@ class Staffing(SysadminDashboardView):
'msg': self.msg, 'msg': self.msg,
'djangopid': os.getpid(), 'djangopid': os.getpid(),
'modeflag': {'staffing': 'active-section'}, '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) return render_to_response(self.template_name, context)
......
...@@ -45,6 +45,10 @@ class SysadminBaseTestCase(ModuleStoreTestCase): ...@@ -45,6 +45,10 @@ class SysadminBaseTestCase(ModuleStoreTestCase):
Base class with common methods used in XML and Mongo tests 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): def setUp(self):
"""Setup test case by adding primary user.""" """Setup test case by adding primary user."""
super(SysadminBaseTestCase, self).setUp() super(SysadminBaseTestCase, self).setUp()
...@@ -58,11 +62,12 @@ class SysadminBaseTestCase(ModuleStoreTestCase): ...@@ -58,11 +62,12 @@ class SysadminBaseTestCase(ModuleStoreTestCase):
GlobalStaff().add_users(self.user) GlobalStaff().add_users(self.user)
self.client.login(username=self.user.username, password='foo') self.client.login(username=self.user.username, password='foo')
def _add_edx4edx(self): def _add_edx4edx(self, branch=None):
"""Adds the edx4edx sample course""" """Adds the edx4edx sample course"""
return self.client.post(reverse('sysadmin_courses'), { post_dict = {'repo_location': self.TEST_REPO, 'action': 'add_course', }
'repo_location': 'https://github.com/mitocw/edx4edx_lite.git', if branch:
'action': 'add_course', }) post_dict['repo_branch'] = branch
return self.client.post(reverse('sysadmin_courses'), post_dict)
def _rm_edx4edx(self): def _rm_edx4edx(self):
"""Deletes the sample course from the XML store""" """Deletes the sample course from the XML store"""
...@@ -301,11 +306,24 @@ class TestSysadmin(SysadminBaseTestCase): ...@@ -301,11 +306,24 @@ class TestSysadmin(SysadminBaseTestCase):
self.assertIsNotNone(course) self.assertIsNotNone(course)
# Delete a course # Delete a course
response = self._rm_edx4edx() self._rm_edx4edx()
course = def_ms.courses.get('{0}/edx4edx_lite'.format( course = def_ms.courses.get('{0}/edx4edx_lite'.format(
os.path.abspath(settings.DATA_DIR)), None) os.path.abspath(settings.DATA_DIR)), None)
self.assertIsNone(course) 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 # Try and delete a non-existent course
response = self.client.post(reverse('sysadmin_courses'), response = self.client.post(reverse('sysadmin_courses'),
{'course_id': 'foobar/foo/blah', {'course_id': 'foobar/foo/blah',
......
...@@ -258,6 +258,17 @@ SSL_AUTH_EMAIL_DOMAIN = ENV_TOKENS.get("SSL_AUTH_EMAIL_DOMAIN", "MIT.EDU") ...@@ -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", 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}") "/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',{}) HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS',{})
############################## SECURE AUTH ITEMS ############### ############################## SECURE AUTH ITEMS ###############
......
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
<li class="field text is-not-editable" id="field-course-started"> <li class="field text is-not-editable" id="field-course-started">
<label for="start-date">${_("Has the course started?")}</label> <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> </li>
......
...@@ -43,7 +43,10 @@ ...@@ -43,7 +43,10 @@
%endif %endif
<p><b>${_("Reports Available for Download")}</b></p> <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 class="grade-downloads-table" id="grade-downloads-table" data-endpoint="${ section_data['list_grade_downloads_url'] }" ></div>
</div> </div>
%endif %endif
......
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
<p> <p>
${_("Staff cannot modify staff or beta tester lists. To modify these lists, " ${_("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 " "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> </p>
%endif %endif
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
data-display-name="${_("Instructors")}" data-display-name="${_("Instructors")}"
data-info-text=" data-info-text="
${_("Instructors are the core administration of your course. Instructors can " ${_("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-list-endpoint="${ section_data['list_course_role_members_url'] }"
data-modify-endpoint="${ section_data['modify_access_url'] }" data-modify-endpoint="${ section_data['modify_access_url'] }"
data-add-button-label="${_("Add Instructor")}" data-add-button-label="${_("Add Instructor")}"
...@@ -114,23 +114,23 @@ ...@@ -114,23 +114,23 @@
<div class="auth-list-container" <div class="auth-list-container"
data-rolename="Administrator" data-rolename="Administrator"
data-display-name="${_("Forum Admins")}" data-display-name="${_("Discussion Admins")}"
data-info-text=" 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. " "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'.")}" "They CAN add/delete other moderators and their posts are marked as 'staff'.")}"
data-list-endpoint="${ section_data['list_forum_members_url'] }" data-list-endpoint="${ section_data['list_forum_members_url'] }"
data-modify-endpoint="${ section_data['update_forum_role_membership_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> ></div>
%endif %endif
%if section_data['access']['instructor'] or section_data['access']['forum_admin']: %if section_data['access']['instructor'] or section_data['access']['forum_admin']:
<div class="auth-list-container" <div class="auth-list-container"
data-rolename="Moderator" data-rolename="Moderator"
data-display-name="${_("Forum Moderators")}" data-display-name="${_("Discussion Moderators")}"
data-info-text=" 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. " "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'.")}" "They CANNOT add/delete other moderators and their posts are marked as 'staff'.")}"
data-list-endpoint="${ section_data['list_forum_members_url'] }" data-list-endpoint="${ section_data['list_forum_members_url'] }"
...@@ -140,10 +140,10 @@ ...@@ -140,10 +140,10 @@
<div class="auth-list-container" <div class="auth-list-container"
data-rolename="Community TA" data-rolename="Community TA"
data-display-name="${_("Forum Community TAs")}" data-display-name="${_("Discussion Community TAs")}"
data-info-text=" data-info-text="
${_("Community TA's are members of the community whom you deem particularly " ${_("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. " "close and re-open threads, endorse responses, and see posts from all cohorts. "
"Their posts are marked 'Community TA'.")}" "Their posts are marked 'Community TA'.")}"
data-list-endpoint="${ section_data['list_forum_members_url'] }" data-list-endpoint="${ section_data['list_forum_members_url'] }"
......
...@@ -126,10 +126,20 @@ textarea { ...@@ -126,10 +126,20 @@ textarea {
<ul class="list-input"> <ul class="list-input">
<li class="field text"> <li class="field text">
<label for="repo_location"> <label for="repo_location">
${_('Repo location')}: ## Translators: Repo is short for git repository or source of
## courseware
${_('Repo Location')}:
</label> </label>
<input type="text" name="repo_location" style="width:60%" /> <input type="text" name="repo_location" style="width:60%" />
</li> </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> </ul>
<div class="form-actions"> <div class="form-actions">
<button type="submit" name="action" value="add_course">${_('Load new course from github')}</button> <button type="submit" name="action" value="add_course">${_('Load new course from github')}</button>
...@@ -201,6 +211,7 @@ textarea { ...@@ -201,6 +211,7 @@ textarea {
</section> </section>
<div style="text-align:right; float: right"><span id="djangopid">${_('Django PID')}: ${djangopid}</span> <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> </div>
</section> </section>
...@@ -112,7 +112,29 @@ ...@@ -112,7 +112,29 @@
% endif % endif
% if track: % if track:
<li class="video-tracks"> <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> </li>
% endif % endif
</ul> </ul>
......
...@@ -25,4 +25,4 @@ ...@@ -25,4 +25,4 @@
-e git+https://github.com/edx/event-tracking.git@f0211d702d#egg=event-tracking -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/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-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