Commit ee103659 by Greg Price

Merge pull request #6257 from edx/mobile/video-pipeline-squashed

Add video upload feature to Studio
parents 5c1834b6 578c96a8
......@@ -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:
......
"""
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())
"""
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
......
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;
}
);
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;
}
);
......@@ -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"};
......
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);
});
});
}
);
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
};
});
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;
}
);
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;
},
......
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;
}
);
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:
......
.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;
......
.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&hellip;")}</span></p>
</div>
......
<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>
<h4 class="video-detail-name"><%- fileName %></h4>
<p class="video-detail-status"><%- gettext(status) %></p>
<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>
<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>
<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>
<%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">&gt; </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="",
......
/*
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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment