Commit 37a0874c by Greg Price

Add XML export and import API functions

These will be used to support export/import of courses containing video
modules that rely on VAL being populated. This does not allow updating
a video (including adding encodings) via import.

JIRA: MA-110
parent 72f76ecf
...@@ -5,6 +5,7 @@ The internal API for VAL. This is not yet stable ...@@ -5,6 +5,7 @@ The internal API for VAL. This is not yet stable
""" """
import logging import logging
from lxml.etree import Element, SubElement
from enum import Enum from enum import Enum
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
...@@ -123,12 +124,28 @@ def create_profile(profile_name): ...@@ -123,12 +124,28 @@ def create_profile(profile_name):
raise ValCannotCreateError(err.message_dict) raise ValCannotCreateError(err.message_dict)
def get_video_info(edx_video_id, location=None): # pylint: disable=W0613 def _get_video(edx_video_id):
"""
Get a Video instance, prefetching encoded video and course information.
Raises ValVideoNotFoundError if the video cannot be retrieved.
"""
try:
return Video.objects.prefetch_related("encoded_videos", "courses").get(edx_video_id=edx_video_id)
except Video.DoesNotExist:
error_message = u"Video not found for edx_video_id: {0}".format(edx_video_id)
raise ValVideoNotFoundError(error_message)
except Exception:
error_message = u"Could not get edx_video_id: {0}".format(edx_video_id)
logger.exception(error_message)
raise ValInternalError(error_message)
def get_video_info(edx_video_id):
""" """
Retrieves all encoded videos of a video found with given video edx_video_id Retrieves all encoded videos of a video found with given video edx_video_id
Args: Args:
location (str): geographic locations used determine CDN
edx_video_id (str): id for video content. edx_video_id (str): id for video content.
Returns: Returns:
...@@ -175,17 +192,7 @@ def get_video_info(edx_video_id, location=None): # pylint: disable=W0613 ...@@ -175,17 +192,7 @@ def get_video_info(edx_video_id, location=None): # pylint: disable=W0613
] ]
} }
""" """
try: return VideoSerializer(_get_video(edx_video_id)).data
video = Video.objects.prefetch_related("encoded_videos", "courses").get(edx_video_id=edx_video_id)
result = VideoSerializer(video)
except Video.DoesNotExist:
error_message = u"Video not found for edx_video_id: {0}".format(edx_video_id)
raise ValVideoNotFoundError(error_message)
except Exception:
error_message = u"Could not get edx_video_id: {0}".format(edx_video_id)
logger.exception(error_message)
raise ValInternalError(error_message)
return result.data # pylint: disable=E1101
def get_urls_for_profiles(edx_video_id, profiles): def get_urls_for_profiles(edx_video_id, profiles):
...@@ -365,3 +372,96 @@ def copy_course_videos(source_course_id, destination_course_id): ...@@ -365,3 +372,96 @@ def copy_course_videos(source_course_id, destination_course_id):
video=video, video=video,
course_id=destination_course_id course_id=destination_course_id
) )
def export_to_xml(edx_video_id):
"""
Exports data about the given edx_video_id into the given xml object.
Args:
edx_video_id (str): The ID of the video to export
Returns:
An lxml video_asset element containing export data
Raises:
ValVideoNotFoundError: if the video does not exist
"""
video = _get_video(edx_video_id)
video_el = Element(
'video_asset',
attrib={
'client_video_id': video.client_video_id,
'duration': unicode(video.duration),
}
)
for encoded_video in video.encoded_videos.all():
SubElement(
video_el,
'encoded_video',
{
name: unicode(getattr(encoded_video, name))
for name in ['profile', 'url', 'file_size', 'bitrate']
}
)
# Note: we are *not* exporting Subtitle data since it is not currently updated by VEDA or used
# by LMS/Studio.
return video_el
def import_from_xml(xml, edx_video_id, course_id=None):
"""
Imports data from a video_asset element about the given edx_video_id.
If the edx_video_id already exists, then no changes are made. If an unknown
profile is referenced by an encoded video, that encoding will be ignored.
Args:
xml: An lxml video_asset element containing import data
edx_video_id (str): The ID for the video content
course_id (str): The ID of a course to associate the video with
(optional)
Raises:
ValCannotCreateError: if there is an error importing the video
"""
if xml.tag != 'video_asset':
raise ValCannotCreateError('Invalid XML')
if Video.objects.filter(edx_video_id=edx_video_id).exists():
logger.info(
"edx_video_id '%s' present in course '%s' not imported because it exists in VAL.",
edx_video_id,
course_id,
)
return
data = {
'edx_video_id': edx_video_id,
'client_video_id': xml.get('client_video_id'),
'duration': xml.get('duration'),
'status': 'imported',
'encoded_videos': [],
'courses': [],
}
for encoded_video_el in xml.iterfind('encoded_video'):
profile_name = encoded_video_el.get('profile')
try:
Profile.objects.get(profile_name=profile_name)
except Profile.DoesNotExist:
logger.info(
"Imported edx_video_id '%s' contains unknown profile '%s'.",
edx_video_id,
profile_name
)
continue
data['encoded_videos'].append({
'profile': profile_name,
'url': encoded_video_el.get('url'),
'file_size': encoded_video_el.get('file_size'),
'bitrate': encoded_video_el.get('bitrate'),
})
if course_id:
data['courses'].append(course_id)
create_video(data)
...@@ -79,8 +79,9 @@ class CourseSerializer(serializers.RelatedField): ...@@ -79,8 +79,9 @@ class CourseSerializer(serializers.RelatedField):
def from_native(self, data): def from_native(self, data):
if data: if data:
return CourseVideo(course_id=data) course_video = CourseVideo(course_id=data)
course_video.full_clean(exclude=["video"])
return course_video
class VideoSerializer(serializers.ModelSerializer): class VideoSerializer(serializers.ModelSerializer):
""" """
......
...@@ -4,6 +4,7 @@ Tests for the API for Video Abstraction Layer ...@@ -4,6 +4,7 @@ Tests for the API for Video Abstraction Layer
""" """
import mock import mock
from lxml import etree
from django.test import TestCase from django.test import TestCase
from django.db import DatabaseError from django.db import DatabaseError
...@@ -16,9 +17,9 @@ from edxval import api as api ...@@ -16,9 +17,9 @@ from edxval import api as api
from edxval.api import ( from edxval.api import (
SortDirection, SortDirection,
ValCannotCreateError, ValCannotCreateError,
ValVideoNotFoundError,
VideoSortField, VideoSortField,
) )
from edxval.serializers import VideoSerializer
from edxval.tests import constants, APIAuthTestCase from edxval.tests import constants, APIAuthTestCase
...@@ -158,17 +159,6 @@ class GetVideoInfoTest(TestCase): ...@@ -158,17 +159,6 @@ class GetVideoInfoTest(TestCase):
with self.assertRaises(api.ValVideoNotFoundError): with self.assertRaises(api.ValVideoNotFoundError):
api.get_video_info(u"๓ﻉѻฝ๓ٱซ") api.get_video_info(u"๓ﻉѻฝ๓ٱซ")
@mock.patch.object(VideoSerializer, '__init__')
def test_force_internal_error(self, mock_init):
"""
Tests to see if an unknown error will be handled
"""
mock_init.side_effect = Exception("Mock error")
with self.assertRaises(api.ValInternalError):
api.get_video_info(
constants.VIDEO_DICT_FISH.get("edx_video_id")
)
@mock.patch.object(Video, '__init__') @mock.patch.object(Video, '__init__')
def test_force_database_error(self, mock_get): def test_force_database_error(self, mock_get):
""" """
...@@ -687,3 +677,241 @@ class TestCopyCourse(TestCase): ...@@ -687,3 +677,241 @@ class TestCopyCourse(TestCase):
self.assertEqual(len(original_videos), 2) self.assertEqual(len(original_videos), 2)
self.assertTrue(set(copied_videos) == set(original_videos)) self.assertTrue(set(copied_videos) == set(original_videos))
class ExportTest(TestCase):
"""Tests export_to_xml"""
def setUp(self):
mobile_profile = Profile.objects.create(profile_name=constants.PROFILE_MOBILE)
desktop_profile = Profile.objects.create(profile_name=constants.PROFILE_DESKTOP)
Video.objects.create(**constants.VIDEO_DICT_STAR)
video = Video.objects.create(**constants.VIDEO_DICT_FISH)
EncodedVideo.objects.create(
video=video,
profile=mobile_profile,
**constants.ENCODED_VIDEO_DICT_MOBILE
)
EncodedVideo.objects.create(
video=video,
profile=desktop_profile,
**constants.ENCODED_VIDEO_DICT_DESKTOP
)
def assert_xml_equal(self, left, right):
"""
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']:
self.assertEqual(getattr(left, attr), getattr(right, attr))
self.assertEqual(get_child_tags(left), get_child_tags(right))
for left_child, right_child in zip(left, right):
self.assert_xml_equal(left_child, right_child)
def parse_xml(self, xml_str):
"""Parse XML for comparison with export output"""
parser = etree.XMLParser(remove_blank_text=True)
return etree.XML(xml_str, parser=parser)
def test_no_encodings(self):
expected = self.parse_xml("""
<video_asset client_video_id="TWINKLE TWINKLE" duration="122.0"/>
""")
self.assert_xml_equal(
api.export_to_xml(constants.VIDEO_DICT_STAR["edx_video_id"]),
expected
)
def test_basic(self):
expected = self.parse_xml("""
<video_asset client_video_id="Shallow Swordfish" duration="122.0">
<encoded_video url="http://www.meowmix.com" file_size="11" bitrate="22" profile="mobile"/>
<encoded_video url="http://www.meowmagic.com" file_size="33" bitrate="44" profile="desktop"/>
</video_asset>
""")
self.assert_xml_equal(
api.export_to_xml(constants.VIDEO_DICT_FISH["edx_video_id"]),
expected
)
def test_unknown_video(self):
with self.assertRaises(ValVideoNotFoundError):
api.export_to_xml("unknown_video")
class ImportTest(TestCase):
"""Tests import_from_xml"""
def setUp(self):
mobile_profile = Profile.objects.create(profile_name=constants.PROFILE_MOBILE)
Profile.objects.create(profile_name=constants.PROFILE_DESKTOP)
video = Video.objects.create(**constants.VIDEO_DICT_FISH)
EncodedVideo.objects.create(
video=video,
profile=mobile_profile,
**constants.ENCODED_VIDEO_DICT_MOBILE
)
CourseVideo.objects.create(video=video, course_id='existing_course_id')
def make_import_xml(self, video_dict, encoded_video_dicts=None):
ret = etree.Element(
"video_asset",
attrib={
key: unicode(video_dict[key])
for key in ["client_video_id", "duration"]
}
)
for encoding_dict in (encoded_video_dicts or []):
etree.SubElement(
ret,
"encoded_video",
attrib={
key: unicode(val)
for key, val in encoding_dict.items()
}
)
return ret
def assert_obj_matches_dict_for_keys(self, obj, dict_, keys):
for key in keys:
self.assertEqual(getattr(obj, key), dict_[key])
def assert_video_matches_dict(self, video, video_dict):
self.assert_obj_matches_dict_for_keys(
video,
video_dict,
["client_video_id", "duration"]
)
def assert_encoded_video_matches_dict(self, encoded_video, encoded_video_dict):
self.assert_obj_matches_dict_for_keys(
encoded_video,
encoded_video_dict,
["url", "file_size", "bitrate"]
)
def assert_invalid_import(self, xml, course_id=None):
edx_video_id = "test_edx_video_id"
with self.assertRaises(ValCannotCreateError):
api.import_from_xml(xml, edx_video_id, course_id)
self.assertFalse(Video.objects.filter(edx_video_id=edx_video_id).exists())
def test_new_video_full(self):
new_course_id = "new_course_id"
xml = self.make_import_xml(
video_dict=constants.VIDEO_DICT_STAR,
encoded_video_dicts=[constants.ENCODED_VIDEO_DICT_STAR]
)
api.import_from_xml(xml, constants.VIDEO_DICT_STAR["edx_video_id"], new_course_id)
video = Video.objects.get(edx_video_id=constants.VIDEO_DICT_STAR["edx_video_id"])
self.assert_video_matches_dict(video, constants.VIDEO_DICT_STAR)
self.assert_encoded_video_matches_dict(
video.encoded_videos.get(profile__profile_name=constants.PROFILE_MOBILE),
constants.ENCODED_VIDEO_DICT_STAR
)
video.courses.get(course_id=new_course_id)
def test_new_video_minimal(self):
edx_video_id = "test_edx_video_id"
xml = self.make_import_xml(
video_dict={
"client_video_id": "dummy",
"duration": "0",
}
)
api.import_from_xml(xml, edx_video_id)
video = Video.objects.get(edx_video_id=edx_video_id)
self.assertFalse(video.encoded_videos.all().exists())
self.assertFalse(video.courses.all().exists())
def test_existing_video(self):
new_course_id = "new_course_id"
xml = self.make_import_xml(
video_dict={
"client_video_id": "new_client_video_id",
"duration": 0,
},
encoded_video_dicts=[
constants.ENCODED_VIDEO_DICT_FISH_DESKTOP,
{
"url": "http://example.com/new_url",
"file_size": 2733256,
"bitrate": 1597804,
"profile": "mobile",
},
]
)
api.import_from_xml(xml, constants.VIDEO_DICT_FISH["edx_video_id"], new_course_id)
video = Video.objects.get(edx_video_id=constants.VIDEO_DICT_FISH["edx_video_id"])
self.assert_video_matches_dict(video, constants.VIDEO_DICT_FISH)
self.assert_encoded_video_matches_dict(
video.encoded_videos.get(profile__profile_name=constants.PROFILE_MOBILE),
constants.ENCODED_VIDEO_DICT_MOBILE
)
self.assertFalse(
video.encoded_videos.filter(profile__profile_name=constants.PROFILE_DESKTOP).exists()
)
self.assertFalse(video.courses.filter(course_id=new_course_id).exists())
def test_unknown_profile(self):
profile = "unknown_profile"
xml = self.make_import_xml(
video_dict=constants.VIDEO_DICT_STAR,
encoded_video_dicts=[
constants.ENCODED_VIDEO_DICT_STAR,
{
"url": "http://example.com/dummy",
"file_size": -1, # Invalid data in an unknown profile is ignored
"bitrate": 0,
"profile": profile,
}
]
)
api.import_from_xml(xml, constants.VIDEO_DICT_STAR["edx_video_id"])
video = Video.objects.get(edx_video_id=constants.VIDEO_DICT_STAR["edx_video_id"])
self.assertFalse(video.encoded_videos.filter(profile__profile_name=profile).exists())
def test_invalid_tag(self):
xml = etree.Element(
"invalid_tag",
attrib={
"client_video_id": "dummy",
"duration": "0",
}
)
self.assert_invalid_import(xml)
def test_invalid_video_attr(self):
xml = self.make_import_xml(
video_dict={
"client_video_id": "dummy",
"duration": -1,
}
)
self.assert_invalid_import(xml)
def test_invalid_encoded_video_attr(self):
xml = self.make_import_xml(
video_dict=constants.VIDEO_DICT_FISH,
encoded_video_dicts=[{
"url": "http://example.com/dummy",
"file_size": -1,
"bitrate": 0,
"profile": "mobile"
}]
)
self.assert_invalid_import(xml)
def test_invalid_course_id(self):
xml = self.make_import_xml(video_dict=constants.VIDEO_DICT_FISH)
self.assert_invalid_import(xml, "x" * 300)
...@@ -79,6 +79,22 @@ class SerializerTests(TestCase): ...@@ -79,6 +79,22 @@ class SerializerTests(TestCase):
u"edx_video_id has invalid characters" u"edx_video_id has invalid characters"
) )
def test_invalid_course_id(self):
errors = VideoSerializer(
data={
"edx_video_id": "dummy",
"client_video_id": "dummy",
"duration": 0,
"status": "dummy",
"encoded_videos": [],
"courses": ["x" * 300],
}
).errors
self.assertEqual(
errors,
{"courses": ["Ensure this value has at most 255 characters (it has 300)."]}
)
def test_encoded_video_set_output(self): def test_encoded_video_set_output(self):
""" """
Tests for basic structure of EncodedVideoSetSerializer Tests for basic structure of EncodedVideoSetSerializer
......
django>=1.4,<1.5 django>=1.4,<1.5
djangorestframework<2.4 djangorestframework<2.4
enum34==1.0.4 enum34==1.0.4
lxml==3.3.6
South==1.0.1 South==1.0.1
-e git+https://github.com/edx/django-oauth2-provider.git@0.2.7-fork-edx-1#egg=django-oauth2-provider -e git+https://github.com/edx/django-oauth2-provider.git@0.2.7-fork-edx-1#egg=django-oauth2-provider
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment