From 5d881cce73aa9ba24057fb5f39c898f141f62dc0 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 4 Mar 2026 21:42:45 +0500 Subject: [PATCH 1/5] refactor: use bumper_utils from xblocks-contrib package --- .../courseware/tests/test_video_mongo.py | 9 +- .../video_config/transcripts_utils.py | 6 +- xmodule/video_block/__init__.py | 1 - xmodule/video_block/bumper_utils.py | 147 ------------------ xmodule/video_block/video_block.py | 2 +- 5 files changed, 7 insertions(+), 158 deletions(-) delete mode 100644 xmodule/video_block/bumper_utils.py diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index f4d2847cff0f..98a63e394ae2 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -49,7 +49,8 @@ from xmodule.tests.helpers import mock_render_template, override_descriptor_system # pylint: disable=unused-import from xmodule.tests.test_import import DummyModuleStoreRuntime from xmodule.tests.test_video import VideoBlockTestBase -from xmodule.video_block import VideoBlock, bumper_utils, video_utils +from xmodule.video_block import VideoBlock, video_utils +from xblocks_contrib.video import bumper_utils from openedx.core.djangoapps.video_config.transcripts_utils import Transcript, save_to_store, subs_filename from xmodule.video_block.video_block import EXPORT_IMPORT_COURSE_DIR, EXPORT_IMPORT_STATIC_DIR from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW @@ -2323,7 +2324,7 @@ class TestVideoWithBumper(TestVideo): # pylint: disable=test-inherits-tests # Use temporary FEATURES in this test without affecting the original FEATURES = dict(settings.FEATURES) - @patch('xmodule.video_block.bumper_utils.get_bumper_settings') + @patch('xblocks_contrib.video.bumper_utils.get_bumper_settings') def test_is_bumper_enabled(self, get_bumper_settings): """ Check that bumper is (not)shown if ENABLE_VIDEO_BUMPER is (False)True @@ -2348,8 +2349,8 @@ def test_is_bumper_enabled(self, get_bumper_settings): assert not bumper_utils.is_bumper_enabled(self.block) @patch('xblock.utils.resources.ResourceLoader.render_django_template', side_effect=mock_render_template) - @patch('xmodule.video_block.bumper_utils.is_bumper_enabled') - @patch('xmodule.video_block.bumper_utils.get_bumper_settings') + @patch('xblocks_contrib.video.bumper_utils.is_bumper_enabled') + @patch('xblocks_contrib.video.bumper_utils.get_bumper_settings') @patch('edxval.api.get_urls_for_profiles') def test_bumper_metadata( self, get_url_for_profiles, get_bumper_settings, is_bumper_enabled, mock_render_django_template diff --git a/openedx/core/djangoapps/video_config/transcripts_utils.py b/openedx/core/djangoapps/video_config/transcripts_utils.py index 81b22d1c662a..7cf8cbd46d43 100644 --- a/openedx/core/djangoapps/video_config/transcripts_utils.py +++ b/openedx/core/djangoapps/video_config/transcripts_utils.py @@ -28,6 +28,7 @@ from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError +from xblocks_contrib.video.bumper_utils import get_bumper_settings from xblocks_contrib.video.exceptions import TranscriptsGenerationException @@ -786,11 +787,6 @@ def get_transcripts_info(self, is_bumper=False): is_bumper(bool): If True, the request is for the bumper transcripts include_val_transcripts(bool): If True, include edx-val transcripts as well """ - # TODO: This causes a circular import when imported at the top-level. - # This import will be removed as part of the VideoBlock extraction. - # https://github.com/openedx/edx-platform/issues/36282 - from xmodule.video_block.bumper_utils import get_bumper_settings - if is_bumper: transcripts = copy.deepcopy(get_bumper_settings(self).get('transcripts', {})) sub = transcripts.pop("en", "") diff --git a/xmodule/video_block/__init__.py b/xmodule/video_block/__init__.py index 0bb52cb9cb3e..48a6f28bf8d2 100644 --- a/xmodule/video_block/__init__.py +++ b/xmodule/video_block/__init__.py @@ -2,7 +2,6 @@ Container for video block and its utils. """ -from .bumper_utils import * from openedx.core.djangoapps.video_config.transcripts_utils import * # lint-amnesty, pylint: disable=redefined-builtin from .video_block import * from .video_utils import * diff --git a/xmodule/video_block/bumper_utils.py b/xmodule/video_block/bumper_utils.py deleted file mode 100644 index 64b02e915573..000000000000 --- a/xmodule/video_block/bumper_utils.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Utils for video bumper -""" - - -import copy -import json -import logging -from collections import OrderedDict -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo - -from django.conf import settings - -from .video_utils import set_query_parameter - -try: - import edxval.api as edxval_api -except ImportError: - edxval_api = None - -log = logging.getLogger(__name__) - - -def get_bumper_settings(video): - """ - Get bumper settings from video instance. - """ - bumper_settings = copy.deepcopy(getattr(video, 'video_bumper', {})) - - # clean up /static/ prefix from bumper transcripts - for lang, transcript_url in bumper_settings.get('transcripts', {}).items(): - bumper_settings['transcripts'][lang] = transcript_url.replace("/static/", "") - - return bumper_settings - - -def is_bumper_enabled(video): - """ - Check if bumper enabled. - - - Feature flag ENABLE_VIDEO_BUMPER should be set to True - - Do not show again button should not be clicked by user. - - Current time minus periodicity must be greater that last time viewed - - edxval_api should be presented - - Returns: - bool. - """ - bumper_last_view_date = getattr(video, 'bumper_last_view_date', None) - utc_now = datetime.now(ZoneInfo("UTC")) - periodicity = settings.FEATURES.get('SHOW_BUMPER_PERIODICITY', 0) - has_viewed = any([ - video.bumper_do_not_show_again, - (bumper_last_view_date and bumper_last_view_date + timedelta(seconds=periodicity) > utc_now) - ]) - is_studio = getattr(video.runtime, "is_author_mode", False) - return bool( - not is_studio and - settings.FEATURES.get('ENABLE_VIDEO_BUMPER') and - get_bumper_settings(video) and - edxval_api and - not has_viewed - ) - - -def bumperize(video): - """ - Populate video with bumper settings, if they are presented. - """ - video.bumper = { - 'enabled': False, - 'edx_video_id': "", - 'transcripts': {}, - 'metadata': None, - } - - if not is_bumper_enabled(video): - return - - bumper_settings = get_bumper_settings(video) - - try: - video.bumper['edx_video_id'] = bumper_settings['video_id'] - video.bumper['transcripts'] = bumper_settings['transcripts'] - except (TypeError, KeyError): - log.warning( - "Could not retrieve video bumper information from course settings" - ) - return - - sources = get_bumper_sources(video) - if not sources: - return - - video.bumper.update({ - 'metadata': bumper_metadata(video, sources), - 'enabled': True, # Video poster needs this. - }) - - -def get_bumper_sources(video): - """ - Get bumper sources from edxval. - - Returns list of sources. - """ - try: - val_profiles = ["desktop_webm", "desktop_mp4"] - val_video_urls = edxval_api.get_urls_for_profiles(video.bumper['edx_video_id'], val_profiles) - bumper_sources = [url for url in [val_video_urls[p] for p in val_profiles] if url] - except edxval_api.ValInternalError: - # if no bumper sources, nothing will be showed - log.warning( - "Could not retrieve information from VAL for Bumper edx Video ID: %s.", video.bumper['edx_video_id'] - ) - return [] - - return bumper_sources - - -def bumper_metadata(video, sources): - """ - Generate bumper metadata. - """ - transcripts = video.get_transcripts_info(is_bumper=True) - unused_track_url, bumper_transcript_language, bumper_languages = video.get_transcripts_for_student(transcripts) - - metadata = OrderedDict({ - 'saveStateUrl': video.ajax_url + '/save_user_state', - 'showCaptions': json.dumps(video.show_captions), - 'sources': sources, - 'streams': '', - 'transcriptLanguage': bumper_transcript_language, - 'transcriptLanguages': bumper_languages, - 'transcriptTranslationUrl': set_query_parameter( - video.runtime.handler_url(video, 'transcript', 'translation/__lang__').rstrip('/?'), 'is_bumper', 1 - ), - 'transcriptAvailableTranslationsUrl': set_query_parameter( - video.runtime.handler_url(video, 'transcript', 'available_translations').rstrip('/?'), 'is_bumper', 1 - ), - 'publishCompletionUrl': set_query_parameter( - video.runtime.handler_url(video, 'publish_completion', '').rstrip('?'), 'is_bumper', 1 - ), - }) - - return metadata diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index d5c4f509c39f..8429102696ec 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -48,7 +48,7 @@ XModuleMixin, XModuleToXBlockMixin, ) from xmodule.xml_block import XmlMixin, deserialize_field, is_pointer_tag, name_to_pathname -from .bumper_utils import bumperize +from xblocks_contrib.video.bumper_utils import bumperize from openedx.core.djangoapps.video_config.transcripts_utils import ( Transcript, VideoTranscriptsMixin, From a3aa088c5254c51035eb51f3a339fa01b68b9157 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 4 Mar 2026 22:09:52 +0500 Subject: [PATCH 2/5] chore: change patch package name of get_available_transcript_languages --- lms/djangoapps/courseware/tests/test_video_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py index 6353e2aec79f..a55a2edfaeef 100644 --- a/lms/djangoapps/courseware/tests/test_video_handlers.py +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -320,7 +320,7 @@ def test_multiple_available_translations(self, mock_get_video_transcript_content assert sorted(json.loads(response.body.decode('utf-8'))) == sorted(['en', 'uk']) @patch('openedx.core.djangoapps.video_config.transcripts_utils.get_video_transcript_content') - @patch('openedx.core.djangoapps.video_config.transcripts_utils.get_available_transcript_languages') + @patch('edxval.api.get_available_transcript_languages') @ddt.data( ( ['en', 'uk', 'ro'], From c8f973c475dbb1cc1628155643701e87fb537aa5 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 4 Mar 2026 22:17:47 +0500 Subject: [PATCH 3/5] refactor: use video_handlers from xblocks-contrib package --- .../courseware/tests/test_video_handlers.py | 6 +- xmodule/video_block/video_block.py | 2 +- xmodule/video_block/video_handlers.py | 551 ------------------ 3 files changed, 4 insertions(+), 555 deletions(-) delete mode 100644 xmodule/video_block/video_handlers.py diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py index a55a2edfaeef..91b28e974f30 100644 --- a/lms/djangoapps/courseware/tests/test_video_handlers.py +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -504,7 +504,7 @@ def test_download_transcript_not_exist(self): assert response.status == '404 Not Found' @patch( - 'xmodule.video_block.video_handlers.get_transcript', + 'xblocks_contrib.video.video_handlers.get_transcript', return_value=('Subs!', 'test_filename.srt', 'application/x-subrip; charset=utf-8') ) def test_download_srt_exist(self, __): @@ -515,7 +515,7 @@ def test_download_srt_exist(self, __): assert response.headers['Content-Language'] == 'en' @patch( - 'xmodule.video_block.video_handlers.get_transcript', + 'xblocks_contrib.video.video_handlers.get_transcript', return_value=('Subs!', 'txt', 'text/plain; charset=utf-8') ) def test_download_txt_exist(self, __): @@ -545,7 +545,7 @@ def test_download_non_en_non_ascii_filename(self, __): assert response.headers['Content-Disposition'] == 'attachment; filename="en_塞.srt"' @patch('openedx.core.djangoapps.video_config.transcripts_utils.edxval_api.get_video_transcript_data') - @patch('xmodule.video_block.get_transcript', Mock(side_effect=NotFoundError)) + @patch('xblocks_contrib.video.video_handlers.get_transcript', Mock(side_effect=NotFoundError)) def test_download_fallback_transcript(self, mock_get_video_transcript_data): """ Verify val transcript is returned as a fallback if it is not found in the content store. diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index 8429102696ec..91bd8117f436 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -57,7 +57,7 @@ get_html5_ids, subs_filename ) -from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers +from xblocks_contrib.video.video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers from .video_utils import create_youtube_string, format_xml_exception_message, get_poster, rewrite_video_url from .video_xfields import VideoFields diff --git a/xmodule/video_block/video_handlers.py b/xmodule/video_block/video_handlers.py deleted file mode 100644 index f00f2e9f0330..000000000000 --- a/xmodule/video_block/video_handlers.py +++ /dev/null @@ -1,551 +0,0 @@ -""" -Handlers for video block. - -StudentViewHandlers are handlers for video block instance. -StudioViewHandlers are handlers for video descriptor instance. -""" - - -import json -import logging -import math - -from django.utils.timezone import now -from opaque_keys.edx.locator import CourseLocator -from webob import Response -from xblock.core import XBlock -from xblock.exceptions import JsonHandlerError -from xblock.fields import RelativeTime - -from xmodule.exceptions import NotFoundError - -from openedx.core.djangoapps.video_config.transcripts_utils import ( - Transcript, - clean_video_id, - subs_filename, -) -from xblocks_contrib.video.exceptions import ( - TranscriptsGenerationException, - TranscriptNotFoundError, -) - -log = logging.getLogger(__name__) - - -def get_transcript( - video_block, - lang: str | None = None, - output_format: str = 'srt', - youtube_id: str | None = None, - is_bumper: bool = False, -) -> tuple[bytes, str, str]: - """ - Retrieve a transcript using a video block's configuration service. - - Returns: - tuple(bytes, str, str): transcript content, filename, and mimetype. - - Raises: - Exception: If the video config service is not available or the transcript cannot be retrieved. - """ - video_config_service = video_block.runtime.service(video_block, 'video_config') - if not video_config_service: - raise Exception("Video config service not found") - return video_config_service.get_transcript(video_block, lang, output_format, youtube_id, is_bumper) - - -# Disable no-member warning: -# pylint: disable=no-member - -def to_boolean(value): - """ - Convert a value from a GET or POST request parameter to a bool - """ - if isinstance(value, bytes): - value = value.decode('ascii', errors='replace') - if isinstance(value, str): - return value.lower() == 'true' - else: - return bool(value) - - -class VideoStudentViewHandlers: - """ - Handlers for video block instance. - """ - global_speed = None - transcript_language = None - - def handle_ajax(self, dispatch, data): - """ - Update values of xfields, that were changed by student. - """ - accepted_keys = [ - 'speed', 'auto_advance', 'saved_video_position', 'transcript_language', - 'transcript_download_format', 'youtube_is_available', - 'bumper_last_view_date', 'bumper_do_not_show_again' - ] - - conversions = { - 'speed': json.loads, - 'auto_advance': json.loads, - 'saved_video_position': RelativeTime.isotime_to_timedelta, - 'youtube_is_available': json.loads, - 'bumper_last_view_date': to_boolean, - 'bumper_do_not_show_again': to_boolean, - } - - if dispatch == 'save_user_state': - for key in data: - if key in accepted_keys: - if key in conversions: - value = conversions[key](data[key]) - else: - value = data[key] - - if key == 'bumper_last_view_date': - value = now() - - if key == 'speed' and math.isnan(value): - message = f"Invalid speed value {value}, must be a float." - log.warning(message) - return json.dumps({'success': False, 'error': message}) - - setattr(self, key, value) - - if key == 'speed': - self.global_speed = self.speed - - return json.dumps({'success': True}) - - log.debug(f"GET {data}") - log.debug(f"DISPATCH {dispatch}") - - raise NotFoundError('Unexpected dispatch type') - - def get_static_transcript(self, request, transcripts): - """ - Courses that are imported with the --nostatic flag do not show - transcripts/captions properly even if those captions are stored inside - their static folder. This adds a last resort method of redirecting to - the static asset path of the course if the transcript can't be found - inside the contentstore and the course has the static_asset_path field - set. - - transcripts (dict): A dict with all transcripts and a sub. - """ - response = Response(status=404) - # Only do redirect for English - if not self.transcript_language == 'en': - return response - - # If this video lives in library, the code below is not relevant and will error. - if not isinstance(self.course_id, CourseLocator): - return response - - video_id = request.GET.get('videoId', None) - if video_id: - transcript_name = video_id - else: - transcript_name = transcripts["sub"] - - if transcript_name: - # Get the asset path for course - asset_path = None - course = self.runtime.modulestore.get_course(self.course_id) - if course.static_asset_path: - asset_path = course.static_asset_path - else: - # It seems static_asset_path is not set in any XMLModuleStore courses. - asset_path = getattr(course, 'data_dir', '') - - if asset_path: - response = Response( - status=307, - location='/static/{}/{}'.format( - asset_path, - subs_filename(transcript_name, self.transcript_language) - ) - ) - return response - - @XBlock.json_handler - def publish_completion(self, data, dispatch): # pylint: disable=unused-argument - """ - Entry point for completion for student_view. - - Parameters: - data: JSON dict: - key: "completion" - value: float in range [0.0, 1.0] - - dispatch: Ignored. - Return value: JSON response (200 on success, 400 for malformed data) - """ - completion_service = self.runtime.service(self, 'completion') - if completion_service is None: - raise JsonHandlerError(500, "No completion service found") - if not completion_service.completion_tracking_enabled(): - raise JsonHandlerError(404, "Completion tracking is not enabled and API calls are unexpected") - if not isinstance(data['completion'], (int, float)): - message = "Invalid completion value {}. Must be a float in range [0.0, 1.0]" - raise JsonHandlerError(400, message.format(data['completion'])) - if not 0.0 <= data['completion'] <= 1.0: - message = "Invalid completion value {}. Must be in range [0.0, 1.0]" - raise JsonHandlerError(400, message.format(data['completion'])) - self.runtime.publish(self, "completion", data) - return {"result": "ok"} - - @staticmethod - def make_transcript_http_response(content, filename, language, content_type, add_attachment_header=True): - """ - Construct `Response` object. - - Arguments: - content (unicode): transcript content - filename (unicode): transcript filename - language (unicode): transcript language - mimetype (unicode): transcript content type - add_attachment_header (bool): whether to add attachment header or not - """ - headerlist = [ - ('Content-Language', language), - ] - - if add_attachment_header: - headerlist.append( - ( - 'Content-Disposition', - f'attachment; filename="{filename}"' - ) - ) - - response = Response( - content, - headerlist=headerlist, - charset='utf8' - ) - response.content_type = content_type - - return response - - @XBlock.handler - def transcript(self, request, dispatch): - """ - Entry point for transcript handlers for student_view. - - Request GET contains: - (optional) `videoId` for `translation` dispatch. - `is_bumper=1` flag for bumper case. - - Dispatches, (HTTP GET): - /translation/[language_id] - /download - /available_translations/ - - Explanations: - `download`: returns SRT or TXT file. - `translation`: depends on HTTP methods: - Provide translation for requested language, SJSON format is sent back on success, - Proper language_id should be in url. - `available_translations`: - Returns list of languages, for which transcript files exist. - For 'en' check if SJSON exists. For non-`en` check if SRT file exists. - """ - is_bumper = request.GET.get('is_bumper', False) - transcripts = self.get_transcripts_info(is_bumper) - - if dispatch.startswith('translation'): - language = dispatch.replace('translation', '').strip('/') - - # Because scrapers hit video blocks, verify that a user exists. - # use the _request attr to get the django request object. - if not request._request.user: # pylint: disable=protected-access - log.info("Transcript: user must be logged or public view enabled to get transcript") - return Response(status=403) - - if not language: - log.info("Invalid /translation request: no language.") - return Response(status=400) - - if language not in ['en'] + list(transcripts["transcripts"].keys()): - log.info("Video: transcript facilities are not available for given language.") - return Response(status=404) - - if language != self.transcript_language: - self.transcript_language = language - - try: - youtube_id = None if is_bumper else request.GET.get('videoId') - content, filename, mimetype = get_transcript( - self, - lang=self.transcript_language, - output_format=Transcript.SJSON, - youtube_id=youtube_id, - is_bumper=is_bumper - ) - response = self.make_transcript_http_response( - content, - filename, - self.transcript_language, - mimetype, - add_attachment_header=False - ) - except TranscriptNotFoundError as exc: - edx_video_id = clean_video_id(self.edx_video_id) - log.warning( - '[Translation Dispatch] %s: %s', - self.location, - exc if is_bumper else f'Transcript not found for {edx_video_id}, lang: {self.transcript_language}', - ) - response = self.get_static_transcript(request, transcripts) - - elif dispatch == 'download': - lang = request.GET.get('lang', None) - - try: - content, filename, mimetype = get_transcript(self, lang, output_format=self.transcript_download_format) - except TranscriptNotFoundError: - return Response(status=404) - - response = self.make_transcript_http_response( - content, - filename, - self.transcript_language, - mimetype - ) - elif dispatch.startswith('available_translations'): - video_config_service = self.runtime.service(self, 'video_config') - if not video_config_service: - return Response(status=404) - available_translations = video_config_service.available_translations( - self, - transcripts, - verify_assets=True, - is_bumper=is_bumper - ) - if available_translations: - response = Response(json.dumps(available_translations)) - response.content_type = 'application/json' - else: - response = Response(status=404) - else: # unknown dispatch - log.debug("Dispatch is not allowed") - response = Response(status=404) - - return response - - @XBlock.handler - def student_view_user_state(self, request, suffix=''): # lint-amnesty, pylint: disable=unused-argument - """ - Endpoint to get user-specific state, like current position and playback speed, - without rendering the full student_view HTML. This is similar to student_view_state, - but that one cannot contain user-specific info. - """ - view_state = self.student_view_data() - view_state.update({ - "saved_video_position": self.saved_video_position.total_seconds(), - "speed": self.speed, - }) - return Response( - json.dumps(view_state), - content_type='application/json', - charset='UTF-8' - ) - - @XBlock.handler - def yt_video_metadata(self, request, suffix=''): # lint-amnesty, pylint: disable=unused-argument - """ - Endpoint to get YouTube metadata. - This handler is only used in the openedx_content-based runtime. The old - runtime uses a similar REST API that's not an XBlock handler. - """ - from lms.djangoapps.courseware.views.views import load_metadata_from_youtube - if not self.youtube_id_1_0: - # TODO: more informational response to explain that yt_video_metadata not supported for non-youtube videos. - return Response('{}', status=400) - - metadata, status_code = load_metadata_from_youtube(video_id=self.youtube_id_1_0, request=request) - response = Response(json.dumps(metadata), status=status_code) - response.content_type = 'application/json' - return response - - -class VideoStudioViewHandlers: - """ - Handlers for Studio view. - """ - def validate_transcript_upload_data(self, data): - """ - Validates video transcript file. - Arguments: - data: Transcript data to be validated. - Returns: - None or String - If there is error returns error message otherwise None. - """ - error = None - _ = self.runtime.service(self, "i18n").ugettext - # Validate the must have attributes - this error is unlikely to be faced by common users. - must_have_attrs = ['edx_video_id', 'language_code', 'new_language_code'] - missing = [attr for attr in must_have_attrs if attr not in data] - - # Get available transcript languages. - transcripts = self.get_transcripts_info() - video_config_service = self.runtime.service(self, 'video_config') - if not video_config_service: - return error - available_translations = video_config_service.available_translations( - self, - transcripts, - verify_assets=True - ) - - if missing: - error = _('The following parameters are required: {missing}.').format(missing=', '.join(missing)) - elif ( - data['language_code'] != data['new_language_code'] and data['new_language_code'] in available_translations - ): - error = _('A transcript with the "{language_code}" language code already exists.').format( - language_code=data['new_language_code'], - ) - elif 'file' not in data: - error = _('A transcript file is required.') - - return error - - @XBlock.handler - def studio_transcript(self, request, dispatch): - """ - Entry point for Studio transcript handlers. - - Dispatches: - /translation/[language_id] - language_id sould be in url. - - `translation` dispatch support following HTTP methods: - `POST`: - Upload srt file. Check possibility of generation of proper sjson files. - For now, it works only for self.transcripts, not for `en`. - `GET: - Return filename from storage. SRT format is sent back on success. Filename should be in GET dict. - - We raise all exceptions right in Studio: - NotFoundError: - Video or asset was deleted from module/contentstore, but request came later. - Seems impossible to be raised. block_render.py catches NotFoundErrors from here. - - /translation POST: - TypeError: - Unjsonable filename or content. - TranscriptsGenerationException, TranscriptException: - no SRT extension or not parse-able by PySRT - UnicodeDecodeError: non-UTF8 uploaded file content encoding. - """ - if dispatch.startswith('translation'): - - if request.method == 'POST': - response = self._studio_transcript_upload(request) - elif request.method == 'DELETE': - response = self._studio_transcript_delete(request) - elif request.method == 'GET': - response = self._studio_transcript_get(request) - else: - # Any other HTTP method is not allowed. - response = Response(status=404) - - else: # unknown dispatch - log.debug("Dispatch is not allowed") - response = Response(status=404) - - return response - - def _studio_transcript_upload(self, request): - """ - Upload transcript. Used in "POST" method in `studio_transcript` - """ - _ = self.runtime.service(self, "i18n").ugettext - video_config_service = self.runtime.service(self, 'video_config') - if not video_config_service: - return Response(json={'error': _('Runtime does not support transcripts.')}, status=400) - error = self.validate_transcript_upload_data(data=request.POST) - if error: - return Response(json={'error': error}, status=400) - edx_video_id = (request.POST['edx_video_id'] or "").strip() - language_code = request.POST['language_code'] - new_language_code = request.POST['new_language_code'] - try: - video_config_service.upload_transcript( - video_block=self, # NOTE: .edx_video_id and .transcripts may get mutated - edx_video_id=edx_video_id, - language_code=language_code, - new_language_code=new_language_code, - transcript_file=request.POST['file'].file, - ) - return Response( - json.dumps( - { - "edx_video_id": edx_video_id or self.edx_video_id, - "language_code": new_language_code, - } - ), - status=201, - ) - except (TranscriptsGenerationException, UnicodeDecodeError): - return Response( - json={ - 'error': _( - 'There is a problem with this transcript file. Try to upload a different file.' - ) - }, - status=400 - ) - - def _studio_transcript_delete(self, request): - """ - Delete transcript. Used in "DELETE" method in `studio_transcript` - """ - _ = self.runtime.service(self, "i18n").ugettext - video_config_service = self.runtime.service(self, 'video_config') - if not video_config_service: - return Response(json={'error': _('Runtime does not support transcripts.')}, status=400) - request_data = request.json - if 'lang' not in request_data or 'edx_video_id' not in request_data: - return Response(status=400) - video_config_service.delete_transcript( - video_block=self, - edx_video_id=request_data['edx_video_id'], - language_code=request_data['lang'], - ) - return Response(status=200) - - def _studio_transcript_get(self, request): - """ - Get transcript. Used in "GET" method in `studio_transcript` - """ - _ = self.runtime.service(self, "i18n").ugettext - language = request.GET.get('language_code') - if not language: - return Response(json={'error': _('Language is required.')}, status=400) - - try: - video_config_service = self.runtime.service(self, 'video_config') - if not video_config_service: - return Response(status=404) - transcript_content, transcript_name, mime_type = video_config_service.get_transcript( - self, lang=language, output_format=Transcript.SRT - ) - response = Response(transcript_content, headerlist=[ - ( - 'Content-Disposition', - f'attachment; filename="{transcript_name}"' - ), - ('Content-Language', language), - ('Content-Type', mime_type) - ]) - except ( - UnicodeDecodeError, - TranscriptsGenerationException, - TranscriptNotFoundError - ): - response = Response(status=404) - return response From 69e22e84fe96543a180a16902a1d7cacbc0bddc2 Mon Sep 17 00:00:00 2001 From: farhan Date: Fri, 6 Mar 2026 12:54:22 +0500 Subject: [PATCH 4/5] fix: fix test_video_handlers test cases --- lms/djangoapps/courseware/block_render.py | 3 ++- lms/djangoapps/courseware/tests/test_video_handlers.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py index 00828c31c767..6a814fb3ef3c 100644 --- a/lms/djangoapps/courseware/block_render.py +++ b/lms/djangoapps/courseware/block_render.py @@ -46,6 +46,7 @@ from openedx.core.djangoapps.discussions.services import DiscussionConfigService from openedx.core.lib.xblock_services.call_to_action import CallToActionService from xmodule.contentstore.django import contentstore +from xblocks_contrib.video.exceptions import TranscriptNotFoundError from xmodule.exceptions import NotFoundError as XModuleNotFoundError from xmodule.library_tools import LegacyLibraryToolsService from xmodule.modulestore.django import XBlockI18nService, modulestore @@ -975,7 +976,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course raise Http404 # lint-amnesty, pylint: disable=raise-missing-from # If we can't find the block, respond with a 404 - except (XModuleNotFoundError, NotFoundError): + except (XModuleNotFoundError, NotFoundError, TranscriptNotFoundError): log.exception("Module indicating to user that request doesn't exist") raise Http404 # lint-amnesty, pylint: disable=raise-missing-from diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py index 91b28e974f30..d8eeaffae933 100644 --- a/lms/djangoapps/courseware/tests/test_video_handlers.py +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -545,7 +545,6 @@ def test_download_non_en_non_ascii_filename(self, __): assert response.headers['Content-Disposition'] == 'attachment; filename="en_塞.srt"' @patch('openedx.core.djangoapps.video_config.transcripts_utils.edxval_api.get_video_transcript_data') - @patch('xblocks_contrib.video.video_handlers.get_transcript', Mock(side_effect=NotFoundError)) def test_download_fallback_transcript(self, mock_get_video_transcript_data): """ Verify val transcript is returned as a fallback if it is not found in the content store. From 72d57d157813257bb47ff267cdc6d062a32ad8a2 Mon Sep 17 00:00:00 2001 From: farhan Date: Sat, 7 Mar 2026 11:32:07 +0500 Subject: [PATCH 5/5] chore: move the import on top of the file --- openedx/core/djangoapps/video_config/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/video_config/services.py b/openedx/core/djangoapps/video_config/services.py index ec55ac9d1b60..5e72f8babb9e 100644 --- a/openedx/core/djangoapps/video_config/services.py +++ b/openedx/core/djangoapps/video_config/services.py @@ -29,6 +29,7 @@ add_library_block_static_asset_file, delete_library_block_static_asset_file, ) +from openedx.core.djangoapps.video_config.sharing_sites import sharing_sites_info_for_video from openedx.core.djangoapps.video_config.transcripts_utils import ( Transcript, clean_video_id, @@ -93,7 +94,6 @@ def get_public_sharing_context(self, video_block, course_key: CourseKey) -> dict organization = get_course_organization(course_key) - from openedx.core.djangoapps.video_config.sharing_sites import sharing_sites_info_for_video sharing_sites_info = sharing_sites_info_for_video( public_video_url, organization=organization