diff --git a/xblocks_contrib/annotatable/annotatable.py b/xblocks_contrib/annotatable/annotatable.py index 2c5ebfd8..3e698d1a 100644 --- a/xblocks_contrib/annotatable/annotatable.py +++ b/xblocks_contrib/annotatable/annotatable.py @@ -17,7 +17,7 @@ from xblock.fields import Scope, String, XMLString from xblock.utils.resources import ResourceLoader -from xblocks_contrib.common.xml_utils import LegacyXmlMixin +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin log = logging.getLogger(__name__) diff --git a/xblocks_contrib/discussion/discussion.py b/xblocks_contrib/discussion/discussion.py index 2ce9c2bb..5b7d5212 100644 --- a/xblocks_contrib/discussion/discussion.py +++ b/xblocks_contrib/discussion/discussion.py @@ -17,7 +17,7 @@ from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin -from xblocks_contrib.common.xml_utils import LegacyXmlMixin +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin log = logging.getLogger(__name__) loader = ResourceLoader(__name__) diff --git a/xblocks_contrib/html/html.py b/xblocks_contrib/html/html.py index 537b83d2..2d3b85a0 100644 --- a/xblocks_contrib/html/html.py +++ b/xblocks_contrib/html/html.py @@ -20,10 +20,10 @@ from path import Path as path from web_fragments.fragment import Fragment from xblock.core import XBlock -from xblock.fields import Boolean, Scope, String, UserScope +from xblock.fields import Boolean, Scope, String from xblock.utils.resources import ResourceLoader -from xblocks_contrib.common.xml_utils import LegacyXmlMixin, name_to_pathname +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin, name_to_pathname log = logging.getLogger(__name__) resource_loader = ResourceLoader(__name__) @@ -185,18 +185,6 @@ class HtmlBlockMixin(LegacyXmlMixin, XBlock): uses_xmodule_styles_setup = True template_dir_name = "html" show_in_read_only_mode = True - icon_class = "other" - - @property - def xblock_kvs(self): - """ - Retrieves the internal KeyValueStore for this XModule. - - Should only be used by the persistence layer. Use with caution. - """ - # if caller wants kvs, caller's assuming it's up to date; so, decache it - self.save() - return self._field_data._kvs # pylint: disable=protected-access @XBlock.supports("multi_device") def student_view(self, _context): @@ -335,59 +323,6 @@ def index_dictionary(self): xblock_body["content_type"] = "Text" return xblock_body - def bind_for_student(self, user_id, wrappers=None): - """ - Set up this XBlock to act as an XModule instead of an XModuleDescriptor. - - Arguments: - user_id: The user_id to set in scope_ids - wrappers: These are a list functions that put a wrapper, such as - LmsFieldData or OverrideFieldData, around the field_data. - Note that the functions will be applied in the order in - which they're listed. So [f1, f2] -> f2(f1(field_data)) - """ - - # Skip rebinding if we're already bound a user, and it's this user. - if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id: - if getattr(self.runtime, "position", None): - # update the position of the tab - self.position = self.runtime.position - return - - # # If we are switching users mid-request, save the data from the old user. - # self.save() - - # Update scope_ids to point to the new user. - self.scope_ids = self.scope_ids._replace(user_id=user_id) - - # Clear out any cached instantiated children. - self.clear_child_cache() - - # Clear out any cached field data scoped to the old user. - for field in self.fields.values(): - if field.scope in (Scope.parent, Scope.children): - continue - - if field.scope.user == UserScope.ONE: - field._del_cached_value(self) # pylint: disable=protected-access - # not the most elegant way of doing this, but if we're removing - # a field from the module's field_data_cache, we should also - # remove it from its _dirty_fields - if field in self._dirty_fields: - del self._dirty_fields[field] - - if wrappers: - # Put user-specific wrappers around the field-data service for this block. - # Note that these are different from modulestore.xblock_field_data_wrappers, which are not user-specific. - wrapped_field_data = self.runtime.service(self, "field-data-unbound") - for wrapper in wrappers: - wrapped_field_data = wrapper(wrapped_field_data) - self._bound_field_data = wrapped_field_data - if getattr(self.runtime, "uses_deprecated_field_data", False): - # This approach is deprecated but old mongo's CachingDescriptorSystem still requires it. - # For Split mongo's CachingDescriptor system, don't set ._field_data this way. - self._field_data = wrapped_field_data - @staticmethod def serialize_asset_key_with_slash(asset_key): """ diff --git a/xblocks_contrib/common/__init__.py b/xblocks_contrib/legacy_utils/__init__.py similarity index 100% rename from xblocks_contrib/common/__init__.py rename to xblocks_contrib/legacy_utils/__init__.py diff --git a/xblocks_contrib/common/xml_utils.py b/xblocks_contrib/legacy_utils/xml_utils.py similarity index 97% rename from xblocks_contrib/common/xml_utils.py rename to xblocks_contrib/legacy_utils/xml_utils.py index 56e19c90..500d9265 100644 --- a/xblocks_contrib/common/xml_utils.py +++ b/xblocks_contrib/legacy_utils/xml_utils.py @@ -1,4 +1,20 @@ """ +⚠️⚠️⚠️ LEGACY FILE — DO NOT USE IN NEW XBLOCKS ⚠️⚠️⚠️ + +This file is part of an older implementation and is considered **legacy**. + +🚫 Do NOT import, extend, or rely on this file when developing new XBlocks. +🚫 Do NOT copy patterns or logic from this file into new code. + +This file is maintained ONLY for backward compatibility with existing systems. +It is scheduled for removal in a future cleanup. + +If you are building something new, please use the latest supported patterns, +utilities, and modules from the current codebase. + +If you are unsure what to use instead, please check the updated documentation +or reach out to the maintainers. + XML utility functions and classes for XBlocks. Note: Most of the functionality is taken from the edx-platform's XmlMixin. https://github.com/openedx/edx-platform/blob/master/xmodule/xml_block.py diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 0c01621a..90f1a746 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -75,11 +75,11 @@ from web_fragments.fragment import Fragment from webob import Response from xblock.core import List, Scope, String, XBlock -from xblock.fields import Boolean, Float, UserScope +from xblock.fields import Boolean, Float from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin -from xblocks_contrib.common.xml_utils import LegacyXmlMixin +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin from .lti_2_util import LTI20BlockMixin, LTIError @@ -1017,56 +1017,3 @@ def definition_to_xml(self, resource_fs): if self.data: return etree.fromstring(self.data) return etree.Element(self.usage_key.block_type) - - def bind_for_student(self, user_id, wrappers=None): - """ - Set up this XBlock to act as an XModule instead of an XModuleDescriptor. - - Arguments: - user_id: The user_id to set in scope_ids - wrappers: These are a list functions that put a wrapper, such as - LmsFieldData or OverrideFieldData, around the field_data. - Note that the functions will be applied in the order in - which they're listed. So [f1, f2] -> f2(f1(field_data)) - """ - - # Skip rebinding if we're already bound a user, and it's this user. - if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id: - if getattr(self.runtime, "position", None): - # update the position of the tab - self.position = self.runtime.position # pylint: disable=attribute-defined-outside-init - return - - # # If we are switching users mid-request, save the data from the old user. - # self.save() - - # Update scope_ids to point to the new user. - self.scope_ids = self.scope_ids._replace(user_id=user_id) - - # Clear out any cached instantiated children. - self.clear_child_cache() - - # Clear out any cached field data scoped to the old user. - for field in self.fields.values(): - if field.scope in (Scope.parent, Scope.children): - continue - - if field.scope.user == UserScope.ONE: - field._del_cached_value(self) # pylint: disable=protected-access - # not the most elegant way of doing this, but if we're removing - # a field from the module's field_data_cache, we should also - # remove it from its _dirty_fields - if field in self._dirty_fields: - del self._dirty_fields[field] - - if wrappers: - # Put user-specific wrappers around the field-data service for this block. - # Note that these are different from modulestore.xblock_field_data_wrappers, which are not user-specific. - wrapped_field_data = self.runtime.service(self, "field-data-unbound") - for wrapper in wrappers: - wrapped_field_data = wrapper(wrapped_field_data) - self._bound_field_data = wrapped_field_data # pylint: disable=attribute-defined-outside-init - if getattr(self.runtime, "uses_deprecated_field_data", False): - # This approach is deprecated but old mongo's CachingDescriptorSystem still requires it. - # For Split mongo's CachingDescriptor system, don't set ._field_data this way. - self._field_data = wrapped_field_data diff --git a/xblocks_contrib/poll/poll.py b/xblocks_contrib/poll/poll.py index a7cbafd5..7af0ef48 100644 --- a/xblocks_contrib/poll/poll.py +++ b/xblocks_contrib/poll/poll.py @@ -19,7 +19,7 @@ from xblock.fields import Boolean, Dict, List, Scope, String from xblock.utils.resources import ResourceLoader -from xblocks_contrib.common.xml_utils import LegacyXmlMixin +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin Text = markupsafe.escape resource_loader = ResourceLoader(__name__) @@ -107,17 +107,6 @@ class PollBlock(LegacyXmlMixin, XBlock): _tag_name = "poll_question" _child_tag_name = "answer" - @property - def xblock_kvs(self): - """ - Retrieves the internal KeyValueStore for this XModule. - - Should only be used by the persistence layer. Use with caution. - """ - # if caller wants kvs, caller's assuming it's up to date; so, decache it - self.save() - return self._field_data._kvs # pylint: disable=protected-access - def handle_ajax(self, dispatch, data): # legacy support for tests """ Legacy method to mimic old ajax handler behavior for backward compatibility. @@ -261,23 +250,6 @@ def workbench_scenarios(): ), ] - def get_explicitly_set_fields_by_scope(self, scope=Scope.content): - """ - Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including - any set to None.) - """ - result = {} - for field in self.fields.values(): - if field.scope == scope and field.is_set_on(self): - try: - result[field.name] = field.read_json(self) - except TypeError as exception: - exception_message = "{message}, Block-location:{location}, Field-name:{field_name}".format( - message=str(exception), location=str(self.usage_key), field_name=field.name - ) - raise TypeError(exception_message) # pylint: disable=raise-missing-from - return result - @classmethod def definition_from_xml(cls, xml_object, system): """ diff --git a/xblocks_contrib/problem/capa_block.py b/xblocks_contrib/problem/capa_block.py index e43b137a..8186da02 100644 --- a/xblocks_contrib/problem/capa_block.py +++ b/xblocks_contrib/problem/capa_block.py @@ -41,14 +41,13 @@ ScoreField, String, Timedelta, - UserScope, XMLString, ) from xblock.progress import Progress from xblock.runtime import KeyValueStore, KvsFieldData from xblock.scorable import ScorableXBlockMixin, Score, ShowCorrectness -from xblocks_contrib.common.xml_utils import LegacyXmlMixin, is_pointer_tag +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin, is_pointer_tag from xblocks_contrib.problem.capa import responsetypes from xblocks_contrib.problem.capa.capa_problem import LoncapaProblem, LoncapaSystem from xblocks_contrib.problem.capa.inputtypes import Status @@ -93,226 +92,6 @@ def __init__(self, location, msg): self.location = location -class RawMixin: - """ - Common code between RawDescriptor and XBlocks converted from XModules. - """ - - @classmethod - def definition_from_xml(cls, xml_object, system): # pylint: disable=unused-argument - """Convert XML node into a dictionary with 'data' key for XBlock.""" - return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, [] - - def definition_to_xml(self, resource_fs): # pylint: disable=unused-argument - """ - Return an Element if we've kept the import OLX, or None otherwise. - """ - # If there's no self.data, it means that an XBlock/XModule originally - # existed for this data at the time of import/editing, but was later - # uninstalled. RawDescriptor therefore never got to preserve the - # original OLX that came in, and we have no idea how it should be - # serialized for export. It's possible that we could do some smarter - # fallback here and attempt to extract the data, but it's reasonable - # and simpler to just skip this node altogether. - if not self.data: - log.warning( - "Could not serialize %s: No XBlock installed for '%s' tag.", - self.usage_key, - self.usage_key.block_type, - ) - return None - - # Normal case: Just echo back the original OLX we saved. - try: - return etree.fromstring(self.data) - except etree.XMLSyntaxError as err: - # Can't recover here, so just add some info and - # re-raise - lines = self.data.split("\n") - line, offset = err.position - msg = ( - f"Unable to create xml for block {self.usage_key}. " - f"Context: '{lines[line - 1][offset - 40: offset + 40]}'" - ) - raise SerializationError(self.usage_key, msg) from err - - @classmethod - def parse_xml_new_runtime(cls, node, runtime, keys): - """ - Interpret the parsed XML in `node`, creating a new instance of this - module. - """ - # In the new/learning-core-based runtime, XModule parsing (from - # XmlMixin) is disabled, so definition_from_xml will not be - # called, and instead the "normal" XBlock parse_xml will be used. - # However, it's not compatible with RawMixin, so we implement - # support here. - data_field_value = cls.definition_from_xml(node, None)[0]["data"] - for child in node.getchildren(): - node.remove(child) - # Get attributes, if any, via normal parse_xml. - try: - block = super().parse_xml_new_runtime(node, runtime, keys) - except AttributeError: - block = super().parse_xml(node, runtime, keys) - block.data = data_field_value - return block - - -class XModuleMixin(XBlock): - """ - Fields and methods used by XModules internally. - - Adding this Mixin to an :class:`XBlock` allows it to cooperate with old-style :class:`XModules` - - TODO: This mixin is legacy tech debt. Refactor the codebase to remove reliance - on XModule-style internals and remove this class. - """ - - @property - def xblock_kvs(self): - """ - Retrieves the internal KeyValueStore for this XBlock. - - Should only be used by the persistence layer. Use with caution. - """ - # if caller wants kvs, caller's assuming it's up to date; so, decache it - self.save() - return self._field_data._kvs # pylint: disable=protected-access - - def bind_for_student(self, user_id, wrappers=None): - """ - Set up this XBlock to act as an XModule instead of an XModuleDescriptor. - - Arguments: - user_id: The user_id to set in scope_ids - wrappers: These are a list functions that put a wrapper, such as - LmsFieldData or OverrideFieldData, around the field_data. - Note that the functions will be applied in the order in - which they're listed. So [f1, f2] -> f2(f1(field_data)) - """ - - # Skip rebinding if we're already bound a user, and it's this user. - if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id: - if getattr(self.runtime, "position", None): - self.position = self.runtime.position # update the position of the tab - return - - # If we are switching users mid-request, save the data from the old user. - self.save() - - # Update scope_ids to point to the new user. - self.scope_ids = self.scope_ids._replace(user_id=user_id) - - # Clear out any cached instantiated children. - self.clear_child_cache() - - # Clear out any cached field data scoped to the old user. - for field in self.fields.values(): - if field.scope in (Scope.parent, Scope.children): - continue - - if field.scope.user == UserScope.ONE: - field._del_cached_value(self) # pylint: disable=protected-access - # not the most elegant way of doing this, but if we're removing - # a field from the module's field_data_cache, we should also - # remove it from its _dirty_fields - if field in self._dirty_fields: - del self._dirty_fields[field] - - if wrappers: - # Put user-specific wrappers around the field-data service for this block. - # Note that these are different from modulestore.xblock_field_data_wrappers, which are not user-specific. - wrapped_field_data = self.runtime.service(self, "field-data-unbound") - for wrapper in wrappers: - wrapped_field_data = wrapper(wrapped_field_data) - self._bound_field_data = wrapped_field_data - if getattr(self.runtime, "uses_deprecated_field_data", False): - # This approach is deprecated but OldModuleStoreRuntime still requires it. - # For SplitModuleStoreRuntime, don't set ._field_data this way. - self._field_data = wrapped_field_data - - @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 public_view(self, _context): - """ - Default message for blocks that don't implement public_view - """ - alert_html = HTML( - '
' - ) - - if self.display_name: - display_text = _( - "{display_name} is only accessible to enrolled learners. " - "Sign in or register, and enroll in this course to view it." - ).format(display_name=self.display_name) - else: - display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE) # pylint: disable=translation-of-non-string - - return Fragment(alert_html.format(display_text)) - - -class XModuleToXBlockMixin: - """ - Common code needed by XModule and XBlocks converted from XModules. - - TODO: This mixin is legacy tech debt. Refactor ajax handling and - XModule-to-XBlock conversion logic to remove the need for this class. - """ - - @property - def ajax_url(self): - """ - Returns the URL for the ajax handler. - """ - return self.runtime.handler_url(self, "xmodule_handler", "", "").rstrip("/?") - - @XBlock.handler - def xmodule_handler(self, request, suffix=None): - """ - XBlock handler that wraps `handle_ajax` - """ - - 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") - - class InheritanceKeyValueStore(KeyValueStore): """ Common superclass for kvs's which know about inheritance of settings. Offers simple @@ -357,108 +136,6 @@ def default(self, key): return self.inherited_settings[key.field_name] -class XmlMixin(LegacyXmlMixin): # pylint: disable=abstract-method - """ - TODO: This mixin is legacy tech debt. Transition toward modern XBlock - serialization and remove this legacy XML parsing logic. - """ - - @classmethod - def parse_xml(cls, node, runtime, keys): - """ - Use `node` to construct a new block. - - Arguments: - node (etree.Element): The xml node to parse into an xblock. - - runtime (:class:`.Runtime`): The runtime to use while parsing. - - keys (:class:`.ScopeIds`): The keys identifying where this block - will store its data. - - Returns (XBlock): The newly parsed XBlock - - """ - if keys is None: - # Passing keys=None is against the XBlock API but some platform tests do it. - def_id = runtime.id_generator.create_definition(node.tag, node.get("url_name")) - keys = ScopeIds(None, node.tag, def_id, runtime.id_generator.create_usage(def_id)) - aside_children = [] - - # VS[compat] - # In 2012, when the platform didn't have CMS, and all courses were handwritten XML files, problem tags - # contained XML problem descriptions withing themselves. Later, when Studio has been created, and "pointer" tags - # became the preferred problem format, edX has to add this compatibility code to 1) support both pre- and - # post-Studio course formats simulteneously, and 2) be able to migrate 2012-fall courses to Studio. Old style - # support supposed to be removed, but the deprecation process have never been initiated, so this - # compatibility must stay, probably forever. - if is_pointer_tag(node): - # new style: - # read the actual definition file--named using url_name.replace(':','/') - definition_xml, filepath = cls.load_definition_xml(node, runtime, keys.def_id) - aside_children = runtime.parse_asides(definition_xml, keys.def_id, keys.usage_id, runtime.id_generator) - else: - filepath = None - definition_xml = node - - # Note: removes metadata. - definition, children = cls.load_definition(definition_xml, runtime, keys.def_id, runtime.id_generator) - - # VS[compat] - # Make Ike's github preview links work in both old and new file layouts. - if is_pointer_tag(node): - # new style -- contents actually at filepath - definition["filename"] = [filepath, filepath] - - metadata = cls.load_metadata(definition_xml) - - # move definition metadata into dict - dmdata = definition.get("definition_metadata", "") - if dmdata: - metadata["definition_metadata_raw"] = dmdata - try: - metadata.update(json.loads(dmdata)) - except Exception as err: # pylint: disable=broad-exception-caught - log.debug("Error in loading metadata %r", dmdata, exc_info=True) - metadata["definition_metadata_err"] = str(err) - - definition_aside_children = definition.pop("aside_children", None) - if definition_aside_children: - aside_children.extend(definition_aside_children) - - # Set/override any metadata specified by policy - cls.apply_policy(metadata, runtime.get_policy(keys.usage_id)) - - field_data = {**metadata, **definition, "children": children} - field_data["xml_attributes"]["filename"] = definition.get("filename", ["", None]) # for git link - if "filename" in field_data: - del field_data["filename"] # filename should only be in xml_attributes. - - if type(runtime).__name__ == "XMLImportingModuleStoreRuntime": - kvs = InheritanceKeyValueStore(field_data) - field_data = KvsFieldData(kvs) - xblock = runtime.construct_xblock_from_class(cls, keys, field_data) - else: - # The "normal" / new way to set field data: - xblock = runtime.construct_xblock_from_class(cls, keys) - for key, value_jsonish in field_data.items(): - if key in cls.fields: - setattr(xblock, key, cls.fields[key].from_json(value_jsonish)) - elif key == "children": - xblock.children = value_jsonish - else: - log.warning( - "Imported %s XBlock does not have field %s found in XML.", - xblock.scope_ids.block_type, - key, - ) - - if aside_children: - cls.add_applicable_asides_to_block(xblock, runtime, aside_children) - - return xblock - - class SHOWANSWER: """ Constants for when to show answer @@ -525,13 +202,7 @@ def from_json(self, value): @XBlock.needs("xqueue") @XBlock.needs("replace_urls") @XBlock.wants("call_to_action") -class ProblemBlock( - ScorableXBlockMixin, - RawMixin, - XmlMixin, - XModuleToXBlockMixin, - XModuleMixin, -): +class ProblemBlock(ScorableXBlockMixin, LegacyXmlMixin, XBlock): """ An XBlock representing a "problem". @@ -736,7 +407,10 @@ class ProblemBlock( ) def bind_for_student(self, *args, **kwargs): - super().bind_for_student(*args, **kwargs) + """ + Set up this XBlock to act as an XModule instead of an XModuleDescriptor. + """ + super().bind_for_student(*args, **kwargs) # pylint: disable=no-member # Capa was an XModule. When bind_for_student() was called on it with a new runtime, a new CapaModule object # was initialized when XModuleDescriptor._xmodule() was called next. self.lcp was constructed in CapaModule @@ -785,7 +459,7 @@ def public_view(self, context): return self.student_view(context) # Show a message that this content requires users to login/enroll. - return super().public_view(context) + return super().public_view(context) # pylint: disable=no-member def author_view(self, context): """ @@ -2740,6 +2414,203 @@ def score_from_lcp(self, lcp): lcp_score = lcp.calculate_score() return Score(raw_earned=lcp_score["score"], raw_possible=lcp_score["total"]) + @classmethod + def definition_from_xml(cls, xml_object, system): + """Convert XML node into a dictionary with 'data' key for XBlock.""" + return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, [] + + def definition_to_xml(self, resource_fs): + """ + Return an Element if we've kept the import OLX, or None otherwise. + """ + # If there's no self.data, it means that an XBlock/XModule originally + # existed for this data at the time of import/editing, but was later + # uninstalled. RawDescriptor therefore never got to preserve the + # original OLX that came in, and we have no idea how it should be + # serialized for export. It's possible that we could do some smarter + # fallback here and attempt to extract the data, but it's reasonable + # and simpler to just skip this node altogether. + if not self.data: + log.warning( + "Could not serialize %s: No XBlock installed for '%s' tag.", + self.usage_key, + self.usage_key.block_type, + ) + return None + + # Normal case: Just echo back the original OLX we saved. + try: + return etree.fromstring(self.data) + except etree.XMLSyntaxError as err: + # Can't recover here, so just add some info and + # re-raise + lines = self.data.split("\n") + line, offset = err.position # pylint: disable=unpacking-non-sequence + msg = ( + f"Unable to create xml for block {self.usage_key}. " + f"Context: '{lines[line - 1][offset - 40: offset + 40]}'" + ) + raise SerializationError(self.usage_key, msg) from err + + @classmethod + def parse_xml_new_runtime(cls, node, runtime, keys): + """ + Interpret the parsed XML in `node`, creating a new instance of this + module. + """ + # In the new/learning-core-based runtime, XModule parsing (from + # XmlMixin) is disabled, so definition_from_xml will not be + # called, and instead the "normal" XBlock parse_xml will be used. + # However, it's not compatible with RawMixin, so we implement + # support here. + data_field_value = cls.definition_from_xml(node, None)[0]["data"] + for child in node.getchildren(): + node.remove(child) + # Get attributes, if any, via normal parse_xml. + try: + block = super().parse_xml_new_runtime(node, runtime, keys) + except AttributeError: + block = super().parse_xml(node, runtime, keys) + block.data = data_field_value + return block + + @classmethod + def parse_xml(cls, node, runtime, keys): + """ + Use `node` to construct a new block. + + Arguments: + node (etree.Element): The xml node to parse into an xblock. + + runtime (:class:`.Runtime`): The runtime to use while parsing. + + keys (:class:`.ScopeIds`): The keys identifying where this block + will store its data. + + Returns (XBlock): The newly parsed XBlock + + """ + if keys is None: + # Passing keys=None is against the XBlock API but some platform tests do it. + def_id = runtime.id_generator.create_definition(node.tag, node.get("url_name")) + keys = ScopeIds(None, node.tag, def_id, runtime.id_generator.create_usage(def_id)) + aside_children = [] + + # VS[compat] + # In 2012, when the platform didn't have CMS, and all courses were handwritten XML files, problem tags + # contained XML problem descriptions withing themselves. Later, when Studio has been created, and "pointer" tags + # became the preferred problem format, edX has to add this compatibility code to 1) support both pre- and + # post-Studio course formats simulteneously, and 2) be able to migrate 2012-fall courses to Studio. Old style + # support supposed to be removed, but the deprecation process have never been initiated, so this + # compatibility must stay, probably forever. + if is_pointer_tag(node): + # new style: + # read the actual definition file--named using url_name.replace(':','/') + definition_xml, filepath = cls.load_definition_xml(node, runtime, keys.def_id) + aside_children = runtime.parse_asides(definition_xml, keys.def_id, keys.usage_id, runtime.id_generator) + else: + filepath = None + definition_xml = node + + # Note: removes metadata. + definition, children = cls.load_definition(definition_xml, runtime, keys.def_id, runtime.id_generator) + + # VS[compat] + # Make Ike's github preview links work in both old and new file layouts. + if is_pointer_tag(node): + # new style -- contents actually at filepath + definition["filename"] = [filepath, filepath] + + metadata = cls.load_metadata(definition_xml) + + # move definition metadata into dict + dmdata = definition.get("definition_metadata", "") + if dmdata: + metadata["definition_metadata_raw"] = dmdata + try: + metadata.update(json.loads(dmdata)) + except Exception as err: # pylint: disable=broad-exception-caught + log.debug("Error in loading metadata %r", dmdata, exc_info=True) + metadata["definition_metadata_err"] = str(err) + + definition_aside_children = definition.pop("aside_children", None) + if definition_aside_children: + aside_children.extend(definition_aside_children) + + # Set/override any metadata specified by policy + cls.apply_policy(metadata, runtime.get_policy(keys.usage_id)) + + field_data = {**metadata, **definition, "children": children} + field_data["xml_attributes"]["filename"] = definition.get("filename", ["", None]) # for git link + if "filename" in field_data: + del field_data["filename"] # filename should only be in xml_attributes. + + if type(runtime).__name__ == "XMLImportingModuleStoreRuntime": + kvs = InheritanceKeyValueStore(field_data) + field_data = KvsFieldData(kvs) + xblock = runtime.construct_xblock_from_class(cls, keys, field_data) + else: + # The "normal" / new way to set field data: + xblock = runtime.construct_xblock_from_class(cls, keys) + for key, value_jsonish in field_data.items(): + if key in cls.fields: # pylint: disable=unsupported-membership-test + # pylint: disable=unsubscriptable-object + setattr(xblock, key, cls.fields[key].from_json(value_jsonish)) + elif key == "children": + xblock.children = value_jsonish + else: + log.warning( + "Imported %s XBlock does not have field %s found in XML.", + xblock.scope_ids.block_type, + key, + ) + + if aside_children: + cls.add_applicable_asides_to_block(xblock, runtime, aside_children) + + return xblock + + @property + def ajax_url(self): + """ + Returns the URL for the ajax handler. + """ + return self.runtime.handler_url(self, "xmodule_handler", "", "").rstrip("/?") + + @XBlock.handler + def xmodule_handler(self, request, suffix=None): + """ + XBlock handler that wraps `handle_ajax` + """ + + 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") + class GradingMethodHandler: """ diff --git a/xblocks_contrib/video/ajax_handler_mixin.py b/xblocks_contrib/video/ajax_handler_mixin.py deleted file mode 100644 index 11dfd053..00000000 --- a/xblocks_contrib/video/ajax_handler_mixin.py +++ /dev/null @@ -1,48 +0,0 @@ -""" 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 deleted file mode 100644 index 38fd9345..00000000 --- a/xblocks_contrib/video/studio_metadata_mixin.py +++ /dev/null @@ -1,167 +0,0 @@ -""" 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 1cb6a110..892374ad 100644 --- a/xblocks_contrib/video/video.py +++ b/xblocks_contrib/video/video.py @@ -29,25 +29,25 @@ 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 Scope, XBlock -from xblock.fields import ScopeIds, UserScope +from xblock.core import XBlock +from xblock.fields import ScopeIds from xblock.utils.resources import ResourceLoader -from xblocks_contrib.common.xml_utils import ( +from xblocks_contrib.legacy_utils.xml_utils import ( EdxJSONEncoder, LegacyXmlMixin, deserialize_field, 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 ( @@ -60,6 +60,7 @@ VideoTranscriptsMixin, clean_video_id, get_endonym_or_label, + get_html5_ids, subs_filename, ) from xblocks_contrib.video.video_utils import ( @@ -110,9 +111,7 @@ @XBlock.needs('i18n', 'user') class VideoBlock( VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, VideoStudentViewHandlers, - LegacyXmlMixin, XBlock, - AjaxHandlerMixin, StudioMetadataMixin, - LicenseMixin): + LegacyXmlMixin, XBlock, LicenseMixin): """ XML source example:: @@ -150,28 +149,6 @@ class VideoBlock( uses_xmodule_styles_setup = True - @property - def display_name_with_default(self): - """ - Return a display name for the module: use display_name if defined in - metadata, otherwise convert the url name. - """ - return ( - self.display_name if self.display_name is not None - else self.usage_key.block_id.replace('_', ' ') - ) - - @property - def xblock_kvs(self): - """ - Retrieves the internal KeyValueStore for this XModule. - - Should only be used by the persistence layer. Use with caution. - """ - # if caller wants kvs, caller's assuming it's up to date; so, decache it - self.save() - return self._field_data._kvs # pylint: disable=protected-access - def get_transcripts_for_student(self, transcripts, dest_lang=None): """Return transcript information necessary for rendering the XModule student view. This is more or less a direct extraction from `get_html`. @@ -504,7 +481,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, + 'display_name': self.display_name_with_default, # pylint: disable=no-member 'download_video_link': download_video_link, 'is_video_from_same_origin': is_video_from_same_origin, 'handout': self.handout, @@ -1189,58 +1166,44 @@ def _poster(self): ) return None - def bind_for_student(self, user_id, wrappers=None): + @property + def ajax_url(self): """ - Set up this XBlock to act as an XModule instead of an XModuleDescriptor. - - Arguments: - user_id: The user_id to set in scope_ids - wrappers: These are a list functions that put a wrapper, such as - LmsFieldData or OverrideFieldData, around the field_data. - Note that the functions will be applied in the order in - which they're listed. So [f1, f2] -> f2(f1(field_data)) + Returns the URL for the ajax handler. """ + return self.runtime.handler_url(self, 'ajax_handler', '', '').rstrip('/?') - # Skip rebinding if we're already bound a user, and it's this user. - if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id: - if getattr(self.runtime, "position", None): - # update the position of the tab - self.position = self.runtime.position # pylint: disable=attribute-defined-outside-init - return + @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. - # If we are switching users mid-request, save the data from the old user. - self.save() + 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. - # Update scope_ids to point to the new user. - self.scope_ids = self.scope_ids._replace(user_id=user_id) + """ + def __init__(self, webob_file): + self.file = webob_file.file + self.name = webob_file.filename - # Clear out any cached instantiated children. - self.clear_child_cache() + def __getattr__(self, name): + return getattr(self.file, name) - # Clear out any cached field data scoped to the old user. - for field in self.fields.values(): - if field.scope in (Scope.parent, Scope.children): - continue + # 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))) - if field.scope.user == UserScope.ONE: - field._del_cached_value(self) # pylint: disable=protected-access - # not the most elegant way of doing this, but if we're removing - # a field from the module's field_data_cache, we should also - # remove it from its _dirty_fields - if field in self._dirty_fields: - del self._dirty_fields[field] - - if wrappers: - # Put user-specific wrappers around the field-data service for this block. - # Note that these are different from modulestore.xblock_field_data_wrappers, which are not user-specific. - wrapped_field_data = self.runtime.service(self, "field-data-unbound") - for wrapper in wrappers: - wrapped_field_data = wrapper(wrapped_field_data) - self._bound_field_data = wrapped_field_data # pylint: disable=attribute-defined-outside-init - if getattr(self.runtime, "uses_deprecated_field_data", False): - # This approach is deprecated but OldModuleStoreRuntime still requires it. - # For SplitModuleStoreRuntime, don't set ._field_data this way. - self._field_data = wrapped_field_data + 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): @@ -1248,20 +1211,66 @@ def definition_from_xml(cls, xml_object, system): return {"data": ""}, [] return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, [] - def get_explicitly_set_fields_by_scope(self, scope=Scope.content): + @property + def editable_metadata_fields(self): """ - Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including - any set to None.) + Returns the metadata fields to be edited in Studio. """ - result = {} - for field in self.fields.values(): - if field.scope == scope and field.is_set_on(self): + 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: - result[field.name] = field.read_json(self) - except TypeError as exception: - exception_message = f"{exception}, Block-location:{self.usage_key}, Field-name:{field.name}" - raise TypeError(exception_message) from exception - return result + _, 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(): diff --git a/xblocks_contrib/word_cloud/word_cloud.py b/xblocks_contrib/word_cloud/word_cloud.py index e1b8be4b..1d28a96f 100644 --- a/xblocks_contrib/word_cloud/word_cloud.py +++ b/xblocks_contrib/word_cloud/word_cloud.py @@ -15,7 +15,7 @@ from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin -from xblocks_contrib.common.xml_utils import LegacyXmlMixin +from xblocks_contrib.legacy_utils.xml_utils import LegacyXmlMixin resource_loader = ResourceLoader(__name__)