Merge pull request #6257 from edx/mobile/video-pipeline-squashed
Add video upload feature to Studio
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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:
|
||||
|
||||
245
cms/djangoapps/contentstore/views/tests/test_videos.py
Normal file
245
cms/djangoapps/contentstore/views/tests/test_videos.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
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_get_html(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertRegexpMatches(response["Content-Type"], "^text/html(;.*)?$")
|
||||
# Crude check for presence of data in returned HTML
|
||||
for video in self.previous_uploads:
|
||||
self.assertIn(video["edx_video_id"], response.content)
|
||||
|
||||
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())
|
||||
214
cms/djangoapps/contentstore/views/videos.py
Normal file
214
cms/djangoapps/contentstore/views/videos.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
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 contentstore.utils import reverse_course_url
|
||||
from edxmako.shortcuts import render_to_response
|
||||
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
|
||||
html: return an HTML page to display previous video uploads and allow
|
||||
new ones
|
||||
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":
|
||||
if "application/json" in request.META.get("HTTP_ACCEPT", ""):
|
||||
return videos_index_json(course)
|
||||
else:
|
||||
return videos_index_html(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_html(course):
|
||||
"""
|
||||
Returns an HTML page to display previous video uploads and allow new ones
|
||||
"""
|
||||
return render_to_response(
|
||||
"videos_index.html",
|
||||
{
|
||||
"context_course": course,
|
||||
"post_url": reverse_course_url("videos_handler", unicode(course.id)),
|
||||
"previous_uploads": _get_videos(course),
|
||||
"concurrent_upload_limit": settings.VIDEO_UPLOAD_PIPELINE.get("CONCURRENT_UPLOAD_LIMIT", 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
'js/factories/settings_advanced',
|
||||
'js/factories/settings_graders',
|
||||
'js/factories/textbooks',
|
||||
'js/factories/videos_index',
|
||||
'js/factories/xblock_validation'
|
||||
]),
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,7 @@ requirejs.config({
|
||||
"draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd",
|
||||
"domReady": "xmodule_js/common_static/js/vendor/domReady",
|
||||
"URI": "xmodule_js/common_static/js/vendor/URI.min",
|
||||
"mock-ajax": "xmodule_js/common_static/js/vendor/mock-ajax",
|
||||
|
||||
"mathjax": "//cdn.mathjax.org/mathjax/2.2-latest/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured",
|
||||
"youtube": "//www.youtube.com/player_api?noext",
|
||||
@@ -190,6 +191,9 @@ requirejs.config({
|
||||
exports: "XBlock",
|
||||
deps: ["xblock/core"]
|
||||
},
|
||||
"mock-ajax": {
|
||||
deps: ["jasmine", "jquery"]
|
||||
}
|
||||
|
||||
"coffee/src/main": {
|
||||
deps: ["coffee/src/ajax_prefix"]
|
||||
@@ -228,6 +232,9 @@ define([
|
||||
"js/spec/utils/handle_iframe_binding_spec",
|
||||
"js/spec/utils/module_spec",
|
||||
|
||||
"js/spec/views/active_video_upload_list_spec",
|
||||
"js/spec/views/previous_video_upload_spec",
|
||||
"js/spec/views/previous_video_upload_list_spec",
|
||||
"js/spec/views/paging_spec",
|
||||
"js/spec/views/assets_spec",
|
||||
"js/spec/views/baseview_spec",
|
||||
|
||||
@@ -9,7 +9,7 @@ define([
|
||||
assets.url = config.assetCallbackUrl;
|
||||
assetsView = new AssetsView({
|
||||
collection: assets,
|
||||
el: $('.assets-wrapper'),
|
||||
el: $('.wrapper-assets'),
|
||||
uploadChunkSizeInMBs: config.uploadChunkSizeInMBs,
|
||||
maxFileSizeInMBs: config.maxFileSizeInMBs,
|
||||
maxFileSizeRedirectUrl: config.maxFileSizeRedirectUrl
|
||||
|
||||
19
cms/static/js/factories/videos_index.js
Normal file
19
cms/static/js/factories/videos_index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
define(
|
||||
["jquery", "backbone", "js/views/active_video_upload_list", "js/views/previous_video_upload_list"],
|
||||
function ($, Backbone, ActiveVideoUploadListView, PreviousVideoUploadListView) {
|
||||
"use strict";
|
||||
var VideosIndexFactory = function($contentWrapper, postUrl, concurrentUploadLimit, uploadButton, previousUploads) {
|
||||
var activeView = new ActiveVideoUploadListView({
|
||||
postUrl: postUrl,
|
||||
concurrentUploadLimit: concurrentUploadLimit,
|
||||
uploadButton: uploadButton
|
||||
});
|
||||
$contentWrapper.append(activeView.render().$el);
|
||||
var previousCollection = new Backbone.Collection(previousUploads);
|
||||
var previousView = new PreviousVideoUploadListView({collection: previousCollection});
|
||||
$contentWrapper.append(previousView.render().$el);
|
||||
};
|
||||
|
||||
return VideosIndexFactory;
|
||||
}
|
||||
);
|
||||
30
cms/static/js/models/active_video_upload.js
Normal file
30
cms/static/js/models/active_video_upload.js
Normal file
@@ -0,0 +1,30 @@
|
||||
define(
|
||||
["backbone", "gettext"],
|
||||
function(Backbone, gettext) {
|
||||
"use strict";
|
||||
|
||||
var statusStrings = {
|
||||
// Translators: This is the status of a video upload that is queued
|
||||
// waiting for other uploads to complete
|
||||
STATUS_QUEUED: gettext("Queued"),
|
||||
// Translators: This is the status of an active video upload
|
||||
STATUS_UPLOADING: gettext("Uploading"),
|
||||
// Translators: This is the status of a video upload that has
|
||||
// completed successfully
|
||||
STATUS_COMPLETED: gettext("Upload completed"),
|
||||
// Translators: This is the status of a video upload that has failed
|
||||
STATUS_FAILED: gettext("Upload failed")
|
||||
};
|
||||
|
||||
var ActiveVideoUpload = Backbone.Model.extend(
|
||||
{
|
||||
defaults: {
|
||||
status: statusStrings.STATUS_QUEUED
|
||||
}
|
||||
},
|
||||
statusStrings
|
||||
);
|
||||
|
||||
return ActiveVideoUpload;
|
||||
}
|
||||
);
|
||||
209
cms/static/js/spec/views/active_video_upload_list_spec.js
Normal file
209
cms/static/js/spec/views/active_video_upload_list_spec.js
Normal file
@@ -0,0 +1,209 @@
|
||||
define(
|
||||
["jquery", "js/models/active_video_upload", "js/views/active_video_upload_list", "js/common_helpers/template_helpers", "mock-ajax", "jasmine-jquery"],
|
||||
function($, ActiveVideoUpload, ActiveVideoUploadListView, TemplateHelpers) {
|
||||
"use strict";
|
||||
var concurrentUploadLimit = 2;
|
||||
|
||||
describe("ActiveVideoUploadListView", function() {
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplate("active-video-upload", true);
|
||||
TemplateHelpers.installTemplate("active-video-upload-list");
|
||||
this.postUrl = "/test/post/url";
|
||||
this.uploadButton = $("<button>");
|
||||
this.view = new ActiveVideoUploadListView({
|
||||
concurrentUploadLimit: concurrentUploadLimit,
|
||||
postUrl: this.postUrl,
|
||||
uploadButton: this.uploadButton
|
||||
});
|
||||
this.view.render();
|
||||
jasmine.Ajax.useMock();
|
||||
clearAjaxRequests();
|
||||
this.globalAjaxError = jasmine.createSpy();
|
||||
$(document).ajaxError(this.globalAjaxError);
|
||||
});
|
||||
|
||||
it("should trigger file selection when either the upload button or the drop zone is clicked", function() {
|
||||
var clickSpy = jasmine.createSpy();
|
||||
clickSpy.andCallFake(function(event) { event.preventDefault(); });
|
||||
this.view.$(".js-file-input").on("click", clickSpy);
|
||||
this.view.$(".file-drop-area").click();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
clickSpy.reset();
|
||||
this.uploadButton.click();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
var makeUploadUrl = function(fileName) {
|
||||
return "http://www.example.com/test_url/" + fileName;
|
||||
}
|
||||
|
||||
var getSentRequests = function() {
|
||||
return _.filter(
|
||||
ajaxRequests,
|
||||
function(request) { return request.readyState > 0; }
|
||||
);
|
||||
}
|
||||
|
||||
_.each(
|
||||
[
|
||||
{desc: "a single file", numFiles: 1},
|
||||
{desc: "multiple files", numFiles: concurrentUploadLimit},
|
||||
{desc: "more files than upload limit", numFiles: concurrentUploadLimit + 1},
|
||||
],
|
||||
function(caseInfo) {
|
||||
var fileNames = _.map(
|
||||
_.range(caseInfo.numFiles),
|
||||
function(i) { return "test" + i + ".mp4";}
|
||||
);
|
||||
|
||||
describe("on selection of " + caseInfo.desc, function() {
|
||||
beforeEach(function() {
|
||||
// The files property cannot be set on a file input for
|
||||
// security reasons, so we must mock the access mechanism
|
||||
// that jQuery-File-Upload uses to retrieve it.
|
||||
var realProp = $.prop;
|
||||
spyOn($, "prop").andCallFake(function(el, propName) {
|
||||
if (arguments.length == 2 && propName == "files") {
|
||||
return _.map(
|
||||
fileNames,
|
||||
function(fileName) { return {name: fileName}; }
|
||||
);
|
||||
} else {
|
||||
realProp.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
this.view.$(".js-file-input").change();
|
||||
this.request = mostRecentAjaxRequest();
|
||||
});
|
||||
|
||||
it("should trigger the correct request", function() {
|
||||
expect(this.request.url).toEqual(this.postUrl);
|
||||
expect(this.request.method).toEqual("POST");
|
||||
expect(this.request.requestHeaders["Content-Type"]).toEqual("application/json");
|
||||
expect(this.request.requestHeaders["Accept"]).toContain("application/json");
|
||||
expect(JSON.parse(this.request.params)).toEqual({
|
||||
"files": _.map(
|
||||
fileNames,
|
||||
function(fileName) { return {"file_name": fileName}; }
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
it("should trigger the global AJAX error handler on server error", function() {
|
||||
this.request.response({status: 500});
|
||||
expect(this.globalAjaxError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("and successful server response", function() {
|
||||
beforeEach(function() {
|
||||
clearAjaxRequests();
|
||||
this.request.response({
|
||||
status: 200,
|
||||
responseText: JSON.stringify({
|
||||
files: _.map(
|
||||
fileNames,
|
||||
function(fileName) {
|
||||
return {
|
||||
"file_name": fileName,
|
||||
"upload_url": makeUploadUrl(fileName)
|
||||
};
|
||||
}
|
||||
)
|
||||
})
|
||||
});
|
||||
this.$uploadElems = this.view.$(".active-video-upload");
|
||||
});
|
||||
|
||||
it("should start uploads", function() {
|
||||
var spec = this;
|
||||
var sentRequests = getSentRequests();
|
||||
expect(sentRequests.length).toEqual(
|
||||
_.min([concurrentUploadLimit, caseInfo.numFiles])
|
||||
);
|
||||
_.each(
|
||||
sentRequests,
|
||||
function(uploadRequest, i) {
|
||||
expect(uploadRequest.url).toEqual(
|
||||
makeUploadUrl(fileNames[i])
|
||||
);
|
||||
expect(uploadRequest.method).toEqual("PUT");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should display status", function() {
|
||||
var spec = this;
|
||||
expect(this.$uploadElems.length).toEqual(caseInfo.numFiles);
|
||||
this.$uploadElems.each(function(i, uploadElem) {
|
||||
var $uploadElem = $(uploadElem);
|
||||
expect($.trim($uploadElem.find(".video-detail-name").text())).toEqual(
|
||||
fileNames[i]
|
||||
);
|
||||
expect($.trim($uploadElem.find(".video-detail-status").text())).toEqual(
|
||||
i >= concurrentUploadLimit ?
|
||||
ActiveVideoUpload.STATUS_QUEUED :
|
||||
ActiveVideoUpload.STATUS_UPLOADING
|
||||
);
|
||||
expect($uploadElem.find(".success").length).toEqual(0);
|
||||
expect($uploadElem.find(".error").length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
_.each(
|
||||
[
|
||||
{
|
||||
desc: "completion",
|
||||
responseStatus: 204,
|
||||
statusText: ActiveVideoUpload.STATUS_COMPLETED,
|
||||
presentSelector: ".success",
|
||||
absentSelector: ".error"
|
||||
},
|
||||
{
|
||||
desc: "failure",
|
||||
responseStatus: 500,
|
||||
statusText: ActiveVideoUpload.STATUS_FAILED,
|
||||
presentSelector: ".error",
|
||||
absentSelector: ".success"
|
||||
},
|
||||
],
|
||||
function(subCaseInfo) {
|
||||
describe("and upload " + subCaseInfo.desc, function() {
|
||||
beforeEach(function() {
|
||||
getSentRequests()[0].response({status: subCaseInfo.responseStatus});
|
||||
});
|
||||
|
||||
it("should update status", function() {
|
||||
var $uploadElem = this.view.$(".active-video-upload:first");
|
||||
expect($uploadElem.length).toEqual(1);
|
||||
expect($.trim($uploadElem.find(".video-detail-status").text())).toEqual(
|
||||
subCaseInfo.statusText
|
||||
);
|
||||
expect($uploadElem.find(subCaseInfo.presentSelector).length).toEqual(1);
|
||||
expect($uploadElem.find(subCaseInfo.absentSelector).length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not trigger the global AJAX error handler", function() {
|
||||
expect(this.globalAjaxError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
if (caseInfo.numFiles > concurrentUploadLimit) {
|
||||
it("should start a new upload", function() {
|
||||
expect(getSentRequests().length).toEqual(
|
||||
concurrentUploadLimit + 1
|
||||
);
|
||||
var $uploadElem = $(this.$uploadElems[concurrentUploadLimit]);
|
||||
expect($.trim($uploadElem.find(".video-detail-status").text())).toEqual(
|
||||
ActiveVideoUpload.STATUS_UPLOADING
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -20,6 +20,8 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views
|
||||
appendSetFixtures(uploadModalTpl);
|
||||
appendSetFixtures(sandbox({ id: "asset_table_body" }));
|
||||
|
||||
spyOn($.fn, "fileupload").andReturn("");
|
||||
|
||||
var collection = new AssetCollection();
|
||||
collection.url = "assets-url";
|
||||
assetsView = new AssetsView({
|
||||
@@ -57,10 +59,6 @@ define([ "jquery", "js/common_helpers/ajax_helpers", "js/views/asset", "js/views
|
||||
files: [{name: 'largefile', size: 0}]
|
||||
};
|
||||
|
||||
$.fn.fileupload = function() {
|
||||
return '';
|
||||
};
|
||||
|
||||
var event = {}
|
||||
event.target = {"value": "dummy.jpg"};
|
||||
|
||||
|
||||
42
cms/static/js/spec/views/previous_video_upload_list_spec.js
Normal file
42
cms/static/js/spec/views/previous_video_upload_list_spec.js
Normal file
@@ -0,0 +1,42 @@
|
||||
define(
|
||||
["jquery", "underscore", "backbone", "js/views/previous_video_upload_list", "js/common_helpers/template_helpers"],
|
||||
function($, _, Backbone, PreviousVideoUploadListView, TemplateHelpers) {
|
||||
"use strict";
|
||||
describe("PreviousVideoUploadListView", function() {
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplate("previous-video-upload", true);
|
||||
TemplateHelpers.installTemplate("previous-video-upload-list");
|
||||
});
|
||||
|
||||
var render = function(numModels) {
|
||||
var modelData = {
|
||||
client_video_id: "foo.mp4",
|
||||
duration: 42,
|
||||
created: "2014-11-25T23:13:05",
|
||||
edx_video_id: "dummy_id",
|
||||
status: "uploading"
|
||||
};
|
||||
var collection = new Backbone.Collection(
|
||||
_.map(
|
||||
_.range(numModels),
|
||||
function() { return new Backbone.Model(modelData); }
|
||||
)
|
||||
);
|
||||
var view = new PreviousVideoUploadListView({collection: collection});
|
||||
return view.render().$el;
|
||||
};
|
||||
|
||||
it("should render an empty collection", function() {
|
||||
var $el = render(0);
|
||||
expect($el.find(".js-table-body").length).toEqual(1);
|
||||
expect($el.find(".js-table-body tr").length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should render a non-empty collection", function() {
|
||||
var $el = render(5);
|
||||
expect($el.find(".js-table-body").length).toEqual(1);
|
||||
expect($el.find(".js-table-body tr").length).toEqual(5);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
90
cms/static/js/spec/views/previous_video_upload_spec.js
Normal file
90
cms/static/js/spec/views/previous_video_upload_spec.js
Normal file
@@ -0,0 +1,90 @@
|
||||
define(
|
||||
["jquery", "backbone", "js/views/previous_video_upload", "js/common_helpers/template_helpers"],
|
||||
function($, Backbone, PreviousVideoUploadView, TemplateHelpers) {
|
||||
"use strict";
|
||||
describe("PreviousVideoUploadView", function() {
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplate("previous-video-upload", true);
|
||||
});
|
||||
|
||||
var render = function(modelData) {
|
||||
var defaultData = {
|
||||
client_video_id: "foo.mp4",
|
||||
duration: 42,
|
||||
created: "2014-11-25T23:13:05",
|
||||
edx_video_id: "dummy_id",
|
||||
status: "uploading"
|
||||
};
|
||||
var view = new PreviousVideoUploadView(
|
||||
{model: new Backbone.Model($.extend({}, defaultData, modelData))}
|
||||
);
|
||||
return view.render().$el;
|
||||
};
|
||||
|
||||
it("should render video name correctly", function() {
|
||||
var testName = "test name";
|
||||
var $el = render({client_video_id: testName});
|
||||
expect($el.find(".name-col").text()).toEqual(testName);
|
||||
});
|
||||
|
||||
_.each(
|
||||
[
|
||||
{desc: "zero as pending", seconds: 0, expected: "Pending"},
|
||||
{desc: "less than one second as zero", seconds: 0.75, expected: "0:00"},
|
||||
{desc: "with minutes and without seconds", seconds: 900, expected: "15:00"},
|
||||
{desc: "with seconds and without minutes", seconds: 15, expected: "0:15"},
|
||||
{desc: "with minutes and seconds", seconds: 915, expected: "15:15"},
|
||||
{desc: "with seconds padded", seconds: 5, expected: "0:05"},
|
||||
{desc: "longer than an hour as many minutes", seconds: 7425, expected: "123:45"}
|
||||
],
|
||||
function(caseInfo) {
|
||||
it("should render duration " + caseInfo.desc, function() {
|
||||
var $el = render({duration: caseInfo.seconds});
|
||||
expect($el.find(".duration-col").text()).toEqual(caseInfo.expected);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("should render created timestamp correctly", function() {
|
||||
var fakeDate = "fake formatted date";
|
||||
spyOn(Date.prototype, "toLocaleString").andCallFake(
|
||||
function(locales, options) {
|
||||
expect(locales).toEqual([]);
|
||||
expect(options.timeZone).toEqual("UTC");
|
||||
expect(options.timeZoneName).toEqual("short");
|
||||
return fakeDate;
|
||||
}
|
||||
);
|
||||
var $el = render({});
|
||||
expect($el.find(".date-col").text()).toEqual(fakeDate);
|
||||
});
|
||||
|
||||
it("should render video id correctly", function() {
|
||||
var testId = "test_id";
|
||||
var $el = render({edx_video_id: testId});
|
||||
expect($el.find(".video-id-col").text()).toEqual(testId);
|
||||
});
|
||||
|
||||
_.each(
|
||||
[
|
||||
{status: "upload", expected: "Uploading"},
|
||||
{status: "ingest", expected: "In Progress"},
|
||||
{status: "transcode_queue", expected: "In Progress"},
|
||||
{status: "transcode_active", expected: "In Progress"},
|
||||
{status: "file_delivered", expected: "Complete"},
|
||||
{status: "file_complete", expected: "Complete"},
|
||||
{status: "file_corrupt", expected: "Failed"},
|
||||
{status: "pipeline_error", expected: "Failed"},
|
||||
{status: "invalid_token", expected: "Invalid Token"},
|
||||
{status: "unexpected_status_string", expected: "Unknown"}
|
||||
],
|
||||
function(caseInfo) {
|
||||
it("should render " + caseInfo.status + " status correctly", function() {
|
||||
var $el = render({status: caseInfo.status});
|
||||
expect($el.find(".status-col").text()).toEqual(caseInfo.expected);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -25,8 +25,19 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) {
|
||||
}
|
||||
};
|
||||
|
||||
var renderDate = function(dateArg) {
|
||||
// Render a localized date from an argument that can be passed to
|
||||
// the Date constructor (e.g. another Date or an ISO 8601 string)
|
||||
var date = new Date(dateArg);
|
||||
return date.toLocaleString(
|
||||
[],
|
||||
{timeZone: "UTC", timeZoneName: "short"}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
getDate: getDate,
|
||||
setDate: setDate
|
||||
setDate: setDate,
|
||||
renderDate: renderDate
|
||||
};
|
||||
});
|
||||
|
||||
26
cms/static/js/views/active_video_upload.js
Normal file
26
cms/static/js/views/active_video_upload.js
Normal file
@@ -0,0 +1,26 @@
|
||||
define(
|
||||
["js/models/active_video_upload", "js/views/baseview"],
|
||||
function(ActiveVideoUpload, BaseView) {
|
||||
"use strict";
|
||||
var ActiveVideoUploadView = BaseView.extend({
|
||||
tagName: "li",
|
||||
className: "active-video-upload",
|
||||
|
||||
initialize: function() {
|
||||
this.template = this.loadTemplate("active-video-upload");
|
||||
this.listenTo(this.model, "change", this.render);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.template(this.model.attributes));
|
||||
var $statusEl = this.$el.find(".video-detail-status");
|
||||
var status = this.model.get("status");
|
||||
$statusEl.toggleClass("success", status == ActiveVideoUpload.STATUS_COMPLETED);
|
||||
$statusEl.toggleClass("error", status == ActiveVideoUpload.STATUS_FAILED);
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
return ActiveVideoUploadView;
|
||||
}
|
||||
);
|
||||
142
cms/static/js/views/active_video_upload_list.js
Normal file
142
cms/static/js/views/active_video_upload_list.js
Normal file
@@ -0,0 +1,142 @@
|
||||
define(
|
||||
["jquery", "underscore", "backbone", "js/models/active_video_upload", "js/views/baseview", "js/views/active_video_upload", "jquery.fileupload"],
|
||||
function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView) {
|
||||
"use strict";
|
||||
|
||||
var ActiveVideoUploadListView = BaseView.extend({
|
||||
tagName: "div",
|
||||
events: {
|
||||
"click .file-drop-area": "chooseFile",
|
||||
"dragleave .file-drop-area": "dragleave",
|
||||
"drop .file-drop-area": "dragleave"
|
||||
},
|
||||
|
||||
initialize: function(options) {
|
||||
this.template = this.loadTemplate("active-video-upload-list");
|
||||
this.collection = new Backbone.Collection();
|
||||
this.itemViews = [];
|
||||
this.listenTo(this.collection, "add", this.addUpload);
|
||||
this.concurrentUploadLimit = options.concurrentUploadLimit || 0;
|
||||
this.postUrl = options.postUrl;
|
||||
if (options.uploadButton) {
|
||||
options.uploadButton.click(this.chooseFile.bind(this));
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.template());
|
||||
_.each(this.itemViews, this.renderUploadView.bind(this));
|
||||
this.$uploadForm = this.$(".file-upload-form");
|
||||
this.$dropZone = this.$uploadForm.find(".file-drop-area");
|
||||
this.$uploadForm.fileupload({
|
||||
type: "PUT",
|
||||
singleFileUploads: false,
|
||||
limitConcurrentUploads: this.concurrentUploadLimit,
|
||||
dropZone: this.$dropZone,
|
||||
dragover: this.dragover.bind(this),
|
||||
add: this.fileUploadAdd.bind(this),
|
||||
send: this.fileUploadSend.bind(this),
|
||||
done: this.fileUploadDone.bind(this),
|
||||
fail: this.fileUploadFail.bind(this)
|
||||
});
|
||||
|
||||
// Disable default drag and drop behavior for the window (which
|
||||
// is to load the file in place)
|
||||
var preventDefault = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
$(window).on("dragover", preventDefault);
|
||||
$(window).on("drop", preventDefault);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
addUpload: function(model) {
|
||||
var itemView = new ActiveVideoUploadView({model: model});
|
||||
this.itemViews.push(itemView);
|
||||
this.renderUploadView(itemView);
|
||||
},
|
||||
|
||||
renderUploadView: function(view) {
|
||||
this.$(".active-video-upload-list").append(view.render().$el);
|
||||
},
|
||||
|
||||
chooseFile: function(event) {
|
||||
event.preventDefault();
|
||||
this.$uploadForm.find(".js-file-input").click();
|
||||
},
|
||||
|
||||
dragover: function(event) {
|
||||
event.preventDefault();
|
||||
this.$dropZone.addClass("is-dragged");
|
||||
},
|
||||
|
||||
dragleave: function(event) {
|
||||
event.preventDefault();
|
||||
this.$dropZone.removeClass("is-dragged");
|
||||
},
|
||||
|
||||
// Each file is ultimately sent to a separate URL, but we want to make a
|
||||
// single API call to get the URLs for all videos that the user wants to
|
||||
// upload at one time. The file upload plugin only allows for this one
|
||||
// callback, so this makes the API call and then breaks apart the
|
||||
// individual file uploads, using the extra `redirected` field to
|
||||
// indicate that the correct upload url has already been retrieved
|
||||
fileUploadAdd: function(event, uploadData) {
|
||||
var view = this;
|
||||
if (uploadData.redirected) {
|
||||
var model = new ActiveVideoUpload({fileName: uploadData.files[0].name});
|
||||
this.collection.add(model);
|
||||
uploadData.cid = model.cid;
|
||||
uploadData.submit();
|
||||
} else {
|
||||
$.ajax({
|
||||
url: this.postUrl,
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
files: _.map(
|
||||
uploadData.files,
|
||||
function(file) {
|
||||
return {"file_name": file.name, "content_type": file.type};
|
||||
}
|
||||
)
|
||||
}),
|
||||
dataType: "json",
|
||||
type: "POST"
|
||||
}).done(function(responseData) {
|
||||
_.each(
|
||||
responseData["files"],
|
||||
function(file, index) {
|
||||
view.$uploadForm.fileupload("add", {
|
||||
files: [uploadData.files[index]],
|
||||
url: file["upload_url"],
|
||||
multipart: false,
|
||||
global: false, // Do not trigger global AJAX error handler
|
||||
redirected: true
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setStatus: function(cid, status) {
|
||||
this.collection.get(cid).set("status", status);
|
||||
},
|
||||
|
||||
fileUploadSend: function(event, data) {
|
||||
this.setStatus(data.cid, ActiveVideoUpload.STATUS_UPLOADING);
|
||||
},
|
||||
|
||||
fileUploadDone: function(event, data) {
|
||||
this.setStatus(data.cid, ActiveVideoUpload.STATUS_COMPLETED);
|
||||
},
|
||||
|
||||
fileUploadFail: function(event, data) {
|
||||
this.setStatus(data.cid, ActiveVideoUpload.STATUS_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
return ActiveVideoUploadListView;
|
||||
}
|
||||
);
|
||||
@@ -65,7 +65,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
|
||||
this.pagingFooter.render();
|
||||
|
||||
// Hide the contents until the collection has loaded the first time
|
||||
this.$('.asset-library').hide();
|
||||
this.$('.assets-library').hide();
|
||||
this.$('.no-asset-content').hide();
|
||||
}
|
||||
return tableBody;
|
||||
@@ -85,7 +85,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
|
||||
}
|
||||
);
|
||||
}
|
||||
self.$('.asset-library').toggle(hasAssets);
|
||||
self.$('.assets-library').toggle(hasAssets);
|
||||
self.$('.no-asset-content').toggle(!hasAssets);
|
||||
return this;
|
||||
},
|
||||
|
||||
71
cms/static/js/views/previous_video_upload.js
Normal file
71
cms/static/js/views/previous_video_upload.js
Normal file
@@ -0,0 +1,71 @@
|
||||
define(
|
||||
["gettext", "js/utils/date_utils", "js/views/baseview"],
|
||||
function(gettext, DateUtils, BaseView) {
|
||||
"use strict";
|
||||
|
||||
var statusDisplayStrings = {
|
||||
// Translators: This is the status of an active video upload
|
||||
UPLOADING: gettext("Uploading"),
|
||||
// Translators: This is the status for a video that the servers
|
||||
// are currently processing
|
||||
IN_PROGRESS: gettext("In Progress"),
|
||||
// Translators: This is the status for a video that the servers
|
||||
// have successfully processed
|
||||
COMPLETE: gettext("Complete"),
|
||||
// Translators: This is the status for a video that the servers
|
||||
// have failed to process
|
||||
FAILED: gettext("Failed"),
|
||||
// Translators: This is the status for a video for which an invalid
|
||||
// processing token was provided in the course settings
|
||||
INVALID_TOKEN: gettext("Invalid Token"),
|
||||
// Translators: This is the status for a video that is in an unknown
|
||||
// state
|
||||
UNKNOWN: gettext("Unknown")
|
||||
};
|
||||
|
||||
var statusMap = {
|
||||
"upload": statusDisplayStrings.UPLOADING,
|
||||
"ingest": statusDisplayStrings.IN_PROGRESS,
|
||||
"transcode_queue": statusDisplayStrings.IN_PROGRESS,
|
||||
"transcode_active": statusDisplayStrings.IN_PROGRESS,
|
||||
"file_delivered": statusDisplayStrings.COMPLETE,
|
||||
"file_complete": statusDisplayStrings.COMPLETE,
|
||||
"file_corrupt": statusDisplayStrings.FAILED,
|
||||
"pipeline_error": statusDisplayStrings.FAILED,
|
||||
"invalid_token": statusDisplayStrings.INVALID_TOKEN
|
||||
};
|
||||
|
||||
var PreviousVideoUploadView = BaseView.extend({
|
||||
tagName: "tr",
|
||||
|
||||
initialize: function() {
|
||||
this.template = this.loadTemplate("previous-video-upload");
|
||||
},
|
||||
|
||||
renderDuration: function(seconds) {
|
||||
var minutes = Math.floor(seconds/ 60);
|
||||
var seconds = Math.floor(seconds - minutes * 60);
|
||||
|
||||
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var duration = this.model.get("duration");
|
||||
var renderedAttributes = {
|
||||
// Translators: This is listed as the duration for a video
|
||||
// that has not yet reached the point in its processing by
|
||||
// the servers where its duration is determined.
|
||||
duration: duration > 0 ? this.renderDuration(duration) : gettext("Pending"),
|
||||
created: DateUtils.renderDate(this.model.get("created")),
|
||||
status: statusMap[this.model.get("status")] || statusDisplayStrings.UNKNOWN
|
||||
};
|
||||
this.$el.html(
|
||||
this.template(_.extend({}, this.model.attributes, renderedAttributes))
|
||||
);
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return PreviousVideoUploadView;
|
||||
}
|
||||
);
|
||||
29
cms/static/js/views/previous_video_upload_list.js
Normal file
29
cms/static/js/views/previous_video_upload_list.js
Normal file
@@ -0,0 +1,29 @@
|
||||
define(
|
||||
["jquery", "underscore", "backbone", "js/views/baseview", "js/views/previous_video_upload"],
|
||||
function($, _, Backbone, BaseView, PreviousVideoUploadView) {
|
||||
"use strict";
|
||||
var PreviousVideoUploadListView = BaseView.extend({
|
||||
tagName: "section",
|
||||
className: "wrapper-assets",
|
||||
|
||||
initialize: function() {
|
||||
this.template = this.loadTemplate("previous-video-upload-list");
|
||||
this.itemViews = this.collection.map(function(model) {
|
||||
return new PreviousVideoUploadView({model: model});
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var $el = this.$el;
|
||||
$el.html(this.template());
|
||||
var $tabBody = $el.find(".js-table-body");
|
||||
_.each(this.itemViews, function(view) {
|
||||
$tabBody.append(view.render().$el);
|
||||
});
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
return PreviousVideoUploadListView;
|
||||
}
|
||||
);
|
||||
@@ -52,6 +52,7 @@ lib_paths:
|
||||
- xmodule_js/common_static/js/vendor/jasmine-imagediff.js
|
||||
- xmodule_js/common_static/js/vendor/jasmine.async.js
|
||||
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
|
||||
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js
|
||||
- xmodule_js/src/xmodule.js
|
||||
- xmodule_js/common_static/js/test/i18n.js
|
||||
- xmodule_js/common_static/js/vendor/draggabilly.pkgd.js
|
||||
@@ -67,6 +68,7 @@ lib_paths:
|
||||
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js
|
||||
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js
|
||||
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js
|
||||
- xmodule_js/common_static/js/vendor/mock-ajax.js
|
||||
|
||||
# Paths to source JavaScript files
|
||||
src_paths:
|
||||
|
||||
289
cms/static/sass/elements/_uploaded-assets.scss
Normal file
289
cms/static/sass/elements/_uploaded-assets.scss
Normal file
@@ -0,0 +1,289 @@
|
||||
.wrapper-assets {
|
||||
|
||||
.assets-library {
|
||||
@include clearfix();
|
||||
|
||||
.assets-title {
|
||||
@extend %t-strong;
|
||||
margin-top: ($baseline*2);
|
||||
margin-bottom: ($baseline*2);
|
||||
}
|
||||
|
||||
.meta-wrap {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@extend %t-copy-sub2;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: flex-grid(9, 12);
|
||||
color: $gray-l1;
|
||||
|
||||
.count-current-shown,
|
||||
.count-total,
|
||||
.sort-order {
|
||||
@extend %t-strong;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@include clearfix;
|
||||
display: inline-block;
|
||||
width: flex-grid(3, 12);
|
||||
|
||||
&.pagination-compact {
|
||||
@include text-align(right);
|
||||
}
|
||||
|
||||
&.pagination-full {
|
||||
display: block;
|
||||
width: flex-grid(4, 12);
|
||||
margin: $baseline auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
padding: ($baseline/4) ($baseline*0.75);
|
||||
|
||||
&.previous {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
&.next {
|
||||
margin-left: ($baseline/2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
border-radius: 3px;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background-color: transparent;
|
||||
color: $gray-l2;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
.pagination-form,
|
||||
.current-page,
|
||||
.page-divider,
|
||||
.total-pages {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-page,
|
||||
.page-number-input,
|
||||
.total-pages {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
width: ($baseline*2.5);
|
||||
margin: 0 ($baseline*0.75);
|
||||
padding: ($baseline/4);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
@include left(-($baseline/4));
|
||||
}
|
||||
|
||||
.page-divider {
|
||||
@extend %t-title4;
|
||||
@extend %t-regular;
|
||||
vertical-align: middle;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.pagination-form {
|
||||
@extend %ui-depth2;
|
||||
position: relative;
|
||||
|
||||
.page-number-label,
|
||||
.submit-pagination-form {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
.page-number-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px dotted $gray-l2;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
// borrowing the base input focus styles to match overall app
|
||||
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 0 3px $shadow-d1 inset;
|
||||
background-color: $white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.assets-table {
|
||||
width: 100%;
|
||||
font-size: 80%;
|
||||
word-wrap: break-word;
|
||||
|
||||
th {
|
||||
@extend %t-copy-sub2;
|
||||
background-color: $gray-l5;
|
||||
padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2);
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
color: $gray;
|
||||
|
||||
.column-sort-link {
|
||||
cursor: pointer;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.current-sort {
|
||||
@extend %t-strong;
|
||||
border-bottom: 1px solid $gray-l3;
|
||||
}
|
||||
|
||||
&.embed-col {
|
||||
padding-left: ($baseline*0.75);
|
||||
padding-right: ($baseline*0.75);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: ($baseline/2);
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody {
|
||||
box-shadow: 0 2px 2px $shadow-l1;
|
||||
border: 1px solid $gray-l4;
|
||||
background: $white;
|
||||
|
||||
tr {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border-top: 1px solid $gray-l4;
|
||||
|
||||
.name-col {
|
||||
@extend %t-strong;
|
||||
}
|
||||
|
||||
.status-col {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: $gray-l6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $gray-d1;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-locked {
|
||||
background-image: url('../images/bg-micro-stripes.png');
|
||||
background-position: 0 0;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-l5;
|
||||
|
||||
.date-col,
|
||||
.embed-col,
|
||||
.embed-col .embeddable-xml-input {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-col {
|
||||
padding: ($baseline/2) $baseline;
|
||||
|
||||
.thumb {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.name-col {
|
||||
|
||||
.title {
|
||||
@extend %t-copy-sub1;
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.date-col {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@extend %t-copy-sub2;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.embed-col {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
padding-left: ($baseline*0.75);
|
||||
color: $gray-l2;
|
||||
|
||||
.embeddable-xml-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@extend %t-copy-sub2;
|
||||
box-shadow: none;
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
width: 100%;
|
||||
color: $gray-l2;
|
||||
|
||||
&:focus {
|
||||
background-color: $white;
|
||||
box-shadow: 0 1px 5px $shadow-l1 inset;
|
||||
border: 1px solid $gray-l3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
padding: ($baseline/2);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
@import 'elements/xblocks'; // studio rendering chrome for xblocks
|
||||
@import 'elements/modules'; // content module patterns
|
||||
@import 'elements/layout'; // various standard layouts
|
||||
@import 'elements/uploaded-assets'; // layout for asset tables
|
||||
|
||||
// base - specific views
|
||||
@import 'views/account';
|
||||
@@ -51,6 +52,7 @@
|
||||
@import 'views/textbooks';
|
||||
@import 'views/export-git';
|
||||
@import 'views/group-configuration';
|
||||
@import 'views/video-upload';
|
||||
|
||||
// base - contexts
|
||||
@import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
@import 'elements/xblocks'; // studio rendering chrome for xblocks
|
||||
@import 'elements/modules'; // content module patterns
|
||||
@import 'elements/layout'; // various standard layouts
|
||||
@import 'elements/uploaded-assets'; // layout for asset tables
|
||||
|
||||
// base - specific views
|
||||
@import 'views/account';
|
||||
@@ -52,6 +53,7 @@
|
||||
@import 'views/textbooks';
|
||||
@import 'views/export-git';
|
||||
@import 'views/group-configuration';
|
||||
@import 'views/video-upload';
|
||||
|
||||
// base - contexts
|
||||
@import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs)
|
||||
|
||||
@@ -42,287 +42,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.asset-library {
|
||||
@include clearfix;
|
||||
|
||||
.meta-wrap {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
.meta {
|
||||
@extend %t-copy-sub2;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: flex-grid(9, 12);
|
||||
color: $gray-l1;
|
||||
|
||||
.count-current-shown,
|
||||
.count-total,
|
||||
.sort-order {
|
||||
@extend %t-strong;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@include clearfix;
|
||||
display: inline-block;
|
||||
width: flex-grid(3, 12);
|
||||
|
||||
&.pagination-compact {
|
||||
@include text-align(right);
|
||||
}
|
||||
|
||||
&.pagination-full {
|
||||
display: block;
|
||||
width: flex-grid(4, 12);
|
||||
margin: $baseline auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
padding: ($baseline/4) ($baseline*0.75);
|
||||
|
||||
&.previous {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
&.next {
|
||||
margin-left: ($baseline/2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
border-radius: 3px;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background-color: transparent;
|
||||
color: $gray-l2;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
.pagination-form,
|
||||
.current-page,
|
||||
.page-divider,
|
||||
.total-pages {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-page,
|
||||
.page-number-input,
|
||||
.total-pages {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
width: ($baseline*2.5);
|
||||
margin: 0 ($baseline*0.75);
|
||||
padding: ($baseline/4);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
@include left(-($baseline/4));
|
||||
}
|
||||
|
||||
.page-divider {
|
||||
@extend %t-title4;
|
||||
@extend %t-regular;
|
||||
vertical-align: middle;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
|
||||
.pagination-form {
|
||||
@extend %ui-depth2;
|
||||
position: relative;
|
||||
|
||||
.page-number-label,
|
||||
.submit-pagination-form {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
.page-number-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px dotted $gray-l2;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
// borrowing the base input focus styles to match overall app
|
||||
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 0 3px $shadow-d1 inset;
|
||||
background-color: $white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
|
||||
th {
|
||||
@extend %t-copy-sub2;
|
||||
background-color: $gray-l5;
|
||||
padding: 0 ($baseline/2) ($baseline*0.75) ($baseline/2);
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
color: $gray;
|
||||
|
||||
.column-sort-link {
|
||||
cursor: pointer;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
|
||||
.current-sort {
|
||||
@extend %t-strong;
|
||||
border-bottom: 1px solid $gray-l3;
|
||||
}
|
||||
|
||||
// CASE: embed column
|
||||
&.embed-col {
|
||||
padding-left: ($baseline*0.75);
|
||||
padding-right: ($baseline*0.75);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: ($baseline/2);
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody {
|
||||
box-shadow: 0 2px 2px $shadow-l1;
|
||||
border: 1px solid $gray-l4;
|
||||
background: $white;
|
||||
|
||||
tr {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border-top: 1px solid $gray-l4;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: $gray-l6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $gray-d1;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-locked {
|
||||
background-image: url('../images/bg-micro-stripes.png');
|
||||
background-position: 0 0;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-l5;
|
||||
|
||||
.date-col,
|
||||
.embed-col,
|
||||
.embed-col .embeddable-xml-input {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-col {
|
||||
padding: ($baseline/2) $baseline;
|
||||
|
||||
.thumb {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.name-col {
|
||||
|
||||
.title {
|
||||
@extend %t-copy-sub1;
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.date-col {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@extend %t-copy-sub2;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.embed-col {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
padding-left: ($baseline*0.75);
|
||||
color: $gray-l2;
|
||||
|
||||
.embeddable-xml-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@extend %t-copy-sub2;
|
||||
box-shadow: none;
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
width: 100%;
|
||||
color: $gray-l2;
|
||||
|
||||
&:focus {
|
||||
background-color: $white;
|
||||
box-shadow: 0 1px 5px $shadow-l1 inset;
|
||||
border: 1px solid $gray-l3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
padding: ($baseline/2);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI: assets - calls-to-action
|
||||
.actions-list {
|
||||
@extend %actions-list;
|
||||
|
||||
93
cms/static/sass/views/_video-upload.scss
Normal file
93
cms/static/sass/views/_video-upload.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
.view-video-uploads {
|
||||
|
||||
.content-primary, .content-supplementary {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
|
||||
.content-primary {
|
||||
@extend .ui-col-wide;
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
@extend .ui-col-narrow;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
|
||||
.icon-cloud-upload {
|
||||
@extend %t-copy;
|
||||
vertical-align: bottom;
|
||||
margin-right: ($baseline/5);
|
||||
}
|
||||
}
|
||||
|
||||
.file-upload-form {
|
||||
@include clearfix();
|
||||
margin-bottom: ($baseline*1.5);
|
||||
width: 100%;
|
||||
|
||||
.file-drop-area {
|
||||
border: 2px dashed $gray-l3;
|
||||
border-radius: ($baseline/5);
|
||||
padding: ($baseline*3);
|
||||
background: $white;
|
||||
text-align: center;
|
||||
|
||||
&:hover,
|
||||
&.is-dragged {
|
||||
background: $blue-l5;
|
||||
border-style: solid;
|
||||
border-color: $blue-l4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active-video-upload-container {
|
||||
margin-bottom: ($baseline*2);
|
||||
|
||||
.active-video-upload-list {
|
||||
@extend %cont-no-list;
|
||||
|
||||
.active-video-upload {
|
||||
display: inline-block;
|
||||
min-height: ($baseline*4);
|
||||
width: (flex-grid(4) - 1.85);
|
||||
margin: (flex-gutter() - 1.85);
|
||||
border: 1px solid $gray-l3;
|
||||
border-radius: ($baseline/5);
|
||||
padding: ($baseline/2);
|
||||
vertical-align: top;
|
||||
|
||||
.video-detail-name {
|
||||
@extend %cont-truncated;
|
||||
@extend %t-strong;
|
||||
margin-bottom: ($baseline/2);
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.video-detail-status {
|
||||
@include font-size(12);
|
||||
@include line-height(12);
|
||||
|
||||
&.error {
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: $color-ready;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include transition(all $tmg-f3);
|
||||
background: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@extend %ui-btn-non;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
<div class="assets-wrapper"/>
|
||||
<div class="wrapper-assets" />
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading…")}</span></p>
|
||||
</div>
|
||||
|
||||
10
cms/templates/js/active-video-upload-list.underscore
Normal file
10
cms/templates/js/active-video-upload-list.underscore
Normal file
@@ -0,0 +1,10 @@
|
||||
<form class="file-upload-form">
|
||||
<div class="file-drop-area">
|
||||
<%- gettext("Drag and drop or click here to upload video files.") %>
|
||||
</div>
|
||||
<input type="file" class="sr js-file-input" name="file" multiple>
|
||||
</form>
|
||||
<section class="active-video-upload-container">
|
||||
<h3 class="sr">Active Uploads</h3>
|
||||
<ul class="active-video-upload-list"></ul>
|
||||
</section>
|
||||
2
cms/templates/js/active-video-upload.underscore
Normal file
2
cms/templates/js/active-video-upload.underscore
Normal file
@@ -0,0 +1,2 @@
|
||||
<h4 class="video-detail-name"><%- fileName %></h4>
|
||||
<p class="video-detail-status"><%- gettext(status) %></p>
|
||||
@@ -1,8 +1,7 @@
|
||||
<div class="asset-library">
|
||||
|
||||
<div class="assets-library">
|
||||
<div id="asset-paging-header"></div>
|
||||
|
||||
<table>
|
||||
|
||||
<table class="assets-table">
|
||||
<caption class="sr"><%= gettext("List of uploaded files and assets in this course") %></caption>
|
||||
<colgroup>
|
||||
<col class="thumb-cols" />
|
||||
@@ -21,13 +20,12 @@
|
||||
<th class="actions-col"><span class="sr"><%= gettext("Actions") %></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody id="asset-table-body" ></tbody>
|
||||
<tbody id="asset-table-body"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="asset-paging-footer"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="no-asset-content">
|
||||
<p><%= gettext("You haven't added any assets to this course yet.") %> <a href="#" class="button upload-button new-button"><i class="icon-plus"></i><%= gettext("Upload your first asset") %></a></p>
|
||||
</div>
|
||||
|
||||
15
cms/templates/js/previous-video-upload-list.underscore
Normal file
15
cms/templates/js/previous-video-upload-list.underscore
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="assets-library">
|
||||
<h3 class="assets-title"><%- gettext("Previous Uploads") %></h3>
|
||||
<table class="assets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%- gettext("Name") %></th>
|
||||
<th><%- gettext("Duration") %></th>
|
||||
<th><%- gettext("Date Added") %></th>
|
||||
<th><%- gettext("Video ID") %></th>
|
||||
<th><%- gettext("Status") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="js-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
5
cms/templates/js/previous-video-upload.underscore
Normal file
5
cms/templates/js/previous-video-upload.underscore
Normal file
@@ -0,0 +1,5 @@
|
||||
<td class="name-col"><%- client_video_id %></td>
|
||||
<td class="duration-col"><%- duration %></td>
|
||||
<td class="date-col"><%- created %></td>
|
||||
<td class="video-id-col"><%- edx_video_id %></td>
|
||||
<td class="status-col"><%- status %></td>
|
||||
73
cms/templates/videos_index.html
Normal file
73
cms/templates/videos_index.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import json
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="title">${_("Video Uploads")}</%block>
|
||||
<%block name="bodyclass">is-signedin course view-uploads view-video-uploads</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in ["active-video-upload-list", "active-video-upload", "previous-video-upload-list", "previous-video-upload"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/factories/videos_index"], function (VideosIndexFactory) {
|
||||
"use strict";
|
||||
var $contentWrapper = $(".content-primary");
|
||||
VideosIndexFactory(
|
||||
$contentWrapper,
|
||||
"${post_url}",
|
||||
${concurrent_upload_limit},
|
||||
$(".nav-actions .upload-button"),
|
||||
$contentWrapper.data("previous-uploads")
|
||||
);
|
||||
});
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">${_("Content")}</small>
|
||||
<span class="sr">> </span>${_("Video Uploads")}
|
||||
</h1>
|
||||
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">${_("Page Actions")}</h3>
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button upload-button new-button"><i class="icon-plus"></i> ${_("Upload New File")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main" data-previous-uploads="${json.dumps(previous_uploads, cls=DjangoJSONEncoder) | h}"></article>
|
||||
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("Why upload video files?")}</h3>
|
||||
<p>${_("For a video to play on different devices, it needs to be available in multiple formats. After you upload an original video file in .mp4 or .mov format on this page, an automated process creates those additional formats and stores them for you.")}</p>
|
||||
<h3 class="title-3">${_("Monitoring files as they upload")}</h3>
|
||||
<p>${_("Each video file that you upload needs to reach the video processing servers successfully before additional work can begin. You can monitor the progress of files as they upload, and try again if the upload fails.")}</p>
|
||||
<h3 class="title-3">${_("Managing uploaded files")}</h3>
|
||||
<p>${_("After a file uploads successfully, automated processing begins. After automated processing begins for a file it is listed under Previous Uploads as {em_start}In Progress{em_end}. When the status is {em_start}Complete{em_end}, edX assigns a unique video ID to the video file and you can add it to your course. If something goes wrong, the {em_start}Failed{em_end} status message appears. Check for problems in the file and upload a replacement.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
<h3 class="title-3">${_("How do I get the videos into my course?")}</h3>
|
||||
<p>${_("After processing is complete for the video file, you copy its unique video ID. On the Course Outline page, you create or locate a video component to play this video. Edit the video component to paste the ID into the Advanced {em_start}EdX Video ID{em_end} field.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</%block>
|
||||
@@ -22,6 +22,7 @@
|
||||
course_team_url = reverse('contentstore.views.course_team_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
assets_url = reverse('contentstore.views.assets_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
textbooks_url = reverse('contentstore.views.textbooks_list_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
videos_url = reverse('contentstore.views.videos_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
import_url = reverse('contentstore.views.import_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
course_info_url = reverse('contentstore.views.course_info_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
export_url = reverse('contentstore.views.export_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
@@ -62,6 +63,11 @@
|
||||
<li class="nav-item nav-course-courseware-textbooks">
|
||||
<a href="${textbooks_url}">${_("Textbooks")}</a>
|
||||
</li>
|
||||
% if context_course.video_pipeline_configured:
|
||||
<li class="nav-item nav-course-courseware-videos">
|
||||
<a href="${videos_url}">${_("Video Uploads")}</a>
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'),
|
||||
@@ -110,7 +111,6 @@ urlpatterns += patterns(
|
||||
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict),
|
||||
)
|
||||
|
||||
|
||||
if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
|
||||
urlpatterns += (url(
|
||||
r'^export_git/{}$'.format(
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ class VideoFields(object):
|
||||
scope=Scope.settings,
|
||||
)
|
||||
edx_video_id = String(
|
||||
help=_('Optional. Use this for videos where download and streaming URLs for the videos are completely managed by edX. This will override the settings for "Default Video URL", "Video File URLs", and all YouTube IDs. If you do not know what this setting is, you can leave it blank and continue to use these other settings.'),
|
||||
help=_("If you were assigned a Video ID by edX for the video to play in this component, enter the ID here. In this case, do not enter values in the Default Video URL, the Video File URLs, and the YouTube ID fields. If you were not assigned an edX Video ID, enter values in those other fields and ignore this field."),
|
||||
display_name=_("EdX Video ID"),
|
||||
scope=Scope.settings,
|
||||
default="",
|
||||
|
||||
199
common/static/js/vendor/mock-ajax.js
vendored
Normal file
199
common/static/js/vendor/mock-ajax.js
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
Jasmine-Ajax : a set of helpers for testing AJAX requests under the Jasmine
|
||||
BDD framework for JavaScript.
|
||||
|
||||
Supports jQuery.
|
||||
|
||||
http://github.com/pivotal/jasmine-ajax
|
||||
|
||||
Jasmine Home page: http://pivotal.github.com/jasmine
|
||||
|
||||
Copyright (c) 2008-2013 Pivotal Labs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
*/
|
||||
|
||||
// Jasmine-Ajax interface
|
||||
var ajaxRequests = [];
|
||||
|
||||
function mostRecentAjaxRequest() {
|
||||
if (ajaxRequests.length > 0) {
|
||||
return ajaxRequests[ajaxRequests.length - 1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAjaxRequests() {
|
||||
ajaxRequests = [];
|
||||
}
|
||||
|
||||
// Fake XHR for mocking Ajax Requests & Responses
|
||||
function FakeXMLHttpRequest() {
|
||||
var extend = Object.extend || jQuery.extend;
|
||||
extend(this, {
|
||||
requestHeaders: {},
|
||||
|
||||
open: function() {
|
||||
this.method = arguments[0];
|
||||
this.url = arguments[1];
|
||||
this.username = arguments[3];
|
||||
this.password = arguments[4];
|
||||
this.readyState = 1;
|
||||
},
|
||||
|
||||
setRequestHeader: function(header, value) {
|
||||
this.requestHeaders[header] = value;
|
||||
},
|
||||
|
||||
abort: function() {
|
||||
this.readyState = 0;
|
||||
},
|
||||
|
||||
readyState: 0,
|
||||
|
||||
onload: function() {
|
||||
},
|
||||
|
||||
onreadystatechange: function(isTimeout) {
|
||||
},
|
||||
|
||||
status: null,
|
||||
|
||||
send: function(data) {
|
||||
this.params = data;
|
||||
this.readyState = 2;
|
||||
},
|
||||
|
||||
data: function() {
|
||||
var data = {};
|
||||
if (typeof this.params !== 'string') return data;
|
||||
var params = this.params.split('&');
|
||||
|
||||
for (var i = 0; i < params.length; ++i) {
|
||||
var kv = params[i].replace(/\+/g, ' ').split('=');
|
||||
var key = decodeURIComponent(kv[0]);
|
||||
data[key] = data[key] || [];
|
||||
data[key].push(decodeURIComponent(kv[1]));
|
||||
data[key].sort();
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
getResponseHeader: function(name) {
|
||||
return this.responseHeaders[name];
|
||||
},
|
||||
|
||||
getAllResponseHeaders: function() {
|
||||
var responseHeaders = [];
|
||||
for (var i in this.responseHeaders) {
|
||||
if (this.responseHeaders.hasOwnProperty(i)) {
|
||||
responseHeaders.push(i + ': ' + this.responseHeaders[i]);
|
||||
}
|
||||
}
|
||||
return responseHeaders.join('\r\n');
|
||||
},
|
||||
|
||||
responseText: null,
|
||||
|
||||
response: function(response) {
|
||||
this.status = response.status;
|
||||
this.responseText = response.responseText || "";
|
||||
this.readyState = 4;
|
||||
this.responseHeaders = response.responseHeaders ||
|
||||
{"Content-type": response.contentType || "application/json" };
|
||||
// uncomment for jquery 1.3.x support
|
||||
// jasmine.Clock.tick(20);
|
||||
|
||||
this.onload();
|
||||
this.onreadystatechange();
|
||||
},
|
||||
responseTimeout: function() {
|
||||
this.readyState = 4;
|
||||
jasmine.Clock.tick(jQuery.ajaxSettings.timeout || 30000);
|
||||
this.onreadystatechange('timeout');
|
||||
}
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
jasmine.Ajax = {
|
||||
|
||||
isInstalled: function() {
|
||||
return jasmine.Ajax.installed === true;
|
||||
},
|
||||
|
||||
assertInstalled: function() {
|
||||
if (!jasmine.Ajax.isInstalled()) {
|
||||
throw new Error("Mock ajax is not installed, use jasmine.Ajax.useMock()");
|
||||
}
|
||||
},
|
||||
|
||||
useMock: function() {
|
||||
if (!jasmine.Ajax.isInstalled()) {
|
||||
var spec = jasmine.getEnv().currentSpec;
|
||||
spec.after(jasmine.Ajax.uninstallMock);
|
||||
|
||||
jasmine.Ajax.installMock();
|
||||
}
|
||||
},
|
||||
|
||||
installMock: function() {
|
||||
if (typeof jQuery != 'undefined') {
|
||||
jasmine.Ajax.installJquery();
|
||||
} else {
|
||||
throw new Error("jasmine.Ajax currently only supports jQuery");
|
||||
}
|
||||
jasmine.Ajax.installed = true;
|
||||
},
|
||||
|
||||
installJquery: function() {
|
||||
jasmine.Ajax.mode = 'jQuery';
|
||||
jasmine.Ajax.real = jQuery.ajaxSettings.xhr;
|
||||
jQuery.ajaxSettings.xhr = jasmine.Ajax.jQueryMock;
|
||||
|
||||
},
|
||||
|
||||
uninstallMock: function() {
|
||||
jasmine.Ajax.assertInstalled();
|
||||
if (jasmine.Ajax.mode == 'jQuery') {
|
||||
jQuery.ajaxSettings.xhr = jasmine.Ajax.real;
|
||||
}
|
||||
jasmine.Ajax.reset();
|
||||
},
|
||||
|
||||
reset: function() {
|
||||
jasmine.Ajax.installed = false;
|
||||
jasmine.Ajax.mode = null;
|
||||
jasmine.Ajax.real = null;
|
||||
},
|
||||
|
||||
jQueryMock: function() {
|
||||
var newXhr = new FakeXMLHttpRequest();
|
||||
ajaxRequests.push(newXhr);
|
||||
return newXhr;
|
||||
},
|
||||
|
||||
installed: false,
|
||||
mode: null
|
||||
};
|
||||
@@ -538,6 +538,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
client_video_id="Thunder Cats",
|
||||
duration=111,
|
||||
edx_video_id="thundercats",
|
||||
status='test',
|
||||
encoded_videos=encoded_videos
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user