From 67256cdbc8cb5822410276157cf0babcee386e8f Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Sat, 26 Sep 2026 14:55:27 +0500 Subject: [PATCH 1/4] feat: apply FC-0118 ADRs to youtube transcripts APIs Add a conforming v1 surface for the two YouTube transcript endpoints at /api/authoring/v1/courses/{course_key}/youtube_transcript_{checks,imports}/. The import operation becomes a POST because the legacy GET performs four domain writes. Responses are serializer-produced and errors use the standardized envelope. The v0 routes are untouched and keep resolving at all four of their live spellings; they are marked deprecated in the schema. Co-Authored-By: Claude Opus 5 (1M context) --- .../rest_api/v1/authoring_urls.py | 23 + .../rest_api/v1/serializers/__init__.py | 6 + .../v1/serializers/youtube_transcripts.py | 114 +++ .../rest_api/v1/views/__init__.py | 4 + .../views/tests/test_youtube_transcripts.py | 807 ++++++++++++++++++ .../rest_api/v1/views/youtube_transcripts.py | 175 ++++ .../v1/youtube_transcripts_service.py | 130 +++ cms/envs/devstack.py | 13 +- cms/envs/production.py | 13 +- cms/lib/spectacular.py | 24 +- cms/lib/tests/__init__.py | 0 cms/lib/tests/test_spectacular.py | 120 +++ cms/urls.py | 11 + 13 files changed, 1431 insertions(+), 9 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers/youtube_transcripts.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/youtube_transcripts.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/youtube_transcripts_service.py create mode 100644 cms/lib/tests/__init__.py create mode 100644 cms/lib/tests/test_spectacular.py 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..b5299f490692 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/youtube_transcripts.py @@ -0,0 +1,114 @@ +"""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.', + ) + + 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..2ce36990b072 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py @@ -0,0 +1,807 @@ +"""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" + +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, '