Commit 127bf7ed by polesye

BLD-1000: Download handout.

parent f3842dcc
......@@ -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
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.
Studio: Add edit button to leaf xblocks on the container page. STUD-1306.
......
......@@ -11,6 +11,7 @@ VIDEO_BUTTONS = {
'volume': '.volume',
'play': '.video_control.play',
'pause': '.video_control.pause',
'handout': '.video-handout.video-download-button a',
}
SELECTORS = {
......
......@@ -148,6 +148,7 @@ def correct_video_settings(_step):
['Transcript Display', 'True', False],
['Transcript Download Allowed', 'False', False],
['Transcript Translations', '', False],
['Upload Handout', '', False],
['Video Download Allowed', 'False', False],
['Video Sources', '', 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'
define([
"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) ->
it "is valid by default", ->
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"}
@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"}
@model.set("selectedFile", file);
expect(@model.isValid()).toBeFalsy()
expect(@model.isValid()).toBeTruthy()
it "can accept a file type when explicitly set", ->
file = {"type": "image/png", "name": "filename.png"}
......
......@@ -47,7 +47,8 @@ var FileUpload = Backbone.Model.extend({
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);
},
// 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) {
});
});
describe('has a clear method to revert to the model default', function () {
it('w/ popup, if values were changed', function (){
var requests = create_sinon.requests(this),
options;
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('has a clear method to revert to the model default', function () {
setValue(this.view, {
'fr': 'en.srt',
'uk': 'ru.srt'
});
it('w/o popup, if just keys were changed', function (){
setValue(this.view, {
'fr': 'en.srt',
'uk': 'ru.srt'
});
this.view.$el.find('.create-setting').click();
this.view.clear();
this.view.$el.find('.create-setting').click();
expect(this.view).assertClear({
'en': 'en.srt',
'ru': 'ru.srt'
});
this.view.clear();
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 () {
......@@ -261,26 +240,15 @@ function ($, _, create_sinon, Squire) {
expect(this.view.$el.find('select').length).toEqual(5);
});
describe('can remove an entry', function () {
it('w/ popup, if values were changed', function (){
var requests = create_sinon.requests(this),
options;
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);
it('can remove an entry', 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);
});
it('only allows one blank entry at a time', function () {
......
......@@ -8,12 +8,7 @@ define(["js/views/baseview", "underscore"], function(BaseView, _) {
var templateName = _.result(this, 'templateName');
// Backbone model cid is only unique within the collection.
this.uniqueId = _.uniqueId(templateName + "_");
var tpl = document.getElementById(templateName).text;
if(!tpl) {
console.error("Couldn't load template: " + templateName);
}
this.template = _.template(tpl);
this.template = this.loadTemplate(templateName);
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
......@@ -85,6 +80,20 @@ define(["js/views/baseview", "underscore"], function(BaseView, _) {
}
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(
[
"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/translations_editor"
],
function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslations) {
function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, VideoList, VideoTranslations) {
var Metadata = {};
Metadata.Editor = BaseView.extend({
// Model is CMS.Models.MetadataCollection,
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}));
var counter = 0;
var self = this;
this.collection.each(
function (model) {
var data = {
el: self.$el.find('.metadata_entry')[counter++],
locator: locator,
model: model
},
conversions = {
......@@ -85,7 +88,7 @@ function(BaseView, _, MetadataModel, AbstractEditor, VideoList, VideoTranslation
events : {
"change input" : "updateModel",
"keypress .setting-input" : "showClearButton" ,
"keypress .setting-input" : "showClearButton",
"click .setting-clear" : "clear"
},
......@@ -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;
});
......@@ -609,6 +609,41 @@ body.course.unit,.view-unit {
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
.list-input.settings-list {
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 @@
<script id="metadata-editor-tpl" type="text/template">
<%static:include path="js/metadata-editor.underscore" />
</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">
<%static:include path="js/${template_name}.underscore" />
</script>
......
......@@ -8,7 +8,7 @@
<%static:include path="js/metadata-editor.underscore" />
</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">
<%static:include path="js/${template_name}.underscore" />
</script>
......
......@@ -42,8 +42,7 @@ div.video {
margin: 0;
padding: 0;
.video-sources,
.video-tracks {
.video-download-button{
display: inline-block;
vertical-align: top;
margin: ($baseline*.75) ($baseline/2) 0 0;
......
......@@ -189,6 +189,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
<source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ua" src="ukrainian_translation.srt" />
<transcript language="ge" src="german_translation.srt" />
</video>
......@@ -211,6 +212,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'handout': 'http://www.example.com/handout',
'download_track': True,
'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
'data': '',
......@@ -229,6 +231,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="uk" src="ukrainian_translation.srt" />
<transcript language="de" src="german_translation.srt" />
</video>
......@@ -243,6 +246,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=1),
'end_time': datetime.timedelta(seconds=60),
'track': 'http://www.example.com/track',
'handout': 'http://www.example.com/handout',
'download_track': False,
'download_video': False,
'html5_sources': ['http://www.example.com/source.mp4'],
......@@ -273,6 +277,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'handout': None,
'download_track': False,
'download_video': True,
'html5_sources': ['http://www.example.com/source.mp4'],
......@@ -326,6 +331,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'handout': None,
'download_track': False,
'download_video': False,
'html5_sources': [],
......@@ -345,7 +351,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
show_captions="false"
download_video="true"
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"
youtube_id_0_75="&quot;OEoXaMPEzf65&quot;"
youtube_id_1_25="&quot;OEoXaMPEzf125&quot;"
......@@ -362,7 +369,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'show_captions': False,
'start_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_video': True,
'html5_sources': ["source_1", "source_2"],
......@@ -386,6 +394,7 @@ class VideoDescriptorImportTestCase(unittest.TestCase):
'start_time': datetime.timedelta(seconds=0.0),
'end_time': datetime.timedelta(seconds=0.0),
'track': '',
'handout': None,
'download_track': False,
'download_video': False,
'html5_sources': [],
......@@ -509,6 +518,7 @@ class VideoExportTestCase(unittest.TestCase):
desc.start_time = datetime.timedelta(seconds=1.0)
desc.end_time = datetime.timedelta(seconds=60)
desc.track = 'http://www.example.com/track'
desc.handout = 'http://www.example.com/handout'
desc.download_track = True
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
desc.download_video = True
......@@ -520,6 +530,7 @@ class VideoExportTestCase(unittest.TestCase):
<source src="http://www.example.com/source.mp4"/>
<source src="http://www.example.com/source.ogg"/>
<track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ge" src="german_translation.srt" />
<transcript language="ua" src="ukrainian_translation.srt" />
</video>
......
......@@ -143,6 +143,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
'data_dir': getattr(self, 'data_dir', None),
'display_name': self.display_name_with_default,
'end': self.end_time.total_seconds(),
'handout': self.handout,
'id': self.location.html_id(),
'show_captions': json.dumps(self.show_captions),
'sources': sources,
......@@ -248,6 +249,8 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
editable_fields['transcripts']['languages'] = languages
editable_fields['transcripts']['type'] = 'VideoTranslations'
editable_fields['transcripts']['urlRoot'] = self.runtime.handler_url(self, 'studio_transcript', 'translation').rstrip('/?')
editable_fields['handout']['type'] = 'FileUploader'
return editable_fields
@classmethod
......@@ -320,6 +323,11 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
ele.set('src', self.track)
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
for transcript_language in sorted(self.transcripts.keys()):
ele = etree.Element('transcript')
......@@ -422,6 +430,10 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
if track is not None:
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')
if transcripts:
field_data['transcripts'] = {tr.get('language'): tr.get('src') for tr in transcripts}
......
......@@ -15,8 +15,9 @@ class VideoFields(object):
default="Video",
scope=Scope.settings
)
saved_video_position = RelativeTime(
help="Current position in the video",
help="Current position in the video.",
scope=Scope.user_state,
default=datetime.timedelta(seconds=0)
)
......@@ -105,13 +106,13 @@ class VideoFields(object):
)
# Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'}
transcripts = Dict(
help="Add additional transcripts in other languages",
help="Add additional transcripts in other languages.",
display_name="Transcript Translations",
scope=Scope.settings,
default={}
)
transcript_language = String(
help="Preferred language for transcript",
help="Preferred language for transcript.",
display_name="Preferred language for transcript",
scope=Scope.preferences,
default="en"
......@@ -130,12 +131,18 @@ class VideoFields(object):
scope=Scope.user_state,
)
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,
default=1.0
)
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,
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):
'end': 3610.0,
'id': self.item_descriptor.location.html_id(),
'show_captions': 'true',
'handout': None,
'sources': sources,
'speed': 'null',
'general_speed': 1.0,
......@@ -104,6 +105,7 @@ class TestVideoNonYouTube(TestVideo):
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'end': 3610.0,
'id': self.item_descriptor.location.html_id(),
......@@ -203,6 +205,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
expected_context = {
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'end': 3610.0,
'id': None,
......@@ -324,6 +327,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
expected_context = {
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'end': 3610.0,
'id': None,
......@@ -469,6 +473,7 @@ class TestVideoDescriptorInitialization(BaseTestXmodule):
'options': [],
},
'transcripts': {},
'handout': {},
}
):
metadata = {
......
......@@ -107,12 +107,12 @@
<div class="focus_grabber last"></div>
<ul class="wrapper-downloads">
% if sources.get('main'):
<li class="video-sources">
<li class="video-sources video-download-button">
${('<a href="%s">' + _('Download video') + '</a>') % sources.get('main')}
</li>
% endif
% if track:
<li class="video-tracks">
<li class="video-tracks video-download-button">
% if transcript_download_format:
${('<a href="%s">' + _('Download transcript') + '</a>') % track
}
......@@ -138,5 +138,10 @@
% endif
</li>
% endif
% if handout:
<li class="video-handout video-download-button">
${('<a href="%s" target="_blank">' + _('Download Handout') + '</a>') % handout}
</li>
% endif
</ul>
</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