Skip to content
Open
25 changes: 25 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/unknown_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""API view answering addresses that no endpoint serves."""

from drf_spectacular.utils import extend_schema
from edx_rest_framework_extensions.mixins import StandardizedErrorMixin
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView


@extend_schema(exclude=True)
class UnknownRouteView(StandardizedErrorMixin, APIView):
"""
Answer a video address whose course key the conforming routes refuse.

A key that is malformed or in the deprecated slash-separated form fails to
resolve, and would otherwise be answered by the site's HTML error page, which
an API client cannot read. Nothing here needs the caller's identity, so the
answer is the same for everyone: the address holds no resource.
"""

permission_classes = (AllowAny,)

def initial(self, request, *args, **kwargs):
"""Refuse the request before any handler runs; the address holds nothing."""
raise NotFound()
45 changes: 44 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Contentstore API v2 authoring URLs."""

from django.urls import path
from django.urls import path, re_path

from cms.djangoapps.contentstore.rest_api.v1.views.unknown_route import UnknownRouteView
from cms.djangoapps.contentstore.rest_api.v2.views.textbooks import CourseTextbooksViewSet
from cms.djangoapps.contentstore.rest_api.v2.views.video_archives import CourseVideoArchiveView
from cms.djangoapps.contentstore.rest_api.v2.views.video_settings import CourseVideoSettingsViewSet
from cms.djangoapps.contentstore.rest_api.v2.views.video_usages import CourseVideoUsageViewSet

app_name = "authoring_v2"

Expand All @@ -18,4 +21,44 @@
CourseVideoSettingsViewSet.as_view({"get": "retrieve"}),
name="course_video_settings",
),
path(
"courses/<course_key:course_key>/video_usages/<str:edx_video_id>/",
CourseVideoUsageViewSet.as_view({"get": "retrieve"}),
name="course_video_usage_detail",
),
path(
"courses/<course_key:course_key>/video_archives/",
CourseVideoArchiveView.as_view(),
name="course_video_archive_list",
),
# Every other address under these two collections holds nothing: a course
# key the routes above refuse (malformed, or in the deprecated
# slash-separated form), the usage collection without a video id, an empty
# video id, or a path below a usage or below the archives. They answer with
# the API error body rather than the site's HTML error page. They match
# addresses under these two collections and nothing else, so a resource
# added under courses/ is never shadowed, and only slash-terminated ones, so
# the redirect to the slash-terminated form still happens first. A path
# converter cannot match an empty segment, hence the patterns for the
# addresses below each collection.
path(
"courses/<path:course_key>/video_usages/",
UnknownRouteView.as_view(),
name="course_video_usage_list_unmatched",
),
re_path(
r"^courses/(?P<course_key>.+)/video_usages/(?P<subpath>.*)/$",
UnknownRouteView.as_view(),
name="course_video_usage_detail_unmatched",
),
path(
"courses/<path:course_key>/video_archives/",
UnknownRouteView.as_view(),
name="course_video_archive_list_unmatched",
),
re_path(
r"^courses/(?P<course_key>.+)/video_archives/(?P<subpath>.*)/$",
UnknownRouteView.as_view(),
name="course_video_archive_detail_unmatched",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Serializers for zip archives of a course's videos."""

from rest_framework import serializers


class VideoArchiveFileSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One video to put in the archive."""

# Values are used exactly as sent: the URL is matched character for
# character against the course's video URLs, and the name becomes the entry
# name in the archive.
url = serializers.CharField(
trim_whitespace=False,
help_text=(
"Address of one of the course's encoded video files, exactly as the course's video "
"listing reports it. Any other address is refused."
),
)
name = serializers.CharField(
trim_whitespace=False,
allow_blank=True,
help_text=(
"File name of the video inside the archive. When it does not already end with the "
"extension of the video's content type, that extension is appended."
),
)


class VideoArchiveRequestSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""The videos to bundle into one archive."""

files = VideoArchiveFileSerializer(
many=True,
help_text="The videos to include, in archive order. An empty list gives an empty archive.",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Serializers for where a video is used in a course."""

from rest_framework import serializers


class VideoUsageLocationSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One video component of the course that plays the video."""

display_location = serializers.CharField(
help_text=(
"Where the component sits, as '<subsection> - <unit> / <component>' display names. "
"A subsection or unit with no display name set appears as 'None', and a component with "
"none as 'Video'. The subsection part is empty for a unit that is in no subsection."
),
)
url = serializers.CharField(
help_text=(
"Studio address of the unit holding the component, anchored at the component: "
"'/container/<unit usage key>#<component usage key>'."
),
)


class CourseVideoUsageSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""The places in a course where one video is used."""

usage_locations = VideoUsageLocationSerializer(
many=True,
help_text=(
"One entry per video component that references the video, published or not. A "
"component with no parent in the course outline is left out. Empty when no component "
"is listed."
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Service layer for the video usage and video archive resources of a course."""

from rest_framework.exceptions import NotFound

from cms.djangoapps.contentstore.video_storage_handlers import create_video_zip, get_video_usage_path
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview


def ensure_course_exists(course_key):
"""
Raise ``NotFound`` unless a course run exists under ``course_key``.

Only reads: the course overview table first, then the content store. The
overview row is never created here, so a read request writes nothing.
"""
if not CourseOverview.course_exists(course_key):
raise NotFound("The course does not exist.")


def get_course_video_usages(course_key, edx_video_id):
"""
Return where the video ``edx_video_id`` is placed in the course.

The result is ``{"usage_locations": [{"display_location", "url"}, ...]}``,
one entry per video component that references the id, in content-store
order. Unpublished components are included; a component with no parent is
left out. An id that nothing references gives an empty list.
"""
ensure_course_exists(course_key)
return get_video_usage_path(course_key, edx_video_id)


def create_course_video_archive(course_key, files):
"""
Return a streaming zip response holding the requested videos of the course.

Every ``files[].url`` must be one of the course's own encoded-video URLs;
otherwise ``ValidationError`` is raised before anything is fetched. The
archive is assembled while it is sent and is not stored.
"""
return create_video_zip(str(course_key), files)
Loading
Loading