Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=====

Expand Down
2 changes: 1 addition & 1 deletion xblocks_contrib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
from .video import VideoBlock
from .word_cloud import WordCloudBlock

__version__ = "0.16.0"
__version__ = "0.16.1"
48 changes: 48 additions & 0 deletions xblocks_contrib/video/ajax_handler_mixin.py
Original file line number Diff line number Diff line change
@@ -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')
167 changes: 167 additions & 0 deletions xblocks_contrib/video/studio_metadata_mixin.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading