BLD-1000: Download handout.
This commit is contained in:
@@ -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],
|
||||
71
cms/djangoapps/contentstore/features/video_handout.feature
Normal file
71
cms/djangoapps/contentstore/features/video_handout.feature
Normal file
@@ -0,0 +1,71 @@
|
||||
@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"
|
||||
40
cms/djangoapps/contentstore/features/video_handout.py
Normal file
40
cms/djangoapps/contentstore/features/video_handout.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- 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
|
||||
|
||||
176
cms/static/js/spec/video/file_uploader_editor_spec.js
Normal file
176
cms/static/js/spec/video/file_uploader_editor_spec.js
Normal file
@@ -0,0 +1,176 @@
|
||||
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.$el.find('.create-setting').click();
|
||||
this.view.clear();
|
||||
|
||||
this.view.clear();
|
||||
|
||||
expect(this.view).assertClear({
|
||||
'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 () {
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
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() {
|
||||
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;
|
||||
|
||||
9
cms/templates/js/metadata-file-uploader-entry.underscore
Normal file
9
cms/templates/js/metadata-file-uploader-entry.underscore
Normal file
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
3
cms/templates/js/metadata-file-uploader-item.underscore
Normal file
3
cms/templates/js/metadata-file-uploader-item.underscore
Normal file
@@ -0,0 +1,3 @@
|
||||
<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=""html5_subtitles""
|
||||
track=""http://download_track""
|
||||
track=""http://www.example.com/track""
|
||||
handout=""http://www.example.com/handout""
|
||||
download_track="true"
|
||||
youtube_id_0_75=""OEoXaMPEzf65""
|
||||
youtube_id_1_25=""OEoXaMPEzf125""
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user