Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
"""
Public rest API endpoints for the Authoring API video assets.

.. deprecated::
The video upload endpoints in this module are superseded by
``CourseVideoUploadsViewSet`` in
``cms.djangoapps.contentstore.rest_api.v1.views.video_uploads``.
Use ``/api/authoring/v1/courses/{course_key}/videos/`` going forward.
These v0 endpoints will be removed in a future release.
"""
import logging

Expand Down Expand Up @@ -27,6 +34,10 @@
@view_auth_classes()
class VideosUploadsView(DeveloperErrorViewMixin, RetrieveAPIView, DestroyAPIView):
"""
**DEPRECATED** — use ``GET /api/authoring/v1/courses/{course_key}/videos/`` for the listing
this GET returns, and ``DELETE /api/authoring/v1/courses/{course_key}/videos/{edx_video_id}/``
to remove one video from the course. These v0 endpoints will be removed in a future release.

public rest API endpoints for the CMS API video assets.
course_key: required argument, needed to authorize course authors and identify the video.
video_id: required argument, needed to identify the video.
Expand All @@ -35,17 +46,34 @@ class VideosUploadsView(DeveloperErrorViewMixin, RetrieveAPIView, DestroyAPIView

@course_author_access_required
def retrieve(self, request, course_key, edx_video_id=None): # pylint: disable=arguments-differ
"""
**DEPRECATED** — use ``GET /api/authoring/v1/courses/{course_key}/videos/`` instead.

This GET ignores ``edx_video_id`` and returns the whole course listing;
``/api/authoring/v1/courses/{course_key}/videos/`` is the successor for that listing.
It will be removed in a future release.
"""
return handle_videos(request, course_key.html_id(), edx_video_id)

@course_author_access_required
@expect_json_in_class_view
def destroy(self, request, course_key, edx_video_id): # pylint: disable=arguments-differ
"""
**DEPRECATED** — use
``DELETE /api/authoring/v1/courses/{course_key}/videos/{edx_video_id}/`` instead.

It will be removed in a future release.
"""
return handle_videos(request, course_key.html_id(), edx_video_id)


@view_auth_classes()
class VideosCreateUploadView(DeveloperErrorViewMixin, CreateAPIView):
"""
**DEPRECATED** — use ``POST /api/authoring/v1/courses/{course_key}/videos/`` instead.

These v0 endpoints will be removed in a future release.

public rest API endpoints for the CMS API video assets.
course_key: required argument, needed to authorize course authors and identify the video.
"""
Expand Down
47 changes: 47 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Authoring API v1 URLs."""

from django.urls import path

from cms.djangoapps.contentstore.rest_api.v1.error_types import register_request_error_types
from cms.djangoapps.contentstore.rest_api.v1.views.unknown_route import UnknownRouteView
from cms.djangoapps.contentstore.rest_api.v1.views.video_uploads import CourseVideoUploadsViewSet

app_name = "authoring_v1"

register_request_error_types()

