Commit 025ad8df by Dave St.Germain

Merge pull request #11 from edx/dcs/subtitle-support

Added subtitle model and API
parents df4728fc 7ab8ff9b
......@@ -3,9 +3,9 @@ Admin file for django app edxval.
"""
from django.contrib import admin
from .models import Video, Profile, EncodedVideo
from .models import Video, Profile, EncodedVideo, Subtitle
admin.site.register(Video)
admin.site.register(Profile)
admin.site.register(EncodedVideo)
admin.site.register(Subtitle)
......@@ -139,6 +139,11 @@ def get_video_info(edx_video_id, location=None): # pylint: disable=W0613
extension: 3 letter extension of video
width: horizontal pixel resolution
height: vertical pixel resolution
subtitles: a list of Subtitle dicts
fmt: file format (SRT or SJSON)
language: language code
content_url: url of file
url: api url to subtitle
}
Raises:
......@@ -174,4 +179,4 @@ def get_video_info(edx_video_id, location=None): # pylint: disable=W0613
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
return result.data # pylint: disable=E1101
......@@ -29,6 +29,7 @@ invalid profile_name will be returned.
from django.db import models
from django.core.validators import MinValueValidator, RegexValidator
from django.core.urlresolvers import reverse
url_regex = r'^[a-zA-Z0-9\-]*$'
......@@ -78,6 +79,9 @@ class Video(models.Model):
client_video_id = models.CharField(max_length=255, db_index=True)
duration = models.FloatField(validators=[MinValueValidator(0)])
def __str__(self):
return self.edx_video_id
class CourseVideos(models.Model):
"""
......@@ -89,7 +93,7 @@ class CourseVideos(models.Model):
course_id = models.CharField(max_length=255)
video = models.ForeignKey(Video)
class Meta:
class Meta: # pylint: disable=C1001
"""
course_id is listed first in this composite index
"""
......@@ -108,3 +112,34 @@ class EncodedVideo(models.Model):
profile = models.ForeignKey(Profile, related_name="+")
video = models.ForeignKey(Video, related_name="encoded_videos")
SUBTITLE_FORMATS = (
('srt', 'SubRip'),
('sjson', 'SRT JSON')
)
class Subtitle(models.Model):
"""
Subtitle for video
"""
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
video = models.ForeignKey(Video, related_name="subtitles")
fmt = models.CharField(max_length=20, db_index=True, choices=SUBTITLE_FORMATS)
language = models.CharField(max_length=8, db_index=True)
content = models.TextField(default='')
def __str__(self):
return '%s Subtitle for %s' % (self.language, self.video)
def get_absolute_url(self):
return reverse('subtitle-content', args=[self.video.edx_video_id, self.language])
@property
def content_type(self):
if self.fmt == 'sjson':
return 'application/json'
else:
return 'text/plain'
......@@ -7,7 +7,7 @@ EncodedVideoSerializer which uses the profile_name as it's profile field.
from rest_framework import serializers
from django.core.exceptions import ValidationError
from edxval.models import Profile, Video, EncodedVideo
from edxval.models import Profile, Video, EncodedVideo, Subtitle
class ProfileSerializer(serializers.ModelSerializer):
......@@ -51,6 +51,39 @@ class EncodedVideoSerializer(serializers.ModelSerializer):
return data.get('profile', None)
class SubtitleSerializer(serializers.ModelSerializer):
"""
Serializer for Subtitle objects
"""
content_url = serializers.CharField(source='get_absolute_url', read_only=True)
content = serializers.CharField(write_only=True)
def validate_content(self, attrs, source):
"""
Validate that the subtitle is in the correct format
"""
value = attrs[source]
if attrs.get('fmt') == 'sjson':
import json
try:
loaded = json.loads(value)
except ValueError:
raise serializers.ValidationError("Not in JSON format")
else:
attrs[source] = json.dumps(loaded)
return attrs
class Meta: # pylint: disable=C1001, C0111
model = Subtitle
lookup_field = "id"
fields = (
"fmt",
"language",
"content_url",
"content",
)
class VideoSerializer(serializers.HyperlinkedModelSerializer):
"""
Serializer for Video object
......@@ -58,6 +91,7 @@ class VideoSerializer(serializers.HyperlinkedModelSerializer):
encoded_videos takes a list of dicts EncodedVideo data.
"""
encoded_videos = EncodedVideoSerializer(many=True, allow_add_remove=True)
subtitles = SubtitleSerializer(many=True, allow_add_remove=True, required=False)
class Meta: # pylint: disable=C0111
model = Video
......
......@@ -39,7 +39,8 @@ VIDEO_DICT_NEGATIVE_DURATION = dict(
client_video_id="Thunder Cats S01E01",
duration=-111,
edx_video_id="thisis12char-thisis7",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
VIDEO_DICT_BEE_INVALID = dict(
client_video_id="Barking Bee",
......@@ -50,7 +51,8 @@ VIDEO_DICT_INVALID_ID = dict(
client_video_id="SuperSloth",
duration=42,
edx_video_id="sloppy/sloth!!",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
ENCODED_VIDEO_DICT_NEGATIVE_FILESIZE = dict(
url="http://www.meowmix.com",
......@@ -69,13 +71,15 @@ VIDEO_DICT_NON_LATIN_TITLE = dict(
client_video_id=u"배고픈 햄스터",
duration=42,
edx_video_id="ID",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
VIDEO_DICT_NON_LATIN_ID = dict(
client_video_id="Hungry Hamster",
duration=42,
edx_video_id="밥줘",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
PROFILE_DICT_NON_LATIN = dict(
profile_name=u"배고파",
......@@ -112,6 +116,19 @@ PROFILE_DICT_MANY_INVALID = dict(
height="lol",
)
"""
Subtitles
"""
SUBTITLE_DICT_SRT = dict(
fmt="srt",
language="en",
content="0:0:0\nhello"
)
SUBTITLE_DICT_SJSON = dict(
fmt="sjson",
language="fr",
content='{"start": "00:00:00"}'
)
"""
Fish
"""
VIDEO_DICT_FISH = dict(
......@@ -159,6 +176,7 @@ COMPLETE_SET_FISH = dict(
ENCODED_VIDEO_DICT_FISH_MOBILE,
ENCODED_VIDEO_DICT_FISH_DESKTOP
],
subtitles=[SUBTITLE_DICT_SRT, SUBTITLE_DICT_SJSON],
**VIDEO_DICT_FISH
)
COMPLETE_SET_TWO_MOBILE_FISH = dict(
......@@ -166,6 +184,7 @@ COMPLETE_SET_TWO_MOBILE_FISH = dict(
ENCODED_VIDEO_DICT_FISH_MOBILE,
ENCODED_VIDEO_DICT_FISH_MOBILE
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_FISH
)
COMPLETE_SET_UPDATE_FISH = dict(
......@@ -173,6 +192,7 @@ COMPLETE_SET_UPDATE_FISH = dict(
ENCODED_VIDEO_DICT_UPDATE_FISH_MOBILE,
ENCODED_VIDEO_DICT_UPDATE_FISH_DESKTOP
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_FISH
)
COMPLETE_SET_DIFFERENT_ID_UPDATE_FISH = dict(
......@@ -180,6 +200,7 @@ COMPLETE_SET_DIFFERENT_ID_UPDATE_FISH = dict(
ENCODED_VIDEO_DICT_UPDATE_FISH_MOBILE,
ENCODED_VIDEO_DICT_UPDATE_FISH_DESKTOP
],
subtitles=[SUBTITLE_DICT_SRT, SUBTITLE_DICT_SJSON],
**VIDEO_DICT_DIFFERENT_ID_FISH
)
COMPLETE_SET_FIRST_HALF_UPDATE_FISH = dict(
......@@ -187,12 +208,14 @@ COMPLETE_SET_FIRST_HALF_UPDATE_FISH = dict(
ENCODED_VIDEO_DICT_UPDATE_FISH_MOBILE,
ENCODED_VIDEO_DICT_FISH_DESKTOP
],
subtitles=[SUBTITLE_DICT_SRT, SUBTITLE_DICT_SJSON],
**VIDEO_DICT_FISH
)
COMPLETE_SET_UPDATE_ONLY_DESKTOP_FISH = dict(
encoded_videos=[
ENCODED_VIDEO_DICT_UPDATE_FISH_DESKTOP
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_FISH
)
COMPLETE_SET_INVALID_ENCODED_VIDEO_FISH = dict(
......@@ -200,6 +223,7 @@ COMPLETE_SET_INVALID_ENCODED_VIDEO_FISH = dict(
ENCODED_VIDEO_DICT_FISH_MOBILE,
ENCODED_VIDEO_DICT_FISH_INVALID_PROFILE
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_FISH
)
COMPLETE_SET_INVALID_VIDEO_FISH = dict(
......@@ -209,7 +233,8 @@ COMPLETE_SET_INVALID_VIDEO_FISH = dict(
encoded_videos=[
ENCODED_VIDEO_DICT_FISH_MOBILE,
ENCODED_VIDEO_DICT_FISH_DESKTOP
]
],
subtitles=[SUBTITLE_DICT_SRT]
)
COMPLETE_SETS_ALL_INVALID = [
......@@ -240,12 +265,14 @@ COMPLETE_SET_STAR = dict(
encoded_videos=[
ENCODED_VIDEO_DICT_STAR
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_STAR
)
COMPLETE_SET_UPDATE_STAR = dict(
encoded_videos=[
ENCODED_VIDEO_UPDATE_DICT_STAR
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_STAR
)
COMPLETE_SET_NOT_A_LIST = dict(
......@@ -255,6 +282,7 @@ COMPLETE_SET_NOT_A_LIST = dict(
bitrate=42,
profile=1
),
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_STAR
)
COMPLETE_SET_EXTRA_VIDEO_FIELD = dict(
......@@ -267,6 +295,7 @@ COMPLETE_SET_EXTRA_VIDEO_FIELD = dict(
video="This should be overridden by parent video field"
)
],
subtitles=[SUBTITLE_DICT_SRT],
**VIDEO_DICT_STAR
)
"""
......@@ -276,17 +305,20 @@ VIDEO_DICT_ZEBRA = dict(
client_video_id="Zesty Zebra",
duration=111.00,
edx_video_id="zestttt",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
VIDEO_DICT_ANIMAL = dict(
client_video_id="Average Animal",
duration=111.00,
edx_video_id="mediocrity",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
VIDEO_DICT_UPDATE_ANIMAL = dict(
client_video_id="Above Average Animal",
duration=999.00,
edx_video_id="mediocrity",
encoded_videos=[]
encoded_videos=[],
subtitles=[]
)
......@@ -225,7 +225,7 @@ class GetVideoInfoTestWithHttpCalls(APITestCase):
"""
Tests number of queries for a Video/EncodedVideo(1) pair
"""
with self.assertNumQueries(4):
with self.assertNumQueries(7):
api.get_video_info(constants.COMPLETE_SET_FISH.get("edx_video_id"))
def test_get_info_queries_for_one_encoded_video(self):
......@@ -237,7 +237,7 @@ class GetVideoInfoTestWithHttpCalls(APITestCase):
url, constants.COMPLETE_SET_STAR, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
with self.assertNumQueries(3):
with self.assertNumQueries(5):
api.get_video_info(constants.COMPLETE_SET_STAR.get("edx_video_id"))
def test_get_info_queries_for_only_video(self):
......@@ -249,6 +249,6 @@ class GetVideoInfoTestWithHttpCalls(APITestCase):
url, constants.VIDEO_DICT_ZEBRA, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
with self.assertNumQueries(2):
with self.assertNumQueries(3):
api.get_video_info(constants.VIDEO_DICT_ZEBRA.get("edx_video_id"))
......@@ -34,11 +34,11 @@ class SerializerTests(TestCase):
Tests negative inputs for bitrate, file_size in EncodedVideo
"""
errors = EncodedVideoSerializer( # pylint: disable=E1101
errors = EncodedVideoSerializer( # pylint: disable=E1101
data=constants.ENCODED_VIDEO_DICT_NEGATIVE_BITRATE).errors
self.assertEqual(errors.get('bitrate')[0],
u"Ensure this value is greater than or equal to 0.")
errors = EncodedVideoSerializer( # pylint: disable=E1101
errors = EncodedVideoSerializer( # pylint: disable=E1101
data=constants.ENCODED_VIDEO_DICT_NEGATIVE_FILESIZE).errors
self.assertEqual(errors.get('file_size')[0],
u"Ensure this value is greater than or equal to 0.")
......@@ -49,7 +49,7 @@ class SerializerTests(TestCase):
Tests negative inputs for duration in model Video
"""
errors = VideoSerializer( # pylint: disable=E1101
errors = VideoSerializer( # pylint: disable=E1101
data=constants.VIDEO_DICT_NEGATIVE_DURATION).errors
self.assertEqual(errors.get('duration')[0],
u"Ensure this value is greater than or equal to 0.")
......@@ -58,7 +58,7 @@ class SerializerTests(TestCase):
"""
Tests if the serializers can accept non-latin chars
"""
#TODO not the best test. Need to understand what result we want
# TODO not the best test. Need to understand what result we want
self.assertIsInstance(
ProfileSerializer(Profile.objects.get(profile_name="배고파")),
ProfileSerializer
......@@ -68,7 +68,7 @@ class SerializerTests(TestCase):
"""
Test the Video model regex validation for edx_video_id field
"""
error = VideoSerializer(data=constants.VIDEO_DICT_INVALID_ID).errors # pylint: disable=E1101
error = VideoSerializer(data=constants.VIDEO_DICT_INVALID_ID).errors # pylint: disable=E1101
message = error.get("edx_video_id")[0]
self.assertEqual(
message,
......@@ -89,7 +89,7 @@ class SerializerTests(TestCase):
profile=Profile.objects.get(profile_name="mobile"),
**constants.ENCODED_VIDEO_DICT_MOBILE
)
result = VideoSerializer(video).data # pylint: disable=E1101
result = VideoSerializer(video).data # pylint: disable=E1101
# Check for 2 EncodedVideo entries
self.assertEqual(len(result.get("encoded_videos")), 2)
# Check for original Video data
......
# pylint: disable=E1103, W0106
# pylint: disable=E1103, W0106
"""
Tests for Video Abstraction Layer views
"""
......@@ -22,7 +22,7 @@ class VideoDetail(APITestCase):
Profile.objects.create(**constants.PROFILE_DICT_MOBILE)
Profile.objects.create(**constants.PROFILE_DICT_DESKTOP)
#Tests for successful PUT requests.
# Tests for successful PUT requests.
def test_update_video(self):
"""
......@@ -91,7 +91,7 @@ class VideoDetail(APITestCase):
'video-detail',
kwargs={"edx_video_id": constants.COMPLETE_SET_FISH.get("edx_video_id")}
)
response = self.client.patch( # pylint: disable=E1101
response = self.client.patch( # pylint: disable=E1101
path=url,
data=constants.COMPLETE_SET_UPDATE_FISH,
format='json'
......@@ -140,6 +140,7 @@ class VideoDetail(APITestCase):
Tests PUTting one of two EncodedVideo(s) and then a single EncodedVideo PUT back.
"""
url = reverse('video-list')
response = self.client.post(url, constants.COMPLETE_SET_FISH, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
url = reverse(
......@@ -216,7 +217,7 @@ class VideoDetail(APITestCase):
constants.ENCODED_VIDEO_DICT_UPDATE_FISH_DESKTOP.get("url")
)
#Tests for bad PUT requests.
# Tests for bad PUT requests.
def test_update_an_invalid_encoded_videos(self):
"""
......@@ -304,7 +305,7 @@ class VideoListTest(APITestCase):
def test_complete_set_two_encoded_video_post(self):
"""
Tests POSTing Video and EncodedVideo pair
""" #pylint: disable=R0801
""" # pylint: disable=R0801
url = reverse('video-list')
response = self.client.post(
url, constants.COMPLETE_SET_FISH, format='json'
......@@ -434,7 +435,7 @@ class VideoListTest(APITestCase):
Tests number of queries for a Video with no Encoded Videos
"""
url = reverse('video-list')
with self.assertNumQueries(3):
with self.assertNumQueries(4):
self.client.post(url, constants.VIDEO_DICT_ZEBRA, format='json')
def test_queries_for_two_encoded_video(self):
......@@ -442,7 +443,7 @@ class VideoListTest(APITestCase):
Tests number of queries for a Video/EncodedVideo(2) pair
"""
url = reverse('video-list')
with self.assertNumQueries(11):
with self.assertNumQueries(16):
self.client.post(url, constants.COMPLETE_SET_FISH, format='json')
def test_queries_for_single_encoded_videos(self):
......@@ -450,7 +451,7 @@ class VideoListTest(APITestCase):
Tests number of queries for a Video/EncodedVideo(1) pair
"""
url = reverse('video-list')
with self.assertNumQueries(7):
with self.assertNumQueries(10):
self.client.post(url, constants.COMPLETE_SET_STAR, format='json')
......@@ -486,13 +487,94 @@ class VideoDetailTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self.client.post(url, constants.VIDEO_DICT_ZEBRA, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
with self.assertNumQueries(2):
with self.assertNumQueries(4):
self.client.get("/edxval/video/").data
response = self.client.post(url, constants.COMPLETE_SET_FISH, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
with self.assertNumQueries(4):
with self.assertNumQueries(9):
self.client.get("/edxval/video/").data
response = self.client.post(url, constants.COMPLETE_SET_STAR, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
with self.assertNumQueries(5):
with self.assertNumQueries(12):
self.client.get("/edxval/video/").data
class SubtitleDetailTest(APITestCase):
"""
Tests for subtitle API
"""
def setUp(self):
Profile.objects.create(**constants.PROFILE_DICT_MOBILE)
Profile.objects.create(**constants.PROFILE_DICT_DESKTOP)
def test_get_subtitle_content(self):
"""
Get subtitle content
"""
url = reverse('video-list')
response = self.client.post(
url, constants.COMPLETE_SET_FISH, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
video = self.client.get("/edxval/video/").data
self.assertEqual(len(video), 1)
self.assertEqual(len(video[0].get("subtitles")), 2)
st = video[0]['subtitles'][0]
response = self.client.get(st['content_url'])
self.assertEqual(response.content, constants.SUBTITLE_DICT_SRT['content'])
self.assertEqual(response['Content-Type'], 'text/plain')
st = video[0]['subtitles'][1]
response = self.client.get(st['content_url'])
self.assertEqual(response.content, constants.SUBTITLE_DICT_SJSON['content'])
self.assertEqual(response['Content-Type'], 'application/json')
def test_update_subtitle(self):
"""
Update an SRT subtitle
"""
url = reverse('video-list')
response = self.client.post(
url, constants.COMPLETE_SET_FISH, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
video = response.data
st = video['subtitles'][0]
url = reverse('subtitle-detail', kwargs={'video__edx_video_id': video['edx_video_id'], 'language': st['language']})
st['content'] = 'testing 123'
response = self.client.put(
url, st, format='json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.client.get(st['content_url']).content, 'testing 123')
def test_update_json_subtitle(self):
"""
Update a JSON subtitle
"""
url = reverse('video-list')
response = self.client.post(
url, constants.COMPLETE_SET_FISH, format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
video = response.data
st = video['subtitles'][1]
url = reverse('subtitle-detail', kwargs={'video__edx_video_id': video['edx_video_id'], 'language': st['language']})
st['content'] = 'testing 123'
response = self.client.put(
url, st, format='json'
)
# not in json format
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
st['content'] = """{"start": "00:00:00"
}"""
response = self.client.put(
url, st, format='json'
)
self.assertEqual(self.client.get(st['content_url']).content, '{"start": "00:00:00"}')
......@@ -14,8 +14,18 @@ urlpatterns = patterns(
name="video-list"
),
url(
r'^edxval/video/(?P<edx_video_id>[-\w]+)',
r'^edxval/video/(?P<edx_video_id>[-\w]+)$',
views.VideoDetail.as_view(),
name="video-detail"
),
url(
r'^edxval/video/(?P<video__edx_video_id>[-\w]+)/(?P<language>[-_\w]+)$',
views.SubtitleDetail.as_view(),
name="subtitle-detail"
),
url(
r'^edxval/video/(?P<edx_video_id>[-\w]+)/(?P<language>[-_\w]+)/subtitle$',
views.get_subtitle,
name="subtitle-content"
),
)
......@@ -3,13 +3,30 @@ Views file for django app edxval.
"""
from rest_framework import generics
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import last_modified
from edxval.models import Video, Profile
from edxval.models import Video, Profile, Subtitle
from edxval.serializers import (
VideoSerializer,
ProfileSerializer
ProfileSerializer,
SubtitleSerializer
)
class MultipleFieldLookupMixin(object):
"""
Apply this mixin to any view or viewset to get multiple field filtering
based on a `lookup_fields` attribute, instead of the default single field filtering.
"""
def get_object(self):
queryset = self.get_queryset() # Get the base queryset
queryset = self.filter_queryset(queryset) # Apply any filter backends
filter = {}
for field in self.lookup_fields:
filter[field] = self.kwargs[field]
return get_object_or_404(queryset, **filter) # Lookup the object
class VideoList(generics.ListCreateAPIView):
"""
......@@ -36,3 +53,25 @@ class VideoDetail(generics.RetrieveUpdateDestroyAPIView):
lookup_field = "edx_video_id"
queryset = Video.objects.all()
serializer_class = VideoSerializer
class SubtitleDetail(MultipleFieldLookupMixin, generics.RetrieveUpdateDestroyAPIView):
"""
Gets a subtitle instance given its id
"""
lookup_fields = ("video__edx_video_id", "language")
queryset = Subtitle.objects.all()
serializer_class = SubtitleSerializer
def _last_modified_subtitle(request, edx_video_id, language):
return Subtitle.objects.get(video__edx_video_id=edx_video_id, language=language).modified
@last_modified(last_modified_func=_last_modified_subtitle)
def get_subtitle(request, edx_video_id, language):
"""
Return content of subtitle by id
"""
sub = Subtitle.objects.get(video__edx_video_id=edx_video_id, language=language)
response = HttpResponse(sub.content, content_type=sub.content_type)
return response
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