MA-169 return requested val encoded video profiles
MA-169 Mobile api now calls for mobile_low (default), mobile_high, and youtube profiles instead of only mobile_low.
This commit is contained in:
9
lms/djangoapps/mobile_api/admin.py
Normal file
9
lms/djangoapps/mobile_api/admin.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Django admin dashboard configuration for LMS XBlock infrastructure.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from config_models.admin import ConfigurationModelAdmin
|
||||
from mobile_api.models import MobileApiConfig
|
||||
|
||||
admin.site.register(MobileApiConfig, ConfigurationModelAdmin)
|
||||
78
lms/djangoapps/mobile_api/migrations/0001_initial.py
Normal file
78
lms/djangoapps/mobile_api/migrations/0001_initial.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'MobileApiConfig'
|
||||
db.create_table('mobile_api_mobileapiconfig', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
|
||||
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('video_profiles', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
))
|
||||
db.send_create_signal('mobile_api', ['MobileApiConfig'])
|
||||
|
||||
if not db.dry_run:
|
||||
orm.MobileApiConfig.objects.create(
|
||||
video_profiles="mobile_low,mobile_high,youtube",
|
||||
)
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'MobileApiConfig'
|
||||
db.delete_table('mobile_api_mobileapiconfig')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'mobile_api.mobileapiconfig': {
|
||||
'Meta': {'object_name': 'MobileApiConfig'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'video_profiles': ('django.db.models.fields.TextField', [], {'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['mobile_api']
|
||||
0
lms/djangoapps/mobile_api/migrations/__init__.py
Normal file
0
lms/djangoapps/mobile_api/migrations/__init__.py
Normal file
@@ -1,3 +1,27 @@
|
||||
"""
|
||||
A models.py is required to make this an app (until we move to Django 1.7)
|
||||
ConfigurationModel for the mobile_api djangoapp.
|
||||
"""
|
||||
|
||||
from django.db.models.fields import TextField
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
|
||||
|
||||
class MobileApiConfig(ConfigurationModel):
|
||||
"""
|
||||
Configuration for the video upload feature.
|
||||
|
||||
The order in which the comma-separated list of names of profiles are given
|
||||
is in priority order.
|
||||
"""
|
||||
video_profiles = TextField(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of names of profiles to include for videos returned from the mobile API."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_video_profiles(cls):
|
||||
"""
|
||||
Get the list of profiles in priority order when requesting from VAL
|
||||
"""
|
||||
return [profile.strip() for profile in cls.current().video_profiles.split(",") if profile] # pylint: disable=no-member
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for mobile API utilities.
|
||||
"""
|
||||
|
||||
import ddt
|
||||
from django.test import TestCase
|
||||
from mobile_api.models import MobileApiConfig
|
||||
|
||||
from .utils import mobile_course_access, mobile_view
|
||||
|
||||
@@ -25,3 +27,33 @@ class TestMobileAPIDecorators(TestCase):
|
||||
self.assertIn("Test docstring of decorated function.", decorated_func.__doc__)
|
||||
self.assertEquals(decorated_func.__name__, "decorated_func")
|
||||
self.assertTrue(decorated_func.__module__.endswith("tests"))
|
||||
|
||||
|
||||
class TestMobileApiConfig(TestCase):
|
||||
"""
|
||||
Tests MobileAPIConfig
|
||||
"""
|
||||
|
||||
def test_video_profile_list(self):
|
||||
"""Check that video_profiles config is returned in order as a list"""
|
||||
MobileApiConfig(video_profiles="mobile_low,mobile_high,youtube").save()
|
||||
video_profile_list = MobileApiConfig.get_video_profiles()
|
||||
self.assertEqual(
|
||||
video_profile_list,
|
||||
[u'mobile_low', u'mobile_high', u'youtube']
|
||||
)
|
||||
|
||||
def test_video_profile_list_with_whitespace(self):
|
||||
"""Check video_profiles config with leading and trailing whitespace"""
|
||||
MobileApiConfig(video_profiles=" mobile_low , mobile_high,youtube ").save()
|
||||
video_profile_list = MobileApiConfig.get_video_profiles()
|
||||
self.assertEqual(
|
||||
video_profile_list,
|
||||
[u'mobile_low', u'mobile_high', u'youtube']
|
||||
)
|
||||
|
||||
def test_empty_video_profile(self):
|
||||
"""Test an empty video_profile"""
|
||||
MobileApiConfig(video_profiles="").save()
|
||||
video_profile_list = MobileApiConfig.get_video_profiles()
|
||||
self.assertEqual(video_profile_list, [])
|
||||
|
||||
@@ -10,7 +10,7 @@ from courseware.module_render import get_module_for_descriptor
|
||||
from util.module_utils import get_dynamic_descriptor_children
|
||||
|
||||
from edxval.api import (
|
||||
get_video_info_for_course_and_profile, ValInternalError
|
||||
get_video_info_for_course_and_profiles, ValInternalError
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class BlockOutline(object):
|
||||
"""
|
||||
Serializes course videos, pulling data from VAL and the video modules.
|
||||
"""
|
||||
def __init__(self, course_id, start_block, block_types, request):
|
||||
def __init__(self, course_id, start_block, block_types, request, video_profiles):
|
||||
"""Create a BlockOutline using `start_block` as a starting point."""
|
||||
self.start_block = start_block
|
||||
self.block_types = block_types
|
||||
@@ -26,8 +26,8 @@ class BlockOutline(object):
|
||||
self.request = request # needed for making full URLS
|
||||
self.local_cache = {}
|
||||
try:
|
||||
self.local_cache['course_videos'] = get_video_info_for_course_and_profile(
|
||||
unicode(course_id), "mobile_low"
|
||||
self.local_cache['course_videos'] = get_video_info_for_course_and_profiles(
|
||||
unicode(course_id), video_profiles
|
||||
)
|
||||
except ValInternalError: # pragma: nocover
|
||||
self.local_cache['course_videos'] = {}
|
||||
@@ -159,7 +159,7 @@ def find_urls(course_id, block, child_to_parent, request):
|
||||
return unit_url, section_url
|
||||
|
||||
|
||||
def video_summary(course, course_id, video_descriptor, request, local_cache):
|
||||
def video_summary(video_profiles, course_id, video_descriptor, request, local_cache):
|
||||
"""
|
||||
returns summary dict for the given video module
|
||||
"""
|
||||
@@ -186,15 +186,29 @@ def video_summary(course, course_id, video_descriptor, request, local_cache):
|
||||
val_video_info = local_cache['course_videos'].get(video_descriptor.edx_video_id, {})
|
||||
if val_video_info:
|
||||
video_url = val_video_info['url']
|
||||
# Get encoded videos
|
||||
video_data = local_cache['course_videos'].get(video_descriptor.edx_video_id, {})
|
||||
|
||||
# Get highest priority video to populate backwards compatible field
|
||||
default_encoded_video = {}
|
||||
|
||||
if video_data:
|
||||
for profile in video_profiles:
|
||||
default_encoded_video = video_data['profiles'].get(profile, {})
|
||||
if default_encoded_video:
|
||||
break
|
||||
|
||||
if default_encoded_video:
|
||||
video_url = default_encoded_video['url']
|
||||
# Then fall back to VideoDescriptor fields for video URLs
|
||||
elif video_descriptor.html5_sources:
|
||||
video_url = video_descriptor.html5_sources[0]
|
||||
else:
|
||||
video_url = video_descriptor.source
|
||||
|
||||
# If we have the video information from VAL, we also have duration and size.
|
||||
duration = val_video_info.get('duration', None)
|
||||
size = val_video_info.get('file_size', 0)
|
||||
# Get duration/size, else default
|
||||
duration = video_data.get('duration', None)
|
||||
size = default_encoded_video.get('file_size', 0)
|
||||
|
||||
# Transcripts...
|
||||
transcript_langs = video_descriptor.available_translations(verify_assets=False)
|
||||
@@ -219,6 +233,9 @@ def video_summary(course, course_id, video_descriptor, request, local_cache):
|
||||
"size": size,
|
||||
"transcripts": transcripts,
|
||||
"language": video_descriptor.get_default_transcript_language(),
|
||||
"category": video_descriptor.category,
|
||||
"id": unicode(video_descriptor.scope_ids.usage_id),
|
||||
"encoded_videos": video_data.get('profiles')
|
||||
}
|
||||
ret.update(always_available_data)
|
||||
return ret
|
||||
|
||||
@@ -9,6 +9,7 @@ from uuid import uuid4
|
||||
from collections import namedtuple
|
||||
|
||||
from edxval import api
|
||||
from mobile_api.models import MobileApiConfig
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
from xmodule.video_module import transcripts_utils
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -58,6 +59,8 @@ class TestVideoAPITestCase(MobileAPITestCase):
|
||||
|
||||
self.edx_video_id = 'testing-123'
|
||||
self.video_url = 'http://val.edx.org/val/video.mp4'
|
||||
self.video_url_high = 'http://val.edx.org/val/video_high.mp4'
|
||||
self.youtube_url = 'http://val.edx.org/val/youtube.mp4'
|
||||
self.html5_video_url = 'http://video.edx.org/html5/video.mp4'
|
||||
|
||||
api.create_profile({
|
||||
@@ -66,6 +69,12 @@ class TestVideoAPITestCase(MobileAPITestCase):
|
||||
'width': 1280,
|
||||
'height': 720
|
||||
})
|
||||
api.create_profile({
|
||||
'profile_name': 'mobile_high',
|
||||
'extension': 'mp4',
|
||||
'width': 750,
|
||||
'height': 590
|
||||
})
|
||||
api.create_profile({
|
||||
'profile_name': 'mobile_low',
|
||||
'extension': 'mp4',
|
||||
@@ -92,9 +101,19 @@ class TestVideoAPITestCase(MobileAPITestCase):
|
||||
'url': self.video_url,
|
||||
'file_size': 12345,
|
||||
'bitrate': 250
|
||||
}
|
||||
},
|
||||
{
|
||||
'profile': 'mobile_high',
|
||||
'url': self.video_url_high,
|
||||
'file_size': 99999,
|
||||
'bitrate': 250
|
||||
},
|
||||
|
||||
]})
|
||||
|
||||
# Set requested profiles
|
||||
MobileApiConfig(video_profiles="mobile_low,mobile_high,youtube").save()
|
||||
|
||||
|
||||
class TestVideoAPIMixin(object):
|
||||
"""
|
||||
@@ -410,6 +429,7 @@ class TestVideoSummaryList(
|
||||
"""
|
||||
REVERSE_INFO = {'name': 'video-summary-list', 'params': ['course_id']}
|
||||
|
||||
|
||||
def test_only_on_web(self):
|
||||
self.login_and_enroll()
|
||||
|
||||
@@ -450,6 +470,111 @@ class TestVideoSummaryList(
|
||||
self.assertEqual(course_outline[0]["summary"]["category"], "video")
|
||||
self.assertTrue(course_outline[0]["summary"]["only_on_web"])
|
||||
|
||||
def test_mobile_api_config(self):
|
||||
"""
|
||||
Tests VideoSummaryList with different MobileApiConfig video_profiles
|
||||
"""
|
||||
self.login_and_enroll()
|
||||
edx_video_id = "testing_mobile_high"
|
||||
api.create_video({
|
||||
'edx_video_id': edx_video_id,
|
||||
'status': 'test',
|
||||
'client_video_id': u"test video omega \u03a9",
|
||||
'duration': 12,
|
||||
'courses': [unicode(self.course.id)],
|
||||
'encoded_videos': [
|
||||
{
|
||||
'profile': 'youtube',
|
||||
'url': self.youtube_url,
|
||||
'file_size': 2222,
|
||||
'bitrate': 4444
|
||||
},
|
||||
{
|
||||
'profile': 'mobile_high',
|
||||
'url': self.video_url_high,
|
||||
'file_size': 111,
|
||||
'bitrate': 333
|
||||
},
|
||||
|
||||
]})
|
||||
ItemFactory.create(
|
||||
parent=self.other_unit,
|
||||
category="video",
|
||||
display_name=u"testing mobile high video",
|
||||
edx_video_id=edx_video_id,
|
||||
)
|
||||
|
||||
expected_output = {
|
||||
'category': u'video',
|
||||
'video_thumbnail_url': None,
|
||||
'language': u'en',
|
||||
'name': u'testing mobile high video',
|
||||
'video_url': self.video_url_high,
|
||||
'duration': 12.0,
|
||||
'transcripts': {
|
||||
'en': 'http://testserver/api/mobile/v0.5/video_outlines/transcripts/{}/testing_mobile_high_video/en'.format(self.course.id) # pylint: disable=line-too-long
|
||||
},
|
||||
'encoded_videos': {
|
||||
u'mobile_high': {
|
||||
'url': self.video_url_high,
|
||||
'file_size': 111
|
||||
},
|
||||
u'youtube': {
|
||||
'url': self.youtube_url,
|
||||
'file_size': 2222
|
||||
}
|
||||
},
|
||||
'size': 111
|
||||
}
|
||||
|
||||
# Testing when video_profiles='mobile_low,mobile_high,youtube'
|
||||
course_outline = self.api_response().data
|
||||
course_outline[0]['summary'].pop("id")
|
||||
self.assertEqual(course_outline[0]['summary'], expected_output)
|
||||
|
||||
# Testing when there is no mobile_low, and that mobile_high doesn't show
|
||||
MobileApiConfig(video_profiles="mobile_low,youtube").save()
|
||||
|
||||
course_outline = self.api_response().data
|
||||
|
||||
expected_output['encoded_videos'].pop('mobile_high')
|
||||
expected_output['video_url'] = self.youtube_url
|
||||
expected_output['size'] = 2222
|
||||
|
||||
course_outline[0]['summary'].pop("id")
|
||||
self.assertEqual(course_outline[0]['summary'], expected_output)
|
||||
|
||||
# Testing where youtube is the default video over mobile_high
|
||||
MobileApiConfig(video_profiles="youtube,mobile_high").save()
|
||||
|
||||
course_outline = self.api_response().data
|
||||
|
||||
expected_output['encoded_videos']['mobile_high'] = {
|
||||
'url': self.video_url_high,
|
||||
'file_size': 111
|
||||
}
|
||||
|
||||
course_outline[0]['summary'].pop("id")
|
||||
self.assertEqual(course_outline[0]['summary'], expected_output)
|
||||
|
||||
def test_video_not_in_val(self):
|
||||
self.login_and_enroll()
|
||||
self._create_video_with_subs()
|
||||
ItemFactory.create(
|
||||
parent=self.other_unit,
|
||||
category="video",
|
||||
edx_video_id="some_non_existent_id_in_val",
|
||||
display_name=u"some non existent video in val",
|
||||
html5_sources=[self.html5_video_url]
|
||||
)
|
||||
|
||||
summary = self.api_response().data[1]['summary']
|
||||
self.assertEqual(summary['name'], "some non existent video in val")
|
||||
self.assertIsNone(summary['encoded_videos'])
|
||||
self.assertIsNone(summary['duration'])
|
||||
self.assertEqual(summary['size'], 0)
|
||||
self.assertEqual(summary['video_url'], self.html5_video_url)
|
||||
|
||||
def test_course_list(self):
|
||||
self.login_and_enroll()
|
||||
self._create_video_with_subs()
|
||||
@@ -488,7 +613,6 @@ class TestVideoSummaryList(
|
||||
self.assertFalse(course_outline[1]['summary']['only_on_web'])
|
||||
self.assertEqual(course_outline[1]['path'][2]['name'], self.other_unit.display_name)
|
||||
self.assertEqual(course_outline[1]['path'][2]['id'], unicode(self.other_unit.location))
|
||||
|
||||
self.assertEqual(course_outline[2]['summary']['video_url'], self.html5_video_url)
|
||||
self.assertEqual(course_outline[2]['summary']['size'], 0)
|
||||
self.assertFalse(course_outline[2]['summary']['only_on_web'])
|
||||
|
||||
@@ -9,6 +9,7 @@ general XBlock representation in this rather specialized formatting.
|
||||
from functools import partial
|
||||
|
||||
from django.http import Http404, HttpResponse
|
||||
from mobile_api.models import MobileApiConfig
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework.response import Response
|
||||
@@ -78,12 +79,14 @@ class VideoSummaryList(generics.ListAPIView):
|
||||
|
||||
@mobile_course_access(depth=None)
|
||||
def list(self, request, course, *args, **kwargs):
|
||||
video_profiles = MobileApiConfig.get_video_profiles()
|
||||
video_outline = list(
|
||||
BlockOutline(
|
||||
course.id,
|
||||
course,
|
||||
{"video": partial(video_summary, course)},
|
||||
{"video": partial(video_summary, video_profiles)},
|
||||
request,
|
||||
video_profiles,
|
||||
)
|
||||
)
|
||||
return Response(video_outline)
|
||||
|
||||
@@ -35,7 +35,7 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a
|
||||
-e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease
|
||||
-e git+https://github.com/edx/i18n-tools.git@193cebd9aa784f8899ef496f2aa050b08eff402b#egg=i18n-tools
|
||||
-e git+https://github.com/edx/edx-oauth2-provider.git@0.4.2#egg=oauth2-provider
|
||||
-e git+https://github.com/edx/edx-val.git@fbec6efc86abb36f55de947baacc2092881dcde2#egg=edx-val
|
||||
-e git+https://github.com/edx/edx-val.git@64aa7637e3459fb3000a85a9e156880a40307dd1#egg=edx-val
|
||||
-e git+https://github.com/pmitros/RecommenderXBlock.git@9b07e807c89ba5761827d0387177f71aa57ef056#egg=recommender-xblock
|
||||
-e git+https://github.com/edx/edx-milestones.git@547f2250ee49e73ce8d7ff4e78ecf1b049892510#egg=edx-milestones
|
||||
-e git+https://github.com/edx/edx-search.git@21ac6b06b3bfe789dcaeaf4e2ab5b00a688324d4#egg=edx-search
|
||||
|
||||
Reference in New Issue
Block a user