From ebf91fc9add5e14028c3adda1b286e3210acc552 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 12 Dec 2023 09:37:48 -0500 Subject: [PATCH] feat: add api for multiple video downloads (#33882) --- .../rest_api/v1/serializers/__init__.py | 3 +- .../rest_api/v1/serializers/videos.py | 9 +++ .../contentstore/rest_api/v1/urls.py | 8 ++- .../rest_api/v1/views/__init__.py | 1 + .../contentstore/rest_api/v1/views/videos.py | 44 +++++++++++++- .../contentstore/video_storage_handlers.py | 59 ++++++++++++++++++- 6 files changed, 120 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index ac1f2cd1fb..f47c9911b2 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -17,5 +17,6 @@ from .videos import ( CourseVideosSerializer, VideoUploadSerializer, VideoImageSerializer, - VideoUsageSerializer + VideoUsageSerializer, + VideoDownloadSerializer ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py index 50100f6a2b..590b34fff0 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/videos.py @@ -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. diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index ad5765a673..168fa9bcab 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -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(), diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index 780f0059aa..81666919a4 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -11,5 +11,6 @@ from .settings import CourseSettingsView from .videos import ( CourseVideosView, VideoUsageView, + VideoDownloadView ) from .help_urls import HelpUrlsView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/videos.py b/cms/djangoapps/contentstore/rest_api/v1/views/videos.py index c5e7ae1bb2..c41deec0e5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/videos.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/videos.py @@ -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) diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py index f68d71a6bf..bd5c548564 100644 --- a/cms/djangoapps/contentstore/video_storage_handlers.py +++ b/cms/djangoapps/contentstore/video_storage_handlers.py @@ -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.