urlpatterns = [
path(
"courses/<course_key:course_key>/videos/",
CourseVideoUploadsViewSet.as_view({"get": "list", "post": "create"}),
name="course_video_list",
),
path(
"courses/<course_key:course_key>/videos/<str:edx_video_id>/",
CourseVideoUploadsViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
name="course_video_detail",
),
# The three routes below stand behind the two above and are reached only by a
# request the ones above turned down: a course key the converter refuses
# because it is malformed or in the deprecated slash-separated form, or an
# address under a course that this API does not serve. They answer with the
# API error body, which a client can read, instead of the site's HTML error
# page. Order is what keeps them out of the way of real requests, and each
# accepts only addresses ending in a slash so that the redirect to the
# slash-terminated form keeps working.
path(
"courses/<path:course_key>/videos/",
UnknownRouteView.as_view(),
name="course_video_list_unmatched",
),
path(
"courses/<path:course_key>/videos/<str:edx_video_id>/",
UnknownRouteView.as_view(),
name="course_video_detail_unmatched",
),
path(
"courses/<path:course_key>/",
UnknownRouteView.as_view(),
name="course_unmatched",
),
]
30 changes: 30 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/error_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Error-type catalog entries for request-level refusals."""

from edx_rest_framework_extensions.errors import register_error_type
from rest_framework.exceptions import (
MethodNotAllowed,
NotAcceptable,
ParseError,
UnsupportedMediaType,
)

#: Refusals of the request itself - its body, its method, its media type - with
#: the error type and title each is answered with.
_REQUEST_ERROR_TYPES = (
(ParseError, "validation", "Malformed Request"),
(MethodNotAllowed, "method-not-allowed", "Method Not Allowed"),
(NotAcceptable, "not-acceptable", "Not Acceptable"),
(UnsupportedMediaType, "unsupported-media-type", "Unsupported Media Type"),
)


def register_request_error_types():
"""
Give each request-level refusal its own error type.

Uncataloged errors are answered with the type and title of an internal
server error while keeping their 4xx status, which tells a client that its
own unparseable body or unsupported method was a fault of the server.
"""
for exception_class, slug, title in _REQUEST_ERROR_TYPES:
register_error_type(exception_class, slug, title)
204 changes: 204 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/serializers/video_uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""API Serializers for course video uploads."""

from edx_rest_framework_extensions.shaping import MinimalViewMixin
from rest_framework import serializers
from rest_framework.settings import api_settings

from cms.djangoapps.contentstore.rest_api.serializers.common import StrictSerializer
from cms.djangoapps.contentstore.rest_api.v1.video_uploads_service import VIDEO_ORDERING_CHOICES
from cms.djangoapps.contentstore.video_storage_handlers import VIDEO_SUPPORTED_FILE_FORMATS

SUPPORTED_UPLOAD_CONTENT_TYPES = sorted(set(VIDEO_SUPPORTED_FILE_FORMATS.values()))

NON_FIELD_ERRORS_KEY = api_settings.NON_FIELD_ERRORS_KEY

#: The fields one video is reduced to under the minimal response preset.
MINIMAL_VIDEO_FIELDS = ("edx_video_id", "client_video_id", "status", "created", "duration")


def _flat_messages(detail, path=""):
"""
Return validation ``detail`` as a map of field path to messages.

Errors reported for the entries of a list of objects are nested one or two
levels below the field they belong to. Flattening them to
``{"files[0].content_type": ["..."]}`` gives a client the message of every
invalid field without walking the containers it is reported inside.
"""
if isinstance(detail, dict):
flat = {}
for field, value in detail.items():
nested = path if field == NON_FIELD_ERRORS_KEY and path else _join(path, field)
flat.update(_flat_messages(value, nested))
return flat
if isinstance(detail, list) and any(isinstance(entry, (dict, list)) for entry in detail):
flat = {}
for index, entry in enumerate(detail):
if entry:
flat.update(_flat_messages(entry, f"{path}[{index}]"))
return flat
messages = detail if isinstance(detail, list) else [detail]
return {path or NON_FIELD_ERRORS_KEY: [str(message) for message in messages]}


def _join(path, field):
"""Return the path of ``field`` inside ``path``."""
return f"{path}.{field}" if path else str(field)


class VideoUploadFileSerializer(StrictSerializer):
"""One requested upload slot: the file the caller intends to PUT."""

file_name = serializers.CharField(
help_text="Name of the video file being uploaded. ASCII characters only.",
)
content_type = serializers.ChoiceField(
choices=SUPPORTED_UPLOAD_CONTENT_TYPES,
help_text="MIME type of the video file. Only these types can be stored.",
)

def validate_file_name(self, value):
"""Reject names the storage backend cannot carry in its object metadata."""
try:
value.encode("ascii")
except UnicodeEncodeError as error:
raise serializers.ValidationError(
"The file name must contain only ASCII characters."
) from error
return value


