Commit aecc20af by Anton Stupak Committed by polesye

Add Timed Transcripts Editor.

parent 1f7bb112
......@@ -64,6 +64,8 @@ LMS: Improved accessibility of parts of forum navigation sidebar.
LMS: enhanced accessibility labeling and aria support for the discussion forum
new post dropdown as well as response and comment area labeling.
Blades: Add Studio timed transcripts editor to video player.
LMS: enhanced shib support, including detection of linked shib account
at login page and support for the ?next= GET parameter.
......
......@@ -51,6 +51,13 @@ def create_component_instance(step, category, component_type=None, is_advanced=F
module_count_before + 1))
@world.absorb
def click_new_component_button(step, component_button_css):
step.given('I have clicked the new unit button')
world.css_click(component_button_css)
def _click_advanced():
css = 'ul.problem-type-tabs a[href="#tab2"]'
world.css_click(css)
......@@ -122,24 +129,29 @@ def verify_setting_entry(setting, display_name, value, explicitly_set):
----------
setting: the WebDriverElement object found in the browser
display_name: the string expected as the label
value: the expected field value
html: the expected field value
explicitly_set: True if the value is expected to have been explicitly set
for the problem, rather than derived from the defaults. This is verified
by the existence of a "Clear" button next to the field value.
"""
assert_equal(display_name, setting.find_by_css('.setting-label')[0].value)
assert_equal(display_name, setting.find_by_css('.setting-label')[0].html)
# Check if the web object is a list type
# If so, we use a slightly different mechanism for determining its value
if setting.has_class('metadata-list-enum'):
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
assert_equal(value, list_value)
elif setting.has_class('metadata-videolist-enum'):
list_value = ', '.join(ele.find_by_css('input')[0].value for ele in setting.find_by_css('.videolist-settings-item'))
assert_equal(value, list_value)
else:
assert_equal(value, setting.find_by_css('.setting-input')[0].value)
settingClearButton = setting.find_by_css('.setting-clear')[0]
assert_equal(explicitly_set, settingClearButton.has_class('active'))
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
# VideoList doesn't have clear button
if not setting.has_class('metadata-videolist-enum'):
settingClearButton = setting.find_by_css('.setting-clear')[0]
assert_equal(explicitly_set, settingClearButton.has_class('active'))
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
@world.absorb
......
# disable missing docstring
# pylint: disable=C0111
import os
from lettuce import world, step
from django.conf import settings
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
# We should wait 300 ms for event handler invocation + 200ms for safety.
DELAY = 0.5
ERROR_MESSAGES = {
'url_format': u'Incorrect url format.',
'file_type': u'Link types should be unique.',
}
STATUSES = {
'found': u'Timed Transcript Found',
'not found': u'No Timed Transcript',
'replace': u'Timed Transcript Conflict',
'uploaded_successfully': u'Timed Transcript uploaded successfully',
'use existing': u'Timed Transcript Not Updated',
}
SELECTORS = {
'error_bar': '.transcripts-error-message',
'url_inputs': '.videolist-settings-item input.input',
'collapse_link': '.collapse-action.collapse-setting',
'collapse_bar': '.videolist-extra-videos',
'status_bar': '.transcripts-message-status',
}
# button type , button css selector, button message
BUTTONS = {
'import': ('.setting-import', 'Import from YouTube'),
'download_to_edit': ('.setting-download', 'Download to Edit'),
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download to Edit'),
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Timed Transcript'),
'replace': ('.setting-replace', 'Yes, Replace EdX Timed Transcript with YouTube Timed Transcript'),
'choose': ('.setting-choose', 'Timed Transcript from {}'),
'use_existing': ('.setting-use-existing', 'Use Existing Timed Transcript'),
}
@step('I clear fields$')
def clear_fields(_step):
js_str = '''
$('{selector}')
.eq({index})
.prop('disabled', false)
.removeClass('is-disabled');
'''
for index in range(1, 4):
js = js_str.format(selector=SELECTORS['url_inputs'], index=index - 1)
world.browser.execute_script(js)
_step.given('I clear field number {0}'.format(index))
@step('I clear field number (.+)$')
def clear_field(_step, index):
index = int(index) - 1
world.css_fill(SELECTORS['url_inputs'], '', index)
# In some reason chromeDriver doesn't trigger 'input' event after filling
# field by an empty value. That's why we trigger it manually via jQuery.
world.trigger_event(SELECTORS['url_inputs'], event='input', index=index)
@step('I expect (.+) inputs are disabled$')
def inputs_are_disabled(_step, indexes):
index_list = [int(i.strip()) - 1 for i in indexes.split(',')]
for index in index_list:
el = world.css_find(SELECTORS['url_inputs'])[index]
assert el['disabled']
@step('I expect inputs are enabled$')
def inputs_are_enabled(_step):
for index in range(3):
el = world.css_find(SELECTORS['url_inputs'])[index]
assert not el['disabled']
@step('I do not see error message$')
def i_do_not_see_error_message(_step):
world.wait(DELAY)
assert not world.css_visible(SELECTORS['error_bar'])
@step('I see error message "([^"]*)"$')
def i_see_error_message(_step, error):
world.wait(DELAY)
assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error.strip()])
@step('I do not see status message$')
def i_do_not_see_status_message(_step):
world.wait(DELAY)
world.wait_for_ajax_complete()
assert not world.css_visible(SELECTORS['status_bar'])
@step('I see status message "([^"]*)"$')
def i_see_status_message(_step, status):
world.wait(DELAY)
world.wait_for_ajax_complete()
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
@step('I (.*)see button "([^"]*)"$')
def i_see_button(_step, not_see, button_type):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
if not_see.strip():
assert world.is_css_not_present(BUTTONS[button][0])
else:
assert world.css_has_text(BUTTONS[button][0], BUTTONS[button][1])
@step('I (.*)see (.*)button "([^"]*)" number (\d+)$')
def i_see_button_with_custom_text(_step, not_see, button_type, custom_text, index):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
custom_text = custom_text.strip()
index = int(index.strip()) - 1
if not_see.strip():
assert world.is_css_not_present(BUTTONS[button][0])
else:
assert world.css_has_text(BUTTONS[button][0], BUTTONS[button][1].format(custom_text), index)
@step('I click button "([^"]*)"$')
def click_button(_step, button_type):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
world.css_click(BUTTONS[button][0])
@step('I click button "([^"]*)" number (\d+)$')
def click_button_index(_step, button_type, index):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
index = int(index.strip()) - 1
world.css_click(BUTTONS[button][0], index)
@step('I remove "([^"]+)" transcripts id from store')
def remove_transcripts_from_store(_step, subs_id):
"""Remove from store, if transcripts content exists."""
filename = 'subs_{0}.srt.sjson'.format(subs_id.strip())
content_location = StaticContent.compute_location(
world.scenario_dict['COURSE'].org,
world.scenario_dict['COURSE'].number,
filename
)
try:
content = contentstore().find(content_location)
contentstore().delete(content.get_id())
print('Transcript file was removed from store.')
except NotFoundError:
print('Transcript file was NOT found and not removed.')
@step('I enter a "([^"]+)" source to field number (\d+)$')
def i_enter_a_source(_step, link, index):
world.wait(DELAY)
world.wait_for_ajax_complete()
index = int(index) - 1
if index is not 0 and not world.css_visible(SELECTORS['collapse_bar']):
world.css_click(SELECTORS['collapse_link'])
assert world.css_visible(SELECTORS['collapse_bar'])
world.css_fill(SELECTORS['url_inputs'], link, index)
@step('I upload the transcripts file "([^"]*)"$')
def upload_file(_step, file_name):
path = os.path.join(TEST_ROOT, 'uploads/', file_name.strip())
world.browser.execute_script("$('form.file-chooser').show()")
world.browser.attach_file('file', os.path.abspath(path))
@step('I see "([^"]*)" text in the captions')
def check_text_in_the_captions(_step, text):
assert world.browser.is_text_present(text.strip(), 5)
@step('I see value "([^"]*)" in the field "([^"]*)"$')
def check_transcripts_field(_step, values, field_name):
world.wait(DELAY)
world.wait_for_ajax_complete()
world.click_link_by_text('Advanced')
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')]
assert any(values_list)
world.click_link_by_text('Basic')
@step('I save changes$')
def save_changes(_step):
world.wait(DELAY)
world.wait_for_ajax_complete()
save_css = 'a.save-button'
world.css_click(save_css)
@step('I open tab "([^"]*)"$')
def open_tab(_step, tab_name):
world.click_link_by_text(tab_name.strip())
@step('I set value "([^"]*)" to the field "([^"]*)"$')
def set_value_transcripts_field(_step, value, field_name):
world.wait(DELAY)
world.wait_for_ajax_complete()
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
world.css_fill(field_id, value.strip())
......@@ -19,12 +19,12 @@ Feature: CMS.Video Component Editor
@skip_sauce
Scenario: Captions are hidden when "show captions" is false
Given I have created a Video component with subtitles
And I have set "show captions" to False
And I have set "show transcript" to False
Then when I view the video it does not show the captions
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are shown when "show captions" is true
Given I have created a Video component with subtitles
And I have set "show captions" to True
And I have set "show transcript" to True
Then when I view the video it does show the captions
......@@ -5,14 +5,15 @@ from lettuce import world, step
from terrain.steps import reload_the_page
@step('I have set "show captions" to (.*)$')
@step('I have set "show transcript" to (.*)$')
def set_show_captions(step, setting):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
world.css_click('a.edit-button')
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
world.browser.select('Show Captions', setting)
world.click_link_by_text('Advanced')
world.browser.select('Show Transcript', setting)
world.css_click('a.save-button')
......@@ -33,12 +34,17 @@ def shows_captions(_step, show_captions):
@step('I see the correct video settings and default values$')
def correct_video_settings(_step):
expected_entries = [
# basic
['Display Name', 'Video', False],
['Download Track', '', False],
['Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False],
# advanced
['Display Name', 'Video', False],
['Download Transcript', '', False],
['Download Video', '', False],
['End Time', '0', False],
['HTML5 Timed Transcript', '', False],
['Show Captions', 'True', False],
['HTML5 Transcript', '', False],
['Show Transcript', 'True', False],
['Start Time', '0', False],
['Video Sources', '', False],
['Youtube ID', 'OEoXaMPEzfM', False],
......
......@@ -43,11 +43,7 @@ def i_created_a_video_with_subs_with_name(_step, sub_id):
@step('I have uploaded subtitles "([^"]*)"$')
def i_have_uploaded_subtitles(_step, sub_id):
_step.given('I go to the files and uploads page')
sub_id = sub_id.strip()
if not sub_id:
sub_id = 'OEoXaMPEzfM'
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id))
_step.given('I upload the test file "subs_{}.srt.sjson"'.format(sub_id.strip()))
@step('when I view the (.*) it does not have autoplay enabled$')
......
#pylint: disable=C0111
#pylint: disable=W0621
from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer
from lettuce import before, after, world
from django.conf import settings
import threading
from logging import getLogger
logger = getLogger(__name__)
@before.all
def setup_mock_youtube_server():
server_host = '127.0.0.1'
server_port = settings.VIDEO_PORT
address = (server_host, server_port)
# Create the mock server instance
server = MockYoutubeServer(address)
logger.debug("Youtube server started at {} port".format(str(server_port)))
server.time_to_response = 0.1 # seconds
server.address = address
# Start the server running in a separate daemon thread
# Because the thread is a daemon, it will terminate
# when the main thread terminates.
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
# Store the server instance in lettuce's world
# so that other steps can access it
# (and we can shut it down later)
world.youtube_server = server
@after.all
def teardown_mock_youtube_server(total):
# Stop the LTI server and free up the port
world.youtube_server.shutdown()
"""Tests for items views."""
import json
import datetime
from pytz import UTC
from django.core.urlresolvers import reverse
from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
from xmodule.capa_module import CapaDescriptor
import json
from xmodule.modulestore.django import modulestore
import datetime
from pytz import UTC
class DeleteItem(CourseTestCase):
"""Tests for '/delete_item' url."""
def setUp(self):
""" Creates the test course with a static page in it. """
super(DeleteItem, self).setUp()
......
""" Tests for utils. """
from contentstore import utils
import mock
import unittest
import collections
import copy
import json
from uuid import uuid4
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.exceptions import NotFoundError
class LMSLinksTestCase(TestCase):
""" Tests for LMS links. """
......@@ -88,8 +98,10 @@ class ExtraPanelTabTestCase(TestCase):
else:
return []
def get_course_with_tabs(self, tabs=[]):
def get_course_with_tabs(self, tabs=None):
""" Returns a mock course object with a tabs attribute. """
if tabs is None:
tabs = []
course = collections.namedtuple('MockCourse', ['tabs'])
if isinstance(tabs, basestring):
course.tabs = self.get_tab_type_dicts(tabs)
......
......@@ -61,6 +61,7 @@ class CourseTestCase(ModuleStoreTestCase):
number='999',
display_name='Robot Super Course',
)
self.course_location = self.course.location
def createNonStaffAuthedUserClient(self):
"""
......
#pylint: disable=E1103, E1101
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
import copy
import logging
import re
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from django.conf import settings
from django.utils.translation import ugettext as _
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from django_comment_common.utils import unseed_permissions_roles
from auth.authz import _delete_course_group
from xmodule.modulestore.store_utilities import delete_course
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
log = logging.getLogger(__name__)
......
......@@ -16,6 +16,7 @@ from .preview import *
from .public import *
from .user import *
from .tabs import *
from .transcripts_ajax import *
try:
from .dev import *
except ImportError:
......
"""Views for items (modules)."""
import logging
from uuid import uuid4
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from util.json_request import expect_json, JsonResponse
from ..transcripts_utils import manage_video_subtitles_save
from ..utils import get_modulestore
from .access import has_access
from .helpers import _xmodule_recurse
from xmodule.x_module import XModuleDescriptor
__all__ = ['save_item', 'create_item', 'delete_item']
log = logging.getLogger(__name__)
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
log = logging.getLogger(__name__)
@login_required
@expect_json
......@@ -52,8 +59,13 @@ def save_item(request):
inspect.currentframe().f_back.f_code.co_name,
inspect.currentframe().f_back.f_code.co_filename
)
return HttpResponseBadRequest()
return JsonResponse({"error": "Request missing required attribute 'id'."}, 400)
try:
old_item = modulestore().get_item(item_location)
except (ItemNotFoundError, InvalidLocationError):
log.error("Can't find item by location.")
return JsonResponse({"error": "Can't find item by location"}, 404)
# check permissions for this user within this course
if not has_access(request.user, item_location):
......@@ -101,12 +113,16 @@ def save_item(request):
# commit to datastore
store.update_metadata(item_location, own_metadata(existing_item))
if existing_item.category == 'video':
manage_video_subtitles_save(old_item, existing_item)
return JsonResponse()
@login_required
@expect_json
def create_item(request):
"""View for create items."""
parent_location = Location(request.POST['parent_location'])
category = request.POST['category']
......@@ -149,6 +165,7 @@ def create_item(request):
@login_required
@expect_json
def delete_item(request):
"""View for removing items."""
item_location = request.POST['id']
item_location = Location(item_location)
......
......@@ -114,3 +114,16 @@ if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
LETTUCE_SERVER_PORT = choice(PORTS)
else:
LETTUCE_SERVER_PORT = randint(1024, 65535)
# Set up Video information so that the cms will send
# requests to a mock Youtube server running locally
if LETTUCE_SELENIUM_CLIENT == 'saucelabs':
VIDEO_PORT = choice(PORTS)
PORTS.remove(VIDEO_PORT)
else:
VIDEO_PORT = randint(1024, 65535)
# for testing Youtube
YOUTUBE_API['url'] = "http://127.0.0.1:" + str(VIDEO_PORT) + '/test_transcripts_youtube/'
......@@ -423,3 +423,9 @@ TRACKING_BACKENDS = {
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
TRACKING_ENABLED = True
# Current youtube api for requesting transcripts.
# for example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
YOUTUBE_API = {
'url': "http://video.google.com/timedtext",
'params': {'lang': 'en', 'v': 'set_youtube_id_of_11_symbols_here'}
}
......@@ -8,6 +8,7 @@ requirejs.config({
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
"jquery.ajaxQueue": "xmodule_js/common_static/js/vendor/jquery.ajaxQueue",
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
......@@ -30,6 +31,7 @@ requirejs.config({
"utility": "xmodule_js/common_static/js/src/utility",
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
"squire": "xmodule_js/common_static/js/vendor/Squire",
"jasmine-jquery": "xmodule_js/common_static/js/vendor/jasmine-jquery",
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
"draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd",
......@@ -68,6 +70,10 @@ requirejs.config({
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
},
"jquery.ajaxQueue": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxQueue"
},
"jquery.scrollTo": {
deps: ["jquery"],
exports: "jQuery.fn.scrollTo"
......@@ -136,6 +142,9 @@ requirejs.config({
"sinon": {
exports: "sinon"
},
"jasmine-jquery": {
deps: ["jasmine"]
},
"jasmine-stealth": {
deps: ["jasmine"]
},
......@@ -178,6 +187,10 @@ define([
"coffee/spec/views/overview_spec",
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
"js_spec/transcripts/utils_spec", "js_spec/transcripts/editor_spec",
"js_spec/transcripts/videolist_spec", "js_spec/transcripts/message_manager_spec",
"js_spec/transcripts/file_uploader_spec"
# these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js
# "coffee/spec/views/assets_spec"
......
......@@ -37,6 +37,8 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
collection: new MetadataCollection(models)
})
@module.setMetadataEditor(@metadataEditor) if @module.setMetadataEditor
# Need to update set "active" class on data editor if there is one.
# If we are only showing settings, hide the data editor controls and update settings accordingly.
if @hasDataEditor()
......
......@@ -107,6 +107,7 @@ define(["backbone"], function(Backbone) {
Metadata.FLOAT_TYPE = "Float";
Metadata.GENERIC_TYPE = "Generic";
Metadata.LIST_TYPE = "List";
Metadata.VIDEO_LIST_TYPE = "VideoList";
return Metadata;
});
define(["backbone", "underscore"], function(Backbone, _) {
var AbstractEditor = Backbone.View.extend({
// Model is MetadataModel
initialize : function() {
var self = this;
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.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
},
/**
* The ID/name of the template. Subclasses must override this.
*/
templateName: '',
/**
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
*/
getValueFromEditor : function () {},
/**
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
*/
setValueInEditor : function (value) {},
/**
* Sets the value in the model, using the value currently displayed in the view.
*/
updateModel: function () {
this.model.setValue(this.getValueFromEditor());
},
/**
* Clears the value currently set in the model (reverting to the default).
*/
clear: function () {
this.model.clear();
},
/**
* Shows the clear button, if it is not already showing.
*/
showClearButton: function() {
if (!this.$el.hasClass('is-set')) {
this.$el.addClass('is-set');
this.getClearButton().removeClass('inactive');
this.getClearButton().addClass('active');
}
},
/**
* Returns the clear button.
*/
getClearButton: function () {
return this.$el.find('.setting-clear');
},
/**
* Renders the editor, updating the value displayed in the view, as well as the state of
* the clear button.
*/
render: function () {
if (!this.template) return;
this.setValueInEditor(this.model.getDisplayValue());
if (this.model.isExplicitlySet()) {
this.showClearButton();
}
else {
this.$el.removeClass('is-set');
this.getClearButton().addClass('inactive');
this.getClearButton().removeClass('active');
}
return this;
}
});
return AbstractEditor;
});
define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, MetadataModel) {
define(
[
"backbone", "underscore", "js/models/metadata", "js/views/abstract_editor",
"js/views/transcripts/metadata_videolist"
],
function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
var Metadata = {};
Metadata.Editor = Backbone.View.extend({
......@@ -32,6 +37,9 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
else if(model.getType() === MetadataModel.LIST_TYPE) {
new Metadata.List(data);
}
else if(model.getType() === MetadataModel.VIDEO_LIST_TYPE) {
new VideoList(data);
}
else {
// Everything else is treated as GENERIC_TYPE, which uses String editor.
new Metadata.String(data);
......@@ -74,95 +82,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.AbstractEditor = Backbone.View.extend({
// Model is MetadataModel
initialize : function() {
var self = this;
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.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
},
/**
* The ID/name of the template. Subclasses must override this.
*/
templateName: '',
/**
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
*/
getValueFromEditor : function () {},
/**
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
*/
setValueInEditor : function (value) {},
/**
* Sets the value in the model, using the value currently displayed in the view.
*/
updateModel: function () {
this.model.setValue(this.getValueFromEditor());
},
/**
* Clears the value currently set in the model (reverting to the default).
*/
clear: function () {
this.model.clear();
},
/**
* Shows the clear button, if it is not already showing.
*/
showClearButton: function() {
if (!this.$el.hasClass('is-set')) {
this.$el.addClass('is-set');
this.getClearButton().removeClass('inactive');
this.getClearButton().addClass('active');
}
},
/**
* Returns the clear button.
*/
getClearButton: function () {
return this.$el.find('.setting-clear');
},
/**
* Renders the editor, updating the value displayed in the view, as well as the state of
* the clear button.
*/
render: function () {
if (!this.template) return;
this.setValueInEditor(this.model.getDisplayValue());
if (this.model.isExplicitlySet()) {
this.showClearButton();
}
else {
this.$el.removeClass('is-set');
this.getClearButton().addClass('inactive');
this.getClearButton().removeClass('active');
}
return this;
}
});
Metadata.String = Metadata.AbstractEditor.extend({
Metadata.String = AbstractEditor.extend({
events : {
"change input" : "updateModel",
......@@ -181,7 +101,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.Number = Metadata.AbstractEditor.extend({
Metadata.Number = AbstractEditor.extend({
events : {
"change input" : "updateModel",
......@@ -191,7 +111,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
},
render: function () {
Metadata.AbstractEditor.prototype.render.apply(this);
AbstractEditor.prototype.render.apply(this);
if (!this.initialized) {
var numToString = function (val) {
return val.toFixed(4);
......@@ -279,7 +199,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
});
Metadata.Option = Metadata.AbstractEditor.extend({
Metadata.Option = AbstractEditor.extend({
events : {
"change select" : "updateModel",
......@@ -316,7 +236,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.List = Metadata.AbstractEditor.extend({
Metadata.List = AbstractEditor.extend({
events : {
"click .setting-clear" : "clear",
......@@ -355,7 +275,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
// We don't call updateModel here since it's bound to the
// change event
var list = this.model.get('value') || [];
this.setValueInEditor(list.concat(['']))
this.setValueInEditor(list.concat(['']));
this.$el.find('.create-setting').addClass('is-disabled');
},
......
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils",
"js/views/metadata", "js/collections/metadata",
"js/views/transcripts/metadata_videolist"
],
function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
var Editor = Backbone.View.extend({
tagName: 'div',
initialize: function () {
// prepare data for MetadataView.Editor
var metadata = this.$el.data('metadata'),
models = this.toModels(metadata);
this.collection = new MetadataCollection(models);
// initialize MetadataView.Editor
this.metadataEditor = new MetadataView.Editor({
el: this.$el,
collection: this.collection
});
},
/**
* @function
*
* Convert JSON metadata to List of models
*
* @param {object|string} data Data containing information about metadata
* setting editors.
*
* @returns {array} Processed objects list.
*
* @example:
* var metadata = {
* field_1: {.1.},
* field_2: {.2.}
* };
*
* toModels(metadata) // => [{.1.}, {.2.}]
*
*/
toModels: function (data) {
var metadata = (_.isString(data)) ? JSON.parse(data) : data,
models = [];
for (var model in metadata) {
if (metadata.hasOwnProperty(model)) {
models.push(metadata[model]);
}
}
return models;
},
/**
* @function
*
* Synchronize data from `Advanced` tab of Video player with data in
* `Basic` tab. It is called when we go from `Advanced` to `Basic` tab.
*
* @param {object} metadataCollection Collection containing all models
* with information about metadata
* setting editors in `Advanced` tab.
*
*/
syncBasicTab: function (metadataCollection, metadataView) {
var result = [],
getField = Utils.getField,
component_id = this.$el.closest('.component').data('id'),
subs = getField(metadataCollection, 'sub'),
values = {},
videoUrl, metadata, modifiedValues;
// If metadataCollection is not passed, just exit.
if (!metadataCollection || !metadataView) {
return false;
}
// Get field that should be synchronized with `Advanced` tab fields.
videoUrl = getField(this.collection, 'video_url');
modifiedValues = metadataView.getModifiedMetadataValues();
var isSubsModified = (function (values) {
var isSubsChanged = subs.hasChanged("value");
return Boolean(isSubsChanged && _.isString(values.sub));
}(modifiedValues));
// When we change value of `sub` field in the `Advanced`,
// we update data on backend. That provides possibility to remove
// transcripts.
if (isSubsModified) {
metadata = $.extend(true, {}, modifiedValues);
// Save module state
Utils.command('save', component_id, null, {
metadata: metadata,
current_subs: _.pluck(
Utils.getVideoList(videoUrl.getDisplayValue()),
'video'
)
});
}
// Get values from `Advanced` tab fields (`html5_sources`,
// `youtube_id_1_0`) that should be synchronized.
values.html5Sources = getField(metadataCollection, 'html5_sources')
.getDisplayValue();
values.youtube = getField(metadataCollection, 'youtube_id_1_0')
.getDisplayValue();
// The length of youtube video_id should be 11 characters.
if (values.youtube.length === 11) {
// Just video id is retrieved from `Advanced` tab field and
// it should be transformed to appropriate format.
// OEoXaMPEzfM => http://youtu.be/OEoXaMPEzfM
values.youtube = Utils.getYoutubeLink(values.youtube);
} else {
values.youtube = '';
}
result.push(values.youtube);
result = result.concat(values.html5Sources);
videoUrl.setValue(result);
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(metadataCollection, this.collection);
if (isSubsModified){
// When `sub` field is changed, clean Storage to avoid overwriting.
Utils.Storage.remove('sub');
// Trigger `change` event manually if `video_url` model
// isn't changed.
if (!videoUrl.hasChanged()) {
videoUrl.trigger('change');
}
}
},
/**
* @function
*
* Synchronize data from `Basic` tab of Video player with data in
* `Advanced` tab. It is called when we go from `Basic` to `Advanced` tab.
*
* @param {object} metadataCollection Collection containing all models
* with information about metadata
* setting editors in `Advanced` tab.
*
*/
syncAdvancedTab: function (metadataCollection, metadataView) {
var getField = Utils.getField,
subsValue = Utils.Storage.get('sub'),
subs = getField(metadataCollection, 'sub'),
html5Sources, youtube, videoUrlValue, result;
// if metadataCollection is not passed, just exit.
if (!metadataCollection) {
return false;
}
// Get fields from `Advenced` tab (`html5_sources`, `youtube_id_1_0`)
// that should be synchronized.
html5Sources = getField(metadataCollection, 'html5_sources');
youtube = getField(metadataCollection, 'youtube_id_1_0');
// Get value from `Basic` tab `VideoUrl` field that should be
// synchronized.
videoUrlValue = getField(this.collection, 'video_url')
.getDisplayValue();
// Change list representation format to more convenient and group
// them by mode (`youtube`, `html5`).
// Before:
// [
// 'http://youtu.be/OEoXaMPEzfM',
// 'video_name.mp4',
// 'video_name.webm'
// ]
// After:
// {
// youtube: [{mode: `youtube`, type: `youtube`, ...}],
// html5: [
// {mode: `html5`, type: `mp4`, ...},
// {mode: `html5`, type: `webm`, ...}
// ]
// }
result = _.groupBy(
videoUrlValue,
function (value) {
return Utils.parseLink(value).mode;
}
);
if (html5Sources) {
html5Sources.setValue(result.html5 || []);
}
if (youtube) {
if (result.youtube) {
result = Utils.parseLink(result.youtube[0]).video;
} else {
result = '';
}
youtube.setValue(result);
}
// If Utils.Storage contain some subtitles, update them.
if (_.isString(subsValue)) {
subs.setValue(subsValue);
// After updating should be removed, because it might overwrite
// subtitles added by user manually.
Utils.Storage.remove('sub');
}
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(this.collection, metadataCollection);
}
});
return Editor;
});
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils"
],
function($, Backbone, _, Utils) {
var FileUploader = Backbone.View.extend({
invisibleClass: 'is-invisible',
// Pre-defined list of supported file formats.
validFileExtensions: ['srt'],
events: {
'change .file-input': 'changeHandler',
'click .setting-upload': 'clickHandler'
},
uploadTpl: '#file-upload',
initialize: function () {
_.bindAll(this);
this.file = false;
this.render();
},
render: function () {
var tpl = $(this.uploadTpl).text(),
tplContainer = this.$el.find('.transcripts-file-uploader'),
videoList = this.options.videoListObject.getVideoObjectsList();
if (tplContainer.length) {
if (!tpl) {
console.error('Couldn\'t load Transcripts File Upload template');
return;
}
this.template = _.template(tpl);
tplContainer.html(this.template({
ext: this.validFileExtensions,
component_id: this.options.component_id,
video_list: videoList
}));
this.$form = this.$el.find('.file-chooser');
this.$input = this.$form.find('.file-input');
this.$progress = this.$el.find('.progress-fill');
}
},
/**
* @function
*
* Uploads file to the server. Get file from the `file` property.
*
*/
upload: function () {
if (!this.file) {
return;
}
this.$form.ajaxSubmit({
beforeSend: this.xhrResetProgressBar,
uploadProgress: this.xhrProgressHandler,
complete: this.xhrCompleteHandler
});
},
/**
* @function
*
* Handle click event on `upload` button.
*
* @param {object} event Event object.
*
*/
clickHandler: function (event) {
event.preventDefault();
this.$input
.val(null)
// Show system upload window
.trigger('click');
},
/**
* @function
*
* Handle change event.
*
* @param {object} event Event object.
*
*/
changeHandler: function (event) {
event.preventDefault();
this.options.messenger.hideError();
this.file = this.$input.get(0).files[0];
// if file has valid file extension, than upload file.
// Otherwise, show error message.
if (this.checkExtValidity(this.file)) {
this.upload();
} else {
this.options.messenger
.showError('Please select a file in .srt format.');
}
},
/**
* @function
*
* Checks that file has supported extension.
*
* @param {object} file Object with information about file.
*
* @returns {boolean} Indicate that file has supported or unsupported
* extension.
*
*/
checkExtValidity: function (file) {
if (!file.name) {
return void(0);
}
var fileExtension = file.name
.split('.')
.pop()
.toLowerCase();
if ($.inArray(fileExtension, this.validFileExtensions) !== -1) {
return true;
}
return false;
},
/**
* @function
*
* Resets progress bar.
*
*/
xhrResetProgressBar: function () {
var percentVal = '0%';
this.$progress
.width(percentVal)
.html(percentVal)
.removeClass(this.invisibleClass);
},
/**
* @function
*
* Callback function to be invoked with upload progress information
* (if supported by the browser).
*
* @param {object} event Event object.
*
* @param {integer} position Amount of transmitted bytes.
* *
* @param {integer} total Total size of file.
* *
* @param {integer} percentComplete Object with information about file.
*
*/
xhrProgressHandler: function (event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
this.$progress
.width(percentVal)
.html(percentVal);
},
/**
* @function
*
* Handle complete uploading.
*
*/
xhrCompleteHandler: function (xhr) {
var resp = JSON.parse(xhr.responseText),
err = resp.status || 'Error: Uploading failed.',
sub = resp.subs;
this.$progress
.addClass(this.invisibleClass);
if (xhr.status === 200) {
this.options.messenger.render('uploaded', resp);
Utils.Storage.set('sub', sub);
} else {
this.options.messenger.showError(err);
}
}
});
return FileUploader;
});
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
"gettext"
],
function($, Backbone, _, Utils, FileUploader, gettext) {
var MessageManager = Backbone.View.extend({
tagName: 'div',
elClass: '.wrapper-transcripts-message',
invisibleClass: 'is-invisible',
events: {
'click .setting-import': 'importHandler',
'click .setting-replace': 'replaceHandler',
'click .setting-choose': 'chooseHandler',
'click .setting-use-existing': 'useExistingHandler'
},
// Pre-defined dict with anchors to status templates.
templates: {
not_found: '#transcripts-not-found',
found: '#transcripts-found',
import: '#transcripts-import',
replace: '#transcripts-replace',
uploaded: '#transcripts-uploaded',
use_existing: '#transcripts-use-existing',
choose: '#transcripts-choose'
},
initialize: function () {
_.bindAll(this);
this.component_id = this.$el.closest('.component').data('id');
this.fileUploader = new FileUploader({
el: this.$el,
messenger: this,
component_id: this.component_id,
videoListObject: this.options.parent
});
},
render: function (template_id, params) {
var tplHtml = $(this.templates[template_id]).text(),
videoList = this.options.parent.getVideoObjectsList(),
// Change list representation format to more convenient and group
// them by video property.
// Before:
// [
// {mode: `html5`, type: `mp4`, video: `video_name_1`},
// {mode: `html5`, type: `webm`, video: `video_name_2`}
// ]
// After:
// {
// `video_name_1`: [{mode: `html5`, type: `webm`, ...}],
// `video_name_2`: [{mode: `html5`, type: `mp4`, ...}]
// }
groupedList = _.groupBy(
videoList,
function (value) {
return value.video;
}
),
html5List = (params) ? params.html5_local : [],
template;
if (!tplHtml) {
console.error('Couldn\'t load Transcripts status template');
return;
}
template = _.template(tplHtml);
this.$el.find('.transcripts-status')
.removeClass('is-invisible')
.find(this.elClass).html(template({
component_id: encodeURIComponent(this.component_id),
html5_list: html5List,
grouped_list: groupedList,
subs_id: (params) ? params.subs: ''
}));
this.fileUploader.render();
return this;
},
/**
* @function
*
* Shows error message.
*
* @param {string} err Error message that will be shown
*
* @param {boolean} hideButtons Hide buttons
*
*/
showError: function (err, hideButtons) {
var $error = this.$el.find('.transcripts-error-message');
if (err) {
// Hide any other error messages.
this.hideError();
$error
.html(gettext(err))
.removeClass(this.invisibleClass);
if (hideButtons) {
this.$el.find('.wrapper-transcripts-buttons')
.addClass(this.invisibleClass);
}
}
},
/**
* @function
*
* Hides error message.
*
*/
hideError: function () {
this.$el.find('.transcripts-error-message')
.addClass(this.invisibleClass);
this.$el.find('.wrapper-transcripts-buttons')
.removeClass(this.invisibleClass);
},
/**
* @function
*
* Handle import button.
*
* @params {object} event Event object.
*
*/
importHandler: function (event) {
event.preventDefault();
this.processCommand('replace', 'Error: Import failed.');
},
/**
* @function
*
* Handle replace button.
*
* @params {object} event Event object.
*
*/
replaceHandler: function (event) {
event.preventDefault();
this.processCommand('replace', 'Error: Replacing failed.');
},
/**
* @function
*
* Handle choose buttons.
*
* @params {object} event Event object.
*
*/
chooseHandler: function (event) {
event.preventDefault();
var videoId = $(event.currentTarget).data('video-id');
this.processCommand('choose', 'Error: Choosing failed.', videoId);
},
/**
* @function
*
* Handle `use existing` button.
*
* @params {object} event Event object.
*
*/
useExistingHandler: function (event) {
event.preventDefault();
this.processCommand('rename', 'Error: Choosing failed.');
},
/**
* @function
*
* Decorator for `command` function in the Utils.
*
* @params {string} action Action that will be invoked on server. Is a part
* of url.
*
* @params {string} errorMessage Error massage that will be shown if any
* connection error occurs
*
* @params {string} videoId Extra parameter that sometimes should be sent
* to the server
*
*/
processCommand: function (action, errorMessage, videoId) {
var self = this,
component_id = this.component_id,
videoList = this.options.parent.getVideoObjectsList(),
extraParam, xhr;
if (videoId) {
extraParam = { html5_id: videoId };
}
xhr = Utils.command(action, component_id, videoList, extraParam)
.done(function (resp) {
var sub = resp.subs;
self.render('found', resp);
Utils.Storage.set('sub', sub);
})
.fail(function (resp) {
var message = resp.status || errorMessage;
self.showError(message);
});
return xhr;
}
});
return MessageManager;
});
define(
[
"jquery", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
"xmodule", "jquery.form", "jasmine-jquery"
],
function ($, _, Utils, FileUploader) {
describe('Transcripts.FileUploader', function () {
var videoListEntryTemplate = readFixtures(
'transcripts/metadata-videolist-entry.underscore'
),
fileUploadTemplate = readFixtures(
'transcripts/file-upload.underscore'
),
view;
beforeEach(function () {
setFixtures(
$("<div>", {id: "metadata-videolist-entry"})
.html(videoListEntryTemplate)
);
appendSetFixtures(
$("<script>",
{
id: "file-upload",
type: "text/template"
}
).text(fileUploadTemplate)
);
var messenger = jasmine.createSpyObj(
'MessageManager',
['render', 'showError', 'hideError']
),
videoListObject = jasmine.createSpyObj(
'MetadataView.VideoList',
['render', 'getVideoObjectsList']
),
$container = $('.transcripts-status');
$container
.append('<div class="transcripts-file-uploader" />')
.append('<a class="setting-upload" href="#">Upload</a>');
spyOn(FileUploader.prototype, 'render').andCallThrough();
view = new FileUploader({
el: $container,
messenger: messenger,
videoListObject: videoListObject,
component_id: 'component_id'
});
});
it('Initialize', function () {
expect(view.file).toBe(false);
expect(FileUploader.prototype.render).toHaveBeenCalled();
});
describe('Render', function () {
beforeEach(function () {
spyOn(_, 'template').andCallThrough();
});
it('Template doesn\'t exist', function () {
spyOn(console, 'error');
view.uploadTpl = '';
view.render();
expect(console.error).toHaveBeenCalled();
expect(view.render).not.toThrow();
expect(_.template).not.toHaveBeenCalled();
});
it('Container where template will be inserted doesn\'t exist',
function () {
$('.transcripts-file-uploader').remove();
view.render();
expect(view.render).not.toThrow();
expect(_.template).not.toHaveBeenCalled();
}
);
it('All works okay if all data is okay', function () {
var elList = ['$form', '$input', '$progress'],
validFileExtensions = ['srt', 'sjson'],
result = $.map(validFileExtensions, function(item, index) {
return '.' + item;
}).join(', ');
view.validFileExtensions = validFileExtensions;
view.render();
expect(view.render).not.toThrow();
expect(_.template).toHaveBeenCalled();
$.each(elList, function(index, el) {
expect(view[el].length).not.toBe(0);
});
expect(view.$input.attr('accept')).toBe(result);
});
});
describe('Upload', function () {
it('File is not chosen', function () {
spyOn($.fn, 'ajaxSubmit');
view.upload();
expect(view.$form.ajaxSubmit).not.toHaveBeenCalled();
});
it('File is chosen', function () {
spyOn($.fn, 'ajaxSubmit');
view.file = {};
view.upload();
expect(view.$form.ajaxSubmit).toHaveBeenCalled();
});
});
it('clickHandler', function () {
spyOn($.fn, 'trigger');
$('.setting-upload').click();
expect($('.setting-upload').trigger).toHaveBeenCalledWith('click');
expect(view.$input).toHaveValue('');
});
describe('changeHadler', function () {
beforeEach(function () {
spyOn(view, 'upload');
});
it('Valid File Type - error should be hided', function () {
spyOn(view, 'checkExtValidity').andReturn(true);
view.$input.change();
expect(view.checkExtValidity).toHaveBeenCalled();
expect(view.upload).toHaveBeenCalled();
expect(view.options.messenger.hideError).toHaveBeenCalled();
});
it('Invalid File Type - error should be shown', function () {
spyOn(view, 'checkExtValidity').andReturn(false);
view.$input.change();
expect(view.checkExtValidity).toHaveBeenCalled();
expect(view.upload).not.toHaveBeenCalled();
expect(view.options.messenger.showError).toHaveBeenCalled();
});
});
describe('checkExtValidity', function () {
var data = {
Correct: {
name: 'file_name.srt',
isValid: true
},
Incorrect: {
name: 'file_name.mp4',
isValid: false
}
};
$.each(data, function(fileType, fileInfo) {
it(fileType + ' file type', function () {
var result = view.checkExtValidity(fileInfo);
expect(result).toBe(fileInfo.isValid);
});
});
});
it('xhrResetProgressBar', function () {
view.xhrResetProgressBar();
expect(view.$progress.width()).toBe(0);
expect(view.$progress.html()).toBe('0%');
expect(view.$progress).not.toHaveClass('is-invisible');
});
it('xhrProgressHandler', function () {
var percent = 26;
spyOn($.fn, 'width').andCallThrough();
view.xhrProgressHandler(null, null, null, percent);
expect(view.$progress.width).toHaveBeenCalledWith(percent + '%');
expect(view.$progress.html()).toBe(percent + '%');
});
describe('xhrCompleteHandler', function () {
it('Ajax Success', function () {
var xhr = {
status: 200,
responseText: JSON.stringify({
status: 'Success',
subs: 'test'
})
};
spyOn(Utils.Storage, 'set');
view.xhrCompleteHandler(xhr);
expect(view.$progress).toHaveClass('is-invisible');
expect(view.options.messenger.render.mostRecentCall.args[0])
.toEqual('uploaded');
expect(Utils.Storage.set)
.toHaveBeenCalledWith('sub', 'test');
});
var assertAjaxError = function (xhr) {
spyOn(Utils.Storage, 'set');
view.xhrCompleteHandler(xhr);
expect(view.options.messenger.showError).toHaveBeenCalled();
expect(view.$progress).toHaveClass('is-invisible');
expect(view.options.messenger.render)
.not
.toHaveBeenCalled();
expect(Utils.Storage.set)
.not
.toHaveBeenCalledWith('sub', 'test');
};
it('Ajax transport Error', function () {
var xhr = {
status: 400,
responseText: JSON.stringify({})
};
assertAjaxError(xhr);
});
});
});
});
define(
[
"jquery", "underscore",
"js/views/transcripts/utils",
"underscore.string", "xmodule", "jasmine-jquery"
],
function ($, _, Utils, _str) {
describe('Transcripts.Utils', function () {
var videoId = 'OEoXaMPEzfM',
ytLinksList = (function (id) {
var links = [
'http://www.youtube.com/watch?v=%s&feature=feedrec_grec_index',
'http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/%s',
'http://www.youtube.com/v/%s?fs=1&amp;hl=en_US&amp;rel=0',
'http://www.youtube.com/watch?v=%s#t=0m10s',
'http://www.youtube.com/embed/%s?rel=0',
'http://www.youtube.com/watch?v=%s',
'http://youtu.be/%s'
];
return $.map(links, function (link) {
return _str.sprintf(link, id);
});
} (videoId)),
html5FileName = 'file_name',
html5LinksList = (function (videoName) {
var videoTypes = ['mp4', 'webm'],
links = [
'http://somelink.com/%s.%s?param=1&param=2#hash',
'http://somelink.com/%s.%s#hash',
'http://somelink.com/%s.%s?param=1&param=2',
'http://somelink.com/%s.%s',
'ftp://somelink.com/%s.%s',
'https://somelink.com/%s.%s',
'somelink.com/%s.%s',
'%s.%s'
],
data = {};
$.each(videoTypes, function (index, type) {
data[type] = $.map(links, function (link) {
return _str.sprintf(link, videoName, type);
});
});
return data;
} (html5FileName));
describe('Method: getField', function (){
var collection,
testFieldName = 'test_field';
beforeEach(function() {
collection = jasmine.createSpyObj(
'Collection',
[
'findWhere'
]
);
});
it('All works okay if all arguments are passed', function () {
Utils.getField(collection, testFieldName);
expect(collection.findWhere).toHaveBeenCalledWith({
field_name: testFieldName
});
});
var wrongArgumentLists = [
{
argName: 'collection',
list: [undefined, testFieldName]
},
{
argName: 'field name',
list: [collection, undefined]
},
{
argName: 'both',
list: [undefined, undefined]
}
];
$.each(wrongArgumentLists, function (index, element) {
it(element.argName + ' argument(s) is/are absent', function () {
var result = Utils.getField.apply(this, element.list);
expect(result).toBeUndefined();
});
});
});
describe('Method: parseYoutubeLink', function () {
describe('Supported urls', function () {
$.each(ytLinksList, function (index, link) {
it(link, function () {
var result = Utils.parseYoutubeLink(link);
expect(result).toBe(videoId);
});
});
});
describe('Wrong arguments ', function () {
beforeEach(function(){
spyOn(console, 'log');
});
it('no arguments', function () {
var result = Utils.parseYoutubeLink();
expect(result).toBeUndefined();
});
it('wrong data type', function () {
var result = Utils.parseYoutubeLink(1);
expect(result).toBeUndefined();
});
it('videoId is wrong', function () {
var videoId = 'wrong_id',
link = 'http://youtu.be/' + videoId,
result = Utils.parseYoutubeLink(link);
expect(result).toBeUndefined();
});
var wrongUrls = [
'http://youtu.bee/' + videoId,
'http://youtu.be/',
'example.com',
'http://google.com/somevideo.mp4'
];
$.each(wrongUrls, function (index, link) {
it(link, function () {
var result = Utils.parseYoutubeLink(link);
expect(result).toBeUndefined();
});
});
});
});
describe('Method: parseHTML5Link', function () {
describe('Supported urls', function () {
$.each(html5LinksList, function (format, linksList) {
$.each(linksList, function (index, link) {
it(link, function () {
var result = Utils.parseHTML5Link(link);
expect(result).toEqual({
video: html5FileName,
type: format
});
});
});
});
});
describe('Wrong arguments ', function () {
beforeEach(function(){
spyOn(console, 'log');
});
it('no arguments', function () {
var result = Utils.parseHTML5Link();
expect(result).toBeUndefined();
});
it('wrong data type', function () {
var result = Utils.parseHTML5Link(1);
expect(result).toBeUndefined();
});
var html5WrongUrls = [
'http://youtu.bee/' + videoId,
'http://youtu.be/',
'example.com',
'http://google.com/somevideo.mp1',
'http://google.com/somevideomp4',
'http://google.com/somevideo_mp4',
'http://google.com/somevideo:mp4',
'http://google.com/somevideo',
'http://google.com/somevideo.webm_'
];
$.each(html5WrongUrls, function (index, link) {
it(link, function () {
var result = Utils.parseHTML5Link(link);
expect(result).toBeUndefined();
});
});
});
});
it('Method: getYoutubeLink', function () {
var videoId = 'video_id',
result = Utils.getYoutubeLink(videoId),
expectedResult = 'http://youtu.be/' + videoId;
expect(result).toBe(expectedResult);
});
describe('Method: parseLink', function () {
var resultDataDict = {
'html5': {
link: html5LinksList['mp4'][0],
resp: {
mode: 'html5',
video: html5FileName,
type: 'mp4'
}
},
'youtube': {
link: ytLinksList[0],
resp: {
mode: 'youtube',
video: videoId,
type: 'youtube'
}
},
'incorrect': {
link: 'http://example.com',
resp: {
mode: 'incorrect'
}
}
};
$.each(resultDataDict, function (mode, data) {
it(mode, function () {
var result = Utils.parseLink(data.link);
expect(result).toEqual(data.resp);
});
});
describe('Wrong arguments ', function () {
it('no arguments', function () {
var result = Utils.parseLink();
expect(result).toBeUndefined();
});
it('wrong data type', function () {
var result = Utils.parseLink(1);
expect(result).toBeUndefined();
});
});
});
});
});
......@@ -40,6 +40,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/backbone-associations-min.js
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
- xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js
- xmodule_js/common_static/js/vendor/jquery.form.js
- xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill.js
- xmodule_js/common_static/js/vendor/sinon-1.7.1.js
......@@ -66,6 +67,7 @@ src_paths:
spec_paths:
- coffee/spec/main.js
- coffee/spec
- js_spec
# Paths to fixture files (optional)
# The fixture path will be set automatically when using jasmine-jquery.
......
......@@ -13,3 +13,192 @@
}
}
}
.xmodule_VideoDescriptor {
.wrapper-comp-settings.basic_metadata_edit{
.list-input.settings-list {
.field.comp-setting-entry {
.setting-label {
vertical-align: top;
margin-top: ($baseline/2);
}
.setting-help{
display: block;
width: 45%;
max-width: auto;
margin-left: 33%;
padding: 0 13px;
}
.collapse-setting {
@extend %t-action3;
display: block;
width: 100%;
padding: ($baseline/2);
font-weight: 600;
*[class^="icon-"] {
margin-right: ($baseline/4);
}
}
.videolist-url-tip.setting-help,
.videolist-extra-videos-tip.setting-help{
margin-left: 0;
width: 100%;
padding: 0 10px 10px;
}
.videolist-url-tip.setting-help{
padding: 0 0 10px;
}
.wrapper-comp-setting{
width: 100%;
display: block;
max-width: auto;
}
// inputs and labels
.wrapper-videolist-settings {
width: 45%;
display: inline-block;
min-width: ($baseline*5);
// inputs
.input {
width: 100%;
vertical-align: middle;
&.is-disabled,
&[disabled="disabled"]{
opacity: .5;
}
}
.wrapper-videolist-url{
margin-bottom: ($baseline/2);
}
.wrapper-videolist-urls{
background: $lightGrey;
padding: ($baseline/3);
// enumerated fields
.videolist-extra-videos {
display: none;
&.is-visible{
display: block;
}
.videolist-settings-item {
margin-bottom: ($baseline/2);
}
}
}
}
}
}
.transcripts-status{
margin-top: $baseline;
&.is-invisible{
display: none !important;
}
.wrapper-transcripts-message{
width: 60%;
display: inline-block;
vertical-align: top;
min-width: ($baseline*5);
margin-top: 10px;
.transcripts-message{
@include font-size(12);
}
.transcripts-message-status{
color: $green;
font-weight: 700;
&.status-error{
color: $red;
}
[class^="icon-"],
[class*=" icon-"]{
margin-right: 5px;
@include font-size(18);
}
}
.transcripts-error-message{
background: $red;
color: $white;
@include font-size(14);
padding: ($baseline/3);
&.is-invisible{
display: none;
}
}
.wrapper-transcripts-buttons{
&.is-invisible{
display: none;
}
}
}
.action{
@extend %btn-primary-blue;
@extend %t-action3;
margin-bottom: ($baseline/2);
}
}
// TYPE: enumerated video lists of metadata sets
.metadata-videolist-enum {
* {
@include box-sizing(border-box);
}
}
.file-chooser{
display: none;
}
.progress-bar{
display: block;
height: 30px;
margin: 10px 0;
border: 1px solid $blue;
text-align: center;
font-size: 1.14em;
&.is-invisible {
display: none;
}
&.loaded {
border-color: #66b93d;
.progress-fill {
background: #66b93d;
}
}
.progress-fill {
display: block;
width: 0%;
height: 30px;
background: $blue;
color: #fff;
line-height: 28px;
}
}
}
}
......@@ -45,6 +45,7 @@ var require = {
"jquery.form": "js/vendor/jquery.form",
"jquery.markitup": "js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "js/vendor/jquery.leanModal.min",
"jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue",
"jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min",
"jquery.timepicker": "js/vendor/timepicker/jquery.timepicker",
"jquery.cookie": "js/vendor/jquery.cookie",
......@@ -100,6 +101,10 @@ var require = {
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
},
"jquery.ajaxQueue": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxQueue"
},
"jquery.smoothScroll": {
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
......@@ -210,6 +215,7 @@ var require = {
<%static:include path="js/system-feedback.underscore" />
</script>
% if context_course:
<script type="text/javascript">
require(['js/models/course'], function(Course) {
......
<div class="progress-bar is-invisible">
<div class="progress-fill"></div>
</div>
<form class="file-chooser" action="/transcripts/upload"
method="post" enctype="multipart/form-data">
<input type="file" class="file-input" name="file"
accept="<%= _.map(ext, function(val){ return '.' + val; }).join(', ') %>">
<input type="hidden" name="id" value="<%= component_id %>">
<input type="hidden" name="video_list" value='<%= JSON.stringify(video_list) %>'>
</form>
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Conflict") %>
</div>
<p class="transcripts-message">
<%= gettext("The timed transcript for the first HTML5 source does not appear to be the same as the timed transcript for the second HTML5 source.") %>
<strong>
<%= gettext("Which one would you like to use?") %>
</strong>
</p>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<% _.each(html5_list, function(value, index) {
var type = grouped_list[value][0].type,
file_name = value + ((type) ? ('.' + type) : ''),
message = gettext("Timed Transcript from ") + file_name;
%>
<button
class="action setting-choose"
type="button"
name="setting-choose"
data-video-id="<%= value %>"
value="<%= message %>"
data-tooltip="<%= message %>"
>
<span>
<%= message %>
</span>
</button>
<% }) %>
</div>
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript Found") %></div>
<p class="transcripts-message">
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span>
</a>
</div>
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
<p class="transcripts-message">
<%= gettext("We don\'t have a timed transcript for this video on edX, but we found a transcript for this video on YouTube. Would you like to import it to edX?") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<button class="action setting-import" type="button" name="setting-import" value="<%= gettext("Import from YouTube") %>" data-tooltip="<%= gettext("Import from YouTube") %>">
<span><%= gettext("Import from YouTube") %></span>
</button>
</div>
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
<p class="transcripts-message">
<%= gettext("We don\'t have a timed transcript for this video. Please upload a .srt file:") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<%= gettext("Upload New Timed Transcript") %>
</button>
<a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download to Edit") %>">
<%= gettext("Download to Edit") %>
</a>
</div>
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Conflict") %>
</div>
<p class="transcripts-message">
<%= gettext("The timed transcript file on YouTube does not appear to be the same as the timed transcript file on edX.") %>
<strong>
<%= gettext("Would you like to replace the edX timed transcript with the ones from YouTube?") %>
</strong>
</p>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button
class="action setting-replace"
type="button"
name="setting-replace"
value="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
data-tooltip="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
>
<span>
<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>
</span>
</button>
</div>
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript uploaded successfully") %></div>
<p class="transcripts-message">
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?id=<%= component_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span>
</a>
</div>
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Not Updated") %>
</div>
<p class="transcripts-message">
<%= gettext("You changed a video source, but did not update the timed transcript file. Do you want to upload new timed transcript?") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button
class="action setting-use-existing"
type="button"
name="setting-use-existing"
value="<%= gettext("Use Existing Timed Transcript") %>"
data-tooltip="<%= gettext("Use Existing Timed Transcript") %>"
>
<span>
<%= gettext("Use Existing Timed Transcript") %>
</span>
</button>
<button
class="action setting-upload"
type="button"
name="setting-upload"
value="<%= gettext("Upload New Timed Transcript") %>"
data-tooltip="<%= gettext("Upload New Timed Transcript") %>"
>
<span>
<%= gettext("Upload New Timed Transcript") %>
</span>
</button>
</div>
<div class="wrapper-comp-setting metadata-videolist-enum">
<label class="label setting-label" for="<%= uniqueId %>"><%= model.get('display_name')%></label>
<div class="wrapper-videolist-settings">
<div class="wrapper-videolist-url videolist-settings-item"><input type="text" id="<%= uniqueId %>" class="input videolist-url" value="<%= model.get('value')[0] %>"></div>
<div class="tip videolist-url-tip setting-help"><%= model.get('help') %></div>
<div class="wrapper-videolist-urls">
<a href="#" class="collapse-action collapse-setting">
<i class="icon-plus"></i><%= gettext("Add more video sources") %> <span class="sr"><%= model.get('display_name')%></span>
</a>
<div class="videolist-extra-videos">
<span class="tip videolist-extra-videos-tip setting-help"><%= gettext('To be sure all students can view the video, we recommend providing alternate versions of the same video: mp4, webm and youtube (if available).') %></span>
<ol class="videolist-settings">
<li class="videolist-settings-item">
<input type="text" class="input" value="<%= model.get('value')[1] %>">
</li>
<li class="videolist-settings-item">
<input type="text" class="input" value="<%= model.get('value')[2] %>">
</li>
</ol>
</div>
</div>
</div>
</div>
<div class="transcripts-status is-invisible">
<label class="label setting-label transcripts-label"><%= gettext("Timed Transcript") %></label>
<div class="wrapper-transcripts-message"></div>
</div>
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='../../static_content.html'/>
<%page args="tabName"/>
<%
import json
%>
## include js templates:
% for template_name in ["metadata-videolist-entry", "file-upload"]:
<script type="text/template" id="${template_name}">
<%static:include path="js/transcripts/${template_name}.underscore" />
</script>
% endfor
% for template_name in ["transcripts-found", "transcripts-uploaded", "transcripts-use-existing", "transcripts-not-found", "transcripts-replace", "transcripts-import", "transcripts-choose"]:
<script type="text/template" id="${template_name}">
<%static:include path="js/transcripts/messages/${template_name}.underscore" />
</script>
% endfor
<div class="wrapper-comp-settings basic_metadata_edit" data-metadata='${json.dumps(transcripts_basic_tab_metadata) | h}'></div>
<script type="text/javascript">
require(
[
"domReady!",
"jquery",
"js/views/transcripts/editor"
],
function(doc, $, Editor) {
var transcripts = new Editor({
el: $('#editor-tab-${html_id}').find('.basic_metadata_edit')
}),
storage = TabsEditingDescriptor.getStorage();
TabsEditingDescriptor.Model.addModelUpdate(
'${html_id}',
'${tabName}',
function () {
// Advanced, Save
metadataEditor = storage.MetadataEditor;
if (metadataEditor) {
transcripts.syncAdvancedTab(metadataEditor.collection, metadataEditor);
}
}
);
TabsEditingDescriptor.Model.addOnSwitch(
'${html_id}',
'${tabName}',
function () {
// Basic
metadataEditor = storage.MetadataEditor;
if (metadataEditor) {
transcripts.syncBasicTab(metadataEditor.collection, metadataEditor);
}
}
);
}
);
</script>
......@@ -21,6 +21,15 @@ urlpatterns = patterns('', # nopep8
url(r'^save_item$', 'contentstore.views.save_item', name='save_item'),
url(r'^delete_item$', 'contentstore.views.delete_item', name='delete_item'),
url(r'^create_item$', 'contentstore.views.create_item', name='create_item'),
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
url(r'^transcripts/check$', 'contentstore.views.check_transcripts', name='check_transcripts'),
url(r'^transcripts/choose$', 'contentstore.views.choose_transcripts', name='choose_transcripts'),
url(r'^transcripts/replace$', 'contentstore.views.replace_transcripts', name='replace_transcripts'),
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'),
url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'),
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
......
......@@ -212,3 +212,11 @@ def i_answer_prompts_with(step, prompt):
In addition, this method changes the functionality of ONLY future alerts
"""
world.browser.execute_script('window.prompt = function(){return %s;}') % prompt
@step('I run ipdb')
def run_ipdb(_step):
"""Run ipdb as step for easy debugging"""
import ipdb
ipdb.set_trace()
assert True
......@@ -468,6 +468,11 @@ def click_link(partial_text, index=0):
@world.absorb
def click_link_by_text(text, index=0):
retry_on_exception(lambda: world.browser.find_link_by_text(text)[index].click())
@world.absorb
def css_text(css_selector, index=0, timeout=30):
# Wait for the css selector to appear
if is_css_present(css_selector):
......
......@@ -5,4 +5,5 @@
# Video are written in pure JavaScript.
!video/*.js
\ No newline at end of file
!video/*.js
!video/transcripts/*.js
\ No newline at end of file
......@@ -65,6 +65,15 @@ class @TabsEditingDescriptor
current_tab = @$tabs.filter('.current').html()
data: TabsEditingDescriptor.Model.getValue(@html_id, current_tab)
setMetadataEditor : (metadataEditor) ->
TabsEditingDescriptor.setMetadataEditor.apply(TabsEditingDescriptor, arguments)
getStorage : () ->
TabsEditingDescriptor.getStorage()
addToStorage : (id, data) ->
TabsEditingDescriptor.addToStorage.apply(TabsEditingDescriptor, arguments)
@Model :
addModelUpdate : (id, tabName, modelUpdateFunction) ->
###
......@@ -115,6 +124,7 @@ class @TabsEditingDescriptor
# html_id's of descriptors will be stored in modules variable as
# containers for callbacks.
modules: {}
Storage: {}
initialize : (id) ->
###
......@@ -123,3 +133,13 @@ class @TabsEditingDescriptor
@modules[id] = @modules[id] or {}
@modules[id].tabSwitch = @modules[id]['tabSwitch'] or {}
@modules[id].modelUpdate = @modules[id]['modelUpdate'] or {}
@setMetadataEditor : (metadataEditor) ->
TabsEditingDescriptor.Model.Storage['MetadataEditor'] = metadataEditor
@addToStorage : (id, data) ->
TabsEditingDescriptor.Model.Storage[id] = data
@getStorage : () ->
TabsEditingDescriptor.Model.Storage
......@@ -141,9 +141,13 @@ class VideoDescriptorTest(unittest.TestCase):
""""test get_context"""
correct_tabs = [
{
'name': "Settings",
'template': "tabs/metadata-edit-tab.html",
'name': "Basic",
'template': "video/transcripts.html",
'current': True
},
{
'name': 'Advanced',
'template': 'tabs/metadata-edit-tab.html'
}
]
rendered_context = self.descriptor.get_context()
......
......@@ -15,37 +15,70 @@ class MockYoutubeRequestHandler(BaseHTTPRequestHandler):
protocol = "HTTP/1.0"
def do_HEAD(self):
self._send_head()
code = 200
if 'test_transcripts_youtube' in self.path:
if not 'trans_exist' in self.path:
code = 404
self._send_head(code)
def do_GET(self):
'''
Handle a GET request from the client and sends response back.
'''
self._send_head()
logger.debug("Youtube provider received GET request to path {}".format(
self.path)
) # Log the request
status_message = "I'm youtube."
response_timeout = float(self.server.time_to_response)
if 'test_transcripts_youtube' in self.path:
if 't__eq_exist' in self.path:
status_message = """<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.0" dur="1.0">Equal transcripts</text></transcript>"""
self._send_head()
self._send_transcripts_response(status_message)
elif 't_neq_exist' in self.path:
status_message = """<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.1" dur="5.5">Transcripts sample, different that on server</text></transcript>"""
self._send_head()
self._send_transcripts_response(status_message)
else:
self._send_head(404)
elif 'test_youtube' in self.path:
self._send_head()
#testing videoplayers
status_message = "I'm youtube."
response_timeout = float(self.server.time_to_response)
# threading timer produces TypeError: 'NoneType' object is not callable here
# so we use time.sleep, as we already in separate thread.
time.sleep(response_timeout)
self._send_response(status_message)
# threading timer produces TypeError: 'NoneType' object is not callable here
# so we use time.sleep, as we already in separate thread.
time.sleep(response_timeout)
self._send_video_response(status_message)
else:
# unused url
self._send_head()
self._send_transcripts_response('Unused url')
logger.debug("Request to unused url.")
def _send_head(self):
def _send_head(self, code=200):
'''
Send the response code and MIME headers
'''
self.send_response(200)
self.send_response(code)
self.send_header('Content-type', 'text/html')
self.end_headers()
def _send_response(self, message):
def _send_transcripts_response(self, message):
'''
Send message back to the client for transcripts ajax requests.
'''
response = message
# Log the response
logger.debug("Youtube: sent response {}".format(message))
self.wfile.write(response)
def _send_video_response(self, message):
'''
Send message back to the client
Send message back to the client for video player requests.
Requires sending back callback id.
'''
callback = urlparse.parse_qs(self.path)['callback'][0]
response = callback + '({})'.format(json.dumps({'message': message}))
......
......@@ -3,15 +3,13 @@ Test for Mock_Youtube_Server
"""
import unittest
import threading
import urllib
import requests
from mock_youtube_server import MockYoutubeServer
from nose.plugins.skip import SkipTest
class MockYoutubeServerTest(unittest.TestCase):
'''
A mock version of the Youtube provider server that listens on a local
A mock version of the YouTube provider server that listens on a local
port and responds with jsonp.
Used for lettuce BDD tests in lms/courseware/features/video.feature
......@@ -19,11 +17,6 @@ class MockYoutubeServerTest(unittest.TestCase):
def setUp(self):
# This is a test of the test setup,
# so it does not need to run as part of the unit test suite
# You can re-enable it by commenting out the line below
raise SkipTest
# Create the server
server_port = 8034
server_host = '127.0.0.1'
......@@ -46,8 +39,39 @@ class MockYoutubeServerTest(unittest.TestCase):
path, and responses with incorrect signature.
"""
# GET request
response_handle = urllib.urlopen(
'http://127.0.0.1:8034/feeds/api/videos/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func',
# unused url
response = requests.get(
'http://127.0.0.1:8034/some url',
)
self.assertEqual("Unused url", response.content)
# video player test url, callback shoud be presented in url params
response = requests.get(
'http://127.0.0.1:8034/test_youtube/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func',
)
self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response.content)
# transcripts test url
response = requests.get(
'http://127.0.0.1:8034/test_transcripts_youtube/t__eq_exist',
)
self.assertEqual(
'<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.0" dur="1.0">Equal transcripts</text></transcript>',
response.content
)
# transcripts test url
response = requests.get(
'http://127.0.0.1:8034/test_transcripts_youtube/t_neq_exist',
)
self.assertEqual(
'<?xml version="1.0" encoding="utf-8" ?><transcript><text start="1.1" dur="5.5">Transcripts sample, different that on server</text></transcript>',
response.content
)
# transcripts test url, not trans_exist youtube_id, so 404 should be returned
response = requests.get(
'http://127.0.0.1:8034/test_transcripts_youtube/some_id',
)
response = response_handle.read()
self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response)
self.assertEqual(404, response.status_code)
......@@ -17,9 +17,11 @@ from lxml import etree
from pkg_resources import resource_string
import datetime
import time
import copy
from django.http import Http404
from django.conf import settings
from django.utils.translation import ugettext as _
from xmodule.x_module import XModule
from xmodule.editing_module import TabsEditingDescriptor
......@@ -30,7 +32,6 @@ from xblock.fields import Scope, String, Boolean, Float, List, Integer, ScopeIds
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
from xblock.runtime import DbModel
log = logging.getLogger(__name__)
......@@ -48,7 +49,7 @@ class VideoFields(object):
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
display_name="Show Captions",
display_name="Show Transcript",
scope=Scope.settings,
default=True
)
......@@ -103,13 +104,13 @@ class VideoFields(object):
)
track = String(
help="The external URL to download the timed transcript track. This appears as a link beneath the video.",
display_name="Download Track",
display_name="Download Transcript",
scope=Scope.settings,
default=""
)
sub = String(
help="The name of the timed transcript track (for non-Youtube videos).",
display_name="HTML5 Timed Transcript",
display_name="HTML5 Transcript",
scope=Scope.settings,
default=""
)
......@@ -196,14 +197,14 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
module_class = VideoModule
tabs = [
# {
# 'name': "Subtitles",
# 'template': "video/subtitles.html",
# },
{
'name': "Settings",
'template': "tabs/metadata-edit-tab.html",
'name': "Basic",
'template': "video/transcripts.html",
'current': True
},
{
'name': "Advanced",
'template': "tabs/metadata-edit-tab.html"
}
]
......@@ -286,6 +287,45 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
xml.append(ele)
return xml
def get_context(self):
"""
Extend context by data for transcripts basic tab.
"""
_context = super(VideoDescriptor, self).get_context()
metadata_fields = copy.deepcopy(self.editable_metadata_fields)
display_name = metadata_fields['display_name']
video_url = metadata_fields['html5_sources']
youtube_id_1_0 = metadata_fields['youtube_id_1_0']
def get_youtube_link(video_id):
if video_id:
return 'http://youtu.be/{0}'.format(video_id)
else:
return ''
video_url.update({
'help': _('A YouTube URL or a link to a file hosted anywhere on the web.'),
'display_name': 'Video URL',
'field_name': 'video_url',
'type': 'VideoList',
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]
})
youtube_id_1_0_value = get_youtube_link(youtube_id_1_0['value'])
if youtube_id_1_0_value:
video_url['value'].insert(0, youtube_id_1_0_value)
metadata = {
'display_name': display_name,
'video_url': video_url
}
_context.update({'transcripts_basic_tab_metadata': metadata})
return _context
@classmethod
def _parse_youtube(cls, data):
"""
......
/*
* jQuery.ajaxQueue - A queue for ajax requests
*
* @copyright: Copyright (c) 2013 Corey Frang
* @license: Licensed under the MIT license. See https://github.com/gnarf/jquery-ajaxQueue/blob/master/LICENSE-MIT.
* @website: https://github.com/gnarf/jquery-ajaxQueue
*/
(function($) {
// jQuery on an empty object, we are going to use this as our Queue
var ajaxQueue = $({});
$.ajaxQueue = function( ajaxOpts ) {
var jqXHR,
dfd = $.Deferred(),
promise = dfd.promise();
// run the actual query
function doRequest( next ) {
jqXHR = $.ajax( ajaxOpts );
jqXHR.done( dfd.resolve )
.fail( dfd.reject )
.then( next, next );
}
// queue our ajax request
ajaxQueue.queue( doRequest );
// add the abort method
promise.abort = function( statusText ) {
// proxy abort to the jqXHR if it is active
if ( jqXHR ) {
return jqXHR.abort( statusText );
}
// if there wasn't already a jqXHR we need to remove from queue
var queue = ajaxQueue.queue(),
index = $.inArray( doRequest, queue );
if ( index > -1 ) {
queue.splice( index, 1 );
}
// and then reject the deferred
dfd.rejectWith( ajaxOpts.context || ajaxOpts, [ promise, statusText, "" ] );
return promise;
};
return promise;
};
})(jQuery);
{
"start": [
1000
],
"end": [
2000
],
"text": [
"Equal transcripts"
]
}
\ No newline at end of file
{
"start": [
270,
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360
],
"end": [
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360,
114220
],
"text": [
"LILA FISHER: Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.",
"As you know, our courses are entirely online.",
"So before we start learning about the subjects that",
"brought you here, let's learn about the tools that you will",
"use to navigate through the course material.",
"Let's start with what is on your screen right now.",
"You are watching a video of me talking.",
"You have several tools associated with these videos.",
"Some of them are standard video buttons, like the play",
"Pause Button on the bottom left.",
"Like most video players, you can see how far you are into",
"this particular video segment and how long the entire video",
"segment is.",
"Something that you might not be used to",
"is the speed option.",
"While you are going through the videos, you can speed up",
"or slow down the video player with these buttons.",
"Go ahead and try that now.",
"Make me talk faster and slower.",
"If you ever get frustrated by the pace of speech, you can",
"adjust it this way.",
"Another great feature is the transcript on the side.",
"This will follow along with everything that I am saying as",
"I am saying it, so you can read along if you like.",
"You can also click on any of the words, and you will notice",
"that the video jumps to that word.",
"The video slider at the bottom of the video will let you",
"navigate through the video quickly.",
"If you ever find the transcript distracting, you",
"can toggle the captioning button in order to make it go",
"away or reappear.",
"Now that you know about the video player, I want to point",
"out the sequence navigator.",
"Right now you're in a lecture sequence, which interweaves",
"many videos and practice exercises.",
"You can see how far you are in a particular sequence by",
"observing which tab you're on.",
"You can navigate directly to any video or exercise by",
"clicking on the appropriate tab.",
"You can also progress to the next element by pressing the",
"Arrow button, or by clicking on the next tab.",
"Try that now.",
"The tutorial will continue in the next video."
]
}
\ No newline at end of file
{
"start": [
270,
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360
],
"end": [
2720,
5430,
7160,
10830,
12880,
15890,
19000,
22070,
25170,
27890,
30590,
32920,
36360,
39630,
41170,
42790,
44590,
47320,
50250,
51880,
54320,
57410,
59160,
62320,
65099,
68430,
71360,
73640,
76580,
78660,
81480,
83940,
86230,
88570,
90520,
93430,
95940,
99090,
100910,
103740,
105610,
108310,
111100,
112360,
114220
],
"text": [
"LILA FISHER: Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.",
"As you know, our courses are entirely online.",
"So before we start learning about the subjects that",
"brought you here, let's learn about the tools that you will",
"use to navigate through the course material.",
"Let's start with what is on your screen right now.",
"You are watching a video of me talking.",
"You have several tools associated with these videos.",
"Some of them are standard video buttons, like the play",
"Pause Button on the bottom left.",
"Like most video players, you can see how far you are into",
"this particular video segment and how long the entire video",
"segment is.",
"Something that you might not be used to",
"is the speed option.",
"While you are going through the videos, you can speed up",
"or slow down the video player with these buttons.",
"Go ahead and try that now.",
"Make me talk faster and slower.",
"If you ever get frustrated by the pace of speech, you can",
"adjust it this way.",
"Another great feature is the transcript on the side.",
"This will follow along with everything that I am saying as",
"I am saying it, so you can read along if you like.",
"You can also click on any of the words, and you will notice",
"that the video jumps to that word.",
"The video slider at the bottom of the video will let you",
"navigate through the video quickly.",
"If you ever find the transcript distracting, you",
"can toggle the captioning button in order to make it go",
"away or reappear.",
"Now that you know about the video player, I want to point",
"out the sequence navigator.",
"Right now you're in a lecture sequence, which interweaves",
"many videos and practice exercises.",
"You can see how far you are in a particular sequence by",
"observing which tab you're on.",
"You can navigate directly to any video or exercise by",
"clicking on the appropriate tab.",
"You can also progress to the next element by pressing the",
"Arrow button, or by clicking on the next tab.",
"Try that now.",
"The tutorial will continue in the next video."
]
}
\ No newline at end of file
0
00:00:00,270 --> 00:00:02,720
LILA FISHER: Hi, welcome to Edx.
1
00:00:02,720 --> 00:00:05,430
I'm Lila Fisher, an Edx fellow helping to put
2
00:00:05,430 --> 00:00:07,160
together these courses.
3
00:00:07,160 --> 00:00:10,830
As you know, our courses are entirely online.
4
00:00:10,830 --> 00:00:12,880
So before we start learning about the subjects that
5
00:00:12,880 --> 00:00:15,890
brought you here, let's learn about the tools that you will
6
00:00:15,890 --> 00:00:19,000
use to navigate through the course material.
7
00:00:19,000 --> 00:00:22,070
Let's start with what is on your screen right now.
8
00:00:22,070 --> 00:00:25,170
You are watching a video of me talking.
9
00:00:25,170 --> 00:00:27,890
You have several tools associated with these videos.
10
00:00:27,890 --> 00:00:30,590
Some of them are standard video buttons, like the play
......@@ -2,5 +2,9 @@
CMS module
*******************************************
.. module:: cms
.. toctree::
transcripts.rst
.. module:: transcripts
======================================================
Developer’s workflow for the timed transcripts in CMS.
======================================================
:download:`Multipage pdf version of Timed Transcripts workflow. <transcripts_workflow.pdf>`
:download:`Open office graph version (source for pdf). <transcripts_workflow.odg>`
:download:`List of implemented acceptance tests. <transcripts_acceptance_tests.odt>`
Description
===========
Timed Transcripts functionality is added in separate tab of Video module Editor, that is active by default. This tab is called `Basic`, another tab is called `Advanced` and contains default metadata fields.
`Basic` tab is a simple representation of `Advanced` tab that provides functionality to speed up adding Video module with transcripts to the course.
To make more accurate adjustments `Advanced` tab should be used.
Front-end part of `Basic` tab has 4 editors/views:
* Display name
* 3 editors for inserting Video URLs.
Video URL fields might contain 3 kinds of URLs:
* **YouTube** link. There are supported formats:
* http://www.youtube.com/watch?v=OEoXaMPEzfM&feature=feedrec_grec_index ;
* http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/OEoXaMPEzfM ;
* http://www.youtube.com/v/OEoXaMPEzfM?fs=1&amp;hl=en_US&amp;rel=0 ;
* http://www.youtube.com/watch?v=OEoXaMPEzfM#t=0m10s ;
* http://www.youtube.com/embed/OEoXaMPEzfM?rel=0 ;
* http://www.youtube.com/watch?v=OEoXaMPEzfM ;
* http://youtu.be/OEoXaMPEzfM ;
* **MP4** video source;
* **WEBM** video source.
Each of these kind of URLs can be specified just **ONCE**. Otherwise, error message occurs on front-end.
After filling editor **transcripts/check** method will be invoked with the parameters described below (see `API`_). Depending on conditions, that are also described below (see `Commands`_), this method responds with a *command* and front-end renders the appropriate View.
Each View can have specific actions. There is a list of supported actions:
* Download Timed Transcripts;
* Upload Timed Transcripts;
* Import Timed Transcripts from YouTube;
* Replace edX Timed Transcripts by Timed Transcripts from YouTube;
* Choose Timed Transcripts;
* Use existing Timed Transcripts.
All of these actions are handled by 7 API methods described below (see `API`_).
Because rollback functionality isn't implemented now, after invoking some of the actions user cannot revert changes by clicking button `Cancel`.
To remove timed transcripts file from the video just go to `Advanced` tab and clear field `sub` then Save changes.
Commands
========
Command from front-end point of view is just a reference to the needed View with possible actions that user can do depending on conditions described below (See edx-platform/cms/static/js/views/transcripts/message_manager.js:21-29).
So,
* **IF** YouTube transcripts present locally **AND** on YouTube server **AND** both of these transcripts files are **DIFFERENT**, we respond with `replace` command. Ask user to replace local transcripts file by YouTube's ones.
* **IF** YouTube transcripts present **ONLY** locally, we respond with `found` command.
* **IF** YouTube transcripts present **ONLY** on YouTube server, we respond with `import` command. Ask user to import transcripts file from YouTube server.
* **IF** player is in HTML5 video mode. It means that **ONLY** html5 sources are added:
* **IF** just 1 html5 source was added or both html5 sources have **EQUAL** transcripts files, then we respond with `found` command.
* **OTHERWISE**, when 2 html5 sources were added and founded transcripts files are **DIFFERENT**, we respond with `choose` command. In this case, user should choose which one transcripts file he wants to use.
* **IF** we are working with just 1 field **AND** item.sub field **HAS** a value **AND** user fills editor/view by the new value/video source without transcripts file, we respond with `use_existing` command. In this case, user will have possibility to use transcripts file from previous video.
* **OTHERWISE**, we will respond with `not_found` command.
Synchronization and Saving workflow
====================================
For now saving mechanism works as follows:
On click `Save` button **ModuleEdit** class (See edx-platform/cms/static/coffee/src/views/module_edit.coffee:83-101) grabs values from all modified metadata fields and sends all this data to the server.
Because of the fact that Timed Transcripts is module specific functionality, ModuleEdit class is not extended. Instead, to apply all changes that user did in the `Basic` tab, we use synchronization mechanism of TabsEditingDescriptor class. That mechanism provides us possibility to do needed actions on Tab switching and on Save (See edx-platform/cms/templates/widgets/video/transcripts.html).
On tab switching and when save action is invoked, JavaScript code synchronize collections (Metadata Collection and Transcripts Collection). You can see synchronization logic in the edx-platform/cms/static/js/views/transcripts/editor.js:72-219. In this case, Metadata fields always have the actual data.
API
===
We provide 7 API methods to work with timed transcripts
(edx-platform/cms/urls.py:23-29):
* transcripts/upload
* transcripts/download
* transcripts/check
* transcripts/choose
* transcripts/replace
* transcripts/rename
* transcripts/save
**"transcripts/upload"** method is used for uploading SRT transcripts for the
HTML5 and YouTube video modules.
*Method:*
POST
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
- file - BLOB file
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/download"** method is used for downloading SRT transcripts for the
HTML5 and YouTube video modules.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- subs_id - file name that is used to find transcripts file in the storage.
*Response:*
HTTP 404
or
HTTP 200 + BLOB of SRT file
**"transcripts/check"** method is used for checking availability of timed transcripts
for the video module.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
command: string with action to front-end what to do and what to show to user,
subs: file name of transcripts file that was found in the storage,
html5_local: [] or [True] or [True, True],
is_youtube_mode: True/False,
youtube_local: True/False,
youtube_server: True/False,
youtube_diff: True/False,
current_item_subs: string with value of item.sub field,
status: 'Error' or 'Success'
}
**"transcripts/choose"** method is used for choosing which transcripts file should be used.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
- html5_id - file name of chosen transcripts file.
*Response:*
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/replace"** method is used for handling `import` and `replace` commands.
Invoking this method starts downloading new transcripts file from YouTube server.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/rename"** method is used for handling `use_existing` command.
After invoking this method current transcripts file will be copied and renamed to another one with name of current video passed in the editor/view.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- video_list - list with information about the links currently passed in the editor/view.
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error',
subs: value of uploaded and saved sub field in the video item.
}
**"transcripts/save"** method is used for handling `save` command.
After invoking this method all changes will be saved that were done before this moment.
*Method:*
GET
*Parameters:*
- id - location ID of the Xmodule
- metadata - new values for the metadata fields.
- currents_subs - list with the file names of videos passed in the editor/view.
*Response:*
HTTP 400
or
HTTP 200 + JSON:
.. code::
{
status: 'Success' or 'Error'
}
Transcripts modules:
====================
.. automodule:: contentstore.views.transcripts_ajax
:members:
:show-inheritance:
.. automodule:: contentstore.transcripts_utils
:members:
:show-inheritance:
#pylint: disable=C0111
#pylint: disable=W0621
from courseware.mock_youtube_server.mock_youtube_server import MockYoutubeServer
from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer
from lettuce import before, after, world
from django.conf import settings
import threading
......@@ -25,6 +24,8 @@ def setup_mock_youtube_server():
server.time_to_response = 1 # seconds
server.address = address
# Start the server running in a separate daemon thread
# Because the thread is a daemon, it will terminate
# when the main thread terminates.
......
......@@ -5,7 +5,7 @@ desc "Invoke sphinx 'make build' to generate docs."
task :builddocs, [:type, :quiet] do |t, args|
args.with_defaults(:quiet => "quiet")
if args.type == 'dev'
path = "docs/developer"
path = "docs/developers"
elsif args.type == 'author'
path = "docs/course_authors"
elsif args.type == 'data'
......@@ -26,7 +26,7 @@ end
desc "Show docs in browser (mac and ubuntu)."
task :showdocs, [:options] do |t, args|
if args.options == 'dev'
path = "docs/developer"
path = "docs/developers"
elsif args.options == 'author'
path = "docs/course_authors"
elsif args.options == 'data'
......
......@@ -56,6 +56,7 @@ pyparsing==1.5.6
python-memcached==1.48
python-openid==2.2.5
pytz==2012h
pysrt==0.4.7
PyYAML==3.10
requests==1.2.3
scipy==0.11.0
......
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