From 0687a62a8f61fe3b87c0931d895cc6d838de0940 Mon Sep 17 00:00:00 2001
From: Nimisha Asthagiri <nasthagiri@edx.org>
Date: Mon, 1 Dec 2014 15:53:55 -0500
Subject: [PATCH] Add back end for Studio video upload feature

This feature allows upload of video assets to S3. This requires that the
VIDEO_UPLOAD_PIPELINE setting be properly configured and that each
course be configured with a token issued by the media team for their
processing purposes (e.g. linking the video with a YouTube channel).

Co-authored-by: Greg Price <gprice@edx.org>
---
 cms/djangoapps/contentstore/tests/utils.py    |   2 +-
 cms/djangoapps/contentstore/views/__init__.py |   1 +
 cms/djangoapps/contentstore/views/course.py   |  24 +-
 .../contentstore/views/tests/test_videos.py   | 237 ++++++++++++++++++
 cms/djangoapps/contentstore/views/videos.py   | 192 ++++++++++++++
 cms/envs/aws.py                               |   4 +
 cms/envs/common.py                            |  11 +
 cms/urls.py                                   |   1 +
 common/djangoapps/util/json_request.py        |   5 +-
 common/lib/xmodule/xmodule/course_module.py   |  16 +-
 .../courseware/tests/test_video_mongo.py      |   1 +
 .../mobile_api/video_outlines/tests.py        |   1 +
 requirements/edx/github.txt                   |   2 +-
 13 files changed, 481 insertions(+), 16 deletions(-)
 create mode 100644 cms/djangoapps/contentstore/views/tests/test_videos.py
 create mode 100644 cms/djangoapps/contentstore/views/videos.py

diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py
index 9eb26acf7d9..af938715cef 100644
--- a/cms/djangoapps/contentstore/tests/utils.py
+++ b/cms/djangoapps/contentstore/tests/utils.py
@@ -94,7 +94,7 @@ class CourseTestCase(ModuleStoreTestCase):
         """
         nonstaff, password = self.create_non_staff_user()
 
-        client = Client()
+        client = AjaxEnabledTestClient()
         if authenticate:
             client.login(username=nonstaff.username, password=password)
             nonstaff.is_authenticated = True
diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py
index 48ff107f117..26bb619fb3a 100644
--- a/cms/djangoapps/contentstore/views/__init__.py
+++ b/cms/djangoapps/contentstore/views/__init__.py
@@ -17,6 +17,7 @@ from .public import *
 from .export_git import *
 from .user import *
 from .tabs import *
+from .videos import *
 from .transcripts_ajax import *
 try:
     from .dev import *
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 6548607eb19..cbc40b6f31e 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -89,7 +89,7 @@ class AccessListFallback(Exception):
     pass
 
 
-def _get_course_module(course_key, user, depth=0):
+def get_course_and_check_access(course_key, user, depth=0):
     """
     Internal method used to calculate and return the locator and course module
     for the view functions in this file.
@@ -214,7 +214,7 @@ def course_handler(request, course_key_string=None):
             if request.method == 'GET':
                 course_key = CourseKey.from_string(course_key_string)
                 with modulestore().bulk_operations(course_key):
-                    course_module = _get_course_module(course_key, request.user, depth=None)
+                    course_module = get_course_and_check_access(course_key, request.user, depth=None)
                     return JsonResponse(_course_outline_json(request, course_module))
             elif request.method == 'POST':  # not sure if this is only post. If one will have ids, it goes after access
                 return _create_or_rerun_course(request)
@@ -251,7 +251,7 @@ def course_rerun_handler(request, course_key_string):
         raise PermissionDenied()
     course_key = CourseKey.from_string(course_key_string)
     with modulestore().bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user, depth=3)
+        course_module = get_course_and_check_access(course_key, request.user, depth=3)
         if request.method == 'GET':
             return render_to_response('course-create-rerun.html', {
                 'source_course_key': course_key,
@@ -434,7 +434,7 @@ def course_index(request, course_key):
     # A depth of None implies the whole course. The course outline needs this in order to compute has_changes.
     # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes.
     with modulestore().bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user, depth=None)
+        course_module = get_course_and_check_access(course_key, request.user, depth=None)
         lms_link = get_lms_link_for_item(course_module.location)
         sections = course_module.get_children()
         course_structure = _course_outline_json(request, course_module)
@@ -662,7 +662,7 @@ def course_info_handler(request, course_key_string):
     """
     course_key = CourseKey.from_string(course_key_string)
     with modulestore().bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user)
+        course_module = get_course_and_check_access(course_key, request.user)
         if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
             return render_to_response(
                 'course_info.html',
@@ -745,7 +745,7 @@ def settings_handler(request, course_key_string):
     """
     course_key = CourseKey.from_string(course_key_string)
     with modulestore().bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user)
