Commit 66e6d1cf by Nimisha Asthagiri

Merge pull request #6352 from edx/gprice/video-upload-csv

Add CSV download button to Studio video upload page
parents cc306843 4b53f4df
"""
Admin site bindings for contentstore
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
from contentstore.models import VideoUploadConfig
admin.site.register(VideoUploadConfig, ConfigurationModelAdmin)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'VideoUploadConfig'
db.create_table('contentstore_videouploadconfig', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
('profile_whitelist', self.gf('django.db.models.fields.TextField')(blank=True)),
('status_whitelist', self.gf('django.db.models.fields.TextField')(blank=True)),
))
db.send_create_signal('contentstore', ['VideoUploadConfig'])
if not db.dry_run:
orm.VideoUploadConfig.objects.create(
profile_whitelist="desktop_mp4,desktop_webm,mobile_low,youtube",
status_whitelist="Uploading,In Progress,Complete,Failed,Invalid Token,Unknown"
)
def backwards(self, orm):
# Deleting model 'VideoUploadConfig'
db.delete_table('contentstore_videouploadconfig')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contentstore.videouploadconfig': {
'Meta': {'object_name': 'VideoUploadConfig'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'profile_whitelist': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'status_whitelist': ('django.db.models.fields.TextField', [], {'blank': 'True'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
}
}
complete_apps = ['contentstore']
"""
Models for contentstore
"""
# pylint: disable=no-member
from django.db.models.fields import TextField
from config_models.models import ConfigurationModel
class VideoUploadConfig(ConfigurationModel):
"""Configuration for the video upload feature."""
profile_whitelist = TextField(
blank=True,
help_text="A comma-separated list of names of profiles to include in video encoding downloads."
)
status_whitelist = TextField(
blank=True,
help_text=(
"A comma-separated list of Studio status values;" +
" only videos with these status values will be included in video encoding downloads."
)
)
@classmethod
def get_profile_whitelist(cls):
"""Get the list of profiles to include in the encoding download"""
return [profile for profile in cls.current().profile_whitelist.split(",") if profile]
@classmethod
def get_status_whitelist(cls):
"""
Get the list of status values to include files for in the encoding
download
"""
return [status for status in cls.current().status_whitelist.split(",") if status]
#-*- coding: utf-8 -*-
""" """
Unit tests for video-related REST APIs. Unit tests for video-related REST APIs.
""" """
# pylint: disable=attribute-defined-outside-init # pylint: disable=attribute-defined-outside-init
import csv
import json import json
import dateutil.parser import dateutil.parser
import re import re
from StringIO import StringIO
from django.conf import settings from django.conf import settings
from django.test.utils import override_settings from django.test.utils import override_settings
from mock import Mock, patch from mock import Mock, patch
from edxval.api import create_video, get_video_info from edxval.api import create_profile, create_video, get_video_info
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, VIDEO_ASSET_TYPE from contentstore.models import VideoUploadConfig
from contentstore.views.videos import KEY_EXPIRATION_IN_SECONDS, VIDEO_ASSET_TYPE, status_display_string
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
from xmodule.assetstore import AssetMetadata from xmodule.assetstore import AssetMetadata
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True}) class VideoUploadTestMixin(object):
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUploadTestCase(CourseTestCase):
""" """
Test cases for the video upload page Test cases for the video upload feature
""" """
@staticmethod def get_url_for_course_key(self, course_key):
def get_url_for_course_key(course_key):
"""Return video handler URL for the given course""" """Return video handler URL for the given course"""
return reverse_course_url("videos_handler", course_key) return reverse_course_url(self.VIEW_NAME, course_key)
def setUp(self): def setUp(self):
super(VideoUploadTestCase, self).setUp() super(VideoUploadTestMixin, self).setUp()
self.url = VideoUploadTestCase.get_url_for_course_key(self.course.id) self.url = self.get_url_for_course_key(self.course.id)
self.test_token = "test_token" self.test_token = "test_token"
self.course.video_upload_pipeline = { self.course.video_upload_pipeline = {
"course_video_upload_token": self.test_token, "course_video_upload_token": self.test_token,
} }
self.save_course() self.save_course()
self.profiles = [
{
"profile_name": "profile1",
"extension": "mp4",
"width": 640,
"height": 480,
},
{
"profile_name": "profile2",
"extension": "mp4",
"width": 1920,
"height": 1080,
},
]
self.previous_uploads = [ self.previous_uploads = [
{ {
"edx_video_id": "test1", "edx_video_id": "test1",
"client_video_id": "test1.mp4", "client_video_id": "test1.mp4",
"duration": 42.0, "duration": 42.0,
"status": "transcode_active", "status": "upload",
"encoded_videos": [], "encoded_videos": [],
}, },
{ {
...@@ -51,9 +67,38 @@ class VideoUploadTestCase(CourseTestCase): ...@@ -51,9 +67,38 @@ class VideoUploadTestCase(CourseTestCase):
"client_video_id": "test2.mp4", "client_video_id": "test2.mp4",
"duration": 128.0, "duration": 128.0,
"status": "file_complete", "status": "file_complete",
"encoded_videos": [], "encoded_videos": [
} {
"profile": "profile1",
"url": "http://example.com/profile1/test2.mp4",
"file_size": 1600,
"bitrate": 100,
},
{
"profile": "profile2",
"url": "http://example.com/profile2/test2.mov",
"file_size": 16000,
"bitrate": 1000,
},
],
},
{
"edx_video_id": "non-ascii",
"client_video_id": u"nón-ascii-näme.mp4",
"duration": 256.0,
"status": "transcode_active",
"encoded_videos": [
{
"profile": "profile1",
"url": u"http://example.com/profile1/nón-ascii-näme.mp4",
"file_size": 3200,
"bitrate": 100,
},
]
},
] ]
for profile in self.profiles:
create_profile(profile)
for video in self.previous_uploads: for video in self.previous_uploads:
create_video(video) create_video(video)
modulestore().save_asset_metadata( modulestore().save_asset_metadata(
...@@ -63,6 +108,14 @@ class VideoUploadTestCase(CourseTestCase): ...@@ -63,6 +108,14 @@ class VideoUploadTestCase(CourseTestCase):
self.user.id self.user.id
) )
def _get_previous_upload(self, edx_video_id):
"""Returns the previous upload with the given video id."""
return next(
video
for video in self.previous_uploads
if video["edx_video_id"] == edx_video_id
)
def test_anon_user(self): def test_anon_user(self):
self.client.logout() self.client.logout()
response = self.client.get(self.url) response = self.client.get(self.url)
...@@ -74,7 +127,7 @@ class VideoUploadTestCase(CourseTestCase): ...@@ -74,7 +127,7 @@ class VideoUploadTestCase(CourseTestCase):
def test_invalid_course_key(self): def test_invalid_course_key(self):
response = self.client.get( response = self.client.get(
VideoUploadTestCase.get_url_for_course_key("Non/Existent/Course") self.get_url_for_course_key("Non/Existent/Course")
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
...@@ -96,24 +149,29 @@ class VideoUploadTestCase(CourseTestCase): ...@@ -96,24 +149,29 @@ class VideoUploadTestCase(CourseTestCase):
self.save_course() self.save_course()
self.assertEqual(self.client.get(self.url).status_code, 404) self.assertEqual(self.client.get(self.url).status_code, 404)
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
"""Test cases for the main video upload endpoint"""
VIEW_NAME = "videos_handler"
def test_get_json(self): def test_get_json(self):
response = self.client.get_json(self.url) response = self.client.get_json(self.url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response_videos = json.loads(response.content)["videos"] response_videos = json.loads(response.content)["videos"]
self.assertEqual(len(response_videos), len(self.previous_uploads)) self.assertEqual(len(response_videos), len(self.previous_uploads))
for response_video in response_videos: for response_video in response_videos:
original_video = dict( original_video = self._get_previous_upload(response_video["edx_video_id"])
next(
video for video in self.previous_uploads if video["edx_video_id"] == response_video["edx_video_id"]
)
)
self.assertEqual( self.assertEqual(
set(response_video.keys()), set(response_video.keys()),
set(["edx_video_id", "client_video_id", "created", "duration", "status"]) set(["edx_video_id", "client_video_id", "created", "duration", "status"])
) )
dateutil.parser.parse(response_video["created"]) dateutil.parser.parse(response_video["created"])
for field in ["edx_video_id", "client_video_id", "duration", "status"]: for field in ["edx_video_id", "client_video_id", "duration"]:
self.assertEqual(response_video[field], original_video[field]) self.assertEqual(response_video[field], original_video[field])
self.assertEqual(response_video["status"], status_display_string(original_video["status"]))
def test_get_html(self): def test_get_html(self):
response = self.client.get(self.url) response = self.client.get(self.url)
...@@ -191,6 +249,7 @@ class VideoUploadTestCase(CourseTestCase): ...@@ -191,6 +249,7 @@ class VideoUploadTestCase(CourseTestCase):
json.dumps({"files": files}), json.dumps({"files": files}),
content_type="application/json" content_type="application/json"
) )
self.assertEqual(response.status_code, 200)
response_obj = json.loads(response.content) response_obj = json.loads(response.content)
mock_conn.assert_called_once_with(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY) mock_conn.assert_called_once_with(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
...@@ -243,3 +302,94 @@ class VideoUploadTestCase(CourseTestCase): ...@@ -243,3 +302,94 @@ class VideoUploadTestCase(CourseTestCase):
response_file = response_obj["files"][i] response_file = response_obj["files"][i]
self.assertEqual(response_file["file_name"], file_info["file_name"]) self.assertEqual(response_file["file_name"], file_info["file_name"])
self.assertEqual(response_file["upload_url"], mock_key_instance.generate_url()) self.assertEqual(response_file["upload_url"], mock_key_instance.generate_url())
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase):
"""Test cases for the CSV download endpoint for video uploads"""
VIEW_NAME = "video_encodings_download"
def setUp(self):
super(VideoUrlsCsvTestCase, self).setUp()
VideoUploadConfig(
profile_whitelist="profile1",
status_whitelist=(
status_display_string("file_complete") + "," +
status_display_string("transcode_active")
)
).save()
def _check_csv_response(self, expected_video_ids, expected_profiles):
"""
Check that the response is a valid CSV response containing rows
corresponding to expected_video_ids.
"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response["Content-Disposition"],
"attachment; filename={course}_video_urls.csv".format(course=self.course.id.course)
)
response_reader = StringIO(response.content)
reader = csv.DictReader(response_reader, dialect=csv.excel)
self.assertEqual(
reader.fieldnames,
(
["Name", "Duration", "Date Added", "Video ID", "Status"] +
["{} URL".format(profile) for profile in expected_profiles]
)
)
actual_video_ids = []
for row in reader:
response_video = {
key.decode("utf-8"): value.decode("utf-8") for key, value in row.items()
}
actual_video_ids.append(response_video["Video ID"])
original_video = self._get_previous_upload(response_video["Video ID"])
self.assertEqual(response_video["Name"], original_video["client_video_id"])
self.assertEqual(response_video["Video ID"], original_video["edx_video_id"])
for profile in expected_profiles:
response_profile_url = response_video["{} URL".format(profile)]
original_encoded_for_profile = next(
(
original_encoded
for original_encoded in original_video["encoded_videos"]
if original_encoded["profile"] == profile
),
None
)
if original_encoded_for_profile:
self.assertEqual(response_profile_url, original_encoded_for_profile["url"])
else:
self.assertEqual(response_profile_url, "")
self.assertEqual(set(actual_video_ids), set(expected_video_ids))
def test_basic(self):
self._check_csv_response(["test2", "non-ascii"], ["profile1"])
def test_config(self):
VideoUploadConfig(
profile_whitelist="profile1,profile2",
status_whitelist=(
status_display_string("file_complete") + "," +
status_display_string("transcode_active") + "," +
status_display_string("upload")
)
).save()
self._check_csv_response(["test1", "test2", "non-ascii"], ["profile1", "profile2"])
def test_non_ascii_course(self):
course = CourseFactory.create(
number=u"nón-äscii",
video_upload_pipeline={
"course_video_upload_token": self.test_token,
}
)
response = self.client.get(self.get_url_for_course_key(course.id))
self.assertEqual(response.status_code, 200)
self.assertEqual(
response["Content-Disposition"],
"attachment; filename=video_urls.csv; filename*=utf-8''n%C3%B3n-%C3%A4scii_video_urls.csv"
)
...@@ -2,16 +2,20 @@ ...@@ -2,16 +2,20 @@
Views related to the video upload feature Views related to the video upload feature
""" """
from boto import s3 from boto import s3
import csv
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponseNotFound from django.http import HttpResponse, HttpResponseNotFound
from django.views.decorators.http import require_http_methods from django.utils.translation import ugettext as _
from django.views.decorators.http import require_GET, require_http_methods
import rfc6266
from edxval.api import create_video, get_videos_for_ids from edxval.api import create_video, get_videos_for_ids
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from contentstore.models import VideoUploadConfig
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from util.json_request import expect_json, JsonResponse from util.json_request import expect_json, JsonResponse
...@@ -21,7 +25,7 @@ from xmodule.modulestore.django import modulestore ...@@ -21,7 +25,7 @@ from xmodule.modulestore.django import modulestore
from .course import get_course_and_check_access from .course import get_course_and_check_access
__all__ = ["videos_handler"] __all__ = ["videos_handler", "video_encodings_download"]
# String constant used in asset keys to identify video assets. # String constant used in asset keys to identify video assets.
...@@ -31,6 +35,43 @@ VIDEO_ASSET_TYPE = "video" ...@@ -31,6 +35,43 @@ VIDEO_ASSET_TYPE = "video"
KEY_EXPIRATION_IN_SECONDS = 86400 KEY_EXPIRATION_IN_SECONDS = 86400
class StatusDisplayStrings(object):
"""
Enum of display strings for Video Status presented in Studio (e.g., in UI and in CSV download).
"""
# Translators: This is the status of an active video upload
UPLOADING = _("Uploading")
# Translators: This is the status for a video that the servers are currently processing
IN_PROGRESS = _("In Progress")
# Translators: This is the status for a video that the servers have successfully processed
COMPLETE = _("Complete")
# Translators: This is the status for a video that the servers have failed to process
FAILED = _("Failed"),
# Translators: This is the status for a video for which an invalid
# processing token was provided in the course settings
INVALID_TOKEN = _("Invalid Token"),
# Translators: This is the status for a video that is in an unknown state
UNKNOWN = _("Unknown")
def status_display_string(val_status):
"""
Converts VAL status string to Studio status string.
"""
status_map = {
"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
}
return status_map.get(val_status, StatusDisplayStrings.UNKNOWN)
@expect_json @expect_json
@login_required @login_required
@require_http_methods(("GET", "POST")) @require_http_methods(("GET", "POST"))
...@@ -48,18 +89,9 @@ def videos_handler(request, course_key_string): ...@@ -48,18 +89,9 @@ def videos_handler(request, course_key_string):
to this endpoint but rather PUT to the respective upload_url values to this endpoint but rather PUT to the respective upload_url values
contained in the response contained in the response
""" """
course_key = CourseKey.from_string(course_key_string) course = _get_and_validate_course(course_key_string, request.user)
# 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 ( if not course:
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() return HttpResponseNotFound()
if request.method == "GET": if request.method == "GET":
...@@ -71,21 +103,141 @@ def videos_handler(request, course_key_string): ...@@ -71,21 +103,141 @@ def videos_handler(request, course_key_string):
return videos_post(course, request) return videos_post(course, request)
@login_required
@require_GET
def video_encodings_download(request, course_key_string):
"""
Returns a CSV report containing the encoded video URLs for video uploads
in the following format:
Video ID,Name,Status,Profile1 URL,Profile2 URL
aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa,video.mp4,Complete,http://example.com/prof1.mp4,http://example.com/prof2.mp4
"""
course = _get_and_validate_course(course_key_string, request.user)
if not course:
return HttpResponseNotFound()
def get_profile_header(profile):
"""Returns the column header string for the given profile's URLs"""
# Translators: This is the header for a CSV file column
# containing URLs for video encodings for the named profile
# (e.g. desktop, mobile high quality, mobile low quality)
return _("{profile_name} URL").format(profile_name=profile)
profile_whitelist = VideoUploadConfig.get_profile_whitelist()
status_whitelist = VideoUploadConfig.get_status_whitelist()
videos = list(_get_videos(course))
name_col = _("Name")
duration_col = _("Duration")
added_col = _("Date Added")
video_id_col = _("Video ID")
status_col = _("Status")
profile_cols = [get_profile_header(profile) for profile in profile_whitelist]
def make_csv_dict(video):
"""
Makes a dictionary suitable for writing CSV output. This involves
extracting the required items from the original video dict and
converting all keys and values to UTF-8 encoded string objects,
because the CSV module doesn't play well with unicode objects.
"""
# 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_val = str(video["duration"]) if video["duration"] > 0 else _("Pending")
ret = dict(
[
(name_col, video["client_video_id"]),
(duration_col, duration_val),
(added_col, video["created"].isoformat()),
(video_id_col, video["edx_video_id"]),
(status_col, video["status"]),
] +
[
(get_profile_header(encoded_video["profile"]), encoded_video["url"])
for encoded_video in video["encoded_videos"]
if encoded_video["profile"] in profile_whitelist
]
)
return {
key.encode("utf-8"): value.encode("utf-8")
for key, value in ret.items()
}
response = HttpResponse(content_type="text/csv")
# Translators: This is the suggested filename when downloading the URL
# listing for videos uploaded through Studio
filename = _("{course}_video_urls").format(course=course.id.course)
# See https://tools.ietf.org/html/rfc6266#appendix-D
response["Content-Disposition"] = rfc6266.build_header(
filename + ".csv",
filename_compat="video_urls.csv"
)
writer = csv.DictWriter(
response,
[name_col, duration_col, added_col, video_id_col, status_col] + profile_cols,
dialect=csv.excel
)
writer.writeheader()
for video in videos:
if video["status"] in status_whitelist:
writer.writerow(make_csv_dict(video))
return response
def _get_and_validate_course(course_key_string, user):
"""
Given a course key, return the course if it exists, the given user has
access to it, and it is properly configured for video uploads
"""
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, user)
if (
settings.FEATURES["ENABLE_VIDEO_UPLOAD_PIPELINE"] and
getattr(settings, "VIDEO_UPLOAD_PIPELINE", None) and
course and
course.video_pipeline_configured
):
return course
else:
return None
def _get_videos(course): def _get_videos(course):
""" """
Retrieves the list of videos from VAL corresponding to the videos listed in Retrieves the list of videos from VAL corresponding to the videos listed in
the asset metadata store and returns the needed subset of fields the asset metadata store.
""" """
edx_videos_ids = [ edx_videos_ids = [
v.asset_id.path v.asset_id.path
for v in modulestore().get_all_asset_metadata(course.id, VIDEO_ASSET_TYPE) for v in modulestore().get_all_asset_metadata(course.id, VIDEO_ASSET_TYPE)
] ]
videos = list(get_videos_for_ids(edx_videos_ids))
# convert VAL's status to studio's Video Upload feature status.
for video in videos:
video["status"] = status_display_string(video["status"])
return videos
def _get_index_videos(course):
"""
Returns the information about each video upload required for the video list
"""
return list( return list(
{ {
attr: video[attr] attr: video[attr]
for attr in ["edx_video_id", "client_video_id", "created", "duration", "status"] for attr in ["edx_video_id", "client_video_id", "created", "duration", "status"]
} }
for video in get_videos_for_ids(edx_videos_ids) for video in _get_videos(course)
) )
...@@ -98,7 +250,8 @@ def videos_index_html(course): ...@@ -98,7 +250,8 @@ def videos_index_html(course):
{ {
"context_course": course, "context_course": course,
"post_url": reverse_course_url("videos_handler", unicode(course.id)), "post_url": reverse_course_url("videos_handler", unicode(course.id)),
"previous_uploads": _get_videos(course), "encodings_download_url": reverse_course_url("video_encodings_download", unicode(course.id)),
"previous_uploads": _get_index_videos(course),
"concurrent_upload_limit": settings.VIDEO_UPLOAD_PIPELINE.get("CONCURRENT_UPLOAD_LIMIT", 0), "concurrent_upload_limit": settings.VIDEO_UPLOAD_PIPELINE.get("CONCURRENT_UPLOAD_LIMIT", 0),
} }
) )
...@@ -117,7 +270,7 @@ def videos_index_json(course): ...@@ -117,7 +270,7 @@ def videos_index_json(course):
}] }]
} }
""" """
return JsonResponse({"videos": _get_videos(course)}, status=200) return JsonResponse({"videos": _get_index_videos(course)}, status=200)
def videos_post(course, request): def videos_post(course, request):
......
...@@ -2,7 +2,14 @@ define( ...@@ -2,7 +2,14 @@ define(
["jquery", "backbone", "js/views/active_video_upload_list", "js/views/previous_video_upload_list"], ["jquery", "backbone", "js/views/active_video_upload_list", "js/views/previous_video_upload_list"],
function ($, Backbone, ActiveVideoUploadListView, PreviousVideoUploadListView) { function ($, Backbone, ActiveVideoUploadListView, PreviousVideoUploadListView) {
"use strict"; "use strict";
var VideosIndexFactory = function($contentWrapper, postUrl, concurrentUploadLimit, uploadButton, previousUploads) { var VideosIndexFactory = function(
$contentWrapper,
postUrl,
encodingsDownloadUrl,
concurrentUploadLimit,
uploadButton,
previousUploads
) {
var activeView = new ActiveVideoUploadListView({ var activeView = new ActiveVideoUploadListView({
postUrl: postUrl, postUrl: postUrl,
concurrentUploadLimit: concurrentUploadLimit, concurrentUploadLimit: concurrentUploadLimit,
...@@ -10,7 +17,10 @@ define( ...@@ -10,7 +17,10 @@ define(
}); });
$contentWrapper.append(activeView.render().$el); $contentWrapper.append(activeView.render().$el);
var previousCollection = new Backbone.Collection(previousUploads); var previousCollection = new Backbone.Collection(previousUploads);
var previousView = new PreviousVideoUploadListView({collection: previousCollection}); var previousView = new PreviousVideoUploadListView({
collection: previousCollection,
encodingsDownloadUrl: encodingsDownloadUrl
});
$contentWrapper.append(previousView.render().$el); $contentWrapper.append(previousView.render().$el);
}; };
......
...@@ -67,16 +67,12 @@ define( ...@@ -67,16 +67,12 @@ define(
_.each( _.each(
[ [
{status: "upload", expected: "Uploading"}, {status: "Uploading", expected: "Uploading"},
{status: "ingest", expected: "In Progress"}, {status: "In Progress", expected: "In Progress"},
{status: "transcode_queue", expected: "In Progress"}, {status: "Complete", expected: "Complete"},
{status: "transcode_active", expected: "In Progress"}, {status: "Failed", expected: "Failed"},
{status: "file_delivered", expected: "Complete"}, {status: "Invalid Token", expected: "Invalid Token"},
{status: "file_complete", expected: "Complete"}, {status: "Unknown", expected: "Unknown"}
{status: "file_corrupt", expected: "Failed"},
{status: "pipeline_error", expected: "Failed"},
{status: "invalid_token", expected: "Invalid Token"},
{status: "unexpected_status_string", expected: "Unknown"}
], ],
function(caseInfo) { function(caseInfo) {
it("should render " + caseInfo.status + " status correctly", function() { it("should render " + caseInfo.status + " status correctly", function() {
......
...@@ -3,38 +3,6 @@ define( ...@@ -3,38 +3,6 @@ define(
function(gettext, DateUtils, BaseView) { function(gettext, DateUtils, BaseView) {
"use strict"; "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({ var PreviousVideoUploadView = BaseView.extend({
tagName: "tr", tagName: "tr",
...@@ -57,7 +25,7 @@ define( ...@@ -57,7 +25,7 @@ define(
// the servers where its duration is determined. // the servers where its duration is determined.
duration: duration > 0 ? this.renderDuration(duration) : gettext("Pending"), duration: duration > 0 ? this.renderDuration(duration) : gettext("Pending"),
created: DateUtils.renderDate(this.model.get("created")), created: DateUtils.renderDate(this.model.get("created")),
status: statusMap[this.model.get("status")] || statusDisplayStrings.UNKNOWN status: this.model.get("status")
}; };
this.$el.html( this.$el.html(
this.template(_.extend({}, this.model.attributes, renderedAttributes)) this.template(_.extend({}, this.model.attributes, renderedAttributes))
......
...@@ -6,8 +6,9 @@ define( ...@@ -6,8 +6,9 @@ define(
tagName: "section", tagName: "section",
className: "wrapper-assets", className: "wrapper-assets",
initialize: function() { initialize: function(options) {
this.template = this.loadTemplate("previous-video-upload-list"); this.template = this.loadTemplate("previous-video-upload-list");
this.encodingsDownloadUrl = options.encodingsDownloadUrl;
this.itemViews = this.collection.map(function(model) { this.itemViews = this.collection.map(function(model) {
return new PreviousVideoUploadView({model: model}); return new PreviousVideoUploadView({model: model});
}); });
...@@ -15,7 +16,7 @@ define( ...@@ -15,7 +16,7 @@ define(
render: function() { render: function() {
var $el = this.$el; var $el = this.$el;
$el.html(this.template()); $el.html(this.template({encodingsDownloadUrl: this.encodingsDownloadUrl}));
var $tabBody = $el.find(".js-table-body"); var $tabBody = $el.find(".js-table-body");
_.each(this.itemViews, function(view) { _.each(this.itemViews, function(view) {
$tabBody.append(view.render().$el); $tabBody.append(view.render().$el);
......
...@@ -90,4 +90,17 @@ ...@@ -90,4 +90,17 @@
@extend %ui-btn-non; @extend %ui-btn-non;
} }
.assets-library {
.assets-title {
display: inline-block;
width: flex-grid(5, 9);
@include margin-right(flex-gutter());
}
.wrapper-encodings-download {
display: inline-block;
width: flex-grid(4, 9);
text-align: right;
}
}
} }
<div class="assets-library"> <div class="assets-library">
<h3 class="assets-title"><%- gettext("Previous Uploads") %></h3> <h3 class="assets-title"><%- gettext("Previous Uploads") %></h3>
<div class="wrapper-encodings-download">
<a href="<%- encodingsDownloadUrl %>">
<%- gettext("Download available encodings (.csv)") %>
</a>
</div>
<table class="assets-table"> <table class="assets-table">
<thead> <thead>
<tr> <tr>
......
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
VideosIndexFactory( VideosIndexFactory(
$contentWrapper, $contentWrapper,
"${post_url}", "${post_url}",
"${encodings_download_url}",
${concurrent_upload_limit}, ${concurrent_upload_limit},
$(".nav-actions .upload-button"), $(".nav-actions .upload-button"),
$contentWrapper.data("previous-uploads") $contentWrapper.data("previous-uploads")
......
...@@ -92,6 +92,7 @@ urlpatterns += patterns( ...@@ -92,6 +92,7 @@ urlpatterns += patterns(
url(r'^textbooks/{}$'.format(settings.COURSE_KEY_PATTERN), 'textbooks_list_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'^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'^videos/{}$'.format(settings.COURSE_KEY_PATTERN), 'videos_handler'),
url(r'^video_encodings_download/{}$'.format(settings.COURSE_KEY_PATTERN), 'video_encodings_download'),
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_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), url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)/?$'.format(settings.COURSE_KEY_PATTERN),
'group_configurations_detail_handler'), 'group_configurations_detail_handler'),
......
...@@ -73,6 +73,7 @@ pysrt==0.4.7 ...@@ -73,6 +73,7 @@ pysrt==0.4.7
PyYAML==3.10 PyYAML==3.10
requests==2.3.0 requests==2.3.0
requests-oauthlib==0.4.1 requests-oauthlib==0.4.1
rfc6266==0.0.4
scipy==0.14.0 scipy==0.14.0
Shapely==1.2.16 Shapely==1.2.16
singledispatch==3.4.0.2 singledispatch==3.4.0.2
......
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