feat: add api for multiple video downloads (#33882)
This commit is contained in:
@@ -17,5 +17,6 @@ from .videos import (
|
||||
CourseVideosSerializer,
|
||||
VideoUploadSerializer,
|
||||
VideoImageSerializer,
|
||||
VideoUsageSerializer
|
||||
VideoUsageSerializer,
|
||||
VideoDownloadSerializer
|
||||
)
|
||||
|
||||
@@ -115,6 +115,15 @@ class VideoUsageSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class VideoDownloadSerializer(serializers.Serializer):
|
||||
"""Serializer for video downloads"""
|
||||
files = serializers.ListField(
|
||||
child=serializers.DictField(
|
||||
child=serializers.CharField()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class VideoUploadSerializer(StrictSerializer):
|
||||
"""
|
||||
Strict Serializer for video upload urls.
|
||||
|
||||
@@ -15,7 +15,8 @@ from .views import (
|
||||
ProctoredExamSettingsView,
|
||||
ProctoringErrorsView,
|
||||
HelpUrlsView,
|
||||
VideoUsageView
|
||||
VideoUsageView,
|
||||
VideoDownloadView
|
||||
)
|
||||
|
||||
app_name = 'v1'
|
||||
@@ -38,6 +39,11 @@ urlpatterns = [
|
||||
VideoUsageView.as_view(),
|
||||
name="video_usage"
|
||||
),
|
||||
re_path(
|
||||
fr'^videos/{COURSE_ID_PATTERN}/download$',
|
||||
VideoDownloadView.as_view(),
|
||||
name="video_usage"
|
||||
),
|
||||
re_path(
|
||||
fr'^proctored_exam_settings/{COURSE_ID_PATTERN}$',
|
||||
ProctoredExamSettingsView.as_view(),
|
||||
|
||||
@@ -11,5 +11,6 @@ from .settings import CourseSettingsView
|
||||
from .videos import (
|
||||
CourseVideosView,
|
||||
VideoUsageView,
|
||||
VideoDownloadView
|
||||
)
|
||||
from .help_urls import HelpUrlsView
|
||||
|
||||
@@ -14,11 +14,13 @@ from common.djangoapps.student.auth import has_studio_read_access
|
||||
from ....utils import get_course_videos_context
|
||||
|
||||
from cms.djangoapps.contentstore.video_storage_handlers import (
|
||||
get_video_usage_path
|
||||
get_video_usage_path,
|
||||
create_video_zip,
|
||||
)
|
||||
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
|
||||
CourseVideosSerializer,
|
||||
VideoUsageSerializer,
|
||||
VideoDownloadSerializer
|
||||
)
|
||||
import cms.djangoapps.contentstore.toggles as contentstore_toggles
|
||||
|
||||
@@ -180,3 +182,43 @@ class VideoUsageView(DeveloperErrorViewMixin, APIView):
|
||||
usage_locations = get_video_usage_path(course_key, edx_video_id)
|
||||
serializer = VideoUsageSerializer(usage_locations)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class VideoDownloadView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
View for course video downloads.
|
||||
"""
|
||||
@apidocs.schema(
|
||||
body=VideoDownloadSerializer,
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: "In case of success, a 200.",
|
||||
401: "The requester is not authenticated",
|
||||
403: "The requester cannot access the specified course",
|
||||
404: "The requested course does not exist",
|
||||
},
|
||||
)
|
||||
@verify_course_exists()
|
||||
def put(self, request: Request, course_id: str) -> Response:
|
||||
"""
|
||||
Get an object containing course videos.
|
||||
**Example Request**
|
||||
PUT /api/contentstore/v1/videos/{course_id}/download, {
|
||||
"files": [
|
||||
{"url": 'someUrl.com', "name": 'test.mp4'}
|
||||
]
|
||||
}
|
||||
**Response Values**
|
||||
If the request is successful, an HTTP 200 "OK" response is returned.
|
||||
The HTTP 200 response contains a zip file attachment containing all the
|
||||
requested videos. The returned file's name will be {course_id}_videos_{random_id}.zip.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
self.permission_denied(request)
|
||||
files = request.data['files']
|
||||
return create_video_zip(course_id, files)
|
||||
|
||||
@@ -8,6 +8,12 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
import shutil
|
||||
import pathlib
|
||||
import zipfile
|
||||
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
@@ -15,7 +21,7 @@ from boto.s3.connection import S3Connection
|
||||
from boto import s3
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.http import FileResponse, HttpResponseNotFound
|
||||
from django.http import FileResponse, HttpResponseNotFound, StreamingHttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_noop
|
||||
@@ -35,10 +41,14 @@ from edxval.api import (
|
||||
update_video_image,
|
||||
update_video_status
|
||||
)
|
||||
from fs.osfs import OSFS
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from path import Path as path
|
||||
from pytz import UTC
|
||||
from rest_framework import status as rest_status
|
||||
from rest_framework.response import Response
|
||||
from tempfile import NamedTemporaryFile, mkdtemp
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
@@ -221,6 +231,53 @@ def handle_videos(request, course_key_string, edx_video_id=None):
|
||||
return JsonResponse(data, status=status)
|
||||
|
||||
|
||||
def send_zip(zip_file, size=None):
|
||||
"""
|
||||
Generates a streaming http response for the zip file
|
||||
"""
|
||||
wrapper = FileWrapper(zip_file, settings.COURSE_EXPORT_DOWNLOAD_CHUNK_SIZE)
|
||||
response = StreamingHttpResponse(wrapper, content_type='application/zip')
|
||||
response['Content-Dispositon'] = 'attachment; filename=%s' % os.path.basename(zip_file.name)
|
||||
response['Content-Length'] = size
|
||||
return response
|
||||
|
||||
|
||||
def create_video_zip(course_key_string, files):
|
||||
"""
|
||||
Generates the video zip, or returns None if there was an error.
|
||||
|
||||
Updates the context with any error information if applicable.
|
||||
"""
|
||||
name = course_key_string + '_videos'
|
||||
video_folder_zip = NamedTemporaryFile(prefix=name + '_',
|
||||
suffix=".zip") # lint-amnesty, pylint: disable=consider-using-with
|
||||
root_dir = path(mkdtemp())
|
||||
video_dir = root_dir + '/' + name
|
||||
zip_folder = None
|
||||
try:
|
||||
for file in files:
|
||||
url = file['url']
|
||||
file_name = file['name']
|
||||
response = requests.get(url, allow_redirects=True)
|
||||
file_type = '.' + response.headers['Content-Type'][6:]
|
||||
if file_type not in file_name:
|
||||
file_name = file['name'] + file_type
|
||||
if not os.path.isdir(video_dir):
|
||||
os.makedirs(video_dir)
|
||||
with OSFS(video_dir).open(file_name, mode="wb") as f:
|
||||
f.write(response.content)
|
||||
directory = pathlib.Path(video_dir)
|
||||
with zipfile.ZipFile(video_folder_zip, mode="w") as archive:
|
||||
for file_path in directory.iterdir():
|
||||
archive.write(file_path, arcname=file_path.name)
|
||||
zip_folder = open(video_folder_zip.name, '+rb')
|
||||
|
||||
return send_zip(zip_folder, video_folder_zip.tell())
|
||||
finally:
|
||||
if os.path.exists(root_dir / name):
|
||||
shutil.rmtree(root_dir / name)
|
||||
|
||||
|
||||
def get_video_usage_path(course_key, edx_video_id):
|
||||
"""
|
||||
API for fetching the locations a specific video is used in a course.
|
||||
|
||||
Reference in New Issue
Block a user