Commit d861a9a0 by Anton Stupak

Merge pull request #3369 from edx/anton/download-handout

Video: Add Download Handout
parents ac980460 127bf7ed
...@@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,9 @@ 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 an upload button for authors to provide students with an option to
download a handout associated with a video (of arbitrary file format). BLD-1000.
Blades: Show the HD button only if there is an HD version available. BLD-937. Blades: Show the HD button only if there is an HD version available. BLD-937.
Studio: Add edit button to leaf xblocks on the container page. STUD-1306. Studio: Add edit button to leaf xblocks on the container page. STUD-1306.
......
...@@ -11,6 +11,7 @@ VIDEO_BUTTONS = { ...@@ -11,6 +11,7 @@ VIDEO_BUTTONS = {
'volume': '.volume', 'volume': '.volume',
'play': '.video_control.play', 'play': '.video_control.play',
'pause': '.video_control.pause', 'pause': '.video_control.pause',
'handout': '.video-handout.video-download-button a',
} }
SELECTORS = { SELECTORS = {
......
...@@ -148,6 +148,7 @@ def correct_video_settings(_step): ...@@ -148,6 +148,7 @@ def correct_video_settings(_step):
['Transcript Display', 'True', False], ['Transcript Display', 'True', False],
['Transcript Download Allowed', 'False', False], ['Transcript Download Allowed', 'False', False],
['Transcript Translations', '', False], ['Transcript Translations', '', False],
['Upload Handout', '', False],
['Video Download Allowed', 'False', False], ['Video Download Allowed', 'False', False],
['Video Sources', '', False], ['Video Sources', '', False],
['Youtube ID', 'OEoXaMPEzfM', False], ['Youtube ID', 'OEoXaMPEzfM', False],
......
@shard_3
Feature: CMS Video Component Handout
As a course author, I want to be able to create video handout
# 1
Scenario: Handout uploading works correctly
Given I have created a Video component with handout file "textbook.pdf"
And I save changes
Then I can see video button "handout"
And I can download handout file with mime type "application/pdf"
# 2
Scenario: Handout downloading works correctly w/ preliminary saving
Given I have created a Video component with handout file "textbook.pdf"
And I save changes
And I edit the component
And I open tab "Advanced"
And I can download handout file in editor with mime type "application/pdf"
# 3
Scenario: Handout downloading works correctly w/o preliminary saving
Given I have created a Video component with handout file "textbook.pdf"
And I can download handout file in editor with mime type "application/pdf"
# 4
Scenario: Handout clearing works correctly w/ preliminary saving
Given I have created a Video component with handout file "textbook.pdf"
And I save changes
And I can download handout file with mime type "application/pdf"
And I edit the component
And I open tab "Advanced"
And I clear handout
And I save changes
Then I do not see video button "handout"
# 5
Scenario: Handout clearing works correctly w/o preliminary saving
Given I have created a Video component with handout file "asset.html"
And I clear handout
And I save changes
Then I do not see video button "handout"
# 6
Scenario: User can easy replace the handout by another one w/ preliminary saving
Given I have created a Video component with handout file "asset.html"
And I save changes
Then I can see video button "handout"
And I can download handout file with mime type "text/html"
And I edit the component
And I open tab "Advanced"
And I replace handout file by "textbook.pdf"
And I save changes
Then I can see video button "handout"
And I can download handout file with mime type "application/pdf"
# 7
Scenario: User can easy replace the handout by another one w/o preliminary saving
Given I have created a Video component with handout file "asset.html"
And I replace handout file by "textbook.pdf"
And I save changes
Then I can see video button "handout"
And I can download handout file with mime type "application/pdf"
# 8
Scenario: Upload file "A" -> Remove it -> Upload file "B"
Given I have created a Video component with handout file "asset.html"
And I clear handout
And I upload handout file "textbook.pdf"
And I save changes
Then I can see video button "handout"
And I can download handout file with mime type "application/pdf"
# -*- coding: utf-8 -*-
# disable missing docstring
# pylint: disable=C0111
from lettuce import world, step
from nose.tools import assert_true # pylint: disable=E0611
from video_editor import RequestHandlerWithSessionId, success_upload_file
@step('I (?:upload|replace) handout file(?: by)? "([^"]*)"$')
def upload_handout(step, filename):
world.css_click('.wrapper-comp-setting.file-uploader .upload-action')
success_upload_file(filename)
@step('I can download handout file( in editor)? with mime type "([^"]*)"$')
def i_can_download_handout_with_mime_type(_step, is_editor, mime_type):
if is_editor:
selector = '.wrapper-comp-setting.file-uploader .download-action'
else:
selector = '.video-handout.video-download-button a'
button = world.css_find(selector).first
url = button['href']
request = RequestHandlerWithSessionId()
assert_true(request.get(url).is_success())
assert_true(request.check_header('content-type', mime_type))
@step('I clear handout$')
def clear_handout(_step):
world.css_click('.wrapper-comp-setting.file-uploader .setting-clear')
@step('I have created a Video component with handout file "([^"]*)"')
def create_video_with_handout(_step, filename):
_step.given('I have created a Video component')
_step.given('I edit the component')
_step.given('I open tab "Advanced"')
_step.given('I upload handout file "{0}"'.format(filename))
...@@ -175,5 +175,6 @@ jasmine.getFixtures().fixturesPath += 'coffee/fixtures' ...@@ -175,5 +175,6 @@ jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
define([ define([
"coffee/spec/views/assets_spec", "coffee/spec/views/assets_spec",
"js/spec/video/translations_editor_spec" "js/spec/video/translations_editor_spec",
"js/spec/video/file_uploader_editor_spec"
]) ])
...@@ -13,15 +13,15 @@ define ["js/models/uploads"], (FileUpload) -> ...@@ -13,15 +13,15 @@ define ["js/models/uploads"], (FileUpload) ->
it "is valid by default", -> it "is valid by default", ->
expect(@model.isValid()).toBeTruthy() expect(@model.isValid()).toBeTruthy()
it "is invalid for text files by default", -> it "is valid for text files by default", ->
file = {"type": "text/plain", "name": "filename.txt"} file = {"type": "text/plain", "name": "filename.txt"}
@model.set("selectedFile", file); @model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy() expect(@model.isValid()).toBeTruthy()
it "is invalid for PNG files by default", -> it "is valid for PNG files by default", ->
file = {"type": "image/png", "name": "filename.png"} file = {"type": "image/png", "name": "filename.png"}
@model.set("selectedFile", file); @model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy() expect(@model.isValid()).toBeTruthy()
it "can accept a file type when explicitly set", -> it "can accept a file type when explicitly set", ->
file = {"type": "image/png", "name": "filename.png"} file = {"type": "image/png", "name": "filename.png"}
......
...@@ -47,7 +47,8 @@ var FileUpload = Backbone.Model.extend({ ...@@ -47,7 +47,8 @@ var FileUpload = Backbone.Model.extend({
return RegExp(('(?:.+)\\.(' + formats.join('|') + ')$'), 'i'); return RegExp(('(?:.+)\\.(' + formats.join('|') + ')$'), 'i');
}; };
return _.contains(attrs.mimeTypes, file.type) || return (attrs.mimeTypes.length === 0 && attrs.fileFormats.length === 0) ||
_.contains(attrs.mimeTypes, file.type) ||
getRegExp(attrs.fileFormats).test(file.name); getRegExp(attrs.fileFormats).test(file.name);
}, },
// Return strings for the valid file types and extensions this // Return strings for the valid file types and extensions this
......
define(
[
'jquery', 'underscore', 'js/spec_helpers/create_sinon', 'squire'
],
function ($, _, create_sinon, Squire) {
'use strict';
describe('FileUploader', function () {
var FileUploaderTemplate = readFixtures(
'metadata-file-uploader-entry.underscore'
),
FileUploaderItemTemplate = readFixtures(
'metadata-file-uploader-item.underscore'
),
locator = 'locator',
feedbackTpl = readFixtures('system-feedback.underscore'),
modelStub = {
default_value: 'http://example.org/test_1',
display_name: 'File Upload',
explicitly_set: false,
field_name: 'file_upload',
help: 'Specifies the name for this component.',
type: 'FileUploader',
value: 'http://example.org/test_1'
},
self, injector;
var setValue = function (view, value) {
view.setValueInEditor(value);
view.updateModel();
};
var createPromptSpy = function (name) {
var spy = jasmine.createSpyObj(name, ['constructor', 'show', 'hide']);
spy.constructor.andReturn(spy);
spy.show.andReturn(spy);
spy.extend = jasmine.createSpy().andReturn(spy.constructor);
return spy;
};
beforeEach(function () {
self = this;
this.addMatchers({
assertValueInView: function(expected) {
var value = this.actual.getValueFromEditor();
return this.env.equals_(value, expected);
},
assertCanUpdateView: function (expected) {
var view = this.actual,
value;
view.setValueInEditor(expected);
value = view.getValueFromEditor();
return this.env.equals_(value, expected);
},
assertClear: function (modelValue) {
var env = this.env,
view = this.actual,
model = view.model;
return model.getValue() === null &&
env.equals_(model.getDisplayValue(), modelValue) &&
env.equals_(view.getValueFromEditor(), modelValue);
},
assertUpdateModel: function (originalValue, newValue) {
var env = this.env,
view = this.actual,
model = view.model,
expectOriginal;
view.setValueInEditor(newValue);
expectOriginal = env.equals_(model.getValue(), originalValue);
view.updateModel();
return expectOriginal &&
env.equals_(model.getValue(), newValue);
},
verifyButtons: function (upload, download, index) {
var view = this.actual,
uploadBtn = view.$('.upload-setting'),
downloadBtn = view.$('.download-setting');
upload = upload ? uploadBtn.length : !uploadBtn.length;
download = download ? downloadBtn.length : !downloadBtn.length;
return upload && download;
}
});
appendSetFixtures($('<script>', {
id: 'metadata-file-uploader-entry',
type: 'text/template'
}).text(FileUploaderTemplate));
appendSetFixtures($('<script>', {
id: 'metadata-file-uploader-item',
type: 'text/template'
}).text(FileUploaderItemTemplate));
this.uploadSpies = createPromptSpy('UploadDialog');
injector = new Squire();
injector.mock('js/views/uploads', function () {
return self.uploadSpies.constructor;
});
injector.mock('js/views/video/transcripts/metadata_videolist');
injector.mock('js/views/video/translations_editor');
runs(function() {
injector.require([
'js/models/metadata', 'js/views/metadata'
],
function(MetadataModel, MetadataView) {
var model = new MetadataModel($.extend(true, {}, modelStub));
self.view = new MetadataView.FileUploader({
model: model,
locator: locator
});
});
});
waitsFor(function() {
return self.view;
}, 'FileUploader was not created', 2000);
});
afterEach(function () {
injector.clean();
injector.remove();
});
it('returns the initial value upon initialization', function () {
expect(this.view).assertValueInView('http://example.org/test_1');
expect(this.view).verifyButtons(true, true);
});
it('updates its value correctly', function () {
expect(this.view).assertCanUpdateView('http://example.org/test_2');
});
it('upload works correctly', function () {
var options;
setValue(this.view, '');
expect(this.view).verifyButtons(true, false);
this.view.$el.find('.upload-setting').click();
expect(this.uploadSpies.constructor).toHaveBeenCalled();
expect(this.uploadSpies.show).toHaveBeenCalled();
options = this.uploadSpies.constructor.mostRecentCall.args[0];
options.onSuccess({
'asset': {
'url': 'http://example.org/test_3'
}
});
expect(this.view).verifyButtons(true, true);
expect(this.view.getValueFromEditor()).toEqual('http://example.org/test_3');
});
it('has a clear method to revert to the model default', function () {
setValue(this.view, 'http://example.org/test_5');
this.view.clear();
expect(this.view).assertClear('http://example.org/test_1');
});
it('has an update model method', function () {
expect(this.view).assertUpdateModel(null, 'http://example.org/test_6');
});
});
});
...@@ -212,43 +212,22 @@ function ($, _, create_sinon, Squire) { ...@@ -212,43 +212,22 @@ function ($, _, create_sinon, Squire) {
}); });
}); });
describe('has a clear method to revert to the model default', function () { it('has a clear method to revert to the model default', function () {
it('w/ popup, if values were changed', function (){ setValue(this.view, {
var requests = create_sinon.requests(this), 'fr': 'en.srt',
options; 'uk': 'ru.srt'
setValue(this.view, {
'fr': 'fr.srt',
'uk': 'uk.srt'
});
this.view.$el.find('.create-setting').click();
this.view.clear();
expect(this.view).assertClear({
'en': 'en.srt',
'ru': 'ru.srt'
});
expect(this.view.$el.find('.create-setting')).not.toHaveClass('is-disabled');
}); });
it('w/o popup, if just keys were changed', function (){ this.view.$el.find('.create-setting').click();
setValue(this.view, {
'fr': 'en.srt',
'uk': 'ru.srt'
});
this.view.$el.find('.create-setting').click();
this.view.clear();
expect(this.view).assertClear({ this.view.clear();
'en': 'en.srt',
'ru': 'ru.srt'
});
expect(this.view.$el.find('.create-setting')).not.toHaveClass('is-disabled'); expect(this.view).assertClear({
'en': 'en.srt',
'ru': 'ru.srt'
}); });
expect(this.view.$el.find('.create-setting')).not.toHaveClass('is-disabled');
}); });
it('has an update model method', function () { it('has an update model method', function () {
...@@ -261,26 +240,15 @@ function ($, _, create_sinon, Squire) { ...@@ -261,26 +240,15 @@ function ($, _, create_sinon, Squire) {
expect(this.view.$el.find('select').length).toEqual(5); expect(this.view.$el.find('select').length).toEqual(5);
}); });
describe('can remove an entry', function () { it('can remove an entry', function () {
it('w/ popup, if values were changed', function (){ setValue(this.view, {
var requests = create_sinon.requests(this), 'en': 'en.srt',
options; 'ru': 'ru.srt',
'fr': ''
expect(_.keys(this.view.model.get('value')).length).toEqual(4);
this.view.$el.find('.remove-setting').last().click();
expect(_.keys(this.view.model.get('value')).length).toEqual(3);
});
it('w/o popup, if just keys were changed', function (){
setValue(this.view, {
'en': 'en.srt',
'ru': 'ru.srt',
'fr': ''
});
expect(_.keys(this.view.model.get('value')).length).toEqual(3);
this.view.$el.find('.remove-setting').last().click();
expect(_.keys(this.view.model.get('value')).length).toEqual(2);
}); });
expect(_.keys(this.view.model.get('value')).length).toEqual(3);
this.view.$el.find('.remove-setting').last().click();
expect(_.keys(this.view.model.get('value')).length).toEqual(2);
}); });
it('only allows one blank entry at a time', function () { it('only allows one blank entry at a time', function () {
......
...@@ -8,12 +8,7 @@ define(["js/views/baseview", "underscore"], function(BaseView, _) { ...@@ -8,12 +8,7 @@ define(["js/views/baseview", "underscore"], function(BaseView, _) {
var templateName = _.result(this, 'templateName'); var templateName = _.result(this, 'templateName');
// Backbone model cid is only unique within the collection. // Backbone model cid is only unique within the collection.
this.uniqueId = _.uniqueId(templateName + "_"); this.uniqueId = _.uniqueId(templateName + "_");
this.template = this.loadTemplate(templateName);
var tpl = document.getElementById(templateName).text;
if(!tpl) {
console.error("Couldn't load template: " + templateName);
}
this.template = _.template(tpl);
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId})); this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render); this.listenTo(this.model, 'change', this.render);
this.render(); this.render();
...@@ -85,6 +80,20 @@ define(["js/views/baseview", "underscore"], function(BaseView, _) { ...@@ -85,6 +80,20 @@ define(["js/views/baseview", "underscore"], function(BaseView, _) {
} }
return this; return this;
},
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @returns The loaded template.
*/
loadTemplate: function(name) {
var templateSelector = "#" + name,
templateText = $(templateSelector).text();
if (!templateText) {
console.error("Failed to load " + name + " template");
}
return _.template(templateText);
} }
}); });
......
define( define(
[ [
"js/views/baseview", "underscore", "js/models/metadata", "js/views/abstract_editor", "js/views/baseview", "underscore", "js/models/metadata", "js/views/abstract_editor",
"js/models/uploads", "js/views/uploads",
"js/views/video/transcripts/metadata_videolist", "js/views/video/transcripts/metadata_videolist",
"js/views/video/translations_editor" "js/views/video/translations_editor"
], ],
function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslations) { function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, VideoList, VideoTranslations) {
var Metadata = {}; var Metadata = {};
Metadata.Editor = BaseView.extend({ Metadata.Editor = BaseView.extend({
// Model is CMS.Models.MetadataCollection, // Model is CMS.Models.MetadataCollection,
initialize : function() { initialize : function() {
this.template = this.loadTemplate('metadata-editor'); var self = this,
counter = 0,
locator = self.$el.closest('[data-locator]').data('locator');
this.template = this.loadTemplate('metadata-editor');
this.$el.html(this.template({numEntries: this.collection.length})); this.$el.html(this.template({numEntries: this.collection.length}));
var counter = 0;
var self = this;
this.collection.each( this.collection.each(
function (model) { function (model) {
var data = { var data = {
el: self.$el.find('.metadata_entry')[counter++], el: self.$el.find('.metadata_entry')[counter++],
locator: locator,
model: model model: model
}, },
conversions = { conversions = {
...@@ -85,7 +88,7 @@ function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslation ...@@ -85,7 +88,7 @@ function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslation
events : { events : {
"change input" : "updateModel", "change input" : "updateModel",
"keypress .setting-input" : "showClearButton" , "keypress .setting-input" : "showClearButton",
"click .setting-clear" : "clear" "click .setting-clear" : "clear"
}, },
...@@ -487,5 +490,62 @@ function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslation ...@@ -487,5 +490,62 @@ function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslation
} }
}); });
/**
* Provides convenient way to upload/download files in component edit.
* The editor uploads files directly to course assets and stores link
* to uploaded file.
*/
Metadata.FileUploader = AbstractEditor.extend({
events : {
"click .upload-setting" : "upload",
"click .setting-clear" : "clear"
},
templateName: "metadata-file-uploader-entry",
templateButtonsName: "metadata-file-uploader-item",
initialize: function () {
this.buttonTemplate = this.loadTemplate(this.templateButtonsName);
AbstractEditor.prototype.initialize.apply(this);
},
getValueFromEditor: function () {
return this.$('#' + this.uniqueId).val();
},
setValueInEditor: function (value) {
var html = this.buttonTemplate({
model: this.model,
uniqueId: this.uniqueId
});
this.$('#' + this.uniqueId).val(value);
this.$('.wrapper-uploader-actions').html(html);
},
upload: function (event) {
var self = this,
target = $(event.currentTarget),
url = /assets/ + this.options.locator,
model = new FileUpload({
title: gettext('Upload File'),
}),
view = new UploadDialog({
model: model,
url: url,
parentElement: target.closest('.xblock-editor'),
onSuccess: function (response) {
if (response['asset'] && response['asset']['url']) {
self.model.setValue(response['asset']['url']);
}
}
}).show();
event.preventDefault();
}
});
return Metadata; return Metadata;
}); });
...@@ -609,6 +609,41 @@ body.course.unit,.view-unit { ...@@ -609,6 +609,41 @@ body.course.unit,.view-unit {
display: block; display: block;
} }
.file-uploader {
.upload-setting {
@extend %ui-btn-flat-outline;
@extend %t-action3;
@include box-sizing(border-box);
display: inline-block;
padding: ($baseline/2);
font-weight: 600;
width: 49%;
margin-right: 2%;
}
.download-setting {
@extend %ui-btn-non;
@extend %t-action4;
@include box-sizing(border-box);
display: inline-block;
padding: ($baseline/2);
font-weight: 600;
width: 49%;
text-align: center;
color: $blue;
&:hover {
background-color: $blue;
}
}
.wrapper-uploader-actions {
width: 45%;
display: inline-block;
min-width: ($baseline*5);
}
}
//settings-list //settings-list
.list-input.settings-list { .list-input.settings-list {
margin: 0; margin: 0;
......
<div class="wrapper-comp-setting file-uploader">
<label class="label setting-label"><%= model.get('display_name') %></label>
<input type="hidden" id="<%= uniqueId %>" class="input setting-input" value="<%= model.get("value") %>">
<div class="wrapper-uploader-actions"></div>
<button class="action setting-clear inactive" type="button" name="setting-clear" value="<%= gettext("Clear") %>" data-tooltip="<%= gettext("Clear") %>">
<i class="icon-undo"></i><span class="sr">"<%= gettext("Clear Value") %>"</span>
</button>
</div>
<span class="tip setting-help"><%= model.get('help') %></span>
<a href="#" class="upload-action upload-setting"><%= model.get('value') ? gettext('Replace') : gettext('Upload') %>
</a><% if (model.get('value')) { %><a href="<%= model.get("value") %>" target="_blank" class="download-action download-setting"><%= gettext("Download") %>
</a><% } %>
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
<script id="metadata-editor-tpl" type="text/template"> <script id="metadata-editor-tpl" type="text/template">
<%static:include path="js/metadata-editor.underscore" /> <%static:include path="js/metadata-editor.underscore" />
</script> </script>
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry"]: % for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
<script id="${template_name}" type="text/template"> <script id="${template_name}" type="text/template">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<%static:include path="js/metadata-editor.underscore" /> <%static:include path="js/metadata-editor.underscore" />
</script> </script>
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry"]: % for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
<script id="${template_name}" type="text/template"> <script id="${template_name}" type="text/template">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
......
...@@ -42,8 +42,7 @@ div.video { ...@@ -42,8 +42,7 @@ div.video {
margin: 0; margin: 0;
padding: 0; padding: 0;
.video-sources, .video-download-button{
.video-tracks {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
margin: ($baseline*.75) ($baseline/2) 0 0; margin: ($baseline*.75) ($baseline/2) 0 0;
......
...@@ -189,6 +189,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -189,6 +189,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
<source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/> <source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/> <track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ua" src="ukrainian_translation.srt" /> <transcript language="ua" src="ukrainian_translation.srt" />
<transcript language="ge" src="german_translation.srt" /> <transcript language="ge" src="german_translation.srt" />
</video> </video>
...@@ -211,6 +212,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -211,6 +212,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1), 'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60), 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track', 'track': 'http://www.example.com/track',
'handout': 'http://www.example.com/handout',
'download_track': True, 'download_track': True,
'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'], 'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
'data': '', 'data': '',
...@@ -229,6 +231,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -229,6 +231,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
end_time="00:01:00"> end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/> <track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="uk" src="ukrainian_translation.srt" /> <transcript language="uk" src="ukrainian_translation.srt" />
<transcript language="de" src="german_translation.srt" /> <transcript language="de" src="german_translation.srt" />
</video> </video>
...@@ -243,6 +246,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -243,6 +246,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1), 'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60), 'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track', 'track': 'http://www.example.com/track',
'handout': 'http://www.example.com/handout',
'download_track': False, 'download_track': False,
'download_video': False, 'download_video': False,
'html5_sources': ['http://www.example.com/source.mp4'], 'html5_sources': ['http://www.example.com/source.mp4'],
...@@ -273,6 +277,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -273,6 +277,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0), 'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0), 'end_time': datetime.timedelta(seconds=0.0),
'track': '', 'track': '',
'handout': None,
'download_track': False, 'download_track': False,
'download_video': True, 'download_video': True,
'html5_sources': ['http://www.example.com/source.mp4'], 'html5_sources': ['http://www.example.com/source.mp4'],
...@@ -326,6 +331,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -326,6 +331,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0), 'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0), 'end_time': datetime.timedelta(seconds=0.0),
'track': '', 'track': '',
'handout': None,
'download_track': False, 'download_track': False,
'download_video': False, 'download_video': False,
'html5_sources': [], 'html5_sources': [],
...@@ -345,7 +351,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -345,7 +351,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
show_captions="false" show_captions="false"
download_video="true" download_video="true"
sub="&quot;html5_subtitles&quot;" sub="&quot;html5_subtitles&quot;"
track="&quot;http://download_track&quot;" track="&quot;http://www.example.com/track&quot;"
handout="&quot;http://www.example.com/handout&quot;"
download_track="true" download_track="true"
youtube_id_0_75="&quot;OEoXaMPEzf65&quot;" youtube_id_0_75="&quot;OEoXaMPEzf65&quot;"
youtube_id_1_25="&quot;OEoXaMPEzf125&quot;" youtube_id_1_25="&quot;OEoXaMPEzf125&quot;"
...@@ -362,7 +369,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -362,7 +369,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'show_captions': False, 'show_captions': False,
'start_time': datetime.timedelta(seconds=0.0), 'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0), 'end_time': datetime.timedelta(seconds=0.0),
'track': 'http://download_track', 'track': 'http://www.example.com/track',
'handout': 'http://www.example.com/handout',
'download_track': True, 'download_track': True,
'download_video': True, 'download_video': True,
'html5_sources': ["source_1", "source_2"], 'html5_sources': ["source_1", "source_2"],
...@@ -386,6 +394,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase): ...@@ -386,6 +394,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0), 'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0), 'end_time': datetime.timedelta(seconds=0.0),
'track': '', 'track': '',
'handout': None,
'download_track': False, 'download_track': False,
'download_video': False, 'download_video': False,
'html5_sources': [], 'html5_sources': [],
...@@ -509,6 +518,7 @@ class VideoExportTestCase(unittest.TestCase): ...@@ -509,6 +518,7 @@ class VideoExportTestCase(unittest.TestCase):
desc.start_time = datetime.timedelta(seconds=1.0) desc.start_time = datetime.timedelta(seconds=1.0)
desc.end_time = datetime.timedelta(seconds=60) desc.end_time = datetime.timedelta(seconds=60)
desc.track = 'http://www.example.com/track' desc.track = 'http://www.example.com/track'
desc.handout = 'http://www.example.com/handout'
desc.download_track = True desc.download_track = True
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'] desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
desc.download_video = True desc.download_video = True
...@@ -520,6 +530,7 @@ class VideoExportTestCase(unittest.TestCase): ...@@ -520,6 +530,7 @@ class VideoExportTestCase(unittest.TestCase):
<source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/> <source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/> <track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ge" src="german_translation.srt" /> <transcript language="ge" src="german_translation.srt" />
<transcript language="ua" src="ukrainian_translation.srt" /> <transcript language="ua" src="ukrainian_translation.srt" />
</video> </video>
......
...@@ -143,6 +143,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): ...@@ -143,6 +143,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
'display_name': self.display_name_with_default, 'display_name': self.display_name_with_default,
'end': self.end_time.total_seconds(), 'end': self.end_time.total_seconds(),
'handout': self.handout,
'id': self.location.html_id(), 'id': self.location.html_id(),
'show_captions': json.dumps(self.show_captions), 'show_captions': json.dumps(self.show_captions),
'sources': sources, 'sources': sources,
...@@ -248,6 +249,8 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto ...@@ -248,6 +249,8 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
editable_fields['transcripts']['languages'] = languages editable_fields['transcripts']['languages'] = languages
editable_fields['transcripts']['type'] = 'VideoTranslations' editable_fields['transcripts']['type'] = 'VideoTranslations'
editable_fields['transcripts']['urlRoot'] = self.runtime.handler_url(self, 'studio_transcript', 'translation').rstrip('/?') editable_fields['transcripts']['urlRoot'] = self.runtime.handler_url(self, 'studio_transcript', 'translation').rstrip('/?')
editable_fields['handout']['type'] = 'FileUploader'
return editable_fields return editable_fields
@classmethod @classmethod
...@@ -320,6 +323,11 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto ...@@ -320,6 +323,11 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
ele.set('src', self.track) ele.set('src', self.track)
xml.append(ele) xml.append(ele)
if self.handout:
ele = etree.Element('handout')
ele.set('src', self.handout)
xml.append(ele)
# sorting for easy testing of resulting xml # sorting for easy testing of resulting xml
for transcript_language in sorted(self.transcripts.keys()): for transcript_language in sorted(self.transcripts.keys()):
ele = etree.Element('transcript') ele = etree.Element('transcript')
...@@ -422,6 +430,10 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto ...@@ -422,6 +430,10 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
if track is not None: if track is not None:
field_data['track'] = track.get('src') field_data['track'] = track.get('src')
handout = xml.find('handout')
if handout is not None:
field_data['handout'] = handout.get('src')
transcripts = xml.findall('transcript') transcripts = xml.findall('transcript')
if transcripts: if transcripts:
field_data['transcripts'] = {tr.get('language'): tr.get('src') for tr in transcripts} field_data['transcripts'] = {tr.get('language'): tr.get('src') for tr in transcripts}
......
...@@ -15,8 +15,9 @@ class VideoFields(object): ...@@ -15,8 +15,9 @@ class VideoFields(object):
default="Video", default="Video",
scope=Scope.settings scope=Scope.settings
) )
saved_video_position = RelativeTime( saved_video_position = RelativeTime(
help="Current position in the video", help="Current position in the video.",
scope=Scope.user_state, scope=Scope.user_state,
default=datetime.timedelta(seconds=0) default=datetime.timedelta(seconds=0)
) )
...@@ -105,13 +106,13 @@ class VideoFields(object): ...@@ -105,13 +106,13 @@ class VideoFields(object):
) )
# Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'} # Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'}
transcripts = Dict( transcripts = Dict(
help="Add additional transcripts in other languages", help="Add additional transcripts in other languages.",
display_name="Transcript Translations", display_name="Transcript Translations",
scope=Scope.settings, scope=Scope.settings,
default={} default={}
) )
transcript_language = String( transcript_language = String(
help="Preferred language for transcript", help="Preferred language for transcript.",
display_name="Preferred language for transcript", display_name="Preferred language for transcript",
scope=Scope.preferences, scope=Scope.preferences,
default="en" default="en"
...@@ -130,12 +131,18 @@ class VideoFields(object): ...@@ -130,12 +131,18 @@ class VideoFields(object):
scope=Scope.user_state, scope=Scope.user_state,
) )
global_speed = Float( global_speed = Float(
help="Default speed in cases when speed wasn't explicitly for specific video", help="Default speed in cases when speed wasn't explicitly for specific video.",
scope=Scope.preferences, scope=Scope.preferences,
default=1.0 default=1.0
) )
youtube_is_available = Boolean( youtube_is_available = Boolean(
help="The availaibility of YouTube API for the user", help="The availaibility of YouTube API for the user.",
scope=Scope.user_info, scope=Scope.user_info,
default=True default=True
) )
handout = String(
help="Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video.",
display_name="Upload Handout",
scope=Scope.settings,
)
...@@ -41,6 +41,7 @@ class TestVideoYouTube(TestVideo): ...@@ -41,6 +41,7 @@ class TestVideoYouTube(TestVideo):
'end': 3610.0, 'end': 3610.0,
'id': self.item_descriptor.location.html_id(), 'id': self.item_descriptor.location.html_id(),
'show_captions': 'true', 'show_captions': 'true',
'handout': None,
'sources': sources, 'sources': sources,
'speed': 'null', 'speed': 'null',
'general_speed': 1.0, 'general_speed': 1.0,
...@@ -104,6 +105,7 @@ class TestVideoNonYouTube(TestVideo): ...@@ -104,6 +105,7 @@ class TestVideoNonYouTube(TestVideo):
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true', 'show_captions': 'true',
'handout': None,
'display_name': u'A Name', 'display_name': u'A Name',
'end': 3610.0, 'end': 3610.0,
'id': self.item_descriptor.location.html_id(), 'id': self.item_descriptor.location.html_id(),
...@@ -203,6 +205,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -203,6 +205,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
expected_context = { expected_context = {
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true', 'show_captions': 'true',
'handout': None,
'display_name': u'A Name', 'display_name': u'A Name',
'end': 3610.0, 'end': 3610.0,
'id': None, 'id': None,
...@@ -324,6 +327,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -324,6 +327,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
expected_context = { expected_context = {
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true', 'show_captions': 'true',
'handout': None,
'display_name': u'A Name', 'display_name': u'A Name',
'end': 3610.0, 'end': 3610.0,
'id': None, 'id': None,
...@@ -469,6 +473,7 @@ class TestVideoDescriptorInitialization(BaseTestXmodule): ...@@ -469,6 +473,7 @@ class TestVideoDescriptorInitialization(BaseTestXmodule):
'options': [], 'options': [],
}, },
'transcripts': {}, 'transcripts': {},
'handout': {},
} }
): ):
metadata = { metadata = {
......
...@@ -107,12 +107,12 @@ ...@@ -107,12 +107,12 @@
<div class="focus_grabber last"></div> <div class="focus_grabber last"></div>
<ul class="wrapper-downloads"> <ul class="wrapper-downloads">
% if sources.get('main'): % if sources.get('main'):
<li class="video-sources"> <li class="video-sources video-download-button">
${('<a href="%s">' + _('Download video') + '</a>') % sources.get('main')} ${('<a href="%s">' + _('Download video') + '</a>') % sources.get('main')}
</li> </li>
% endif % endif
% if track: % if track:
<li class="video-tracks"> <li class="video-tracks video-download-button">
% if transcript_download_format: % if transcript_download_format:
${('<a href="%s">' + _('Download transcript') + '</a>') % track ${('<a href="%s">' + _('Download transcript') + '</a>') % track
} }
...@@ -138,5 +138,10 @@ ...@@ -138,5 +138,10 @@
% endif % endif
</li> </li>
% endif % endif
% if handout:
<li class="video-handout video-download-button">
${('<a href="%s" target="_blank">' + _('Download Handout') + '</a>') % handout}
</li>
% endif
</ul> </ul>
</div> </div>
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