diff --git a/xblocks_contrib/test_settings.py b/test_settings.py
similarity index 77%
rename from xblocks_contrib/test_settings.py
rename to test_settings.py
index 545087f1..384d27e4 100644
--- a/xblocks_contrib/test_settings.py
+++ b/test_settings.py
@@ -5,6 +5,7 @@
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
+ "edxval",
]
DATABASES = {
@@ -12,3 +13,5 @@
"ENGINE": "django.db.backends.sqlite3",
}
}
+
+TRANSCRIPT_LANG_CACHE_TIMEOUT = 60 * 60 * 24 # 24 hours
diff --git a/tox.ini b/tox.ini
index fe9fb231..96a424d5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,12 +14,15 @@ max-line-length = 120
match-dir = (?!migrations)
[pytest]
-DJANGO_SETTINGS_MODULE = xblocks_contrib.test_settings
+DJANGO_SETTINGS_MODULE = test_settings
django_find_project = false
addopts = --cov xblocks_contrib --cov-report term-missing --cov-report xml
norecursedirs = .* docs requirements site-packages
[testenv]
+setenv =
+ PYTHONPATH = {toxinidir}
+ DJANGO_SETTINGS_MODULE = test_settings
deps =
django42: Django>=4.2,<5.0
django52: Django>=5.2,<6.0
diff --git a/xblocks_contrib/video/tests/test_utils.py b/xblocks_contrib/video/tests/test_utils.py
new file mode 100644
index 00000000..0fc48f38
--- /dev/null
+++ b/xblocks_contrib/video/tests/test_utils.py
@@ -0,0 +1,113 @@
+
+from collections import defaultdict
+from unittest.mock import Mock
+
+from web_fragments.fragment import Fragment
+from xblock.core import XBlockAside
+from xblock.field_data import DictFieldData
+from xblock.fields import Scope, ScopeIds, String
+from xblock.test.tools import TestRuntime
+
+EXPORT_IMPORT_STATIC_DIR = 'static'
+VALIDATION_MESSAGE_WARNING = "warning"
+
+# Mock for openedx save_to_store (openedx.core.djangoapps.video_config.transcripts_utils);
+# not available in standalone xblocks-contrib. Use in tests that need to "save" transcript files.
+save_to_store = Mock(name='save_to_store')
+
+# Transcript text used by video_config mock for index_dictionary / get_transcript (lang key).
+_TRANSCRIPT_TEXT_BY_LANG = {
+ 'ge': 'sprechen sie deutsch? Ja, ich spreche Deutsch',
+ 'hr': 'Dobar dan! Kako ste danas?',
+}
+
+
+def _mock_available_translations(block, transcripts_info, verify_assets=True):
+ """Behave like openedx video_config: return transcript langs or ['en'] when empty."""
+ transcripts = (
+ transcripts_info.get('transcripts', {})
+ if isinstance(transcripts_info, dict) else {}
+ )
+ if transcripts:
+ return list(transcripts.keys())
+ try:
+ from django.conf import settings
+ features = getattr(settings, 'FEATURES', {})
+ if features.get('FALLBACK_TO_ENGLISH_TRANSCRIPTS', True):
+ return ['en']
+ except Exception:
+ return ['en']
+ return []
+
+
+def _mock_get_transcript(block, lang=None, output_format=None):
+ """Return (transcript_text,) so index_dictionary can use [0].replace(...)."""
+ text = _TRANSCRIPT_TEXT_BY_LANG.get(lang, '')
+ return (text,)
+
+
+class DummyRuntime(TestRuntime):
+ """
+ Construct a test DummyRuntime instance.
+ """
+
+ def __init__(self, render_template=None, **kwargs):
+ services = kwargs.setdefault('services', {})
+ services['field-data'] = DictFieldData({})
+ video_config_mock = Mock(name='video_config')
+ video_config_mock.available_translations = Mock(side_effect=_mock_available_translations)
+ video_config_mock.get_transcript = Mock(side_effect=_mock_get_transcript)
+ services['video_config'] = video_config_mock
+
+ # Ignore load_error_blocks as it's not supported by modern TestRuntime
+ kwargs.pop('load_error_blocks', None)
+
+ super().__init__(**kwargs)
+ # Use a Mock with root_path so edxval.api create_transcript_objects works
+ # (it does resource_fs.root_path.split('/drafts')[0]). MemoryFS has no root_path.
+ self._resources_fs = Mock(name='DummyRuntime.resources_fs', root_path='.')
+ self._asides = defaultdict(list)
+
+ # TODO: Need to look into all asided code, do we need to add any functionality related to it in edx-platform?
+ def get_asides(self, block):
+ return self._asides.get(block.scope_ids.usage_id, [])
+
+ def get_aside_of_type(self, block, aside_type):
+ for aside in self._asides.get(block.scope_ids.usage_id, []):
+ if getattr(aside.scope_ids, 'block_type', None) == aside_type:
+ return aside
+ return super().get_aside_of_type(block, aside_type)
+
+ def parse_asides(self, node, definition_id, usage_id, id_generator):
+ asides = []
+ for child in node:
+ if child.get('xblock-family') == 'xblock_asides.v1':
+ # Simplified mock parser for tests
+ aside_scope_ids = ScopeIds(None, child.tag, definition_id, usage_id)
+ aside = AsideTestType(runtime=self, scope_ids=aside_scope_ids)
+ aside.tag = child.tag
+ for attr, val in child.attrib.items():
+ if attr in aside.fields:
+ setattr(aside, attr, val)
+ asides.append(aside)
+ self._asides[usage_id].append(aside)
+ return asides
+
+ @property
+ def resources_fs(self):
+ return self._resources_fs
+
+
+class AsideTestType(XBlockAside):
+ """
+ Test Aside type
+ """
+ FRAG_CONTENT = "
Aside rendered
"
+
+ content = String(default="default_content", scope=Scope.content)
+ data_field = String(default="default_data", scope=Scope.settings)
+
+ @XBlockAside.aside_for('student_view')
+ def student_view_aside(self, block, context): # pylint: disable=unused-argument
+ """Add to the student view"""
+ return Fragment(self.FRAG_CONTENT)
diff --git a/xblocks_contrib/video/tests/test_video.py b/xblocks_contrib/video/tests/test_video.py
new file mode 100644
index 00000000..b6b8decb
--- /dev/null
+++ b/xblocks_contrib/video/tests/test_video.py
@@ -0,0 +1,1206 @@
+# pylint: disable=protected-access
+"""Test for Video XBlock functional logic.
+These test data read from xml, not from mongo.
+
+We have a ModuleStoreTestCase class defined in
+xmodule/modulestore/tests/django_utils.py. You can
+search for usages of this in the cms and lms tests for examples. You use
+this so that it will do things like point the modulestore setting to mongo,
+flush the contentstore before and after, load the templates, etc.
+You can then use the CourseFactory and BlockFactory as defined
+in xmodule/modulestore/tests/factories.py to create
+the course, section, subsection, unit, etc.
+"""
+
+
+import datetime
+import shutil
+import unittest
+from tempfile import mkdtemp
+from unittest.mock import ANY, MagicMock, Mock, patch
+from uuid import uuid4
+
+from django.test.utils import override_settings
+import pytest
+import ddt
+from django.conf import settings
+from django.test import TestCase
+from fs.osfs import OSFS
+from lxml import etree
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.locator import CourseLocator
+from xblock.field_data import DictFieldData
+from xblock.fields import ScopeIds
+
+from xblocks_contrib.video.tests.test_utils import (
+ VALIDATION_MESSAGE_WARNING,
+ AsideTestType,
+ DummyRuntime,
+ save_to_store,
+)
+from xblocks_contrib.video.video import (
+ EXPORT_IMPORT_STATIC_DIR,
+ VideoBlock,
+ create_youtube_string,
+)
+from xblock.core import XBlockAside
+
+# TODO: Need to look into it
+VideoBlock.add_aside = MagicMock()
+
+SRT_FILEDATA = '''
+0
+00:00:00,270 --> 00:00:02,720
+sprechen sie deutsch?
+
+1
+00:00:02,720 --> 00:00:05,430
+Ja, ich spreche Deutsch
+'''
+
+CRO_SRT_FILEDATA = '''
+0
+00:00:00,270 --> 00:00:02,720
+Dobar dan!
+
+1
+00:00:02,720 --> 00:00:05,430
+Kako ste danas?
+'''
+
+YOUTUBE_SUBTITLES = (
+ "Sample trascript line 1. "
+ "Sample trascript line 2. "
+ "Sample trascript line 3."
+)
+
+MOCKED_YOUTUBE_TRANSCRIPT_API_RESPONSE = '''
+
+ Sample trascript line 1.
+ Sample trascript line 2.
+ Sample trascript line 3.
+
+'''
+
+ALL_LANGUAGES = (
+ ["en", "English"],
+ ["eo", "Esperanto"],
+ ["ur", "Urdu"]
+)
+
+
+def instantiate_block(**field_data):
+ """
+ Instantiate block with most properties.
+ """
+ if field_data.get('data', None):
+ field_data = VideoBlock.parse_video_xml(field_data['data'])
+ system = DummyRuntime()
+ course_key = CourseLocator('org', 'course', 'run')
+ usage_key = course_key.make_usage_key('video', 'SampleProblem')
+ return system.construct_xblock_from_class(
+ VideoBlock,
+ scope_ids=ScopeIds(None, None, usage_key, usage_key),
+ field_data=DictFieldData(field_data),
+ )
+
+
+# Because of the way xblocks_contrib.video.video imports edxval.api, we
+# must mock the entire module, which requires making mock exception classes.
+
+class _MockValVideoNotFoundError(Exception):
+ """Mock ValVideoNotFoundError exception"""
+ pass # lint-amnesty, pylint: disable=unnecessary-pass
+
+
+class _MockValCannotCreateError(Exception):
+ """Mock ValCannotCreateError exception"""
+ pass # lint-amnesty, pylint: disable=unnecessary-pass
+
+
+class VideoBlockTest(unittest.TestCase):
+ """Logic tests for Video XBlock."""
+
+ raw_field_data = {
+ 'data': ''
+ }
+
+ def test_parse_youtube(self):
+ """Test parsing old-style Youtube ID strings into a dict."""
+ youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': 'jNCf2gIqpeE', '1.00': 'ZwkTiUPN0mg', '1.25': 'rsq9auxASqI', '1.50': 'kMyNdzVHHgg'}
+
+ def test_parse_youtube_one_video(self):
+ """
+ Ensure that all keys are present and missing speeds map to the
+ empty string.
+ """
+ youtube_str = '0.75:jNCf2gIqpeE'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': 'jNCf2gIqpeE', '1.00': '', '1.25': '', '1.50': ''}
+
+ def test_parse_youtube_invalid(self):
+ """Ensure that ids that are invalid return an empty dict"""
+ # invalid id
+ youtube_str = 'thisisaninvalidid'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
+ # another invalid id
+ youtube_str = ',::,:,,'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
+
+ # and another one, partially invalid
+ youtube_str = '0.75_BAD!!!,1.0:AXdE34_U,1.25:KLHF9K_Y,1.5:VO3SxfeD,'
+ output = VideoBlock._parse_youtube(youtube_str)
+ assert output == {'0.75': '', '1.00': 'AXdE34_U', '1.25': 'KLHF9K_Y', '1.50': 'VO3SxfeD'}
+
+ def test_parse_youtube_key_format(self):
+ """
+ Make sure that inconsistent speed keys are parsed correctly.
+ """
+ youtube_str = '1.00:p2Q6BrNhdh8'
+ youtube_str_hack = '1.0:p2Q6BrNhdh8'
+ assert VideoBlock._parse_youtube(youtube_str) == VideoBlock._parse_youtube(youtube_str_hack)
+
+ def test_parse_youtube_empty(self):
+ """
+ Some courses have empty youtube attributes, so we should handle
+ that well.
+ """
+ assert VideoBlock._parse_youtube('') == {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
+
+
+class VideoBlockTestBase(unittest.TestCase):
+ """
+ Base class for tests for VideoBlock
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.block = instantiate_block()
+
+ def assertXmlEqual(self, expected, xml):
+ """
+ Assert that the given XML fragments have the same attributes, text, and
+ (recursively) children
+ """
+ def get_child_tags(elem):
+ """Extract the list of tag names for children of elem"""
+ return [child.tag for child in elem]
+
+ for attr in ['tag', 'attrib', 'text', 'tail']:
+ expected_attr = getattr(expected, attr)
+ actual_attr = getattr(xml, attr)
+ assert expected_attr == actual_attr
+
+ assert get_child_tags(expected) == get_child_tags(xml)
+ for left, right in zip(expected, xml):
+ self.assertXmlEqual(left, right)
+
+
+class TestCreateYoutubeString(VideoBlockTestBase):
+ """
+ Checks that create_youtube_string correcty extracts information from Video block.
+ """
+
+ def test_create_youtube_string(self):
+ """
+ Test that Youtube ID strings are correctly created when writing back out to XML.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8"
+ assert create_youtube_string(self.block) == expected
+
+ def test_create_youtube_string_missing(self):
+ """
+ Test that Youtube IDs which aren't explicitly set aren't included in the output string.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
+ assert create_youtube_string(self.block) == expected
+
+
+class TestCreateYouTubeUrl(VideoBlockTestBase):
+ """
+ Tests for helper method `create_youtube_url`.
+ """
+
+ def test_create_youtube_url_unicode(self):
+ """
+ Test that passing unicode to `create_youtube_url` doesn't throw
+ an error.
+ """
+ self.block.create_youtube_url("üñîçø∂é")
+
+
+@ddt.ddt
+class VideoBlockImportTestCase(TestCase):
+ """
+ Make sure that VideoBlock can import an old XML-based video correctly.
+ """
+
+ def assert_attributes_equal(self, video, attrs):
+ """
+ Assert that `video` has the correct attributes. `attrs` is a map of {metadata_field: value}.
+ """
+ for key, value in attrs.items():
+ assert getattr(video, key) == value
+
+ def test_constructor(self):
+ sample_xml = '''
+
+ '''
+ block = instantiate_block(data=sample_xml)
+ self.assert_attributes_equal(block, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'download_video': True,
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ 'handout': 'http://www.example.com/handout',
+ 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
+ 'data': '',
+ 'transcripts': {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
+ })
+
+ def test_parse_xml(self):
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ 'handout': 'http://www.example.com/handout',
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ 'transcripts': {'uk': 'ukrainian_translation.srt', 'de': 'german_translation.srt'},
+ })
+
+ @XBlockAside.register_temp_plugin(AsideTestType, "test_aside")
+ @patch('xblocks_contrib.video.video.VideoBlock.load_file')
+ @patch('xblocks_contrib.video.video.is_pointer_tag')
+ @ddt.data(True, False)
+ def test_parse_xml_with_asides(self, video_xml_has_aside, mock_is_pointer_tag, mock_load_file):
+ """Test that `parse_xml` parses asides from the video xml"""
+ runtime = DummyRuntime(load_error_blocks=True)
+ if video_xml_has_aside:
+ xml_data = '''
+
+ '''
+ else:
+ xml_data = '''
+
+ '''
+ mock_is_pointer_tag.return_value = True
+ xml_object = etree.fromstring(xml_data)
+ mock_load_file.return_value = xml_object
+ output = VideoBlock.parse_xml(xml_object, runtime, None)
+ aside = runtime.get_aside_of_type(output, "test_aside")
+ if video_xml_has_aside:
+ assert aside.content == "default_content"
+ assert aside.data_field == "aside parsed"
+ else:
+ assert aside.content == "default_content"
+ assert aside.data_field == "default_data"
+
+ @ddt.data(
+ ('course-v1:test_org+test_course+test_run',
+ '/asset-v1:test_org+test_course+test_run+type@asset+block@test.png'),
+ ('test_org/test_course/test_run', '/c4x/test_org/test_course/asset/test.png')
+ )
+ @ddt.unpack
+ def test_parse_xml_when_handout_is_course_asset(self, course_id_string, expected_handout_link):
+ """
+ Test that if handout link is course_asset then it will contain targeted course_id in handout link.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ course_id = CourseKey.from_string(course_id_string)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ module_system.id_generator.target_course_id = course_id
+
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ 'handout': expected_handout_link,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ 'transcripts': {'uk': 'ukrainian_translation.srt', 'de': 'german_translation.srt'},
+ })
+
+ def test_parse_xml_missing_attributes(self):
+ """
+ Ensure that attributes have the right values if they aren't
+ explicitly set in XML.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': '',
+ 'handout': None,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': ''
+ })
+
+ def test_parse_xml_missing_download_track(self):
+ """
+ Ensure that attributes have the right values if they aren't
+ explicitly set in XML.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': 'http://www.example.com/track',
+ 'download_track': True,
+ 'download_video': False,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ 'transcripts': {},
+ })
+
+ def test_parse_xml_no_attributes(self):
+ """
+ Make sure settings are correct if none are explicitly set in XML.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = ''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': '3_yD_cEKoCk',
+ 'youtube_id_1_25': '',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': '',
+ 'handout': None,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': [],
+ 'data': '',
+ 'transcripts': {},
+ })
+
+ def test_parse_xml_double_quotes(self):
+ """
+ Make sure we can handle the double-quoted string format (which was used for exporting for
+ a few weeks).
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'OEoXaMPEzf65',
+ 'youtube_id_1_0': 'OEoXaMPEzf10',
+ 'youtube_id_1_25': 'OEoXaMPEzf125',
+ 'youtube_id_1_5': 'OEoXaMPEzf15',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': 'http://www.example.com/track',
+ 'handout': 'http://www.example.com/handout',
+ 'download_track': True,
+ 'download_video': True,
+ 'html5_sources': ["source_1", "source_2"],
+ 'data': ''
+ })
+
+ def test_parse_xml_double_quote_concatenated_youtube(self):
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = '''
+
+ '''
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': '',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': '',
+ 'show_captions': True,
+ 'start_time': datetime.timedelta(seconds=0.0),
+ 'end_time': datetime.timedelta(seconds=0.0),
+ 'track': '',
+ 'handout': None,
+ 'download_track': False,
+ 'download_video': False,
+ 'html5_sources': [],
+ 'data': ''
+ })
+
+ def test_old_video_format(self):
+ """
+ Test backwards compatibility with VideoBlock's XML format.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ output = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(output, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ # 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': '',
+ })
+
+ def test_old_video_data(self):
+ """
+ Ensure that Video is able to read VideoBlock's model data.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ video = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(video, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ # 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': ''
+ })
+
+ def test_import_with_float_times(self):
+ """
+ Ensure that Video is able to read VideoBlock's model data.
+ """
+ module_system = DummyRuntime(load_error_blocks=True)
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ video = VideoBlock.parse_xml(xml_object, module_system, None)
+ self.assert_attributes_equal(video, {
+ 'youtube_id_0_75': 'izygArpw-Qo',
+ 'youtube_id_1_0': 'p2Q6BrNhdh8',
+ 'youtube_id_1_25': '1EeWXzPdhSA',
+ 'youtube_id_1_5': 'rABDYkeK0x8',
+ 'show_captions': False,
+ 'start_time': datetime.timedelta(seconds=1),
+ 'end_time': datetime.timedelta(seconds=60),
+ 'track': 'http://www.example.com/track',
+ # 'download_track': True,
+ 'html5_sources': ['http://www.example.com/source.mp4'],
+ 'data': ''
+ })
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_import_val_data(self, mock_val_api):
+ """
+ Test that `parse_xml` works method works as expected.
+ """
+ def mock_val_import(xml, edx_video_id, resource_fs, static_dir, external_transcripts, course_id):
+ """Mock edxval.api.import_parse_xml"""
+ assert xml.tag == 'video_asset'
+ assert dict(list(xml.items())) == {'mock_attr': ''}
+ assert edx_video_id == 'test_edx_video_id'
+ assert static_dir == EXPORT_IMPORT_STATIC_DIR
+ assert resource_fs is not None
+ assert external_transcripts == {'en': ['subs_3_yD_cEKoCk.srt.sjson']}
+ assert course_id == 'test_course_id'
+ return edx_video_id
+
+ edx_video_id = 'test_edx_video_id'
+ mock_val_api.import_from_xml = Mock(wraps=mock_val_import)
+ module_system = DummyRuntime(load_error_blocks=True)
+
+ # Create static directory in import file system and place transcript files inside it.
+ module_system.resources_fs.makedirs(EXPORT_IMPORT_STATIC_DIR, recreate=True)
+
+ # import new edx_video_id
+ xml_data = """
+
+ """.format(
+ edx_video_id=edx_video_id
+ )
+ xml_object = etree.fromstring(xml_data)
+ module_system.id_generator.target_course_id = 'test_course_id'
+ video = VideoBlock.parse_xml(xml_object, module_system, None)
+
+ self.assert_attributes_equal(video, {'edx_video_id': edx_video_id})
+ mock_val_api.import_from_xml.assert_called_once_with(
+ ANY,
+ edx_video_id,
+ module_system.resources_fs,
+ EXPORT_IMPORT_STATIC_DIR,
+ {'en': ['subs_3_yD_cEKoCk.srt.sjson']},
+ course_id='test_course_id'
+ )
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_import_val_data_invalid(self, mock_val_api):
+ mock_val_api.ValCannotCreateError = _MockValCannotCreateError
+ mock_val_api.import_from_xml = Mock(side_effect=mock_val_api.ValCannotCreateError)
+ module_system = DummyRuntime(load_error_blocks=True)
+
+ # Negative duration is invalid
+ xml_data = """
+
+ """
+ xml_object = etree.fromstring(xml_data)
+ with pytest.raises(mock_val_api.ValCannotCreateError):
+ VideoBlock.parse_xml(xml_object, module_system, None)
+
+
+class VideoExportTestCase(VideoBlockTestBase):
+ """
+ Make sure that VideoBlock can export itself to XML correctly.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.temp_dir = mkdtemp()
+ self.file_system = OSFS(self.temp_dir)
+ self.addCleanup(shutil.rmtree, self.temp_dir)
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_export_to_xml(self, mock_val_api):
+ """
+ Test that we write the correct XML on export.
+ """
+ edx_video_id = 'test_edx_video_id'
+ mock_val_api.export_to_xml = Mock(
+ return_value=dict(
+ xml=etree.Element('video_asset'),
+ transcripts={}
+ )
+ )
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ self.block.show_captions = False
+ self.block.start_time = datetime.timedelta(seconds=1.0)
+ self.block.end_time = datetime.timedelta(seconds=60)
+ self.block.track = 'http://www.example.com/track'
+ self.block.handout = 'http://www.example.com/handout'
+ self.block.download_track = True
+ self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source1.ogg']
+ self.block.download_video = True
+ self.block.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
+ self.block.edx_video_id = edx_video_id
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
+
+ '''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+ mock_val_api.export_to_xml.assert_called_once_with(
+ video_id=edx_video_id,
+ static_dir=EXPORT_IMPORT_STATIC_DIR,
+ resource_fs=self.file_system,
+ course_id=self.block.scope_ids.usage_id.context_key,
+ )
+
+ def test_export_to_xml_without_video_id(self):
+ """
+ Test that we write the correct XML on export of a video without edx_video_id.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ self.block.show_captions = False
+ self.block.start_time = datetime.timedelta(seconds=1.0)
+ self.block.end_time = datetime.timedelta(seconds=60)
+ self.block.track = 'http://www.example.com/track'
+ self.block.handout = 'http://www.example.com/handout'
+ self.block.download_track = True
+ self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source1.ogg']
+ self.block.download_video = True
+ self.block.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
+
+ '''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+
+ @patch('xblocks_contrib.video.video.edxval_api')
+ def test_export_to_xml_val_error(self, mock_val_api):
+ # Export should succeed without VAL data if video does not exist
+ mock_val_api.ValVideoNotFoundError = _MockValVideoNotFoundError
+ mock_val_api.export_to_xml = Mock(side_effect=mock_val_api.ValVideoNotFoundError)
+ self.block.edx_video_id = 'test_edx_video_id'
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = ''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_empty_end_time(self):
+ """
+ Test that we write the correct XML on export.
+ """
+ self.block.youtube_id_0_75 = 'izygArpw-Qo'
+ self.block.youtube_id_1_0 = 'p2Q6BrNhdh8'
+ self.block.youtube_id_1_25 = '1EeWXzPdhSA'
+ self.block.youtube_id_1_5 = 'rABDYkeK0x8'
+ self.block.show_captions = False
+ self.block.start_time = datetime.timedelta(seconds=5.0)
+ self.block.end_time = datetime.timedelta(seconds=0.0)
+ self.block.track = 'http://www.example.com/track'
+ self.block.download_track = True
+ self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
+ self.block.download_video = True
+
+ xml = self.block.definition_to_xml(self.file_system)
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
+
+ '''
+ expected = etree.XML(xml_string, parser=parser)
+ self.assertXmlEqual(expected, xml)
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_empty_parameters(self):
+ """
+ Test XML export with defaults.
+ """
+ xml = self.block.definition_to_xml(self.file_system)
+ # Check that download_video field is also set to default (False) in xml for backward compatibility
+ expected = '\n'
+ assert expected == etree.tostring(xml, pretty_print=True).decode('utf-8')
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_with_transcripts_as_none(self):
+ """
+ Test XML export with transcripts being overridden to None.
+ """
+ self.block.transcripts = None
+ xml = self.block.definition_to_xml(self.file_system)
+ expected = b'\n'
+ assert expected == etree.tostring(xml, pretty_print=True)
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_invalid_characters_in_attributes(self):
+ """
+ Test XML export will *not* raise TypeError by lxml library if contains illegal characters.
+ The illegal characters in a String field are removed from the string instead.
+ """
+ self.block.display_name = 'Display\x1eName'
+ xml = self.block.definition_to_xml(self.file_system)
+ assert xml.get('display_name') == 'DisplayName'
+
+ @patch('xblocks_contrib.video.video.edxval_api', None)
+ def test_export_to_xml_unicode_characters(self):
+ """
+ Test XML export handles the unicode characters.
+ """
+ self.block.display_name = '这是文'
+ xml = self.block.definition_to_xml(self.file_system)
+ assert xml.get('display_name') == '这是文'
+
+
+@ddt.ddt
+@patch.object(settings, 'FEATURES', create=True, new={
+ 'FALLBACK_TO_ENGLISH_TRANSCRIPTS': False,
+})
+class VideoBlockStudentViewDataTestCase(unittest.TestCase):
+ """
+ Make sure that VideoBlock returns the expected student_view_data.
+ """
+
+ VIDEO_URL_1 = 'http://www.example.com/source_low.mp4'
+ VIDEO_URL_2 = 'http://www.example.com/source_med.mp4'
+ VIDEO_URL_3 = 'http://www.example.com/source_high.mp4'
+
+ @ddt.data(
+ # Ensure no extra data is returned if video block configured only for web display.
+ (
+ {'only_on_web': True},
+ {'only_on_web': True},
+ ),
+ # Ensure that YouTube URLs are included in `encoded_videos`, but not `all_sources`.
+ (
+ {
+ 'only_on_web': False,
+ 'youtube_id_1_0': 'abc',
+ 'html5_sources': [VIDEO_URL_2, VIDEO_URL_3],
+ },
+ {
+ 'only_on_web': False,
+ 'duration': None,
+ 'transcripts': {},
+ 'encoded_videos': {
+ 'fallback': {'url': VIDEO_URL_2, 'file_size': 0},
+ 'youtube': {'url': 'https://www.youtube.com/watch?v=abc', 'file_size': 0},
+ },
+ 'all_sources': [VIDEO_URL_2, VIDEO_URL_3],
+ },
+ ),
+ )
+ @ddt.unpack
+ def test_student_view_data(self, field_data, expected_student_view_data):
+ """
+ Ensure that student_view_data returns the expected results for video blocks.
+ """
+ block = instantiate_block(**field_data)
+ student_view_data = block.student_view_data()
+ assert student_view_data == expected_student_view_data
+
+ @patch(
+ 'xblocks_contrib.video.video.VideoBlock.is_hls_playback_enabled',
+ Mock(return_value=True)
+ )
+ @patch('xblocks_contrib.video.video_transcripts_utils.get_available_transcript_languages', Mock(return_value=['es']))
+ @patch('edxval.api.get_video_info_for_course_and_profiles', Mock(return_value={}))
+ @patch('edxval.api.get_video_info')
+ def test_student_view_data_with_hls_flag(self, mock_get_video_info):
+ mock_get_video_info.return_value = {
+ 'url': '/edxval/video/example',
+ 'edx_video_id': 'example_id',
+ 'duration': 111.0,
+ 'client_video_id': 'The example video',
+ 'encoded_videos': [
+ {
+ 'url': 'http://www.meowmix.com',
+ 'file_size': 25556,
+ 'bitrate': 9600,
+ 'profile': 'hls'
+ }
+ ]
+ }
+
+ block = instantiate_block(edx_video_id='example_id', only_on_web=False)
+ block.runtime.handler_url = MagicMock()
+ student_view_data = block.student_view_data()
+ expected_video_data = {'hls': {'url': 'http://www.meowmix.com', 'file_size': 25556}}
+ self.assertDictEqual(student_view_data.get('encoded_videos'), expected_video_data)
+
+
+@ddt.ddt
+@patch.object(settings, 'YOUTUBE', create=True, new={
+ # YouTube JavaScript API
+ 'API': 'www.youtube.com/iframe_api',
+
+ # URL to get YouTube metadata
+ 'METADATA_URL': 'www.googleapis.com/youtube/v3/videos/',
+
+ # Current youtube api for requesting transcripts.
+ # For example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
+ 'TEXT_API': {
+ 'url': 'video.google.com/timedtext',
+ 'params': {
+ 'lang': 'en',
+ 'v': 'set_youtube_id_of_11_symbols_here',
+ },
+ },
+
+ # Current web page mechanism for scraping transcript information from youtube video pages
+ 'TRANSCRIPTS': {
+ 'CAPTION_TRACKS_REGEX': r"captionTracks\"\:\[(?P[^\]]+)",
+ 'YOUTUBE_URL_BASE': 'https://www.youtube.com/watch?v=',
+ }
+})
+@patch.object(settings, 'CONTENTSTORE', create=True, new={
+ 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
+ 'DOC_STORE_CONFIG': {
+ 'host': 'localhost',
+ 'db': 'test_xcontent_%s' % uuid4().hex,
+ },
+ # allow for additional options that can be keyed on a name, e.g. 'trashcan'
+ 'ADDITIONAL_OPTIONS': {
+ 'trashcan': {
+ 'bucket': 'trash_fs'
+ }
+ }
+})
+@patch.object(settings, 'FEATURES', create=True, new={
+ # The default value in {lms,cms}/envs/common.py and xmodule/tests/test_video.py should be consistent.
+ 'FALLBACK_TO_ENGLISH_TRANSCRIPTS': True,
+})
+class VideoBlockIndexingTestCase(unittest.TestCase):
+ """
+ Make sure that VideoBlock can format data for indexing as expected.
+ """
+
+ def test_video_with_no_subs_index_dictionary(self):
+ """
+ Test index dictionary of a video block without subtitles.
+ """
+ xml_data = '''
+
+ '''
+ block = instantiate_block(data=xml_data)
+ assert block.index_dictionary() == {'content': {'display_name': 'Test Video'}, 'content_type': 'Video'}
+
+ def test_video_with_multiple_transcripts_index_dictionary(self):
+ """
+ Test index dictionary of a video block with
+ two transcripts uploaded by a user.
+ """
+ xml_data_transcripts = '''
+
+ '''
+
+ block = instantiate_block(data=xml_data_transcripts)
+ save_to_store(SRT_FILEDATA, "subs_grmtran1.srt", 'text/srt', block.location)
+ save_to_store(CRO_SRT_FILEDATA, "subs_croatian1.srt", 'text/srt', block.location)
+ assert block.index_dictionary() ==\
+ {'content': {'display_name': 'Test Video',
+ 'transcript_ge': 'sprechen sie deutsch? Ja, ich spreche Deutsch',
+ 'transcript_hr': 'Dobar dan! Kako ste danas?'}, 'content_type': 'Video'}
+
+ def test_video_with_multiple_transcripts_translation_retrieval(self):
+ """
+ Test translation retrieval of a video block with
+ multiple transcripts uploaded by a user.
+ """
+ xml_data_transcripts = '''
+
+ '''
+
+ block = instantiate_block(data=xml_data_transcripts)
+ video_config_service = block.runtime.service(block, 'video_config')
+ translations = video_config_service.available_translations(block, block.get_transcripts_info())
+ assert sorted(translations) == sorted(['hr', 'ge'])
+
+ def test_video_with_no_transcripts_translation_retrieval(self):
+ """
+ Test translation retrieval of a video block with
+ no transcripts uploaded by a user- ie, that retrieval
+ does not throw an exception.
+ """
+ block = instantiate_block(data=None)
+ video_config_service = block.runtime.service(block, 'video_config')
+ translations_with_fallback = video_config_service.available_translations(block, block.get_transcripts_info())
+ assert translations_with_fallback == ['en']
+
+ with patch.dict(settings.FEATURES, FALLBACK_TO_ENGLISH_TRANSCRIPTS=False):
+ # Some organizations don't have English transcripts for all videos
+ # This feature makes it configurable
+ translations_no_fallback = video_config_service.available_translations(block, block.get_transcripts_info())
+ assert translations_no_fallback == []
+
+ @override_settings(ALL_LANGUAGES=ALL_LANGUAGES)
+ def test_video_with_language_do_not_have_transcripts_translation(self):
+ """
+ Test translation retrieval of a video block with
+ a language having no transcripts uploaded by a user.
+ """
+ xml_data_transcripts = '''
+
+ '''
+ block = instantiate_block(data=xml_data_transcripts)
+ video_config_service = block.runtime.service(block, 'video_config')
+ translations = video_config_service.available_translations(
+ block,
+ block.get_transcripts_info(),
+ verify_assets=False
+ )
+ assert translations != ['ur']
+
+ def assert_validation_message(self, validation, expected_msg):
+ """
+ Asserts that the validation message has all expected content.
+
+ Args:
+ validation (StudioValidation): A validation object.
+ expected_msg (string): An expected validation message.
+ """
+ assert not validation.empty
+ # Validation contains some warning/message
+ assert validation.summary
+ assert VALIDATION_MESSAGE_WARNING == validation.summary.type
+ assert expected_msg in validation.summary.text.replace('Urdu, Esperanto', 'Esperanto, Urdu')
+
+ @ddt.data(
+ (
+ '',
+ 'There is no transcript file associated with the Urdu language.'
+ ),
+ (
+ '',
+ 'There are no transcript files associated with the Esperanto, Urdu languages.'
+ ),
+ )
+ @ddt.unpack
+ @override_settings(ALL_LANGUAGES=ALL_LANGUAGES)
+ def test_no_transcript_validation_message(self, xml_transcripts, expected_validation_msg):
+ """
+ Test the validation message when no associated transcript file uploaded.
+ """
+ xml_data_transcripts = '''
+
+ '''.format(xml_transcripts=xml_transcripts)
+ block = instantiate_block(data=xml_data_transcripts)
+ validation = block.validate()
+ self.assert_validation_message(validation, expected_validation_msg)
+
+ def test_video_transcript_none(self):
+ """
+ Test video when transcripts is None.
+ """
+ block = instantiate_block(data=None)
+ block.transcripts = None
+ response = block.get_transcripts_info()
+ expected = {'transcripts': {}, 'sub': ''}
+ assert expected == response