diff --git a/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py new file mode 100644 index 000000000000..abc46e8f8d74 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py @@ -0,0 +1,23 @@ +"""Contentstore API v1 authoring URLs.""" + +from django.urls import path + +from cms.djangoapps.contentstore.rest_api.v1.views import ( + YoutubeTranscriptChecksViewSet, + YoutubeTranscriptImportsViewSet, +) + +app_name = "authoring_v1" + +urlpatterns = [ + path( + "courses//youtube_transcript_checks/", + YoutubeTranscriptChecksViewSet.as_view({"get": "list"}), + name="youtube_transcript_check_list", + ), + path( + "courses//youtube_transcript_imports/", + YoutubeTranscriptImportsViewSet.as_view({"post": "create"}), + name="youtube_transcript_import_list", + ), +] diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index 26403f734775..d97ea430a096 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -26,3 +26,9 @@ VideoUploadSerializer, # noqa: F401 VideoUsageSerializer, # noqa: F401 ) +from .youtube_transcripts import ( + YoutubeTranscriptCheckRequestSerializer, # noqa: F401 + YoutubeTranscriptCheckResultSerializer, # noqa: F401 + YoutubeTranscriptImportRequestSerializer, # noqa: F401 + YoutubeTranscriptImportResultSerializer, # noqa: F401 +) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/youtube_transcripts.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/youtube_transcripts.py new file mode 100644 index 000000000000..e06f5d6f8928 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/youtube_transcripts.py @@ -0,0 +1,120 @@ +"""API Serializers for YouTube transcripts.""" + +from rest_framework import serializers + + +class YoutubeTranscriptVideoSerializer(serializers.Serializer): + """One entry of the video list identifying a source to inspect or import from.""" + + type = serializers.CharField( + help_text=( + 'Source kind. "youtube" names a YouTube video id, "edx_video_id" names a ' + "video in the video pipeline, and any other value is treated as an HTML5 source." + ), + ) + video = serializers.CharField( + help_text="Identifier of the source, interpreted according to the type field.", + ) + mode = serializers.CharField( + required=False, + allow_blank=True, + help_text='Playback mode of an HTML5 source, such as "mp4" or "webm".', + ) + + class Meta: + ref_name = "YoutubeTranscriptVideo" + + +class YoutubeTranscriptCheckRequestSerializer(serializers.Serializer): + """Input identifying the video block whose transcript state is requested.""" + + locator = serializers.CharField( + help_text="Usage key of the video block to inspect.", + ) + videos = YoutubeTranscriptVideoSerializer( + many=True, + help_text="Video sources declared on the block, used to decide which transcripts to compare.", + ) + + class Meta: + ref_name = "YoutubeTranscriptCheckRequest" + + +class YoutubeTranscriptImportRequestSerializer(serializers.Serializer): + """Input identifying the video block to import a YouTube transcript into.""" + + locator = serializers.CharField( + help_text="Usage key of the video block to import the transcript into.", + ) + videos = YoutubeTranscriptVideoSerializer( + many=True, + help_text='Video sources declared on the block. An entry of type "youtube" is required.', + ) + + def validate_videos(self, value): + """Require the YouTube source the transcript is imported from.""" + if not any(video.get("type") == "youtube" for video in value): + raise serializers.ValidationError('An entry of type "youtube" is required.') + return value + + class Meta: + ref_name = "YoutubeTranscriptImportRequest" + + +class YoutubeTranscriptCheckResultSerializer(serializers.Serializer): + """Transcript availability of one video block, across its declared sources.""" + + html5_local = serializers.ListField( + child=serializers.CharField(), + help_text="Identifiers of the HTML5 sources that already have a transcript stored for this course.", + ) + html5_equal = serializers.BooleanField( + help_text="Whether exactly two HTML5 sources were found and their stored transcripts are identical.", + ) + is_youtube_mode = serializers.BooleanField( + help_text="Whether the block declares a YouTube source, which takes priority over HTML5 sources.", + ) + youtube_local = serializers.BooleanField( + help_text="Whether a transcript for the YouTube source is already stored for this course.", + ) + youtube_server = serializers.BooleanField( + help_text="Whether YouTube is currently serving a transcript for the video.", + ) + youtube_diff = serializers.BooleanField( + help_text="Whether the transcript on YouTube differs from the stored one.", + ) + current_item_subs = serializers.CharField( + required=False, + allow_null=True, + help_text="Name of the transcript currently attached to the block, or null when none is attached.", + ) + status = serializers.CharField( + help_text='Outcome of the check: "Success" when the block could be inspected.', + ) + command = serializers.CharField( + help_text=( + "Action the caller should take next: replace, found, import, choose, " + "use_existing, or not_found." + ), + ) + + class Meta: + ref_name = "YoutubeTranscriptCheckResult" + + +class YoutubeTranscriptImportResultSerializer(serializers.Serializer): + """Outcome of importing a YouTube transcript into a video block.""" + + edx_video_id = serializers.CharField( + allow_null=True, + help_text=( + "Video pipeline identifier the transcript was attached to. Null when the block " + "lives in a content library, where transcripts are stored on the block itself." + ), + ) + status = serializers.CharField( + help_text='Outcome of the import: "Success" when the transcript was stored.', + ) + + class Meta: + ref_name = "YoutubeTranscriptImportResult" diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index b413f6705c64..6b3c50cdf31f 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -17,3 +17,7 @@ from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view # noqa: F401 from .videos import CourseVideosView, VideoDownloadView, VideoUsageView # noqa: F401 from .xblock import XblockViewSet # noqa: F401 +from .youtube_transcripts import ( + YoutubeTranscriptChecksViewSet, # noqa: F401 + YoutubeTranscriptImportsViewSet, # noqa: F401 +) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py new file mode 100644 index 000000000000..f1c7cf6935f4 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py @@ -0,0 +1,828 @@ +"""Tests for the v1 YouTube transcript endpoints.""" + +import json +from unittest.mock import Mock, patch + +import ddt +from django.urls import Resolver404, resolve, reverse +from edx_rest_framework_extensions.testing import assert_error_envelope +from edxval.api import create_video +from opaque_keys.edx.keys import UsageKey +from organizations.tests.factories import OrganizationFactory +from rest_framework import status +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.rest_api.v0.views.transcripts import ( + YoutubeTranscriptCheckView, + YoutubeTranscriptUploadView, +) +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory +from openedx.core.djangoapps.content_libraries import api as lib_api +from openedx.core.djangoapps.video_config.transcripts_utils import Transcript +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.video_block import VideoBlock + +SRT_TRANSCRIPT_CONTENT = """0 +00:00:10,500 --> 00:00:13,000 +Elephant's Dream + +1 +00:00:15,000 --> 00:00:18,000 +At the left we can see... + +""" + +SJSON_TRANSCRIPT_CONTENT = Transcript.convert( + SRT_TRANSCRIPT_CONTENT, + Transcript.SRT, + Transcript.SJSON, +) + +DOWNLOAD_PATH = "cms.djangoapps.contentstore.views.transcripts_ajax.download_youtube_subs" +GET_ITEM_PATH = "cms.djangoapps.contentstore.views.transcripts_ajax._get_item" + +CHECK_URL_NAME = "authoring_v1:youtube_transcript_check_list" +IMPORT_URL_NAME = "authoring_v1:youtube_transcript_import_list" +LEGACY_CHECK_URL_NAME = "cms.djangoapps.contentstore:v0:cms_api_youtube_transcripts_check" +LEGACY_IMPORT_URL_NAME = "cms.djangoapps.contentstore:v0:cms_api_youtube_transcripts_upload" +# A V1 content library block: neither a course nor a V2 library context. +UNSUPPORTED_CONTEXT_LOCATOR = "lib-block-v1:edX+Lib+2024+type@video+block@abc" + +YOUTUBE_ID = "test_yt_id" + + +class BaseYoutubeTranscriptTest(CourseTestCase): + """Fixtures shared by every YouTube transcript endpoint test.""" + + def setUp(self): + super().setUp() + self.api_client = APIClient() + self.password = "password" + + self.video_usage_key = self._create_video_block(self.course) + self.item = modulestore().get_item(self.video_usage_key) + self._set_fields_from_xml(self.item, '