From 6499a25db1c6485038499bc524f132f7a2542f27 Mon Sep 17 00:00:00 2001 From: farhan Date: Sat, 4 Oct 2025 13:11:30 +0500 Subject: [PATCH 1/3] chore: Introduce a new service VideoConfig for the Extracted Video XBlock in following PR - https://github.com/openedx/xblocks-contrib/pull/37/commits --- .github/workflows/unit-tests.yml | 1 + cms/djangoapps/contentstore/views/preview.py | 1 + cms/envs/common.py | 2 +- cms/lib/xblock/tagging/apps.py | 12 +++ lms/djangoapps/courseware/block_render.py | 1 + .../core/djangoapps/video_config/services.py | 69 +++++++++++++ .../video_config/transcripts_utils.py | 39 ++++++++ openedx/core/djangoapps/video_config/utils.py | 96 +++++++++++++++++++ openedx/envs/common.py | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 13 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 cms/lib/xblock/tagging/apps.py create mode 100644 openedx/core/djangoapps/video_config/utils.py diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7fba6b6b4cf1..78af44c738e2 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,6 +20,7 @@ jobs: name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }}) runs-on: ${{ matrix.os-version }} strategy: + fail-fast: false matrix: python-version: - "3.11" diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 1c64cfb660e2..dec76205c359 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -29,6 +29,7 @@ from xmodule.util.builtin_assets import add_webpack_js_to_fragment from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin from cms.djangoapps.xblock_config.models import StudioConfig +from openedx.core.djangoapps.video_config.services import VideoConfigService from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id from cms.lib.xblock.field_data import CmsFieldData from cms.lib.xblock.upstream_sync import UpstreamLink diff --git a/cms/envs/common.py b/cms/envs/common.py index 7a2a3343a8db..57303f3e264a 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1089,7 +1089,7 @@ 'statici18n', # Tagging - 'cms.lib.xblock.tagging', + 'cms.lib.xblock.tagging.apps.TaggingConfig', # Enables default site and redirects 'django_sites_extensions', diff --git a/cms/lib/xblock/tagging/apps.py b/cms/lib/xblock/tagging/apps.py new file mode 100644 index 000000000000..d329343e445c --- /dev/null +++ b/cms/lib/xblock/tagging/apps.py @@ -0,0 +1,12 @@ +""" +Django app configuration for the XBlock tagging app +""" +from django.apps import AppConfig + + +class TaggingConfig(AppConfig): + """ + Django app configuration for the XBlock tagging app + """ + name = 'cms.lib.xblock.tagging' + verbose_name = 'XBlock Tagging' \ No newline at end of file diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py index ad04bcb19246..7904b93e07d0 100644 --- a/lms/djangoapps/courseware/block_render.py +++ b/lms/djangoapps/courseware/block_render.py @@ -44,6 +44,7 @@ from lms.djangoapps.teams.services import TeamsService from openedx.core.djangoapps.video_config.services import VideoConfigService from openedx.core.lib.xblock_services.call_to_action import CallToActionService +from openedx.core.djangoapps.video_config.services import VideoConfigService from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.library_tools import LegacyLibraryToolsService diff --git a/openedx/core/djangoapps/video_config/services.py b/openedx/core/djangoapps/video_config/services.py index 66799d7393ce..48abc87fa08c 100644 --- a/openedx/core/djangoapps/video_config/services.py +++ b/openedx/core/djangoapps/video_config/services.py @@ -120,3 +120,72 @@ def get_transcript( raise TranscriptNotFoundError( f"Failed to get transcript: {exc}" ) from exc + + def get_transcript_from_store(self, course_key, filename): + """ + Return transcript from store by course key and filename. + + Args: + course_key: Course key + filename (str): filename of the asset + + Returns: + Asset data from store + + Raises: + NotFoundError: If asset not found + """ + # Import here to avoid circular dependency + from xmodule.video_block.transcripts_utils import Transcript + return Transcript.get_asset_by_course_key(course_key, filename) + + def delete_transcript_from_store(self, course_key, filename): + """ + Delete transcript from store by course key and filename. + + Args: + course_key: Course key + filename (str): filename of the asset + + Returns: + Asset location + """ + # Import here to avoid circular dependency + from xmodule.video_block.transcripts_utils import Transcript + return Transcript.delete_asset_by_course_key(course_key, filename) + + def find_transcript_from_store(self, course_key, filename): + """ + Find transcript from store by course key and filename. + + Args: + course_key: Course key + filename (str): filename of the asset + + Returns: + Asset from store + + Raises: + NotFoundError: If asset not found + """ + # Import here to avoid circular dependency + from xmodule.video_block.transcripts_utils import Transcript + return Transcript.find_asset(course_key, filename) + + def save_transcript_into_store(self, content, filename, mime_type, course_key): + """ + Save transcript into store by course key. + + Args: + content: The content to save + filename: The filename + mime_type: The MIME type of the content + course_key: The course key + + Returns: + Content location of saved transcript in store + """ + # Import here to avoid circular dependency + from xmodule.video_block.transcripts_utils import Transcript + return Transcript.save_transcript(content, filename, mime_type, course_key) + \ No newline at end of file diff --git a/openedx/core/djangoapps/video_config/transcripts_utils.py b/openedx/core/djangoapps/video_config/transcripts_utils.py index be86324cd6e4..70981a65c174 100644 --- a/openedx/core/djangoapps/video_config/transcripts_utils.py +++ b/openedx/core/djangoapps/video_config/transcripts_utils.py @@ -796,6 +796,14 @@ def get_asset(location, filename): """ return contentstore().find(Transcript.asset_location(location, filename)) + @staticmethod + def get_asset_by_course_key(course_key, filename): + """ + Return asset by location and filename. + """ + content_location = StaticContent.compute_location(course_key, filename) + return contentstore().find(content_location) + @staticmethod def asset_location(location, filename): """ @@ -818,6 +826,37 @@ def delete_asset(location, filename): pass return StaticContent.compute_location(location.course_key, filename) + @staticmethod + def delete_asset_by_course_key(course_key, filename): + """ + Delete asset by location and filename. + """ + try: + content_location = StaticContent.compute_location(course_key, filename) + contentstore().delete(content_location) + log.info("Transcript asset %s was removed from store.", filename) + except NotFoundError: + pass + return StaticContent.compute_location(course_key, filename) + + @staticmethod + def find_asset(course_key, filename): + """ + Finds asset by course_key and filename. + """ + content_location = StaticContent.compute_location(course_key, filename) + return contentstore().find(content_location).data.decode('utf-8') + + @staticmethod + def save_transcript(content, filename, mime_type, course_key): + """ + Save transcript to store by course_key and filename. + """ + content_location = StaticContent.compute_location(course_key, filename) + content = StaticContent(content_location, filename, mime_type, content) + contentstore().save(content) + return content_location + class VideoTranscriptsMixin: """Mixin class for transcript functionality. diff --git a/openedx/core/djangoapps/video_config/utils.py b/openedx/core/djangoapps/video_config/utils.py new file mode 100644 index 000000000000..863c2e95731e --- /dev/null +++ b/openedx/core/djangoapps/video_config/utils.py @@ -0,0 +1,96 @@ +""" +Video configuration utilities +""" + +import logging + +from django.conf import settings + +from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE +from openedx.core.lib.courses import get_course_by_id +from xmodule.course_block import ( + COURSE_VIDEO_SHARING_ALL_VIDEOS, + COURSE_VIDEO_SHARING_NONE, +) + +log = logging.getLogger(__name__) + + +class VideoSharingUtils: + """ + Provides utility methods for video sharing functionality. + """ + + @staticmethod + def get_public_video_url(video_block): + """ + Returns the public video url for a video block. + + Args: + video_block: The video XBlock instance + + Returns: + str: The public video URL + """ + return fr'{settings.LMS_ROOT_URL}/videos/{str(video_block.location)}' + + @staticmethod + def is_public_sharing_enabled(video_block): + """ + Check if public sharing is enabled for a video. + + Args: + video_block: The video XBlock instance + + Returns: + bool: True if public sharing is enabled, False otherwise + """ + if not video_block.context_key.is_course: + return False # Only courses support this feature (not libraries) + + try: + # Video share feature must be enabled for sharing settings to take effect + feature_enabled = PUBLIC_VIDEO_SHARE.is_enabled(video_block.context_key) + except Exception as err: # pylint: disable=broad-except + log.exception(f"Error retrieving course for course ID: {video_block.context_key}") + return False + + if not feature_enabled: + return False + + # Check if the course specifies a general setting + course_video_sharing_option = VideoSharingUtils.get_course_video_sharing_override(video_block) + + # Course can override all videos to be shared + if course_video_sharing_option == COURSE_VIDEO_SHARING_ALL_VIDEOS: + return True + + # ... or no videos to be shared + elif course_video_sharing_option == COURSE_VIDEO_SHARING_NONE: + return False + + # ... or can fall back to per-video setting + # Equivalent to COURSE_VIDEO_SHARING_PER_VIDEO or None / unset + else: + return video_block.public_access + + @staticmethod + def get_course_video_sharing_override(video_block): + """ + Return course video sharing options override or None + + Args: + video_block: The video XBlock instance + + Returns: + Course video sharing option or None + """ + if not video_block.context_key.is_course: + return False # Only courses support this feature (not libraries) + + try: + course = get_course_by_id(video_block.context_key) + return getattr(course, 'video_sharing_options', None) + except Exception as err: # pylint: disable=broad-except + log.exception(f"Error retrieving course for course ID: {video_block.course_id}") + return None diff --git a/openedx/envs/common.py b/openedx/envs/common.py index de9e45f235be..e6458af11285 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -1736,7 +1736,7 @@ def _make_locale_paths(settings): # .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. # .. toggle_creation_date: 2024-11-10 # .. toggle_target_removal_date: 2025-06-01 -USE_EXTRACTED_VIDEO_BLOCK = False +USE_EXTRACTED_VIDEO_BLOCK = True ############################## Marketing Site ############################## diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7dbf1e37e2f2..854b9c82d88c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -1279,7 +1279,7 @@ xblock-utils==4.0.0 # via # edx-sga # xblock-poll -xblocks-contrib==0.8.1 +git+https://github.com/openedx/xblocks-contrib.git@farhan/video-xblock-extraction # via -r requirements/edx/bundled.in xmlsec==1.3.14 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 80428b0f1493..ba875406cf6b 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -2312,7 +2312,7 @@ xblock-utils==4.0.0 # -r requirements/edx/testing.txt # edx-sga # xblock-poll -xblocks-contrib==0.8.1 +git+https://github.com/openedx/xblocks-contrib.git@farhan/video-xblock-extraction # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 26e182dc16ed..c44dd97a1e25 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1617,7 +1617,7 @@ xblock-utils==4.0.0 # -r requirements/edx/base.txt # edx-sga # xblock-poll -xblocks-contrib==0.8.1 +git+https://github.com/openedx/xblocks-contrib.git@farhan/video-xblock-extraction # via -r requirements/edx/base.txt xmlsec==1.3.14 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 7680738cda3a..49990336b180 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1710,7 +1710,7 @@ xblock-utils==4.0.0 # -r requirements/edx/base.txt # edx-sga # xblock-poll -xblocks-contrib==0.8.1 +git+https://github.com/openedx/xblocks-contrib.git@farhan/video-xblock-extraction # via -r requirements/edx/base.txt xmlsec==1.3.14 # via From cba4384e07baa1eb10ad6151e0702186e8b520d1 Mon Sep 17 00:00:00 2001 From: farhan Date: Thu, 11 Dec 2025 19:19:09 +0500 Subject: [PATCH 2/3] chore: fix circular dependency --- openedx/core/djangoapps/video_config/services.py | 9 ++++----- .../core/djangoapps/video_config/transcripts_utils.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/openedx/core/djangoapps/video_config/services.py b/openedx/core/djangoapps/video_config/services.py index 48abc87fa08c..c07f2952414e 100644 --- a/openedx/core/djangoapps/video_config/services.py +++ b/openedx/core/djangoapps/video_config/services.py @@ -136,7 +136,7 @@ def get_transcript_from_store(self, course_key, filename): NotFoundError: If asset not found """ # Import here to avoid circular dependency - from xmodule.video_block.transcripts_utils import Transcript + from openedx.core.djangoapps.video_config.transcripts_utils import Transcript return Transcript.get_asset_by_course_key(course_key, filename) def delete_transcript_from_store(self, course_key, filename): @@ -151,7 +151,7 @@ def delete_transcript_from_store(self, course_key, filename): Asset location """ # Import here to avoid circular dependency - from xmodule.video_block.transcripts_utils import Transcript + from openedx.core.djangoapps.video_config.transcripts_utils import Transcript return Transcript.delete_asset_by_course_key(course_key, filename) def find_transcript_from_store(self, course_key, filename): @@ -169,7 +169,7 @@ def find_transcript_from_store(self, course_key, filename): NotFoundError: If asset not found """ # Import here to avoid circular dependency - from xmodule.video_block.transcripts_utils import Transcript + from openedx.core.djangoapps.video_config.transcripts_utils import Transcript return Transcript.find_asset(course_key, filename) def save_transcript_into_store(self, content, filename, mime_type, course_key): @@ -186,6 +186,5 @@ def save_transcript_into_store(self, content, filename, mime_type, course_key): Content location of saved transcript in store """ # Import here to avoid circular dependency - from xmodule.video_block.transcripts_utils import Transcript + from openedx.core.djangoapps.video_config.transcripts_utils import Transcript return Transcript.save_transcript(content, filename, mime_type, course_key) - \ No newline at end of file diff --git a/openedx/core/djangoapps/video_config/transcripts_utils.py b/openedx/core/djangoapps/video_config/transcripts_utils.py index 70981a65c174..98b4765abf58 100644 --- a/openedx/core/djangoapps/video_config/transcripts_utils.py +++ b/openedx/core/djangoapps/video_config/transcripts_utils.py @@ -23,7 +23,6 @@ from pysrt.srtexc import Error from opaque_keys.edx.locator import LibraryLocatorV2 -from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError @@ -1156,6 +1155,7 @@ def get_transcript_from_learning_core(video_block, language, output_format, tran # Grab the underlying Component. There's no version parameter to this call, # so we're just going to grab the file associated with the latest draft # version for now. + from openedx.core.djangoapps.xblock.api import get_component_from_usage_key component = get_component_from_usage_key(usage_key) component_version = component.versioning.draft if not component_version: From 8e13c3b6dee9282413301521473273d94dcc8624 Mon Sep 17 00:00:00 2001 From: farhan Date: Fri, 12 Dec 2025 16:31:19 +0500 Subject: [PATCH 3/3] chore: update service methods --- .../core/djangoapps/video_config/services.py | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/video_config/services.py b/openedx/core/djangoapps/video_config/services.py index c07f2952414e..74642c2d4bf4 100644 --- a/openedx/core/djangoapps/video_config/services.py +++ b/openedx/core/djangoapps/video_config/services.py @@ -20,6 +20,10 @@ from openedx.core.djangoapps.video_config.toggles import TRANSCRIPT_FEEDBACK from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE +from xmodule.contentstore.content import StaticContent +from xmodule.contentstore.django import contentstore +from xmodule.exceptions import NotFoundError + log = logging.getLogger(__name__) @@ -123,21 +127,25 @@ def get_transcript( def get_transcript_from_store(self, course_key, filename): """ - Return transcript from store by course key and filename. + Return transcript from static content store by course key and filename. Args: course_key: Course key filename (str): filename of the asset Returns: - Asset data from store + transcript from store Raises: - NotFoundError: If asset not found + TranscriptNotFoundError: If transcript not found """ - # Import here to avoid circular dependency - from openedx.core.djangoapps.video_config.transcripts_utils import Transcript - return Transcript.get_asset_by_course_key(course_key, filename) + content_location = StaticContent.compute_location(course_key, filename) + try: + return contentstore().find(content_location) + except NotFoundError as exc: + raise TranscriptNotFoundError( + f"Failed to get transcript: {exc}" + ) from exc def delete_transcript_from_store(self, course_key, filename): """ @@ -150,9 +158,15 @@ def delete_transcript_from_store(self, course_key, filename): Returns: Asset location """ - # Import here to avoid circular dependency - from openedx.core.djangoapps.video_config.transcripts_utils import Transcript - return Transcript.delete_asset_by_course_key(course_key, filename) + try: + content_location = StaticContent.compute_location(course_key, filename) + contentstore().delete(content_location) + log.info("Transcript asset %s was removed from store.", filename) + except NotFoundError as exc: + raise TranscriptNotFoundError( + f"Failed to get transcript: {exc}" + ) from exc + return StaticContent.compute_location(course_key, filename) def find_transcript_from_store(self, course_key, filename): """ @@ -163,14 +177,18 @@ def find_transcript_from_store(self, course_key, filename): filename (str): filename of the asset Returns: - Asset from store + transcript from store Raises: - NotFoundError: If asset not found + TranscriptNotFoundError: If transcript not found """ - # Import here to avoid circular dependency - from openedx.core.djangoapps.video_config.transcripts_utils import Transcript - return Transcript.find_asset(course_key, filename) + try: + content_location = StaticContent.compute_location(course_key, filename) + return contentstore().find(content_location).data.decode('utf-8') + except NotFoundError as exc: + raise TranscriptNotFoundError( + f"Failed to get transcript: {exc}" + ) from exc def save_transcript_into_store(self, content, filename, mime_type, course_key): """ @@ -185,6 +203,7 @@ def save_transcript_into_store(self, content, filename, mime_type, course_key): Returns: Content location of saved transcript in store """ - # Import here to avoid circular dependency - from openedx.core.djangoapps.video_config.transcripts_utils import Transcript - return Transcript.save_transcript(content, filename, mime_type, course_key) + content_location = StaticContent.compute_location(course_key, filename) + content = StaticContent(content_location, filename, mime_type, content) + contentstore().save(content) + return content_location