+        course_module = get_course_and_check_access(course_key, request.user)
         if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
             upload_asset_url = reverse_course_url('assets_handler', course_key)
 
@@ -800,7 +800,7 @@ def grading_handler(request, course_key_string, grader_index=None):
     """
     course_key = CourseKey.from_string(course_key_string)
     with modulestore().bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user)
+        course_module = get_course_and_check_access(course_key, request.user)
 
         if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
             course_details = CourseGradingModel.fetch(course_key)
@@ -912,7 +912,7 @@ def advanced_settings_handler(request, course_key_string):
     """
     course_key = CourseKey.from_string(course_key_string)
     with modulestore().bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user)
+        course_module = get_course_and_check_access(course_key, request.user)
         if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
 
             return render_to_response('settings_advanced.html', {
@@ -1026,7 +1026,7 @@ def textbooks_list_handler(request, course_key_string):
     course_key = CourseKey.from_string(course_key_string)
     store = modulestore()
     with store.bulk_operations(course_key):
-        course = _get_course_module(course_key, request.user)
+        course = get_course_and_check_access(course_key, request.user)
 
         if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'):
             # return HTML page
@@ -1102,7 +1102,7 @@ def textbooks_detail_handler(request, course_key_string, textbook_id):
     course_key = CourseKey.from_string(course_key_string)
     store = modulestore()
     with store.bulk_operations(course_key):
-        course_module = _get_course_module(course_key, request.user)
+        course_module = get_course_and_check_access(course_key, request.user)
         matching_id = [tb for tb in course_module.pdf_textbooks
                        if unicode(tb.get("id")) == unicode(textbook_id)]
         if matching_id:
@@ -1333,7 +1333,7 @@ def group_configurations_list_handler(request, course_key_string):
     course_key = CourseKey.from_string(course_key_string)
     store = modulestore()
     with store.bulk_operations(course_key):
-        course = _get_course_module(course_key, request.user)
+        course = get_course_and_check_access(course_key, request.user)
 
         if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
             group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
@@ -1381,7 +1381,7 @@ def group_configurations_detail_handler(request, course_key_string, group_config
     course_key = CourseKey.from_string(course_key_string)
     store = modulestore()
     with store.bulk_operations(course_key):
-        course = _get_course_module(course_key, request.user)
+        course = get_course_and_check_access(course_key, request.user)
         matching_id = [p for p in course.user_partitions
                        if unicode(p.id) == unicode(group_configuration_id)]
         if matching_id:
diff --git a/cms/djangoapps/contentstore/views/tests/test_videos.py b/cms/djangoapps/contentstore/views/tests/test_videos.py
new file mode 100644
index 00000000000..55b94c976e9
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/tests/test_videos.py
@@ -0,0 +1,237 @@
+"""
+Unit tests for video-related REST APIs.
+"""
+# pylint: disable=attribute-defined-outside-init
+import json
+import dateutil.parser
+import re
+
+from django.conf import settings
+from django.test.utils import override_settings
+from mock import Mock, patch
+
+from edxval.api import create_video, get_video_info
+
+from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, VIDEO_ASSET_TYPE
+from contentstore.tests.utils import CourseTestCase
+from contentstore.utils import reverse_course_url
+from xmodule.assetstore import AssetMetadata
+from xmodule.modulestore.django import modulestore
+
+
+@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
+@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
+class VideoUploadTestCase(CourseTestCase):
+    """
+    Test cases for the video upload page
+    """
+    @staticmethod
+    def get_url_for_course_key(course_key):
+        """Return video handler URL for the given course"""
+        return reverse_course_url("videos_handler", course_key)
+
+    def setUp(self):
+        super(VideoUploadTestCase, self).setUp()
+        self.url = VideoUploadTestCase.get_url_for_course_key(self.course.id)
+        self.test_token = "test_token"
+        self.course.video_upload_pipeline = {
+            "course_video_upload_token": self.test_token,
+        }
+        self.save_course()
+        self.previous_uploads = [
+            {
+                "edx_video_id": "test1",
+                "client_video_id": "test1.mp4",
+                "duration": 42.0,
+                "status": "transcode_active",
+                "encoded_videos": [],
+            },
+            {
+                "edx_video_id": "test2",
+                "client_video_id": "test2.mp4",
+                "duration": 128.0,
+                "status": "file_complete",
+                "encoded_videos": [],
+            }
+        ]
+        for video in self.previous_uploads:
+            create_video(video)
+            modulestore().save_asset_metadata(
+                AssetMetadata(
+                    self.course.id.make_asset_key(VIDEO_ASSET_TYPE, video["edx_video_id"])
+                ),
+                self.user.id
+            )
+
+    def test_anon_user(self):
+        self.client.logout()
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 302)
+
+    def test_put(self):
+        response = self.client.put(self.url)
+        self.assertEqual(response.status_code, 405)
+
+    def test_invalid_course_key(self):
+        response = self.client.get(
+            VideoUploadTestCase.get_url_for_course_key("Non/Existent/Course")
+        )
+        self.assertEqual(response.status_code, 404)
+
+    def test_non_staff_user(self):
+        client, __ = self.create_non_staff_authed_user_client()
+        response = client.get(self.url)
+        self.assertEqual(response.status_code, 403)
+
+    def test_video_pipeline_not_enabled(self):
+        settings.FEATURES["ENABLE_VIDEO_UPLOAD_PIPELINE"] = False
+        self.assertEqual(self.client.get(self.url).status_code, 404)
+
+    def test_video_pipeline_not_configured(self):
+        settings.VIDEO_UPLOAD_PIPELINE = None
+        self.assertEqual(self.client.get(self.url).status_code, 404)
+
+    def test_course_not_configured(self):
+        self.course.video_upload_pipeline = {}
+        self.save_course()
+        self.assertEqual(self.client.get(self.url).status_code, 404)
+
+    def test_get_json(self):
+        response = self.client.get_json(self.url)
+        self.assertEqual(response.status_code, 200)
+        response_videos = json.loads(response.content)["videos"]
+        self.assertEqual(len(response_videos), len(self.previous_uploads))
+        for response_video in response_videos:
+            original_video = dict(
+                next(
+                    video for video in self.previous_uploads if video["edx_video_id"] == response_video["edx_video_id"]
+                )
+            )
+            self.assertEqual(
+                set(response_video.keys()),
+                set(["edx_video_id", "client_video_id", "created", "duration", "status"])
+            )
+            dateutil.parser.parse(response_video["created"])
+            for field in ["edx_video_id", "client_video_id", "duration", "status"]:
+                self.assertEqual(response_video[field], original_video[field])
+
+    def test_post_non_json(self):
+        response = self.client.post(self.url, {"files": []})
+        self.assertEqual(response.status_code, 400)
+
+    def test_post_malformed_json(self):
+        response = self.client.post(self.url, "{", content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+    def test_post_invalid_json(self):
+        def assert_bad(content):
+            """Make request with content and assert that response is 400"""
+            response = self.client.post(
+                self.url,
+                json.dumps(content),
+                content_type="application/json"
+            )
+            self.assertEqual(response.status_code, 400)
+
+        # Top level missing files key
+        assert_bad({})
+
+        # Entry missing file_name
+        assert_bad({"files": [{"content_type": "video/mp4"}]})
+
+        # Entry missing content_type
+        assert_bad({"files": [{"file_name": "test.mp4"}]})
+
+    @override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret")
+    @patch("boto.s3.key.Key")
+    @patch("boto.s3.connection.S3Connection")
+    def test_post_success(self, mock_conn, mock_key):
+        files = [
+            {
+                "file_name": "first.mp4",
+                "content_type": "video/mp4",
+            },
+            {
+                "file_name": "second.webm",
+                "content_type": "video/webm",
+            },
+            {
+                "file_name": "third.mov",
+                "content_type": "video/quicktime",
+            },
+            {
+                "file_name": "fourth.mp4",
+                "content_type": "video/mp4",
+            },
+        ]
+
+        bucket = Mock()
+        mock_conn.return_value = Mock(get_bucket=Mock(return_value=bucket))
+        mock_key_instances = [
+            Mock(
+                generate_url=Mock(
+                    return_value="http://example.com/url_{}".format(file_info["file_name"])
+                )
+            )
+            for file_info in files
+        ]
+        # If extra calls are made, return a dummy
+        mock_key.side_effect = mock_key_instances + [Mock()]
+
+        response = self.client.post(
+            self.url,
+            json.dumps({"files": files}),
+            content_type="application/json"
+        )
+        response_obj = json.loads(response.content)
+
+        mock_conn.assert_called_once_with(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
+        self.assertEqual(len(response_obj["files"]), len(files))
+        self.assertEqual(mock_key.call_count, len(files))
+        for i, file_info in enumerate(files):
+            # Ensure Key was set up correctly and extract id
+            key_call_args, __ = mock_key.call_args_list[i]
+            self.assertEqual(key_call_args[0], bucket)
+            path_match = re.match(
+                (
+                    settings.VIDEO_UPLOAD_PIPELINE["ROOT_PATH"] +
+                    "/([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$"
+                ),
+                key_call_args[1]
+            )
+            self.assertIsNotNone(path_match)
+            video_id = path_match.group(1)
+            mock_key_instance = mock_key_instances[i]
+            mock_key_instance.set_metadata.assert_any_call(
+                "course_video_upload_token",
+                self.test_token
+            )
+            mock_key_instance.set_metadata.assert_any_call(
+                "client_video_id",
+                file_info["file_name"]
+            )
+            mock_key_instance.set_metadata.assert_any_call("course_key", unicode(self.course.id))
+            mock_key_instance.generate_url.assert_called_once_with(
+                KEY_EXPIRATION_IN_SECONDS,
+                "PUT",
+                headers={"Content-Type": file_info["content_type"]}
+            )
+
+            # Ensure asset store was updated
+            self.assertIsNotNone(
+                modulestore().find_asset_metadata(
+                    self.course.id.make_asset_key(VIDEO_ASSET_TYPE, video_id)
+                )
+            )
+
+            # Ensure VAL was updated
+            val_info = get_video_info(video_id)
+            self.assertEqual(val_info["status"], "upload")
+            self.assertEqual(val_info["client_video_id"], file_info["file_name"])
+            self.assertEqual(val_info["status"], "upload")
+            self.assertEqual(val_info["duration"], 0)
+
+            # Ensure response is correct
+            response_file = response_obj["files"][i]
+            self.assertEqual(response_file["file_name"], file_info["file_name"])
+            self.assertEqual(response_file["upload_url"], mock_key_instance.generate_url())
diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py
new file mode 100644
index 00000000000..918ab12b5a5
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/videos.py
@@ -0,0 +1,192 @@
+"""
+Views related to the video upload feature
+"""
+from boto import s3
+from uuid import uuid4
+
+from django.conf import settings
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseNotFound
+from django.views.decorators.http import require_http_methods
+
+from edxval.api import create_video, get_videos_for_ids
+from opaque_keys.edx.keys import CourseKey
+
+from util.json_request import expect_json, JsonResponse
+from xmodule.assetstore import AssetMetadata
+from xmodule.modulestore.django import modulestore
+
+from .course import get_course_and_check_access
+
+
+__all__ = ["videos_handler"]
+
+
+# String constant used in asset keys to identify video assets.
+VIDEO_ASSET_TYPE = "video"
+
+# Default expiration, in seconds, of one-time URLs used for uploading videos.
+KEY_EXPIRATION_IN_SECONDS = 86400
+
+
+@expect_json
+@login_required
+@require_http_methods(("GET", "POST"))
+def videos_handler(request, course_key_string):
+    """
+    The restful handler for video uploads.
+
+    GET
+        json: return json representing the videos that have been uploaded and
+            their statuses
+    POST
+        json: create a new video upload; the actual files should not be provided
+            to this endpoint but rather PUT to the respective upload_url values
+            contained in the response
+    """
+    course_key = CourseKey.from_string(course_key_string)
+
+    # For now, assume all studio users that have access to the course can upload videos.
+    # In the future, we plan to add a new org-level role for video uploaders.
+    course = get_course_and_check_access(course_key, request.user)
+
+    if (
+            not settings.FEATURES["ENABLE_VIDEO_UPLOAD_PIPELINE"] or
+            not getattr(settings, "VIDEO_UPLOAD_PIPELINE", None) or
+            not course or
+            not course.video_pipeline_configured
+    ):
+        return HttpResponseNotFound()
+
+    if request.method == 'GET':
+        return videos_index_json(course)
+    else:
+        return videos_post(course, request)
+
+
+def _get_videos(course):
+    """
+    Retrieves the list of videos from VAL corresponding to the videos listed in
+    the asset metadata store and returns the needed subset of fields
+    """
+    edx_videos_ids = [
+        v.asset_id.path
+        for v in modulestore().get_all_asset_metadata(course.id, VIDEO_ASSET_TYPE)
+    ]
+    return list(
+        {
+            attr: video[attr]
+            for attr in ["edx_video_id", "client_video_id", "created", "duration", "status"]
+        }
+        for video in get_videos_for_ids(edx_videos_ids)
+    )
+
+
+def videos_index_json(course):
+    """
+    Returns JSON in the following format:
+    {
+        "videos": [{
+            "edx_video_id": "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa",
+            "client_video_id": "video.mp4",
+            "created": "1970-01-01T00:00:00Z",
+            "duration": 42.5,
+            "status": "upload"
+        }]
+    }
+    """
+    return JsonResponse({"videos": _get_videos(course)}, status=200)
+
+
+def videos_post(course, request):
+    """
+    Input (JSON):
+    {
+        "files": [{
+            "file_name": "video.mp4",
+            "content_type": "video/mp4"
+        }]
+    }
+
+    Returns (JSON):
+    {
+        "files": [{
+            "file_name": "video.mp4",
+            "upload_url": "http://example.com/put_video"
+        }]
+    }
+
+    The returned array corresponds exactly to the input array.
+    """
+    error = None
+    if "files" not in request.json:
+        error = "Request object is not JSON or does not contain 'files'"
+    elif any(
+            "file_name" not in file or "content_type" not in file
+            for file in request.json["files"]
+    ):
+        error = "Request 'files' entry does not contain 'file_name' and 'content_type'"
+
+    if error:
+        return JsonResponse({"error": error}, status=400)
+
+    bucket = storage_service_bucket()
+    course_video_upload_token = course.video_upload_pipeline["course_video_upload_token"]
+    req_files = request.json["files"]
+    resp_files = []
+
+    for req_file in req_files:
+        file_name = req_file["file_name"]
+
+        edx_video_id = unicode(uuid4())
+        key = storage_service_key(bucket, file_name=edx_video_id)
+        for metadata_name, value in [
+            ("course_video_upload_token", course_video_upload_token),
+            ("client_video_id", file_name),
+            ("course_key", unicode(course.id)),
+        ]:
+            key.set_metadata(metadata_name, value)
+        upload_url = key.generate_url(
+            KEY_EXPIRATION_IN_SECONDS,
+            "PUT",
+            headers={"Content-Type": req_file["content_type"]}
+        )
+
+        # persist edx_video_id as uploaded through this course
+        video_meta_data = AssetMetadata(course.id.make_asset_key(VIDEO_ASSET_TYPE, edx_video_id))
+        modulestore().save_asset_metadata(video_meta_data, request.user.id)
+
+        # persist edx_video_id in VAL
+        create_video({
+            "edx_video_id": edx_video_id,
+            "status": "upload",
+            "client_video_id": file_name,
+            "duration": 0,
+            "encoded_videos": [],
+        })
+
+        resp_files.append({"file_name": file_name, "upload_url": upload_url})
+
+    return JsonResponse({"files": resp_files}, status=200)
+
+
+def storage_service_bucket():
+    """
+    Returns an S3 bucket for video uploads.
+    """
+    conn = s3.connection.S3Connection(
+        settings.AWS_ACCESS_KEY_ID,
+        settings.AWS_SECRET_ACCESS_KEY
+    )
+    return conn.get_bucket(settings.VIDEO_UPLOAD_PIPELINE["BUCKET"])
+
+
+def storage_service_key(bucket, file_name):
+    """
+    Returns an S3 key to the given file in the given bucket.
+    """
+    key_name = "{}/{}".format(
+        settings.VIDEO_UPLOAD_PIPELINE.get("ROOT_PATH", ""),
+        file_name
+    )
+    return s3.key.Key(bucket, key_name)
diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index 3530fa72831..1a417e3812e 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -298,3 +298,7 @@ ADVANCED_PROBLEM_TYPES = ENV_TOKENS.get('ADVANCED_PROBLEM_TYPES', ADVANCED_PROBL
 DEPRECATED_ADVANCED_COMPONENT_TYPES = ENV_TOKENS.get(
     'DEPRECATED_ADVANCED_COMPONENT_TYPES', DEPRECATED_ADVANCED_COMPONENT_TYPES
 )
+
+################ VIDEO UPLOAD PIPELINE ###############
+
+VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index d21242c8b5d..a4c2bedc6bc 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -107,6 +107,9 @@ FEATURES = {
 
     # Modulestore to use for new courses
     'DEFAULT_STORE_FOR_NEW_COURSE': None,
+
+    # Turn off Video Upload Pipeline through Studio, by default
+    'ENABLE_VIDEO_UPLOAD_PIPELINE': False,
 }
 ENABLE_JASMINE = False
 
@@ -549,6 +552,14 @@ YOUTUBE = {
     },
 }
 
+############################# VIDEO UPLOAD PIPELINE #############################
+
+VIDEO_UPLOAD_PIPELINE = {
+    'BUCKET': '',
+    'ROOT_PATH': '',
+    'CONCURRENT_UPLOAD_LIMIT': 4,
+}
+
 ############################ APPS #####################################
 
 INSTALLED_APPS = (
diff --git a/cms/urls.py b/cms/urls.py
index 7e06f503390..e6f35eabd40 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -91,6 +91,7 @@ urlpatterns += patterns(
     url(r'^settings/advanced/{}$'.format(settings.COURSE_KEY_PATTERN), 'advanced_settings_handler'),
     url(r'^textbooks/{}$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_list_handler'),
     url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_detail_handler'),
+    url(r'^videos/{}$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'),
     url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
     url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)/?$'.format(settings.COURSE_KEY_PATTERN),
         'group_configurations_detail_handler'),
diff --git a/common/djangoapps/util/json_request.py b/common/djangoapps/util/json_request.py
index 0f1e61d5d3f..30eacb9810a 100644
--- a/common/djangoapps/util/json_request.py
+++ b/common/djangoapps/util/json_request.py
@@ -17,7 +17,10 @@ def expect_json(view_function):
         # cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information
         # e.g. 'charset', so we can't do a direct string compare
         if "application/json" in request.META.get('CONTENT_TYPE', '') and request.body:
-            request.json = json.loads(request.body)
+            try:
+                request.json = json.loads(request.body)
+            except ValueError:
+                return JsonResponseBadRequest({"error": "Invalid JSON"})
         else:
             request.json = {}
 
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index c58535c0e4d..b36bc01668b 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -289,7 +289,11 @@ class CourseFields(object):
         default=False,
         scope=Scope.settings
     )
-
+    video_upload_pipeline = Dict(
+        display_name=_("Video Upload Credentials"),
+        help=_("Enter the unique identifier for your course's video files provided by edX."),
+        scope=Scope.settings
+    )
     no_grade = Boolean(
         display_name=_("Course Not Graded"),
         help=_("Enter true or false. If true, the course will not be graded."),
@@ -1152,3 +1156,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
             return self.display_organization
 
         return self.org
+
+    @property
+    def video_pipeline_configured(self):
+        """
+        Returns whether the video pipeline advanced setting is configured for this course.
+        """
+        return (
+            self.video_upload_pipeline is not None and
+            'course_video_upload_token' in self.video_upload_pipeline
+        )
diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py
index b4d65b24352..5bec95047a9 100644
--- a/lms/djangoapps/courseware/tests/test_video_mongo.py
+++ b/lms/djangoapps/courseware/tests/test_video_mongo.py
@@ -538,6 +538,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
                 client_video_id="Thunder Cats",
                 duration=111,
                 edx_video_id="thundercats",
+                status='test',
                 encoded_videos=encoded_videos
             )
         )
diff --git a/lms/djangoapps/mobile_api/video_outlines/tests.py b/lms/djangoapps/mobile_api/video_outlines/tests.py
index 511e907c4ce..0a4d08d4f59 100644
--- a/lms/djangoapps/mobile_api/video_outlines/tests.py
+++ b/lms/djangoapps/mobile_api/video_outlines/tests.py
@@ -86,6 +86,7 @@ class TestVideoOutline(ModuleStoreTestCase, APITestCase):
         # create the video in VAL
         api.create_video({
             'edx_video_id': self.edx_video_id,
+            'status': 'test',
             'client_video_id': u"test video omega \u03a9",
             'duration': 12,
             'courses': [unicode(self.course.id)],
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 830db725d77..05904a736e5 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -35,4 +35,4 @@ 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@56f048af9b6868613c14aeae760548834c495011#egg=i18n-tools
 -e git+https://github.com/edx/edx-oauth2-provider.git@0.4.0#egg=oauth2-provider
--e git+https://github.com/edx/edx-val.git@a3c54afe30375f7a5755ba6f6412a91de23c3b86#egg=edx-val
+-e git+https://github.com/edx/edx-val.git@8778a6399aacf4b460015350a811626926eedf75#egg=edx-val
-- 
GitLab