diff --git a/lms/djangoapps/mobile_api/admin.py b/lms/djangoapps/mobile_api/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..4a5305ad1ecd51bfc85850bd759fde63af4e9d90 --- /dev/null +++ b/lms/djangoapps/mobile_api/admin.py @@ -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) diff --git a/lms/djangoapps/mobile_api/migrations/0001_initial.py b/lms/djangoapps/mobile_api/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..82a0ecc27e5ce8cd2f637684808309c30e6acefb --- /dev/null +++ b/lms/djangoapps/mobile_api/migrations/0001_initial.py @@ -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'] diff --git a/lms/djangoapps/mobile_api/migrations/__init__.py b/lms/djangoapps/mobile_api/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/mobile_api/models.py b/lms/djangoapps/mobile_api/models.py index d2e8572729c933a2efe3c2c2dde798015add4e63..d3bfe7662fb5642d3a5aa6c7d0bf1ef2f962c216 100644 --- a/lms/djangoapps/mobile_api/models.py +++ b/lms/djangoapps/mobile_api/models.py @@ -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 diff --git a/lms/djangoapps/mobile_api/tests.py b/lms/djangoapps/mobile_api/tests.py index cc66d222e2d8ff24df705a817c5c6acd58c2bf29..88c9368ba298635d57fafc6a3a12a17063bd3dd9 100644 --- a/lms/djangoapps/mobile_api/tests.py +++ b/lms/djangoapps/mobile_api/tests.py @@ -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, []) diff --git a/lms/djangoapps/mobile_api/video_outlines/serializers.py b/lms/djangoapps/mobile_api/video_outlines/serializers.py index 391384478432fdd79f55d464d8c0d54e72a70ee4..346ff70d93e1f1fa9b67662f9bc8976d6a0f8844 100644 --- a/lms/djangoapps/mobile_api/video_outlines/serializers.py +++ b/lms/djangoapps/mobile_api/video_outlines/serializers.py @@ -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 diff --git a/lms/djangoapps/mobile_api/video_outlines/tests.py b/lms/djangoapps/mobile_api/video_outlines/tests.py index d580236b9d2995f56abb8387c439a401c1e04de7..da6ff75c8ce42fd63105c46040a172f3f0e0e41e 100644 --- a/lms/djangoapps/mobile_api/video_outlines/tests.py +++ b/lms/djangoapps/mobile_api/video_outlines/tests.py @@ -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']) diff --git a/lms/djangoapps/mobile_api/video_outlines/views.py b/lms/djangoapps/mobile_api/video_outlines/views.py index 0288f42d4a9b4191ab8de163a7deba5def043ce0..ae1d7361c6a363e973c5e39848fb60624b990cb9 100644 --- a/lms/djangoapps/mobile_api/video_outlines/views.py +++ b/lms/djangoapps/mobile_api/video_outlines/views.py @@ -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) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index c9a5c1daad994a1416c0ec1479393092861d6d51..d7c557ff00182e0e6ecd3b6d1307de7bdd832358 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -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