diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 40ec2ed3c734..8e764d5a17cd 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -54,8 +54,8 @@ def setUp(self):
self.client = Client()
self.client.login(username=uname, password=password)
- course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course')
- self.course_location = course.location
+ self.course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course')
+ self.course_location = self.course.location
class CourseDetailsTestCase(CourseTestCase):
diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py
index 1831a5769a61..6693c8c4fbb3 100644
--- a/cms/djangoapps/contentstore/tests/test_item.py
+++ b/cms/djangoapps/contentstore/tests/test_item.py
@@ -1,15 +1,30 @@
+"""Tests for items views."""
+import os
+import json
+import tempfile
+from uuid import uuid4
+
+from lxml import etree
+from django.core.urlresolvers import reverse
+from django.template.defaultfilters import slugify
+
from contentstore.tests.test_course_settings import CourseTestCase
+from cache_toolbox.core import del_cached_content
from xmodule.modulestore.tests.factories import CourseFactory
-from django.core.urlresolvers import reverse
+from xmodule.modulestore.django import modulestore
+from xmodule.contentstore.django import contentstore
+from xmodule.contentstore.content import StaticContent
+from xmodule.exceptions import NotFoundError
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()
self.course = CourseFactory.create(org='mitX', number='333', display_name='Dummy Course')
- def testDeleteStaticPage(self):
+ def test_delete_static_page(self):
# Add static tab
data = {
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
@@ -24,5 +39,629 @@ def testDeleteStaticPage(self):
self.assertEqual(resp.status_code, 200)
+class BaseSubtitles(CourseTestCase):
+ """Base test class for subtitles tests."""
+
+ org = 'MITx'
+ number = '999'
+
+ def clear_subs_content(self):
+ """Remove, if subtitles content exists."""
+ for youtube_id in self.get_youtube_ids().values():
+ filename = 'subs_{0}.srt.sjson'.format(youtube_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ try:
+ content = contentstore().find(content_location)
+ contentstore().delete(content.get_id())
+ except NotFoundError:
+ pass
+
+ def setUp(self):
+ """Create initial data."""
+ super(BaseSubtitles, self).setUp()
+
+ # Add videoalpha module
+ data = {
+ 'parent_location': str(self.course_location),
+ 'template': 'i4x://edx/templates/videoalpha/Video_Alpha'
+ }
+ resp = self.client.post(reverse('clone_item'), data)
+ self.item_location = json.loads(resp.content).get('id')
+ self.assertEqual(resp.status_code, 200)
+
+ # hI10vDNYz4M - valid Youtube ID with subtitles.
+ # JMD_ifUUfsU, AKqURZnYqpk, DYpADpL7jAY - valid Youtube IDs
+ # without subtitles.
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ self.item = modulestore().get_item(self.item_location)
+
+ # Remove all subtitles for current module.
+ self.clear_subs_content()
+
+ def get_youtube_ids(self):
+ """Return youtube speeds and ids."""
+ xmltree = etree.fromstring(self.item.data)
+ youtube = xmltree.get('youtube')
+ return dict([
+ (float(i.split(':')[0]), i.split(':')[1])
+ for i in youtube.split(',')
+ ])
+
+
+class ImportSubtitles(BaseSubtitles):
+ """Tests for '/import_subtitles' url."""
+
+ def test_success_videoalpha_module_subs_importing(self):
+ # Import subtitles.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(json.loads(resp.content).get('success'))
+
+ # Check assets status after importing subtitles.
+ for youtube_id in self.get_youtube_ids().values():
+ filename = 'subs_{0}.srt.sjson'.format(youtube_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ self.assertTrue(contentstore().find(content_location))
+
+ def test_fail_data_without_id(self):
+ resp = self.client.post(
+ reverse('import_subtitles'), {})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_data_with_bad_location(self):
+ # Test for raising `InvalidLocationError` exception.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': 'BAD_LOCATION'})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ # Test for raising `ItemNotFoundError` exception.
+ resp = self.client.post(
+ reverse('import_subtitles'),
+ {'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION')}
+ )
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_for_non_videoalpha_module(self):
+ # Video module: setup
+ data = {
+ 'parent_location': str(self.course_location),
+ 'template': 'i4x://edx/templates/video/default'
+ }
+ resp = self.client.post(reverse('clone_item'), data)
+ item_location = json.loads(resp.content).get('id')
+ data = ''
+ modulestore().update_item(item_location, data)
+
+ # Video module: testing
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': item_location})
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_bad_xml(self):
+ data = '<<'
+ modulestore().update_item(self.item_location, data)
+
+ # Import subtitles.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_miss_youtube_attr(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ # Import subtitles.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ # Import subtitles.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_bad_youtube_attr(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ # Import subtitles.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_youtube_ids_unavailable(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ # Import subtitles.
+ resp = self.client.post(
+ reverse('import_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def tearDown(self):
+ super(ImportSubtitles, self).tearDown()
+
+ # Remove all subtitles for current module.
+ self.clear_subs_content()
+
+
+class UploadSubtitles(BaseSubtitles):
+ """Tests for '/upload_subtitles' url."""
+
+ def setUp(self):
+ """Create initial data."""
+ super(UploadSubtitles, self).setUp()
+
+ self.good_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
+ self.good_srt_file.write("""
+1
+00:00:10,500 --> 00:00:13,000
+Elephant's Dream
+
+2
+00:00:15,000 --> 00:00:18,000
+At the left we can see...
+ """)
+ self.good_srt_file.seek(0)
+
+ self.bad_data_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
+ self.bad_data_srt_file.write('Some BAD data')
+ self.bad_data_srt_file.seek(0)
+
+ self.bad_name_srt_file = tempfile.NamedTemporaryFile(suffix='.BAD')
+ self.bad_name_srt_file.write("""
+1
+00:00:10,500 --> 00:00:13,000
+Elephant's Dream
+
+2
+00:00:15,000 --> 00:00:18,000
+At the left we can see...
+ """)
+ self.bad_name_srt_file.seek(0)
+
+ def test_success_videoalpha_module_youtube_subs_uploading(self):
+ # Check assets status before uploading subtitles.
+ for youtube_id in self.get_youtube_ids().values():
+ filename = 'subs_{0}.srt.sjson'.format(youtube_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ self.assertRaises(
+ NotFoundError, contentstore().find, content_location)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(json.loads(resp.content).get('success'))
+
+ # Check assets status after uploading subtitles.
+ for youtube_id in self.get_youtube_ids().values():
+ filename = 'subs_{0}.srt.sjson'.format(youtube_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ self.assertTrue(contentstore().find(content_location))
+
+ def test_success_videoalpha_module_source_subs_uploading(self):
+ data = """
+
+
+
+
+
+"""
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(json.loads(resp.content).get('success'))
+ self.assertTrue(json.loads(resp.content).get('xml'))
+
+ filename = slugify(
+ os.path.splitext(os.path.basename(self.good_srt_file.name))[0])
+ item = modulestore().get_item(self.item_location)
+ self.assertEqual(
+ etree.fromstring(item.data).get('sub'),
+ filename)
+
+ content_location = StaticContent.compute_location(
+ self.org, self.number, 'subs_{0}.srt.sjson'.format(filename))
+ self.assertTrue(contentstore().find(content_location))
+
+ def test_fail_data_without_id(self):
+ resp = self.client.post(
+ reverse('upload_subtitles'), {'file': self.good_srt_file})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_data_without_file(self):
+ resp = self.client.post(
+ reverse('upload_subtitles'), {'id': self.item_location})
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_data_with_bad_location(self):
+ # Test for raising `InvalidLocationError` exception.
+ resp = self.client.post(
+ reverse('import_subtitles'),
+ {
+ 'id': 'BAD_LOCATION',
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ # Test for raising `ItemNotFoundError` exception.
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION'),
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_for_non_videoalpha_module(self):
+ # Video module: setup
+ data = {
+ 'parent_location': str(self.course_location),
+ 'template': 'i4x://edx/templates/video/default'
+ }
+ resp = self.client.post(reverse('clone_item'), data)
+ item_location = json.loads(resp.content).get('id')
+ data = ''
+ modulestore().update_item(item_location, data)
+
+ # Video module: testing
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': item_location,
+ 'file': self.good_srt_file
+ })
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_bad_xml(self):
+ data = '<<'
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_bad_youtube_attr(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_miss_youtube_and_source_attrs(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.good_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_bad_data_srt_file(self):
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.bad_data_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_fail_bad_name_srt_file(self):
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': self.bad_name_srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def test_undefined_file_extension(self):
+ srt_file = tempfile.NamedTemporaryFile(suffix='')
+ srt_file.write("""
+1
+00:00:10,500 --> 00:00:13,000
+Elephant's Dream
+
+2
+00:00:15,000 --> 00:00:18,000
+At the left we can see...
+ """)
+ srt_file.seek(0)
+
+ resp = self.client.post(
+ reverse('upload_subtitles'),
+ {
+ 'id': self.item_location,
+ 'file': srt_file
+ })
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertFalse(json.loads(resp.content).get('success'))
+
+ def tearDown(self):
+ super(UploadSubtitles, self).tearDown()
+
+ self.good_srt_file.close()
+ self.bad_data_srt_file.close()
+ self.bad_name_srt_file.close()
+
+
+class DownloadSubtitles(BaseSubtitles):
+ """Tests for '/download_subtitles' url."""
+
+ def save_subs_to_store(self, subs, subs_id):
+ """Save subtitles into `StaticContent`."""
+ filedata = json.dumps(subs, indent=2)
+ mime_type = 'application/json'
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ content = StaticContent(content_location, filename, mime_type, filedata)
+ contentstore().save(content)
+ del_cached_content(content_location)
+ return content_location
+
+ def remove_subs_from_store(self, subs_id):
+ """Remove from store, if subtitles content exists."""
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ try:
+ content = contentstore().find(content_location)
+ contentstore().delete(content.get_id())
+ except NotFoundError:
+ pass
+
+ def test_success_download_youtube_speed_1(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ subs = {
+ 'start': [100, 200, 240],
+ 'end': [200, 240, 380],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3'
+ ]
+ }
+ self.save_subs_to_store(subs, 'JMD_ifUUfsU')
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 200)
+
+ def test_success_download_youtube_speed_2(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ subs = {
+ 'start': [100, 200, 240],
+ 'end': [200, 240, 380],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3'
+ ]
+ }
+ self.save_subs_to_store(subs, 'JMD_ifUUfsU')
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 200)
+
+ def test_success_download_nonyoutube(self):
+ subs_id = str(uuid4())
+ data = """
+
+
+
+
+
+""".format(subs_id)
+ modulestore().update_item(self.item_location, data)
+
+ subs = {
+ 'start': [100, 200, 240],
+ 'end': [200, 240, 380],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3'
+ ]
+ }
+ self.save_subs_to_store(subs, subs_id)
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 200)
+
+ self.remove_subs_from_store(subs_id)
+
+ def test_fail_data_without_file(self):
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': ''})
+ self.assertEqual(resp.status_code, 404)
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_data_with_bad_location(self):
+ # Test for raising `InvalidLocationError` exception.
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': 'BAD_LOCATION'})
+ self.assertEqual(resp.status_code, 404)
+
+ # Test for raising `ItemNotFoundError` exception.
+ resp = self.client.get(
+ reverse('download_subtitles'),
+ {'id': '{0}_{1}'.format(self.item_location, 'BAD_LOCATION')})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_for_non_videoalpha_module(self):
+ # Video module: setup
+ data = {
+ 'parent_location': str(self.course_location),
+ 'template': 'i4x://edx/templates/video/default'
+ }
+ resp = self.client.post(reverse('clone_item'), data)
+ item_location = json.loads(resp.content).get('id')
+ data = ''
+ modulestore().update_item(item_location, data)
+
+ # Video module: testing
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': item_location})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_bad_xml(self):
+ data = '<<'
+ modulestore().update_item(self.item_location, data)
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_bad_youtube_attr(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_youtube_subs_dont_exist(self):
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_nonyoutube_subs_dont_exist(self):
+ data = """
+
+
+
+
+
+"""
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_empty_youtube_attr_and_sub_attr(self):
+ data = """
+
+
+
+
+
+"""
+ modulestore().update_item(self.item_location, data)
+
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 404)
+
+ def test_fail_bad_sjson_subs(self):
+ data = ''
+ modulestore().update_item(self.item_location, data)
+
+ subs = {
+ 'start': [100, 200, 240],
+ 'end': [200, 240, 380],
+ 'text': [
+ 'subs #1'
+ ]
+ }
+ self.save_subs_to_store(subs, 'JMD_ifUUfsU')
+ resp = self.client.get(
+ reverse('download_subtitles'), {'id': self.item_location})
+ self.assertEqual(resp.status_code, 404)
diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index fec82db1bb78..4b0e41e904cc 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -1,12 +1,20 @@
-""" Tests for utils. """
-from contentstore import utils
+"""Tests for utils."""
import mock
+import unittest
import collections
import copy
+import json
+from uuid import uuid4
+
from django.test import TestCase
+
+from contentstore import utils
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.contentstore.content import StaticContent
+from xmodule.contentstore.django import contentstore
+from xmodule.exceptions import NotFoundError
class LMSLinksTestCase(TestCase):
@@ -190,3 +198,384 @@ def test_remove_extra_panel_tab(self):
self.assertFalse(changed)
self.assertEqual(actual_tabs, expected_tabs)
+
+class TestReturnAjaxStatus(unittest.TestCase):
+ """Tests for `return_ajax_status` decorator."""
+ def setUp(self):
+ self.true_view_func = lambda *args, **kwargs: True
+ self.true_extra_view_func = lambda *args, **kwargs: (True, {'msg': 'some message'})
+ self.false_view_func = lambda *args, **kwargs: False
+
+ def test_success_response(self):
+ request = None
+ response = utils.return_ajax_status(self.true_view_func)(request)
+ status = json.loads(response.content).get('success')
+ self.assertTrue(status)
+
+ def test_fail_response(self):
+ request = None
+ response = utils.return_ajax_status(self.false_view_func)(request)
+ status = json.loads(response.content).get('success')
+ self.assertFalse(status)
+
+ def test_extra_response_data(self):
+ request = None
+ response = utils.return_ajax_status(self.true_extra_view_func)(request)
+ resp = json.loads(response.content)
+
+ self.assertTrue(resp.get('success'))
+ self.assertEqual(resp.get('msg'), 'some message')
+
+
+class TestGenerateSubs(unittest.TestCase):
+ """Tests for `generate_subs` function."""
+ def setUp(self):
+ self.source_subs = {
+ 'start': [100, 200, 240, 390, 1000],
+ 'end': [200, 240, 380, 1000, 1500],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3',
+ 'subs #4',
+ 'subs #5'
+ ]
+ }
+
+ def test_generate_subs_increase_speed(self):
+ subs = utils.generate_subs(2, 1, self.source_subs)
+ self.assertDictEqual(
+ subs,
+ {
+ 'start': [200, 400, 480, 780, 2000],
+ 'end': [400, 480, 760, 2000, 3000],
+ 'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
+ }
+ )
+
+ def test_generate_subs_decrease_speed_1(self):
+ subs = utils.generate_subs(0.5, 1, self.source_subs)
+ self.assertDictEqual(
+ subs,
+ {
+ 'start': [50, 100, 120, 195, 500],
+ 'end': [100, 120, 190, 500, 750],
+ 'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
+ }
+ )
+
+ def test_generate_subs_decrease_speed_2(self):
+ """Test for correct devision during `generate_subs` process."""
+ subs = utils.generate_subs(1, 2, self.source_subs)
+ self.assertDictEqual(
+ subs,
+ {
+ 'start': [50, 100, 120, 195, 500],
+ 'end': [100, 120, 190, 500, 750],
+ 'text': ['subs #1', 'subs #2', 'subs #3', 'subs #4', 'subs #5']
+ }
+ )
+
+
+class TestSaveSubsToStore(ModuleStoreTestCase):
+ """Tests for `save_subs_to_store` function."""
+
+ org = 'MITx'
+ number = '999'
+ display_name = 'Test course'
+
+ def clear_subs_content(self):
+ """Remove, if subtitles content exists."""
+ try:
+ content = contentstore().find(self.content_location)
+ contentstore().delete(content.get_id())
+ except NotFoundError:
+ pass
+
+ def setUp(self):
+ self.subs = {
+ 'start': [100, 200, 240, 390, 1000],
+ 'end': [200, 240, 380, 1000, 1500],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3',
+ 'subs #4',
+ 'subs #5'
+ ]
+ }
+
+ self.subs_id = str(uuid4())
+ filename = 'subs_{0}.srt.sjson'.format(self.subs_id)
+ self.course = CourseFactory.create(
+ org=self.org, number=self.number, display_name=self.display_name)
+ self.content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+
+ self.clear_subs_content()
+
+ def test_save_subs_to_store(self):
+ self.assertRaises(
+ NotFoundError,
+ contentstore().find,
+ self.content_location
+ )
+
+ result_location = utils.save_subs_to_store(
+ self.subs,
+ self.subs_id,
+ self.course)
+
+ self.assertTrue(contentstore().find(self.content_location))
+ self.assertEqual(result_location, self.content_location)
+
+ def tearDown(self):
+ self.clear_subs_content()
+
+
+class TestDownloadYoutubeSubs(ModuleStoreTestCase):
+ """Tests for `download_youtube_subs` function."""
+
+ org = 'MITx'
+ number = '999'
+ display_name = 'Test course'
+
+ def clear_subs_content(self, youtube_subs):
+ """Remove, if subtitles content exists."""
+ for subs_id in youtube_subs.values():
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ try:
+ content = contentstore().find(content_location)
+ contentstore().delete(content.get_id())
+ except NotFoundError:
+ pass
+
+ def setUp(self):
+ self.course = CourseFactory.create(
+ org=self.org, number=self.number, display_name=self.display_name)
+
+ def test_success_downloading_subs(self):
+ good_youtube_subs = {
+ 0.5: 'JMD_ifUUfsU',
+ 1.0: 'hI10vDNYz4M',
+ 2.0: 'AKqURZnYqpk'
+ }
+ self.clear_subs_content(good_youtube_subs)
+
+ status = utils.download_youtube_subs(good_youtube_subs, self.course)
+ self.assertTrue(status)
+
+ # Check assets status after importing subtitles.
+ for subs_id in good_youtube_subs.values():
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ self.assertTrue(contentstore().find(content_location))
+
+ self.clear_subs_content(good_youtube_subs)
+
+ def test_fail_downloading_subs(self):
+ bad_youtube_subs = {
+ 0.5: 'BAD_YOUTUBE_ID1',
+ 1.0: 'BAD_YOUTUBE_ID2',
+ 2.0: 'BAD_YOUTUBE_ID3'
+ }
+ self.clear_subs_content(bad_youtube_subs)
+
+ status = utils.download_youtube_subs(bad_youtube_subs, self.course)
+ self.assertFalse(status)
+
+ # Check assets status after importing subtitles.
+ for subs_id in bad_youtube_subs.values():
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ self.assertRaises(
+ NotFoundError, contentstore().find, content_location)
+
+ self.clear_subs_content(bad_youtube_subs)
+
+
+class TestGenerateSubsFromSource(TestDownloadYoutubeSubs):
+ """Tests for `generate_subs_from_source` function."""
+
+ def test_success_generating_subs(self):
+ youtube_subs = {
+ 0.5: 'JMD_ifUUfsU',
+ 1.0: 'hI10vDNYz4M',
+ 2.0: 'AKqURZnYqpk'
+ }
+ srt_filedata = """
+1
+00:00:10,500 --> 00:00:13,000
+Elephant's Dream
+
+2
+00:00:15,000 --> 00:00:18,000
+At the left we can see...
+ """
+ self.clear_subs_content(youtube_subs)
+
+ status = utils.generate_subs_from_source(
+ youtube_subs,
+ 'srt',
+ srt_filedata,
+ self.course)
+ self.assertTrue(status)
+
+ # Check assets status after importing subtitles.
+ for subs_id in youtube_subs.values():
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+ content_location = StaticContent.compute_location(
+ self.org, self.number, filename)
+ self.assertTrue(contentstore().find(content_location))
+
+ self.clear_subs_content(youtube_subs)
+
+ def test_fail_bad_subs_type(self):
+ youtube_subs = {
+ 0.5: 'JMD_ifUUfsU',
+ 1.0: 'hI10vDNYz4M',
+ 2.0: 'AKqURZnYqpk'
+ }
+
+ srt_filedata = """
+1
+00:00:10,500 --> 00:00:13,000
+Elephant's Dream
+
+2
+00:00:15,000 --> 00:00:18,000
+At the left we can see...
+ """
+
+ status = utils.generate_subs_from_source(
+ youtube_subs,
+ 'BAD_FORMAT',
+ srt_filedata,
+ self.course)
+ self.assertFalse(status)
+
+ def test_fail_bad_subs_filedata(self):
+ youtube_subs = {
+ 0.5: 'JMD_ifUUfsU',
+ 1.0: 'hI10vDNYz4M',
+ 2.0: 'AKqURZnYqpk'
+ }
+
+ srt_filedata = """BAD_DATA"""
+
+ status = utils.generate_subs_from_source(
+ youtube_subs,
+ 'srt',
+ srt_filedata,
+ self.course)
+ self.assertFalse(status)
+
+
+class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs):
+ """Tests for `generate_srt_from_sjson` function."""
+
+ def test_success_generating_subs(self):
+ sjson_subs = {
+ 'start': [100, 200, 240, 390, 54000],
+ 'end': [200, 240, 380, 1000, 78400],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3',
+ 'subs #4',
+ 'subs #5'
+ ]
+ }
+ srt_subs = utils.generate_srt_from_sjson(sjson_subs, 1)
+ self.assertIsNotNone(srt_subs)
+ self.assertIn(
+ '00:00:00,100 --> 00:00:00,200\nsubs #1',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,200 --> 00:00:00,240\nsubs #2',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,240 --> 00:00:00,380\nsubs #3',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,390 --> 00:00:01,000\nsubs #4',
+ srt_subs)
+ self.assertIn(
+ '00:00:54,000 --> 00:01:18,400\nsubs #5',
+ srt_subs)
+
+ def test_success_generating_subs_speed_up(self):
+ sjson_subs = {
+ 'start': [100, 200, 240, 390, 54000],
+ 'end': [200, 240, 380, 1000, 78400],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3',
+ 'subs #4',
+ 'subs #5'
+ ]
+ }
+ srt_subs = utils.generate_srt_from_sjson(sjson_subs, 0.5)
+ self.assertIsNotNone(srt_subs)
+ self.assertIn(
+ '00:00:00,050 --> 00:00:00,100\nsubs #1',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,100 --> 00:00:00,120\nsubs #2',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,120 --> 00:00:00,190\nsubs #3',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,195 --> 00:00:00,500\nsubs #4',
+ srt_subs)
+ self.assertIn(
+ '00:00:27,000 --> 00:00:39,200\nsubs #5',
+ srt_subs)
+
+ def test_success_generating_subs_speed_down(self):
+ sjson_subs = {
+ 'start': [100, 200, 240, 390, 54000],
+ 'end': [200, 240, 380, 1000, 78400],
+ 'text': [
+ 'subs #1',
+ 'subs #2',
+ 'subs #3',
+ 'subs #4',
+ 'subs #5'
+ ]
+ }
+ srt_subs = utils.generate_srt_from_sjson(sjson_subs, 2)
+ self.assertIsNotNone(srt_subs)
+ self.assertIn(
+ '00:00:00,200 --> 00:00:00,400\nsubs #1',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,400 --> 00:00:00,480\nsubs #2',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,480 --> 00:00:00,760\nsubs #3',
+ srt_subs)
+ self.assertIn(
+ '00:00:00,780 --> 00:00:02,000\nsubs #4',
+ srt_subs)
+ self.assertIn(
+ '00:01:48,000 --> 00:02:36,800\nsubs #5',
+ srt_subs)
+
+ def test_fail_generating_subs(self):
+ sjson_subs = {
+ 'start': [100, 200],
+ 'end': [100],
+ 'text': [
+ 'subs #1',
+ 'subs #2'
+ ]
+ }
+ srt_subs = utils.generate_srt_from_sjson(sjson_subs, 1)
+ self.assertIsNone(srt_subs)
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index c9c40ab95dac..4aa3ed523826 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -1,11 +1,30 @@
+"""Helpers functions."""
+
+#pylint: disable=E1103
+
+from __future__ import division
+
+import copy
+import logging
+import re
+import json
+import HTMLParser
+import StringIO
+from functools import wraps
+
+import requests
+from lxml import etree
from django.conf import settings
+from django.core.urlresolvers import reverse
+from pysrt import SubRipTime, SubRipItem, SubRipFile
+
+from cache_toolbox.core import del_cached_content
+from django_comment_client.utils import JsonResponse
+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.core.urlresolvers import reverse
-import copy
-import logging
-import re
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
log = logging.getLogger(__name__)
@@ -255,3 +274,215 @@ def remove_extra_panel_tab(tab_type, course):
course_tabs = [ct for ct in course_tabs if ct != tab_panel]
changed = True
return changed, course_tabs
+
+
+def return_ajax_status(view_function):
+ """Except, that view function return True/False, and convert
+ response to JSON HTTP response:
+ {"success": true} or {"success": false}
+ """
+ @wraps(view_function)
+ def new_view_function(request, *args, **kwargs):
+ """New view functions for decorator result."""
+ result = view_function(request, *args, **kwargs)
+ if isinstance(result, tuple):
+ status = result[0]
+ response_data = result[1]
+ else:
+ status = result
+ response_data = {}
+ response_data.update({'success': status})
+ return JsonResponse(response_data)
+ return new_view_function
+
+
+def generate_subs(speed, source_speed, source_subs):
+ """Generate and return subtitles dictionary for speed equal to
+ `speed` value, using `source_speed` and `source_subs`."""
+ if speed == source_speed:
+ return source_subs
+
+ coefficient = speed / source_speed
+ subs = {
+ 'start': [
+ int(round(timestamp * coefficient)) for
+ timestamp in source_subs['start']
+ ],
+ 'end': [
+ int(round(timestamp * coefficient)) for
+ timestamp in source_subs['end']
+ ],
+ 'text': source_subs['text']}
+ return subs
+
+
+def save_subs_to_store(subs, subs_id, item):
+ """Save subtitles into `StaticContent`."""
+ filedata = json.dumps(subs, indent=2)
+ mime_type = 'application/json'
+ filename = 'subs_{0}.srt.sjson'.format(subs_id)
+
+ content_location = StaticContent.compute_location(
+ item.location.org, item.location.course, filename)
+ content = StaticContent(content_location, filename, mime_type, filedata)
+ contentstore().save(content)
+ del_cached_content(content_location)
+ return content_location
+
+
+def download_youtube_subs(youtube_subs, item):
+ """Download subtitles from Youtube using `youtube_ids`, and
+ save them to assets for `item` module."""
+ html_parser = HTMLParser.HTMLParser()
+ status_dict = {}
+
+ # Iterate from lowest to highest speed and try to do download subtitles
+ # from the Youtube service.
+ for speed, youtube_id in sorted(youtube_subs.iteritems()):
+ data = requests.get(
+ "http://video.google.com/timedtext",
+ params={'lang': 'en', 'v': youtube_id})
+
+ if data.status_code != 200 or not data.text:
+ status_dict.update({speed: False})
+ log.error("Can't recieved correct subtitles from Youtube.")
+ continue
+
+ sub_starts = []
+ sub_ends = []
+ sub_texts = []
+
+ xmltree = etree.fromstring(str(data.text))
+ for element in xmltree:
+ if element.tag == "text":
+ start = float(element.get("start"))
+ duration = float(element.get("dur"))
+ text = element.text
+ end = start + duration
+
+ if text:
+ # Start and end are an int representing the
+ # millisecond timestamp.
+ sub_starts.append(int(start * 1000))
+ sub_ends.append(int((end + 0.0001) * 1000))
+ sub_texts.append(
+ html_parser.unescape(text.replace('\n', ' ')))
+
+ available_speed = speed
+ subs = {
+ 'start': sub_starts,
+ 'end': sub_ends,
+ 'text': sub_texts}
+
+ save_subs_to_store(subs, youtube_id, item)
+
+ log.info(
+ """Subtitles for Youtube ID {0} (speed {1})
+ are downloaded from Youtube and
+ saved.""".format(youtube_id, speed)
+ )
+
+ status_dict.update({speed: True})
+
+ if not any(status_dict.itervalues()):
+ log.error("Can't find any subtitles on the Youtube service.")
+ return False
+
+ # When we exit from the previous loop, `available_speed` and `subs`
+ # are the subtitles data with the highest speed available on the
+ # Youtube service. We use the highest speed as main speed for the
+ # generation other subtitles, cause during calculation timestamps
+ # for lower speeds we just use multiplication istead of division.
+
+ # Generate subtitles for missed speeds.
+ for speed, status in status_dict.iteritems():
+ if not status:
+ save_subs_to_store(
+ generate_subs(speed, available_speed, subs),
+ youtube_subs[speed],
+ item)
+
+ log.info(
+ """Subtitles for Youtube ID {0} (speed {1})
+ are generated from Youtube ID {2} (speed {3}) and
+ saved.""".format(
+ youtube_subs[speed],
+ speed,
+ youtube_subs[available_speed],
+ available_speed)
+ )
+
+ return True
+
+
+def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item):
+ """Generate subtitles from source files (like SubRip format, etc.)
+ and save them to assets for `item` module.
+ We expect, that speed of source subs equal to 1
+
+ :param speed_subs: dictionary {speed: sub_id, ...}
+ :param subs_type: type of source subs: "srt", ...
+ :param subs_filedata: content of source subs.
+ :param item: module object.
+ :returns: True, if all subs are generated and saved successfully.
+ """
+ html_parser = HTMLParser.HTMLParser()
+
+ if subs_type != 'srt':
+ log.error("We support only SubRip (*.srt) subtitles format.")
+ return False
+
+ srt_subs_obj = SubRipFile.from_string(subs_filedata)
+ if not srt_subs_obj:
+ log.error("Something wrong with SubRip subtitles file during parsing.")
+ return False
+
+ sub_starts = []
+ sub_ends = []
+ sub_texts = []
+
+ for sub in srt_subs_obj:
+ sub_starts.append(sub.start.ordinal)
+ sub_ends.append(sub.end.ordinal)
+ sub_texts.append(html_parser.unescape(sub.text.replace('\n', ' ')))
+
+ subs = {
+ 'start': sub_starts,
+ 'end': sub_ends,
+ 'text': sub_texts}
+
+ for speed, subs_id in speed_subs.iteritems():
+ save_subs_to_store(
+ generate_subs(speed, 1, subs),
+ subs_id,
+ item)
+
+ return True
+
+
+def generate_srt_from_sjson(sjson_subs, speed):
+ """Generate subtitles with speed = 1.0 from sjson to SubRip (*.srt).
+
+ :param sjson_subs: "sjson" subs.
+ :param speed: speed of `sjson_subs`.
+ :returns: "srt" subs.
+ """
+ if len(sjson_subs['start']) != len(sjson_subs['end']) or \
+ len(sjson_subs['start']) != len(sjson_subs['text']):
+ return None
+
+ sjson_speed_1 = generate_subs(speed, 1, sjson_subs)
+ output = StringIO.StringIO()
+
+ for i in range(len(sjson_speed_1['start'])):
+ item = SubRipItem(
+ index=i,
+ start=SubRipTime(milliseconds=sjson_speed_1['start'][i]),
+ end=SubRipTime(milliseconds=sjson_speed_1['end'][i]),
+ text=sjson_speed_1['text'][i])
+ output.write(unicode(item))
+ output.write('\n')
+
+ output.seek(0)
+
+ return output.read()
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index abc5f48564e6..0739446e5273 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -1,20 +1,36 @@
+"""Views for items (modules)."""
+
+import os
import json
+import logging
from uuid import uuid4
+from lxml import etree
from django.core.exceptions import PermissionDenied
-from django.http import HttpResponse
+from django.http import HttpResponse, Http404
from django.contrib.auth.decorators import login_required
+from django.template.defaultfilters import slugify
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 xmodule.contentstore.django import contentstore
+from xmodule.contentstore.content import StaticContent
+from xmodule.exceptions import NotFoundError
from util.json_request import expect_json
-from ..utils import get_modulestore
+from ..utils import (get_modulestore, download_youtube_subs,
+ return_ajax_status, generate_subs_from_source,
+ generate_srt_from_sjson)
from .access import has_access
from .requests import _xmodule_recurse
-__all__ = ['save_item', 'clone_item', 'delete_item']
+__all__ = [
+ 'save_item', 'clone_item', 'delete_item', 'import_subtitles',
+ 'upload_subtitles', 'download_subtitles']
+
+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']
@@ -23,6 +39,7 @@
@login_required
@expect_json
def save_item(request):
+ """View saving items."""
item_location = request.POST['id']
# check permissions for this user within this course
@@ -71,9 +88,255 @@ def save_item(request):
return HttpResponse()
+@login_required
+@expect_json
+@return_ajax_status
+def import_subtitles(request):
+ """Try to import subtitles from Youtube for current modules."""
+
+ # This view return True/False, cause we use `return_ajax_status`
+ # view decorator.
+
+ item_location = request.POST.get('id')
+ if not item_location:
+ log.error('POST data without "id" property.')
+ return False
+
+ try:
+ item = modulestore().get_item(item_location)
+ except (ItemNotFoundError, InvalidLocationError):
+ log.error("Can't find item by location.")
+ return False
+
+ # Check permissions for this user within this course.
+ if not has_access(request.user, item_location):
+ raise PermissionDenied()
+
+ if item.category != 'videoalpha':
+ log.error('Subtitles are supported only for videoalpha" modules.')
+ return False
+
+ try:
+ xmltree = etree.fromstring(item.data)
+ except etree.XMLSyntaxError:
+ log.error("Can't parse source XML.")
+ return False
+
+ youtube = xmltree.get('youtube')
+ if not youtube:
+ log.error('Missing or blank "youtube" attribute.')
+ return False
+
+ try:
+ youtube_subs = dict([
+ (float(i.split(':')[0]), i.split(':')[1])
+ for i in youtube.split(',')
+ ])
+ except (IndexError, ValueError):
+ # Get `IndexError` if after splitting by ':' we have one item
+ # (missing ':' in the "youtube" attribute value).
+ # Get `ValueError` when after splitting by ':' key can't convert
+ # to float.
+ log.error('Bad "youtube" attribute.')
+ return False
+
+ status = download_youtube_subs(youtube_subs, item)
+
+ return status
+
+
+@login_required
+@return_ajax_status
+def upload_subtitles(request):
+ """Try to upload subtitles for current module."""
+
+ # This view return True/False, cause we use `return_ajax_status`
+ # view decorator.
+
+ item_location = request.POST.get('id')
+ if not item_location:
+ log.error('POST data without "id" form data.')
+ return False
+
+ if 'file' not in request.FILES:
+ log.error('POST data without "file" form data.')
+ return False
+
+ source_subs_filedata = request.FILES['file'].read()
+ source_subs_filename = request.FILES['file'].name
+
+ if '.' not in source_subs_filename:
+ log.error("Undefined file extension.")
+ return False
+
+ basename = os.path.basename(source_subs_filename)
+ source_subs_name = os.path.splitext(basename)[0]
+ source_subs_ext = os.path.splitext(basename)[1][1:]
+
+ try:
+ item = modulestore().get_item(item_location)
+ except (ItemNotFoundError, InvalidLocationError):
+ log.error("Can't find item by location.")
+ return False
+
+ # Check permissions for this user within this course.
+ if not has_access(request.user, item_location):
+ raise PermissionDenied()
+
+ if item.category != 'videoalpha':
+ log.error('Subtitles are supported only for "videoalpha" modules.')
+ return False
+
+ try:
+ xmltree = etree.fromstring(item.data)
+ except etree.XMLSyntaxError:
+ log.error("Can't parse source XML.")
+ return False
+
+ youtube_attr = xmltree.get('youtube')
+ xml_sources = xmltree.findall('source')
+
+ if youtube_attr:
+ try:
+ speed_subs = dict([
+ (float(i.split(':')[0]), i.split(':')[1])
+ for i in youtube_attr.split(',')
+ ])
+ except (IndexError, ValueError):
+ # Get `IndexError` if after splitting by ':' we have one item
+ # (missing ':' in the "youtube" attribute value).
+ # Get `ValueError` when after splitting by ':' key can't convert
+ # to float.
+ log.error('Bad "youtube" attribute.')
+ return False
+
+ status = generate_subs_from_source(
+ speed_subs,
+ source_subs_ext,
+ source_subs_filedata,
+ item)
+
+ elif xml_sources:
+ sub_attr = slugify(source_subs_name)
+
+ # Generate only one subs for speed = 1.0
+ status = generate_subs_from_source(
+ {1: sub_attr},
+ source_subs_ext,
+ source_subs_filedata,
+ item)
+
+ if status:
+ xmltree.set('sub', sub_attr)
+ store = get_modulestore(Location(item_location))
+ store.update_item(item_location, etree.tostring(xmltree))
+ else:
+ log.error('Missing or blank "youtube" attribute and "source" tag.')
+ return False
+
+ return status, {'xml': etree.tostring(xmltree)}
+
+
+@login_required
+def download_subtitles(request):
+ """Try to download subtitles for current modules."""
+
+ # This view return True/False, cause we use `return_ajax_status`
+ # view decorator.
+
+ item_location = request.GET.get('id')
+ if not item_location:
+ log.error('GET data without "id" property.')
+ raise Http404
+
+ try:
+ item = modulestore().get_item(item_location)
+ except (ItemNotFoundError, InvalidLocationError):
+ log.error("Can't find item by location.")
+ raise Http404
+
+ # Check permissions for this user within this course.
+ if not has_access(request.user, item_location):
+ raise PermissionDenied()
+
+ if item.category != 'videoalpha':
+ log.error('Subtitles are supported only for videoalpha" modules.')
+ raise Http404
+
+ try:
+ xmltree = etree.fromstring(item.data)
+ except etree.XMLSyntaxError:
+ log.error("Can't parse source XML.")
+ raise Http404
+
+ youtube_attr = xmltree.get('youtube')
+ sub_attr = xmltree.get('sub')
+
+ speed = 1
+ if youtube_attr:
+ try:
+ speed_subs = dict([
+ (float(i.split(':')[0]), i.split(':')[1])
+ for i in youtube_attr.split(',')
+ ])
+ except (IndexError, ValueError):
+ # Get `IndexError` if after splitting by ':' we have one item
+ # (missing ':' in the "youtube" attribute value).
+ # Get `ValueError` when after splitting by ':' key can't convert
+ # to float.
+ log.error('Bad "youtube" attribute.')
+ raise Http404
+
+ # Iterate from highest to lowest speed and try to find available
+ # subtitles in the store.
+ sjson_subtitles = None
+ youtube_id = None
+ for speed, youtube_id in sorted(speed_subs.iteritems(), reverse=True):
+ filename = 'subs_{0}.srt.sjson'.format(youtube_id)
+ content_location = StaticContent.compute_location(
+ item.location.org, item.location.course, filename)
+ try:
+ sjson_subtitles = contentstore().find(content_location)
+ break
+ except NotFoundError:
+ continue
+
+ if sjson_subtitles is None or youtube_id is None:
+ log.error("Can't find content in storage for youtube IDs.")
+ raise Http404
+
+ srt_file_name = youtube_id
+
+ elif sub_attr:
+ filename = 'subs_{0}.srt.sjson'.format(sub_attr)
+ content_location = StaticContent.compute_location(
+ item.location.org, item.location.course, filename)
+ try:
+ sjson_subtitles = contentstore().find(content_location)
+ except NotFoundError:
+ log.error("Can't find content in storage for non-youtube sub.")
+ raise Http404
+
+ srt_file_name = sub_attr
+ else:
+ log.error('Missing or blank "youtube" attribute and "source" tag.')
+ raise Http404
+
+ str_subs = generate_srt_from_sjson(json.loads(sjson_subtitles.data), speed)
+ if str_subs is None:
+ raise Http404
+
+ response = HttpResponse(str_subs, content_type='application/x-subrip')
+ response['Content-Disposition'] = 'attachment; filename="{0}.srt"'.format(
+ srt_file_name)
+
+ return response
+
+
@login_required
@expect_json
def clone_item(request):
+ """View for cloning items."""
parent_location = Location(request.POST['parent_location'])
template = Location(request.POST['template'])
@@ -102,6 +365,7 @@ def clone_item(request):
@login_required
@expect_json
def delete_item(request):
+ """View for removing items."""
item_location = request.POST['id']
item_location = Location(item_location)
diff --git a/cms/envs/dev.py b/cms/envs/dev.py
index 655092b74bc8..6ec8aca69884 100644
--- a/cms/envs/dev.py
+++ b/cms/envs/dev.py
@@ -8,6 +8,8 @@
from .common import *
from logsettings import get_logger_config
+import os
+
DEBUG = True
TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log",
diff --git a/cms/static/coffee/spec/views/videoalpha/import.coffee b/cms/static/coffee/spec/views/videoalpha/import.coffee
new file mode 100644
index 000000000000..7c204b033cbb
--- /dev/null
+++ b/cms/static/coffee/spec/views/videoalpha/import.coffee
@@ -0,0 +1,58 @@
+describe "CMS.Views.SubtitlesImport", ->
+ beforeEach ->
+ @html_id = "html_id"
+
+ @message = jasmine.createSpy("CMS.Views.SubtitlesMessages")
+ @importFile = jasmine.createSpy("CMS.Views.SubtitlesImportFile")
+ @importYT = jasmine.createSpy("CMS.Views.SubtitlesImportYT")
+
+ setFixtures """
+
+ """
+
+ @options =
+ container: $("#comp-subtitles-#{@html_id}")
+ msg: @message
+ modules: [
+ @importFile,
+ @importYT
+ ]
+
+ spyOn(CMS.Views.SubtitlesImport.prototype, 'render').andCallThrough()
+ @SubtitlesImport = new CMS.Views.SubtitlesImport @options
+
+ describe "class definition", ->
+ it "sets the correct tagName", ->
+ expect(@SubtitlesImport.tagName).toEqual("ul")
+
+ it "sets the correct className", ->
+ expect(@SubtitlesImport.className).toEqual("comp-subtitles-import-list")
+
+ describe "methods", ->
+ describe "initialize", ->
+ it "render the module", ->
+ expect(CMS.Views.SubtitlesImport.prototype.render).toHaveBeenCalled()
+
+ it "message module to be initialized", ->
+ expect(@message).toHaveBeenCalled()
+
+ describe "render", ->
+ it "element is added into the DOM", ->
+ expect(@options.container).toContain(@SubtitlesImport.$el)
+
+ it "element is added with correct className", ->
+ expect(@SubtitlesImport.$el).toHaveClass(@SubtitlesImport.className)
+
+ it "submodules to be initialized", ->
+ options = $.extend(true, {}, @options, {
+ component_id: @html_id
+ msg: @SubtitlesImport.messages
+ $container: @SubtitlesImport.$el
+ }
+ )
+
+ $.each @options.modules, (index, module) ->
+ expect(module).toHaveBeenCalledWith options
diff --git a/cms/static/coffee/spec/views/videoalpha/messages.coffee b/cms/static/coffee/spec/views/videoalpha/messages.coffee
new file mode 100644
index 000000000000..a683b173ecb4
--- /dev/null
+++ b/cms/static/coffee/spec/views/videoalpha/messages.coffee
@@ -0,0 +1,42 @@
+describe "CMS.Views.SubtitlesMessages", ->
+ beforeEach ->
+ @prompt = CMS.Views.Prompt
+ spy = jasmine.createSpyObj(
+ 'CMS.Views.Prompt',
+ [
+ "show",
+ "hide"
+ ]
+ )
+ spy['$el'] = $('')
+
+ CMS.Views.Prompt = () ->
+ spy
+
+ spyOn(CMS.Views.SubtitlesMessages.prototype, 'render').andCallThrough()
+ @view = new CMS.Views.SubtitlesMessages()
+
+ afterEach ->
+ CMS.Views.Prompt = @prompt
+
+ describe "methods", ->
+ describe "initialize", ->
+ it "default messages are defined", ->
+ expect(@view.msg).toBeDefined()
+
+ describe "render", ->
+ it "popup to be shown", ->
+ @view.render()
+ expect(@view.prompt.show).toHaveBeenCalled()
+
+ describe "hide", ->
+ it "popup to be shown", ->
+ @view.render()
+ @view.hide()
+ expect(@view.prompt.hide).toHaveBeenCalled()
+
+ describe "findEl", ->
+ it "element should be found", ->
+ @view.render()
+ expect(@view.findEl('#example').length).toBe(1)
+
diff --git a/cms/static/coffee/spec/views/videoalpha/submodules/download_subtitles.coffee b/cms/static/coffee/spec/views/videoalpha/submodules/download_subtitles.coffee
new file mode 100644
index 000000000000..8bb8dc952f35
--- /dev/null
+++ b/cms/static/coffee/spec/views/videoalpha/submodules/download_subtitles.coffee
@@ -0,0 +1,55 @@
+describe "CMS.Views.SubtitlesDownload", ->
+ beforeEach ->
+ @html_id = "html_id"
+
+ setFixtures """
+
+ """
+ @options =
+ component_id: @html_id
+ msg: @message
+ $container: $(".comp-subtitles-import-list")
+ subtitlesExist: 'True'
+
+ describe "class definition", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesDownload @options
+
+ it "sets the correct tagName", ->
+ expect(@view.tagName).toEqual("li")
+
+ it "sets the correct className", ->
+ expect(@view.className).toEqual("download-file")
+
+ describe "methods", ->
+ describe "initialize", ->
+ beforeEach ->
+ spyOn(CMS.Views.SubtitlesDownload.prototype, 'render').andCallThrough()
+ @view = new CMS.Views.SubtitlesDownload @options
+
+ it "render the module", ->
+ expect(CMS.Views.SubtitlesDownload.prototype.render).toHaveBeenCalled()
+
+ describe "render", ->
+ describe "subtitles exist", ->
+
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesDownload @options
+
+ it "button is added", ->
+ expect(@view.$el).toContain('a')
+
+ it "anchor contain correct url", ->
+ link = @view.$el.find("a").attr("href")
+ expect(link).toBe("/download_subtitles?id=#{@html_id}")
+
+ describe "subtitles doesn't exist", ->
+
+ beforeEach ->
+ options = $.extend({}, @options, {
+ subtitlesExist: 'False'
+ })
+ @view = new CMS.Views.SubtitlesDownload options
+
+ it "button should not be shown", ->
+ expect(@options.$container).not.toContain(@view.$el)
diff --git a/cms/static/coffee/spec/views/videoalpha/submodules/file.coffee b/cms/static/coffee/spec/views/videoalpha/submodules/file.coffee
new file mode 100644
index 000000000000..30b46e1096e7
--- /dev/null
+++ b/cms/static/coffee/spec/views/videoalpha/submodules/file.coffee
@@ -0,0 +1,209 @@
+describe "CMS.Views.SubtitlesImportFile", ->
+ beforeEach ->
+ @html_id = "html_id"
+
+ $.fn.ajaxSubmit = jasmine.createSpy('$.fn.ajaxSubmit')
+ @message = jasmine.createSpyObj("CMS.Views.SubtitlesMessages", [
+ 'render',
+ 'findEl'
+ ])
+
+ setFixtures """
+
+ """
+ @options =
+ component_id: @html_id
+ msg: @message
+ $container: $(".comp-subtitles-import-list")
+ tpl:
+ file: _.template """
+
+
+ """
+
+ describe "class definition", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ it "sets the correct tagName", ->
+ expect(@view.tagName).toEqual("li")
+
+ it "sets the correct className", ->
+ expect(@view.className).toEqual("import-file")
+
+ describe "methods", ->
+ describe "initialize", ->
+ beforeEach ->
+ spyOn(CMS.Views.SubtitlesImportFile.prototype, 'render').andCallThrough()
+ @view = new CMS.Views.SubtitlesImportFile @options
+ it "render the module", ->
+ expect(CMS.Views.SubtitlesImportFile.prototype.render).toHaveBeenCalled()
+
+ describe "render", ->
+ describe "if all required params exist", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ it "element is added into the DOM", ->
+ expect(@options.$container).toContain(@view.$el)
+
+ it "template is added into the DOM", ->
+ expect(@options.$container).toContain('#test_el')
+
+ describe "if params doesn't exist", ->
+ beforeEach ->
+ @options.tpl = null
+ spyOn(window.console, "error")
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ it "template doesn't exist", ->
+ expect(console.error).toHaveBeenCalledWith("Couldn't load template for file uploader")
+
+ describe "import", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ it "files exist", ->
+ @view.files = [{name: "name"}]
+ @view.import()
+ expect($.fn.ajaxSubmit).toHaveBeenCalledWith(
+ beforeSend: @view.xhrResetProgressBar
+ uploadProgress: @view.xhrProgressHandler
+ complete: @view.xhrCompleteHandler
+ )
+
+ it "files doesn't exist", ->
+ @view.files = []
+ @view.import()
+ expect($.fn.ajaxSubmit).not.toHaveBeenCalled()
+
+ describe "events", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ it "on change file", ->
+ expect(@view.$el).toHandle("change")
+
+ it "click on 'Upload from file' button", ->
+ expect(@view.$el).toHandle("click")
+
+ describe "handlers", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ describe "xhrResetProgressBar", ->
+ it "show message", ->
+ @view.files = [{name: "name"}]
+ options = {
+ intent: 'warning'
+ title: gettext("Uploading...")
+ message: """
+ #{@view.files[0].name}
+
+
+
+ """
+ }
+ @view.xhrResetProgressBar()
+ expect(@message.render).toHaveBeenCalledWith(null, options)
+
+ describe "xhrProgressHandler", ->
+ it "show correct percentage", ->
+ percentVal = 10
+ @view.$progressFill = $('#test_el')
+ @view.xhrProgressHandler(null, null, null, percentVal)
+ percentage = @view.$progressFill.html()
+ expect(percentage).toBe(percentVal + "%")
+
+ describe "xhrCompleteHandler", ->
+ it "show success message", ->
+ xhr = {
+ status : 200
+ responseText : JSON.stringify({
+ success: true
+ })
+ }
+ @view.xhrCompleteHandler(xhr)
+ expect(@message.render).toHaveBeenCalledWith('success')
+
+ describe "show error message", ->
+
+ it "if status is not 200", ->
+ xhr = {
+ status : 404
+ responseText : JSON.stringify({
+ success: true
+ })
+ }
+ @view.xhrCompleteHandler(xhr)
+ expect(@message.render).toHaveBeenCalledWith('error')
+
+ it "if success flag is false", ->
+ xhr = {
+ status : 200
+ responseText : JSON.stringify({
+ success: false
+ })
+ }
+ @view.xhrCompleteHandler(xhr)
+ expect(@message.render).toHaveBeenCalledWith('error')
+
+ describe "clickHandler", ->
+ it "should preventDefault", ->
+ spyOnEvent("#import-from-file", "click")
+ @view.$("#import-from-file").click()
+ expect("click").toHaveBeenPreventedOn("#import-from-file")
+
+ it "value of input type file should be empty", ->
+ @view.$("#import-from-file").click()
+ expect(@view.$fileInput).toHaveValue('')
+
+ describe "changeHadler", ->
+ it "should preventDefault", ->
+ spyOnEvent(".file-input", "change")
+ @view.$fileInput.change()
+ expect("change").toHaveBeenPreventedOn(".file-input")
+
+ it "show warning message", ->
+ @view.$fileInput.trigger("change")
+ options =
+ title: gettext("Are you sure that you want to upload the subtitle file?")
+ actions:
+ primary:
+ click: @view.importHandler
+ expect(@message.render).toHaveBeenCalledWith("warn", options)
+
+ describe "updateData", ->
+ beforeEach ->
+ @CodeMirrorStub = jasmine.createSpyObj('CodeMirror',
+ [
+ "setValue",
+ "refresh"
+ ]
+ )
+ $('.edit-box').data('CodeMirror', @CodeMirrorStub)
+
+ @view = new CMS.Views.SubtitlesImportFile @options
+
+ it "advanced editor is updated", ->
+ data = 'Test Data'
+ @view.updateData(data)
+ expect(@CodeMirrorStub.setValue).toHaveBeenCalledWith(data)
+ expect(@CodeMirrorStub.refresh).toHaveBeenCalled()
+
+ it "if data absent anything should happens", ->
+ @view.updateData()
+ expect(@CodeMirrorStub.setValue).not.toHaveBeenCalled()
+ expect(@CodeMirrorStub.refresh).not.toHaveBeenCalled()
+
+ it "if CodeMirror absent anything should happens", ->
+ data = 'Test Data'
+ $('.edit-box').removeData('CodeMirror')
+ @view.updateData(data)
+ expect(@CodeMirrorStub.setValue).not.toHaveBeenCalled()
+ expect(@CodeMirrorStub.refresh).not.toHaveBeenCalled()
diff --git a/cms/static/coffee/spec/views/videoalpha/submodules/yt.coffee b/cms/static/coffee/spec/views/videoalpha/submodules/yt.coffee
new file mode 100644
index 000000000000..06a2c1caaf56
--- /dev/null
+++ b/cms/static/coffee/spec/views/videoalpha/submodules/yt.coffee
@@ -0,0 +1,128 @@
+describe "CMS.Views.SubtitlesImportYT", ->
+ beforeEach ->
+ @html_id = "html_id"
+
+ spyOn($, 'ajax')
+ @message = jasmine.createSpyObj("CMS.Views.SubtitlesMessages", [
+ 'render'
+ ])
+
+ setFixtures """
+
+ """
+ @options =
+ component_id: @html_id
+ msg: @message
+ isYoutube: 'True'
+ $container: $(".comp-subtitles-import-list")
+
+ describe "class definition", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportYT @options
+
+ it "sets the correct tagName", ->
+ expect(@view.tagName).toEqual("li")
+
+ it "sets the correct className", ->
+ expect(@view.className).toEqual("import-youtube")
+
+ describe "methods", ->
+ describe "initialize", ->
+ beforeEach ->
+ spyOn(CMS.Views.SubtitlesImportYT.prototype, 'render').andCallThrough()
+ @view = new CMS.Views.SubtitlesImportYT @options
+ it "render the module", ->
+ expect(CMS.Views.SubtitlesImportYT.prototype.render).toHaveBeenCalled()
+
+ describe "render", ->
+ describe "if video is youtube", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportYT @options
+
+ it "element is added into the DOM", ->
+ expect(@options.$container).toContain(@view.$el)
+
+ it "button is added", ->
+ expect(@view.$el).toContain('a')
+
+ describe "if video is html5", ->
+ beforeEach ->
+ options = $.extend({}, @options, {
+ isYoutube: 'False'
+ })
+ @view = new CMS.Views.SubtitlesImportYT options
+
+ it "button should not be shown", ->
+ expect(@options.$container).not.toContain(@view.$el)
+
+ describe "import", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportYT @options
+
+ it "show wait message", ->
+ @view.import()
+ expect(@message.render).toHaveBeenCalledWith('wait')
+
+ it "ajax is called", ->
+ option =
+ url: @view.url
+ type: "POST"
+ dataType: "json"
+ contentType: "application/json"
+ timeout: 1000*60
+ data: JSON.stringify(
+ 'id': @html_id
+ )
+ success: @view.xhrSuccessHandler
+ error: @view.xhrErrorHandler
+
+ @view.import()
+ expect($.ajax).toHaveBeenCalledWith(option)
+
+ describe "events", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportYT @options
+
+ it "click on 'Import from Youtube' button", ->
+ expect(@view.$el).toHandle("click")
+
+ describe "handlers", ->
+ beforeEach ->
+ @view = new CMS.Views.SubtitlesImportYT @options
+
+ describe "clickHandler", ->
+ it "should preventDefault", ->
+ spyOnEvent("#import-from-youtube", "click")
+ @view.$("#import-from-youtube").click()
+ expect("click").toHaveBeenPreventedOn("#import-from-youtube")
+
+ it "show warn message", ->
+ options =
+ title: gettext('''
+ Are you sure that you want to import the subtitle file
+ found on YouTube?
+ ''')
+ actions:
+ primary:
+ click: @view.importHandler
+
+ @view.$('#import-from-youtube').click()
+ expect(@message.render).toHaveBeenCalledWith("warn", options)
+
+ describe "xhrErrorHandler", ->
+ it "show error message", ->
+ @view.xhrErrorHandler()
+ expect(@message.render).toHaveBeenCalledWith("error")
+
+ describe "xhrSuccessHandler", ->
+ it "show success message", ->
+ @view.xhrSuccessHandler({
+ success: true
+ })
+ expect(@message.render).toHaveBeenCalledWith("success")
+
+ it "show error message", ->
+ @view.xhrSuccessHandler({
+ success: false
+ })
+ expect(@message.render).toHaveBeenCalledWith("error")
diff --git a/cms/static/coffee/src/views/videoalpha/import.coffee b/cms/static/coffee/src/views/videoalpha/import.coffee
new file mode 100644
index 000000000000..c7bb0bf0440d
--- /dev/null
+++ b/cms/static/coffee/src/views/videoalpha/import.coffee
@@ -0,0 +1,24 @@
+class CMS.Views.SubtitlesImport extends Backbone.View
+ tagName: "ul"
+ className: "comp-subtitles-import-list"
+
+ initialize: ->
+ _.bindAll(@)
+ @component_id = @options.container
+ .closest(".component")
+ .data('id')
+ @messages = new @options["msg"]()
+
+ @render()
+
+ render: ->
+ @$el.appendTo(@options.container)
+
+ options = $.extend(true, {}, @options,
+ component_id: @component_id
+ msg: @messages
+ $container: @$el
+ )
+ modules = @options.modules
+ $.each modules, (index) ->
+ new modules[index](options)
diff --git a/cms/static/coffee/src/views/videoalpha/messages.coffee b/cms/static/coffee/src/views/videoalpha/messages.coffee
new file mode 100644
index 000000000000..2002ac432e31
--- /dev/null
+++ b/cms/static/coffee/src/views/videoalpha/messages.coffee
@@ -0,0 +1,59 @@
+class CMS.Views.SubtitlesMessages extends Backbone.View
+
+ initialize: ->
+ @msg =
+ success:
+ intent: 'confirmation'
+ title: gettext("Subtitles were successfully imported.")
+ actions:
+ primary:
+ text: gettext("Ok")
+ click: (view, e) ->
+ view.hide()
+ e.preventDefault()
+ warn:
+ intent: 'warning'
+ title: gettext("Are you sure that you want to import/upload the subtitle?")
+ message: gettext("If subtitles for the video already exist, importing again will overwrite them.")
+ actions:
+ primary:
+ text: gettext("Yes")
+
+ secondary: [
+ text: gettext("No")
+ click: (view, e) ->
+ view.hide()
+ e.preventDefault()
+ ]
+ wait:
+ intent: 'warning'
+ title: gettext("Please wait for the subtitles to download")
+ message: '''
+
+
+
+
+
+ '''
+ error:
+ intent: 'error'
+ title: gettext("Import failed!")
+ actions:
+ primary:
+ text: gettext("Ok")
+ click: (view, e) ->
+ view.hide()
+ e.preventDefault()
+
+ render: (type, data) ->
+ msg = @msg[type] || {}
+ options = $.extend(true, {}, CMS.Views.Prompt.prototype.options, msg, data)
+ @prompt = new CMS.Views.Prompt(options)
+ @prompt.show()
+
+ hide: (event) ->
+ event.preventDefault() if event
+ @prompt.hide() if @prompt
+
+ findEl: (selector) ->
+ @prompt.$el.find(selector) if @prompt
diff --git a/cms/static/coffee/src/views/videoalpha/submodules/download_subtitles.coffee b/cms/static/coffee/src/views/videoalpha/submodules/download_subtitles.coffee
new file mode 100644
index 000000000000..a082b169f922
--- /dev/null
+++ b/cms/static/coffee/src/views/videoalpha/submodules/download_subtitles.coffee
@@ -0,0 +1,23 @@
+class CMS.Views.SubtitlesDownload extends Backbone.View
+ tagName: "li"
+ className: "download-file"
+ link_id: "download-file"
+ url: "/download_subtitles"
+
+ initialize: ->
+ _.bindAll(@)
+ @messages = @options.msg
+ @render()
+
+ render: ->
+ if @options.subtitlesExist is 'True'
+ id = encodeURIComponent(@options.component_id)
+ html = @$el.append(
+ $('',
+ class: "blue-button"
+ id: @link_id
+ href: "#{@url}?id=#{id}"
+ )
+ .text(gettext("Download subtitles"))
+ )
+ .appendTo(@options.$container)
diff --git a/cms/static/coffee/src/views/videoalpha/submodules/file.coffee b/cms/static/coffee/src/views/videoalpha/submodules/file.coffee
new file mode 100644
index 000000000000..9dda66992793
--- /dev/null
+++ b/cms/static/coffee/src/views/videoalpha/submodules/file.coffee
@@ -0,0 +1,111 @@
+class CMS.Views.SubtitlesImportFile extends Backbone.View
+ tagName: "li"
+ className: "import-file"
+ link_id: "import-from-file"
+ url: "/upload_subtitles"
+ files: null
+
+ events:
+ "click #import-from-file": "clickHandler"
+ "change .file-input": "changeHadler"
+
+ initialize: ->
+ _.bindAll(@)
+ @messages = @options.msg
+ @render()
+
+ render: ->
+ container = @options.$container
+ tpl = @options.tpl.file if @options.tpl
+
+ if not tpl
+ console.error("Couldn't load template for file uploader")
+ return
+
+ @$el.append(
+ $('',
+ class: "blue-button"
+ id: @link_id
+ href: "#"
+ )
+ .text(gettext("Upload from file"))
+ )
+ .append(tpl(
+ component_id: @options.component_id
+ ))
+ .appendTo(container)
+
+ @$form = container.find('.file-upload')
+ @$fileInput = @$form.find('.file-input')
+
+ clickHandler: (event) ->
+ event.preventDefault()
+ @$fileInput
+ .val(null)
+ .trigger('click')
+
+ changeHadler: (event) ->
+ event.preventDefault()
+ @files = @$fileInput.get(0).files
+ @messages.render('warn',
+ title: gettext("Are you sure that you want to upload the subtitle file?")
+ actions:
+ primary:
+ click: @importHandler
+ )
+
+ importHandler: (view, event) ->
+ event.preventDefault()
+ @import()
+
+ import: ->
+ if @files.length is 0
+ return
+
+ @$form.find('.file-chooser').ajaxSubmit(
+ beforeSend: @xhrResetProgressBar
+ uploadProgress: @xhrProgressHandler
+ complete: @xhrCompleteHandler
+ )
+
+ xhrResetProgressBar: ->
+ @messages.render(null,
+ intent: 'warning'
+ title: gettext("Uploading...")
+ message: """
+ #{@files[0].name}
+
+
+
+ """
+ )
+ @$progressFill = @messages.findEl('.progress-fill')
+
+ xhrProgressHandler: (event, position, total, percentComplete) ->
+ percentVal = percentComplete + '%'
+ @$progressFill
+ .width(percentVal)
+ .html(percentVal)
+
+ xhrCompleteHandler: (xhr) ->
+ resp = JSON.parse(xhr.responseText)
+ if xhr.status is 200 and resp.success is true
+ @updateData(resp.xml)
+ @messages.render('success')
+ else
+ @messages.render('error')
+
+ updateData: (xml) ->
+ if xml
+ editBox = @options
+ .$container
+ .closest('.tabs-wrapper')
+ .find('.edit-box').first()
+ editBox.val(xml)
+ advanced_editor = editBox.data('CodeMirror')
+
+ if advanced_editor
+ advanced_editor.setValue(xml)
+ advanced_editor.refresh()
+
+
diff --git a/cms/static/coffee/src/views/videoalpha/submodules/yt.coffee b/cms/static/coffee/src/views/videoalpha/submodules/yt.coffee
new file mode 100644
index 000000000000..af6ecbc2fd11
--- /dev/null
+++ b/cms/static/coffee/src/views/videoalpha/submodules/yt.coffee
@@ -0,0 +1,66 @@
+class CMS.Views.SubtitlesImportYT extends Backbone.View
+ tagName: "li"
+ className: "import-youtube"
+ link_id: "import-from-youtube"
+ url: "/import_subtitles"
+
+ events:
+ "click #import-from-youtube": "clickHandler"
+
+ initialize: ->
+ _.bindAll(@)
+ @messages = @options.msg
+ @render()
+
+ render: ->
+ if @options.isYoutube is 'True'
+ html = @$el.append(
+ $('',
+ class: "blue-button"
+ id: @link_id
+ href: "#"
+ )
+ .text(gettext("Import from Youtube"))
+ )
+ .appendTo(@options.$container)
+
+ clickHandler: (event) ->
+ event.preventDefault()
+ @messages.render('warn',
+ title: gettext('''
+ Are you sure that you want to import the subtitle file
+ found on YouTube?
+ ''')
+ actions:
+ primary:
+ click: @importHandler
+ )
+
+ importHandler: (view, event)->
+ event.preventDefault()
+ @import()
+
+ xhrSuccessHandler: (data) ->
+ if data.success is true
+ @messages.render('success')
+ else
+ @xhrErrorHandler()
+
+ xhrErrorHandler: ->
+ @messages.render('error')
+
+ import: ->
+ @messages.render('wait')
+
+ $.ajax(
+ url: @url
+ type: "POST"
+ dataType: "json"
+ contentType: "application/json"
+ timeout: 1000*60
+ data: JSON.stringify(
+ 'id': @options.component_id
+ )
+ success: @xhrSuccessHandler
+ error: @xhrErrorHandler
+ )
diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss
index ff8405db38e7..9c8d8f0555fd 100644
--- a/cms/static/sass/base-style.scss
+++ b/cms/static/sass/base-style.scss
@@ -59,6 +59,10 @@
@import 'views/users';
@import 'views/checklists';
+// Should be removed during the implementation new mockups for videoalpha
+// transcripts
+@import 'views/videoalpha';
+
// temp - inherited
@import 'assets/content-types';
diff --git a/cms/static/sass/views/_videoalpha.scss b/cms/static/sass/views/_videoalpha.scss
new file mode 100644
index 000000000000..402402ef3695
--- /dev/null
+++ b/cms/static/sass/views/_videoalpha.scss
@@ -0,0 +1,92 @@
+// Should be removed during the implementation new mockups for videoalpha
+// transcripts
+
+// Subtitles Preloading animation
+
+@mixin opacityHider {
+ 0% {
+ opacity: 1.0;
+ }
+
+ 50% {
+ opacity: 0.0;
+ }
+}
+
+@-moz-keyframes anim-opacityHider { @include opacityHider(); }
+@-webkit-keyframes anim-opacityHider { @include opacityHider(); }
+@-o-keyframes anim-opacityHider { @include opacityHider(); }
+@keyframes anim-opacityHider { @include opacityHider(); }
+
+@mixin anim-opacityHider($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
+ @include animation-name(anim-opacityHider);
+ @include animation-duration($duration);
+ @include animation-delay($delay);
+ @include animation-timing-function($timing);
+ @include animation-iteration-count($count);
+ @include animation-fill-mode(both);
+}
+
+//==========================
+
+#circle-preloader{
+ display: block;
+ width:70px;
+ padding: 20px 0;
+ margin: 0 auto;
+}
+
+.circle-preloader{
+ display: block;
+ background-color:#EDBD45;
+ float:left;
+ height:15px;
+ margin-left:8px;
+ width:15px;
+ @include anim-opacityHider(1.8s, linear, infinite);
+ border-radius:10px;
+}
+
+#circle-preloader_1{
+ @include animation-delay(0.4s);
+}
+
+#circle-preloader_2{
+ @include animation-delay(0.7s);
+}
+
+#circle-preloader_3{
+ @include animation-delay(1.0s);
+}
+
+.prompt{
+ &.warning{
+ .file-name{
+ word-break: break-all;
+ }
+ .progress-bar{
+ display: block;
+ height: 50px;
+ margin: 30px auto 10px;
+ border: 1px solid $blue;
+ text-align: center;
+ font-size: 1.14em;
+
+ &.loaded {
+ border-color: #66b93d;
+
+ .progress-fill {
+ background: #66b93d;
+ }
+ }
+ .progress-fill {
+ display: block;
+ width: 0%;
+ height: 50px;
+ background: $blue;
+ color: #fff;
+ line-height: 48px;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cms/templates/js/videoalpha/subtitles-import-file.underscore b/cms/templates/js/videoalpha/subtitles-import-file.underscore
new file mode 100644
index 000000000000..9317c04481da
--- /dev/null
+++ b/cms/templates/js/videoalpha/subtitles-import-file.underscore
@@ -0,0 +1,8 @@
+
diff --git a/cms/templates/widgets/edit.html b/cms/templates/widgets/edit.html
new file mode 100644
index 000000000000..f5484013f7ba
--- /dev/null
+++ b/cms/templates/widgets/edit.html
@@ -0,0 +1,29 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%page args="tabName"/>
+
+
+
+
+
diff --git a/cms/templates/widgets/tabs-edit.html b/cms/templates/widgets/tabs-edit.html
new file mode 100644
index 000000000000..80006ec5bbda
--- /dev/null
+++ b/cms/templates/widgets/tabs-edit.html
@@ -0,0 +1,22 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+
+ % if tabs:
+
+
+ % else:
+
+ % endif
+ % for tab in tabs:
+
+ <%include file="${tab['template']}" args="tabName=tab['name']"/>
+
+ % endfor
+
+
+
+<%include file="metadata-edit.html" />
diff --git a/cms/templates/widgets/videoalpha/subtitles.html b/cms/templates/widgets/videoalpha/subtitles.html
new file mode 100644
index 000000000000..c6fdd0340ecd
--- /dev/null
+++ b/cms/templates/widgets/videoalpha/subtitles.html
@@ -0,0 +1,28 @@
+<%! from django.utils.translation import ugettext as _ %>
+<%namespace name='static' file='../../static_content.html'/>
+<%page args="tabName"/>
+
+
+
+
+
+
diff --git a/cms/urls.py b/cms/urls.py
index d04c31116132..7c76357e9c1c 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -23,6 +23,9 @@
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
url(r'^create_new_course', 'contentstore.views.create_new_course', name='create_new_course'),
url(r'^reorder_static_tabs', 'contentstore.views.reorder_static_tabs', name='reorder_static_tabs'),
+ url(r'^import_subtitles$', 'contentstore.views.import_subtitles', name='import_subtitles'),
+ url(r'^upload_subtitles$', 'contentstore.views.upload_subtitles', name='upload_subtitles'),
+ url(r'^download_subtitles$', 'contentstore.views.download_subtitles', name='download_subtitles'),
url(r'^(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$',
'contentstore.views.course_index', name='course_index'),
diff --git a/common/lib/xmodule/xmodule/css/tabs/display.scss b/common/lib/xmodule/xmodule/css/tabs/display.scss
new file mode 100644
index 000000000000..5a21b26a33fe
--- /dev/null
+++ b/common/lib/xmodule/xmodule/css/tabs/display.scss
@@ -0,0 +1,130 @@
+.editor {
+ position: relative;
+
+ .row {
+ position: relative;
+ }
+
+ .editor-bar {
+ position: relative;
+ @include linear-gradient(top, #d4dee8, #c9d5e2);
+ padding: 5px;
+ border-bottom-color: #a5aaaf;
+ @include clearfix;
+
+ a {
+ display: block;
+ float: left;
+ padding: 3px 10px 7px;
+ margin-left: 7px;
+ border-radius: 2px;
+
+ &:hover {
+ background: rgba(255, 255, 255, .5);
+ }
+ }
+ }
+
+ .editor-tabs {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+
+ li {
+ float: left;
+ margin-right: 5px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .tab {
+ display: block;
+ height: 24px;
+ padding: 7px 20px 3px;
+ border: 1px solid #a5aaaf;
+ border-radius: 3px 3px 0 0;
+ @include linear-gradient(top, rgba(0, 0, 0, 0) 87%, rgba(0, 0, 0, .06));
+ background-color: #e5ecf3;
+ font-size: 13px;
+ color: #3c3c3c;
+ box-shadow: 1px -1px 1px rgba(0, 0, 0, .05);
+
+ &.current {
+ background: #fff;
+ border-bottom-color: #fff;
+ }
+ }
+ }
+}
+
+.component-tab{
+ background: $white;
+ padding: 20px;
+ position: relative;
+ border-top: 1px solid #8891a1;
+
+ advanced{
+ padding: 0;
+ border: none;
+ }
+ .blue-button{
+ @include blue-button;
+ }
+}
+
+.editor{
+ @include clearfix();
+
+ .CodeMirror {
+ @include box-sizing(border-box);
+ width: 100%;
+ position: relative;
+ height: 379px;
+ border: 1px solid #3c3c3c;
+ border-top: 1px solid #8891a1;
+ background: $white;
+ color: #3c3c3c;
+ }
+
+ .CodeMirror-scroll {
+ height: 100%;
+ }
+
+ .editor-tabs {
+ top: 11px !important;
+ right: 10px;
+ z-index: 99;
+ }
+
+ .is-inactive {
+ display: none;
+ }
+
+ .comp-subtitles-entry{
+ text-align: center;
+ .file-upload{
+ display: none;
+ }
+ .comp-subtitles-import-list{
+ > li{
+ display: block;
+ margin: $baseline/2 0px $baseline/2 0;
+ }
+
+ .blue-button{
+ font-size: 1em;
+ display: block;
+ width: 70%;
+ margin: 0 auto;
+ text-align: center;
+ }
+ }
+ }
+}
+
+.tabs-wrapper{
+ padding-top: 46px;
+ position: relative;
+}
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/editing_module.py b/common/lib/xmodule/xmodule/editing_module.py
index df4ebc564613..6b9ab8d610dc 100644
--- a/common/lib/xmodule/xmodule/editing_module.py
+++ b/common/lib/xmodule/xmodule/editing_module.py
@@ -29,6 +29,28 @@ def get_context(self):
return _context
+class TabsEditingDescriptor(EditingFields, MakoModuleDescriptor):
+ """
+ Module that provides tabs interface
+ """
+ mako_template = "widgets/tabs-edit.html"
+ css = {'scss': [resource_string(__name__, 'css/tabs/display.scss')]}
+ js = {'coffee': [resource_string(__name__, 'js/src/tabs/edit.coffee')]}
+ js_module_name = "TabsEditorDescriptor"
+
+ def get_context(self):
+ _context = super(TabsEditingDescriptor, self).get_context()
+ # Add our specific template information (the raw data body)
+ _context.update({
+ 'tabs': self.tabs,
+ 'id': self.location,
+ 'html_id': self.location.html_id(),
+ 'data': self.data
+
+ })
+ return _context
+
+
class XMLEditingDescriptor(EditingDescriptor):
"""
Module that provides a raw editing view of its data as XML. It does not perform
diff --git a/common/lib/xmodule/xmodule/js/fixtures/tabs-edit.html b/common/lib/xmodule/xmodule/js/fixtures/tabs-edit.html
new file mode 100644
index 000000000000..b1d3f4886c5c
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/fixtures/tabs-edit.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+ Some content
+
+
+
+
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee b/common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee
new file mode 100644
index 000000000000..3b81b306d6c2
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee
@@ -0,0 +1,68 @@
+describe "TabsEditorDescriptor", ->
+ beforeEach ->
+ @isInactiveClass = "is-inactive"
+ @isCurrent = "current"
+ loadFixtures 'tabs-edit.html'
+ @descriptor = new TabsEditorDescriptor($('.tabs-edit'))
+
+ describe "constructor", ->
+ it "first tab should be visible", ->
+ expect(@descriptor.$tabs.first()).toHaveClass(@isCurrent)
+ expect(@descriptor.$content.first()).not.toHaveClass(@isInactiveClass)
+
+ describe "onSwitchEditor", ->
+ it "switch tabs", ->
+ @descriptor.$tabs.eq(1).trigger("click")
+ expect(@descriptor.$tabs.eq(0)).not.toHaveClass(@isCurrent)
+ expect(@descriptor.$content.eq(0)).toHaveClass(@isInactiveClass)
+ expect(@descriptor.$tabs.eq(1)).toHaveClass(@isCurrent)
+ expect(@descriptor.$content.eq(1)).not.toHaveClass(@isInactiveClass)
+
+ it "event 'TabsEditor:changeTab' is triggered", ->
+ spyOn($.fn, 'trigger').andCallThrough()
+ @descriptor.$tabs.eq(1).trigger("click")
+ expect($.fn.trigger.mostRecentCall.args[0]).toEqual('TabsEditor:changeTab')
+ expect($.fn.trigger.mostRecentCall.args[1]).toEqual(
+ [
+ 'Tab 1', # tab_name
+ '#tab-1' # tab_id
+ ]
+ )
+
+ it "if click on current tab, anything should happens", ->
+ spyOn($.fn, 'trigger').andCallThrough()
+ currentTab = @descriptor.$tabs.filter('.' + @isCurrent)
+ @descriptor.$tabs.eq(0).trigger("click")
+ expect(@descriptor.$tabs.filter('.' + @isCurrent)).toEqual(currentTab)
+ expect($.fn.trigger.calls.length).toEqual(1)
+
+ describe "save", ->
+ it "if CodeMirror exist, data should be retreived", ->
+ editBox = $('.edit-box')
+ CodeMirrorStub =
+ getValue: () ->
+ editBox.val()
+
+ editBox.data('CodeMirror', CodeMirrorStub)
+ data = @descriptor.save().data
+ expect(data).toEqual('Advanced Editor Text')
+
+ it "detach click event", ->
+ spyOn($.fn, "off")
+ @descriptor.save()
+ expect($.fn.off).toHaveBeenCalledWith(
+ 'click',
+ '.editor-tabs .tab',
+ @descriptor.onSwitchEditor
+ )
+
+ describe "registerTabCallback", ->
+ beforeEach ->
+ @id = 'id'
+ TabsEditorDescriptor.registerTabCallback("#{@id}")
+
+ afterEach ->
+ $("#editor-tab-#{@id}").off 'TabsEditor:changeTab'
+
+ it "event subscribed", ->
+ expect($("#editor-tab-#{@id}")).toHandle('TabsEditor:changeTab')
diff --git a/common/lib/xmodule/xmodule/js/src/tabs/edit.coffee b/common/lib/xmodule/xmodule/js/src/tabs/edit.coffee
new file mode 100644
index 000000000000..f865d81cd58f
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/tabs/edit.coffee
@@ -0,0 +1,59 @@
+class @TabsEditorDescriptor
+ @isInactiveClass : "is-inactive"
+
+ constructor: (element) ->
+ @element = element;
+ @$tabs = $(".tab", @element)
+ @$content = $(".component-tab", @element)
+
+ @element.on('click', '.editor-tabs .tab', @onSwitchEditor)
+
+ # If default visible tab is not setted or if were marked as current
+ # more than 1 tab just first tab will be shown
+ currentTab = @$tabs.filter('.current')
+ currentTab = @$tabs.first() if currentTab.length isnt 1
+ currentTab.trigger("click", [true])
+
+ onSwitchEditor: (e, reset) =>
+ e.preventDefault();
+
+ isInactiveClass = TabsEditorDescriptor.isInactiveClass
+ $currentTarget = $(e.currentTarget)
+
+ if not $currentTarget.hasClass('current') or reset is true
+
+ @$tabs.removeClass('current')
+ $currentTarget.addClass('current')
+
+ # Tabs are implemeted like anchors. Therefore we can use hash to find
+ # corresponding content
+ content_id = $currentTarget.attr('href')
+
+ @$content
+ .addClass(isInactiveClass)
+ .filter(content_id)
+ .removeClass(isInactiveClass)
+
+ @$tabs.closest('.wrapper-comp-editor').trigger(
+ 'TabsEditor:changeTab',
+ [
+ $currentTarget.text(), # tab_name
+ content_id # tab_id
+ ]
+ )
+
+ save: ->
+ @element.off('click', '.editor-tabs .tab', @onSwitchEditor)
+ # Link to instance of CodeMirror is stored in data attribute of DOM element
+ # If it exist we retreive the data from CodeMirror
+ advanced_editor = $('.edit-box', @element).first().data('CodeMirror')
+ if advanced_editor
+ text = advanced_editor.getValue()
+ data: text
+
+window.TabsEditorDescriptor = window.TabsEditorDescriptor || {};
+TabsEditorDescriptor.registerTabCallback = (id, name, callback) ->
+ $('#editor-tab-' + id).on 'TabsEditor:changeTab', (e, tab_name, tab_id) ->
+ e.stopPropagation()
+ callback() if typeof callback is "function" and tab_name is name
+
diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py
index 3b5b90e6743c..ef25a7eecaa8 100644
--- a/common/lib/xmodule/xmodule/videoalpha_module.py
+++ b/common/lib/xmodule/xmodule/videoalpha_module.py
@@ -20,10 +20,12 @@
from django.conf import settings
from xmodule.x_module import XModule
-from xmodule.raw_module import RawDescriptor
+from xmodule.editing_module import TabsEditingDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
+from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
+from xmodule.exceptions import NotFoundError
from xblock.core import Integer, Scope, String
import datetime
@@ -148,6 +150,7 @@ def get_html(self):
return self.system.render_template('videoalpha.html', {
'youtube_streams': self.youtube_streams,
+ 'component_location': self.location,
'id': self.location.html_id(),
'sub': self.sub,
'sources': self.sources,
@@ -164,7 +167,58 @@ def get_html(self):
})
-class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor):
+class VideoAlphaDescriptor(VideoAlphaFields, TabsEditingDescriptor):
"""Descriptor for `VideoAlphaModule`."""
module_class = VideoAlphaModule
template_dir_name = "videoalpha"
+ tabs = [
+ {
+ 'name': "XML",
+ 'template': "edit.html",
+ 'current': True,
+ },
+ {
+ 'name': "Subtitles",
+ 'template': "videoalpha/subtitles.html",
+ }
+ ]
+
+ def get_context(self):
+ """Extend context and add two additional flags:
+ 'is_youtube' and 'has_subs_content'.VideoAlphaDescriptor
+
+ This context variables we use for CMS subtitles feature, where
+ we try to understand, must we show some buttons or not.
+ """
+ _context = super(VideoAlphaDescriptor, self).get_context()
+
+ xmltree = etree.fromstring(self.data)
+ youtube_attr = xmltree.get('youtube')
+ sub_attr = xmltree.get('sub')
+
+ content = None
+ if youtube_attr:
+ youtube_ids = [i.split(':')[1] for i in youtube_attr.split(',')]
+ for subs in youtube_ids:
+ filename = 'subs_{0}.srt.sjson'.format(subs)
+ content_location = StaticContent.compute_location(
+ self.location.org, self.location.course, filename)
+ try:
+ content = contentstore().find(content_location)
+ break
+ except NotFoundError:
+ continue
+ elif sub_attr:
+ filename = 'subs_{0}.srt.sjson'.format(sub_attr)
+ content_location = StaticContent.compute_location(
+ self.location.org, self.location.course, filename)
+ try:
+ content = contentstore().find(content_location)
+ except NotFoundError:
+ pass
+
+ _context.update({
+ 'is_youtube': bool(youtube_attr),
+ 'has_subs_content': bool(content)
+ })
+ return _context
diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py
index 182cbab9e7d9..09ebab31e7d9 100644
--- a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py
@@ -49,6 +49,7 @@ def test_videoalpha_constructor(self):
'sub': self.item_module.sub,
'track': self.item_module.track,
'youtube_streams': self.item_module.youtube_streams,
+ 'component_location': self.item_module.location,
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
}
self.assertDictEqual(context, expected_context)
@@ -93,6 +94,7 @@ def test_videoalpha_constructor(self):
'sub': self.item_module.sub,
'track': self.item_module.track,
'youtube_streams': '',
+ 'component_location': self.item_module.location,
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
}
self.assertDictEqual(context, expected_context)
diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py
index a14fc6cac6ee..ba8e6fab3465 100644
--- a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py
+++ b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py
@@ -120,6 +120,7 @@ def test_videoalpha_constructor(self):
'sources': module.sources,
'youtube_streams': module.youtube_streams,
'track': module.track,
+ 'component_location': module.location,
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
}
self.assertDictEqual(context, expected_context)
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 0db55bacb2ed..ce31ebd9d9e1 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -48,6 +48,7 @@ sorl-thumbnail==11.12
South==0.7.6
sympy==0.7.1
xmltodict==0.4.1
+pysrt==0.4.7
# Used for debugging
ipython==0.13.1