class VideoUploadRequestSerializer(StrictSerializer):
"""Request body for creating upload slots."""

files = VideoUploadFileSerializer(
many=True,
allow_empty=False,
help_text="Files to create upload slots for. One slot is returned per entry, in order.",
)

def run_validation(self, data=serializers.empty):
"""Validate the body, reporting every invalid field as a list of messages."""
try:
return super().run_validation(data)
except serializers.ValidationError as error:
raise serializers.ValidationError(_flat_messages(error.detail)) from error


class VideoUploadLinkSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One created upload slot."""

file_name = serializers.CharField(
help_text="Name of the video file, echoed from the request.",
)
upload_url = serializers.CharField(
help_text="Short-lived pre-signed URL to PUT the video file to. Expires after 24 hours.",
)
edx_video_id = serializers.CharField(
help_text="Identifier assigned to the video. Use it to address the video afterwards.",
)


class VideoUploadResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Response body for creating upload slots."""

files = VideoUploadLinkSerializer(
many=True,
help_text="Created upload slots, one per requested file, in request order.",
)


class CourseVideoSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One video asset attached to a course."""

edx_video_id = serializers.CharField(
help_text="Identifier of the video.",
)
client_video_id = serializers.CharField(
allow_blank=True,
help_text="Original file name the video was uploaded under.",
)
created = serializers.DateTimeField(
allow_null=True,
help_text="Time the video record was created.",
)
duration = serializers.FloatField(
allow_null=True,
help_text="Length of the video in seconds; 0 until the video has been processed.",
)
status = serializers.CharField(
help_text=(
"Processing state of the video, in stable English: for example Uploading, "
"In Progress, Ready, Failed, YouTube Duplicate."
),
)
error_description = serializers.CharField(
allow_null=True,
allow_blank=True,
help_text="Details of the processing failure, when the video failed to process.",
)
course_video_image_url = serializers.CharField(
allow_null=True,
help_text="Thumbnail image URL for this video in this course, or null when none is set.",
)
download_link = serializers.CharField(
allow_blank=True,
help_text="URL of the desktop MP4 encoding, or an empty string when it is not ready.",
)
file_size = serializers.IntegerField(
help_text="Size of the desktop MP4 encoding in bytes; 0 when it is not ready.",
)
transcripts = serializers.ListField(
child=serializers.CharField(),
help_text="Language codes the video has transcripts for.",
)
transcription_status = serializers.CharField(
allow_blank=True,
help_text=(
"Transcription state for courses on the current video workflow, in stable English; "
"empty for courses still using a course video upload token."
),
)
transcript_urls = serializers.DictField(
child=serializers.CharField(),
help_text="Transcript download URL keyed by language code.",
)


class CourseVideoMinimalSerializer(CourseVideoSerializer):
"""One video asset, reduced to the fields that identify it."""

def get_fields(self):
"""Return only the fields the minimal preset keeps."""
fields = super().get_fields()
return {name: field for name, field in fields.items() if name in MINIMAL_VIDEO_FIELDS}


class CourseVideoQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Query parameters accepted by every course video address."""

view = serializers.ChoiceField(
choices=[MinimalViewMixin.minimal_view_value],
required=False,
help_text="Response preset to return. Omit it for the full representation.",
)


class CourseVideoListQuerySerializer(CourseVideoQuerySerializer):
"""Query parameters accepted by the course video collection."""

ordering = serializers.ChoiceField(
choices=VIDEO_ORDERING_CHOICES,
required=False,
help_text="Field to sort by. Prefix the field name with '-' to sort descending.",
)
page = serializers.IntegerField(
required=False,
min_value=1,
help_text="Number of the page to return.",
)
page_size = serializers.IntegerField(
required=False,
min_value=1,
help_text="Number of videos per page; values above the maximum are reduced to it.",
)
Loading
Loading