From d7e1199202e61dc37043f2831d8e6398940ef3b1 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 29 Apr 2026 20:36:10 +0500 Subject: [PATCH 1/4] fix: revert some refactoring that caused the issue --- xblocks_contrib/video/ajax_handler_mixin.py | 48 +++++ .../video/studio_metadata_mixin.py | 167 ++++++++++++++++++ xblocks_contrib/video/video.py | 106 +---------- 3 files changed, 220 insertions(+), 101 deletions(-) create mode 100644 xblocks_contrib/video/ajax_handler_mixin.py create mode 100644 xblocks_contrib/video/studio_metadata_mixin.py diff --git a/xblocks_contrib/video/ajax_handler_mixin.py b/xblocks_contrib/video/ajax_handler_mixin.py new file mode 100644 index 00000000..11dfd053 --- /dev/null +++ b/xblocks_contrib/video/ajax_handler_mixin.py @@ -0,0 +1,48 @@ +""" Mixin that provides AJAX handling for Video XBlock """ +from webob import Response +from webob.multidict import MultiDict +from xblock.core import XBlock + + +class AjaxHandlerMixin: + """ + Mixin that provides AJAX handling for Video XBlock + """ + @property + def ajax_url(self): + """ + Returns the URL for the ajax handler. + """ + return self.runtime.handler_url(self, 'ajax_handler', '', '').rstrip('/?') + + @XBlock.handler + def ajax_handler(self, request, suffix=None): + """ + XBlock handler that wraps `ajax_handler` + """ + class FileObjForWebobFiles: + """ + Turn Webob cgi.FieldStorage uploaded files into pure file objects. + + Webob represents uploaded files as cgi.FieldStorage objects, which + have a .file attribute. We wrap the FieldStorage object, delegating + attribute access to the .file attribute. But the files have no + name, so we carry the FieldStorage .filename attribute as the .name. + + """ + def __init__(self, webob_file): + self.file = webob_file.file + self.name = webob_file.filename + + def __getattr__(self, name): + return getattr(self.file, name) + + # WebOb requests have multiple entries for uploaded files. handle_ajax + # expects a single entry as a list. + request_post = MultiDict(request.POST) + for key in set(request.POST.keys()): + if hasattr(request.POST[key], "file"): + request_post[key] = list(map(FileObjForWebobFiles, request.POST.getall(key))) + + response_data = self.handle_ajax(suffix, request_post) + return Response(response_data, content_type='application/json', charset='UTF-8') diff --git a/xblocks_contrib/video/studio_metadata_mixin.py b/xblocks_contrib/video/studio_metadata_mixin.py new file mode 100644 index 00000000..38fd9345 --- /dev/null +++ b/xblocks_contrib/video/studio_metadata_mixin.py @@ -0,0 +1,167 @@ +""" Studio Metadata Mixin""" +from django.conf import settings +from xblock.core import XBlock +from xblock.fields import Dict, Float, Integer, List, Scope, String + +from xblocks_contrib.video.exceptions import TranscriptNotFoundError +from xblocks_contrib.video.video_transcripts_utils import TranscriptExtensions, get_html5_ids +from xblocks_contrib.video.video_xfields import RelativeTime + + +class StudioMetadataMixin: + """ + Mixin providing Studio metadata editing capabilities for XBlocks. + """ + + @property + def non_editable_metadata_fields(self): + """ + Return the list of fields that should not be editable in Studio. + + When overriding, be sure to append to the superclasses' list. + """ + # We are not allowing editing of xblock tag and name fields at this time (for any component). + return [XBlock.tags, XBlock.name] + + def _create_metadata_editor_info(self, field): + """ + Creates the information needed by the metadata editor for a specific field. + """ + + def jsonify_value(field, json_choice): + """ + Convert field value to JSON, if needed. + """ + if isinstance(json_choice, dict): + new_json_choice = dict(json_choice) # make a copy so below doesn't change the original + if "display_name" in json_choice: + new_json_choice["display_name"] = get_text(json_choice["display_name"]) + if "value" in json_choice: + new_json_choice["value"] = field.to_json(json_choice["value"]) + else: + new_json_choice = field.to_json(json_choice) + return new_json_choice + + def get_text(value): + """Localize a text value that might be None.""" + if value is None: + return None + else: + return self.runtime.service(self, "i18n").ugettext(value) + + # gets the 'default_value' and 'explicitly_set' attrs + metadata_field_editor_info = self.runtime.get_field_provenance(self, field) + metadata_field_editor_info["field_name"] = field.name + metadata_field_editor_info["display_name"] = get_text(field.display_name) + metadata_field_editor_info["help"] = get_text(field.help) + metadata_field_editor_info["value"] = field.read_json(self) + + # We support the following editors: + # 1. A select editor for fields with a list of possible values (includes Booleans). + # 2. Number editors for integers and floats. + # 3. A generic string editor for anything else (editing JSON representation of the value). + editor_type = "Generic" + values = field.values + if "values_provider" in field.runtime_options: + values = field.runtime_options["values_provider"](self) + if isinstance(values, (tuple, list)) and len(values) > 0: + editor_type = "Select" + values = [jsonify_value(field, json_choice) for json_choice in values] + elif isinstance(field, Integer): + editor_type = "Integer" + elif isinstance(field, Float): + editor_type = "Float" + elif isinstance(field, List): + editor_type = "List" + elif isinstance(field, Dict): + editor_type = "Dict" + elif isinstance(field, RelativeTime): + editor_type = "RelativeTime" + elif isinstance(field, String) and field.name == "license": + editor_type = "License" + metadata_field_editor_info["type"] = editor_type + metadata_field_editor_info["options"] = [] if values is None else values + + return metadata_field_editor_info + + def _get_editable_metadata_fields(self): + """ + Returns the metadata fields to be edited in Studio. These are fields with scope `Scope.settings`. + + Can be limited by extending `non_editable_metadata_fields`. + """ + metadata_fields = {} + + # Only use the fields from this class, not mixins + fields = getattr(self, "unmixed_class", self.__class__).fields + + for field in fields.values(): + if field in self.non_editable_metadata_fields: + continue + if field.scope not in (Scope.settings, Scope.content): + continue + + metadata_fields[field.name] = self._create_metadata_editor_info(field) + + return metadata_fields + + @property + def editable_metadata_fields(self): + """ + Returns the metadata fields to be edited in Studio. + """ + editable_fields = self._get_editable_metadata_fields() + + settings_service = self.runtime.service(self, 'settings') + if settings_service: + xb_settings = settings_service.get_settings_bucket(self) + if not xb_settings.get("licensing_enabled", False) and "license" in editable_fields: + del editable_fields["license"] + + # Default Timed Transcript a.k.a `sub` has been deprecated and end users shall + # not be able to modify it. + editable_fields.pop('sub') + + languages = [{'label': label, 'code': lang} for lang, label in settings.ALL_LANGUAGES] + languages.sort(key=lambda lang_item: lang_item['label']) + editable_fields['transcripts']['custom'] = True + editable_fields['transcripts']['languages'] = languages + editable_fields['transcripts']['type'] = 'VideoTranslations' + + # We need to send ajax requests to show transcript status + # whenever edx_video_id changes on frontend. Thats why we + # are changing type to `VideoID` so that a specific + # Backbonjs view can handle it. + editable_fields['edx_video_id']['type'] = 'VideoID' + + # `public_access` is a boolean field and by default backbonejs code render it as a dropdown with 2 options + # but in our case we also need to show an input field with dropdown, the input field will show the url to + # be shared with leaners. This is not possible with default rendering logic in backbonjs code, that is why + # we are setting a new type and then do a custom rendering in backbonejs code to render the desired UI. + editable_fields['public_access']['type'] = 'PublicAccess' + editable_fields['public_access']['url'] = self.get_public_video_url() + + # construct transcripts info and also find if `en` subs exist + transcripts_info = self.get_transcripts_info() + possible_sub_ids = [self.sub, self.youtube_id_1_0] + get_html5_ids(self.html5_sources) + video_config_service = self.runtime.service(self, 'video_config') + if video_config_service: + for sub_id in possible_sub_ids: + try: + _, sub_id, _ = video_config_service.get_transcript( + self, lang='en', output_format=TranscriptExtensions.TXT + ) + transcripts_info['transcripts'] = dict(transcripts_info['transcripts'], en=sub_id) + break + except TranscriptNotFoundError: + continue + + editable_fields['transcripts']['value'] = transcripts_info['transcripts'] + editable_fields['transcripts']['urlRoot'] = self.runtime.handler_url( + self, + 'studio_transcript', + 'translation' + ).rstrip('/?') + editable_fields['handout']['type'] = 'FileUploader' + + return editable_fields diff --git a/xblocks_contrib/video/video.py b/xblocks_contrib/video/video.py index 892374ad..5ce13a07 100644 --- a/xblocks_contrib/video/video.py +++ b/xblocks_contrib/video/video.py @@ -43,11 +43,13 @@ is_pointer_tag, name_to_pathname, ) +from xblocks_contrib.video.ajax_handler_mixin import AjaxHandlerMixin from xblocks_contrib.video.bumper_utils import bumperize from xblocks_contrib.video.cache_utils import request_cached from xblocks_contrib.video.constants import ATTR_KEY_REQUEST_COUNTRY_CODE, ATTR_KEY_USER_ID, PUBLIC_VIEW, STUDENT_VIEW from xblocks_contrib.video.exceptions import TranscriptNotFoundError from xblocks_contrib.video.mixin import LicenseMixin +from xblocks_contrib.video.studio_metadata_mixin import StudioMetadataMixin from xblocks_contrib.video.validation import StudioValidation, StudioValidationMessage from xblocks_contrib.video.video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers from xblocks_contrib.video.video_static_content_utils import ( @@ -111,7 +113,9 @@ @XBlock.needs('i18n', 'user') class VideoBlock( VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, VideoStudentViewHandlers, - LegacyXmlMixin, XBlock, LicenseMixin): + LegacyXmlMixin, XBlock, + AjaxHandlerMixin, StudioMetadataMixin, + LicenseMixin): """ XML source example:: @@ -1166,112 +1170,12 @@ def _poster(self): ) return None - @property - def ajax_url(self): - """ - Returns the URL for the ajax handler. - """ - return self.runtime.handler_url(self, 'ajax_handler', '', '').rstrip('/?') - - @XBlock.handler - def ajax_handler(self, request, suffix=None): - """ - XBlock handler that wraps `ajax_handler` - """ - class FileObjForWebobFiles: - """ - Turn Webob cgi.FieldStorage uploaded files into pure file objects. - - Webob represents uploaded files as cgi.FieldStorage objects, which - have a .file attribute. We wrap the FieldStorage object, delegating - attribute access to the .file attribute. But the files have no - name, so we carry the FieldStorage .filename attribute as the .name. - - """ - def __init__(self, webob_file): - self.file = webob_file.file - self.name = webob_file.filename - - def __getattr__(self, name): - return getattr(self.file, name) - - # WebOb requests have multiple entries for uploaded files. handle_ajax - # expects a single entry as a list. - request_post = MultiDict(request.POST) - for key in set(request.POST.keys()): - if hasattr(request.POST[key], "file"): - request_post[key] = list(map(FileObjForWebobFiles, request.POST.getall(key))) - - response_data = self.handle_ajax(suffix, request_post) - return Response(response_data, content_type='application/json', charset='UTF-8') - @classmethod def definition_from_xml(cls, xml_object, system): if len(xml_object) == 0 and len(list(xml_object.items())) == 0: return {"data": ""}, [] return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, [] - @property - def editable_metadata_fields(self): - """ - Returns the metadata fields to be edited in Studio. - """ - editable_fields = super().editable_metadata_fields # pylint: disable=no-member - - settings_service = self.runtime.service(self, 'settings') - if settings_service: - xb_settings = settings_service.get_settings_bucket(self) - if not xb_settings.get("licensing_enabled", False) and "license" in editable_fields: - del editable_fields["license"] - - # Default Timed Transcript a.k.a `sub` has been deprecated and end users shall - # not be able to modify it. - editable_fields.pop('sub') - - languages = [{'label': label, 'code': lang} for lang, label in settings.ALL_LANGUAGES] - languages.sort(key=lambda lang_item: lang_item['label']) - editable_fields['transcripts']['custom'] = True - editable_fields['transcripts']['languages'] = languages - editable_fields['transcripts']['type'] = 'VideoTranslations' - - # We need to send ajax requests to show transcript status - # whenever edx_video_id changes on frontend. Thats why we - # are changing type to `VideoID` so that a specific - # Backbonjs view can handle it. - editable_fields['edx_video_id']['type'] = 'VideoID' - - # `public_access` is a boolean field and by default backbonejs code render it as a dropdown with 2 options - # but in our case we also need to show an input field with dropdown, the input field will show the url to - # be shared with leaners. This is not possible with default rendering logic in backbonjs code, that is why - # we are setting a new type and then do a custom rendering in backbonejs code to render the desired UI. - editable_fields['public_access']['type'] = 'PublicAccess' - editable_fields['public_access']['url'] = self.get_public_video_url() - - # construct transcripts info and also find if `en` subs exist - transcripts_info = self.get_transcripts_info() - possible_sub_ids = [self.sub, self.youtube_id_1_0] + get_html5_ids(self.html5_sources) - video_config_service = self.runtime.service(self, 'video_config') - if video_config_service: - for sub_id in possible_sub_ids: - try: - _, sub_id, _ = video_config_service.get_transcript( - self, lang='en', output_format=TranscriptExtensions.TXT - ) - transcripts_info['transcripts'] = dict(transcripts_info['transcripts'], en=sub_id) - break - except TranscriptNotFoundError: - continue - - editable_fields['transcripts']['value'] = transcripts_info['transcripts'] - editable_fields['transcripts']['urlRoot'] = self.runtime.handler_url( - self, - 'studio_transcript', - 'translation' - ).rstrip('/?') - editable_fields['handout']['type'] = 'FileUploader' - - return editable_fields - @staticmethod def workbench_scenarios(): """Create canned scenario for display in the workbench.""" From 35054a046ccf28195876a19cd60998a61b0fcfad Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 29 Apr 2026 20:49:08 +0500 Subject: [PATCH 2/4] fix: fix quality issues --- xblocks_contrib/video/video.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/xblocks_contrib/video/video.py b/xblocks_contrib/video/video.py index 5ce13a07..a228179c 100644 --- a/xblocks_contrib/video/video.py +++ b/xblocks_contrib/video/video.py @@ -29,8 +29,6 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import AssetLocator from web_fragments.fragment import Fragment -from webob import Response -from webob.multidict import MultiDict from xblock.completable import XBlockCompletionMode from xblock.core import XBlock from xblock.fields import ScopeIds @@ -62,7 +60,6 @@ VideoTranscriptsMixin, clean_video_id, get_endonym_or_label, - get_html5_ids, subs_filename, ) from xblocks_contrib.video.video_utils import ( @@ -485,7 +482,7 @@ def get_html(self, view=STUDENT_VIEW, context=None): # pylint: disable=too-many 'bumper_metadata': json.dumps(self.bumper['metadata']), # pylint: disable=E1101 'cdn_eval': cdn_eval, 'cdn_exp_group': cdn_exp_group, - 'display_name': self.display_name_with_default, # pylint: disable=no-member + 'display_name': self.display_name_with_default, 'download_video_link': download_video_link, 'is_video_from_same_origin': is_video_from_same_origin, 'handout': self.handout, From 2fdf76a404eeb70179f8f916e9d7006493bbf645 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 29 Apr 2026 21:06:19 +0500 Subject: [PATCH 3/4] chore: bump the version --- xblocks_contrib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xblocks_contrib/__init__.py b/xblocks_contrib/__init__.py index 0de2e705..d0c88910 100644 --- a/xblocks_contrib/__init__.py +++ b/xblocks_contrib/__init__.py @@ -9,4 +9,4 @@ from .video import VideoBlock from .word_cloud import WordCloudBlock -__version__ = "0.16.0" +__version__ = "0.16.1" From dd01986b156bf74022399e1785ace88975b53943 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 29 Apr 2026 21:48:37 +0500 Subject: [PATCH 4/4] chore: update change logs --- CHANGELOG.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4c63b98d..a1b5bd11 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,13 @@ Unreleased 0.16.0 - 2026-04-21 ********************************************** +Fixed +===== +* Fix video block editor issues while editing in the Content Library + +0.16.0 - 2026-04-21 +********************************************** + Fixed =====