Skip to content

Commit 905542c

Browse files
committed
chore: refactor XmlMixin as LegacyXmlMixin
1 parent b9f09a7 commit 905542c

File tree

1 file changed

+85
-93
lines changed

1 file changed

+85
-93
lines changed

xblocks_contrib/common/xml_utils.py

Lines changed: 85 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,60 @@
1-
# NOTE: Code has been copied from the source file: https://github.com/openedx/edx-platform/blob/master/xmodule/xml_block.py
1+
"""
2+
XML utility functions and classes for XBlocks.
3+
Note: Most of the functionality is taken from the edx-platform's XmlMixin.
4+
https://github.com/openedx/edx-platform/blob/master/xmodule/xml_block.py
5+
"""
26

3-
# lint-amnesty, pylint: disable=missing-module-docstring
47
import copy
58
import datetime
69
import json
710
import logging
811
import os
12+
from typing import Any
913

14+
from django.core.serializers.json import DjangoJSONEncoder
1015
from lxml import etree
1116
from lxml.etree import ElementTree, XMLParser
12-
from xblock.core import XML_NAMESPACES
13-
from xblock.fields import Dict, Scope, ScopeIds
14-
from xblock.runtime import KvsFieldData
15-
from xmodule.modulestore import EdxJSONEncoder
16-
from xmodule.modulestore.inheritance import InheritanceKeyValueStore, own_metadata
17+
from lxml.etree import _Element
18+
from opaque_keys.edx.keys import CourseKey, UsageKey
19+
from xblock.core import XBlock, XML_NAMESPACES
20+
from xblock.fields import Dict, Field, Scope, ScopeIds
1721

1822
log = logging.getLogger(__name__)
1923

2024
# assume all XML files are persisted as utf-8.
2125
EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8')
2226

2327

24-
def name_to_pathname(name):
28+
class EdxJSONEncoder(DjangoJSONEncoder):
29+
"""
30+
Custom JSONEncoder that handles ``Location`` and ``datetime.datetime`` objects.
31+
Encodes ``Location`` as its URL string form, and ``datetime.datetime`` as an ISO 8601 string.
32+
"""
33+
34+
def default(self, o):
35+
if isinstance(o, (CourseKey, UsageKey)):
36+
return str(o)
37+
elif isinstance(o, datetime.datetime):
38+
if o.tzinfo is not None:
39+
if o.utcoffset() is None:
40+
return o.isoformat() + "Z"
41+
else:
42+
return o.isoformat()
43+
else:
44+
return o.isoformat()
45+
else:
46+
return super().default(o)
47+
48+
49+
def name_to_pathname(name: str) -> str:
2550
"""
2651
Convert a location name for use in a path: replace ':' with '/'.
2752
This allows users of the xml format to organize content into directories
2853
"""
2954
return name.replace(':', '/')
3055

3156

32-
def is_pointer_tag(xml_obj):
57+
def is_pointer_tag(xml_obj: _Element) -> bool:
3358
"""
3459
Check if xml_obj is a pointer tag: <blah url_name="something" />.
3560
No children, one attribute named url_name, no text.
@@ -53,7 +78,7 @@ def is_pointer_tag(xml_obj):
5378
return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text
5479

5580

56-
def serialize_field(value):
81+
def serialize_field(value: Any) -> str:
5782
"""
5883
Return a string version of the value (where value is the JSON-formatted, internally stored value).
5984
@@ -70,7 +95,7 @@ def serialize_field(value):
7095
return json.dumps(value, cls=EdxJSONEncoder)
7196

7297

73-
def deserialize_field(field, value):
98+
def deserialize_field(field: Field, value: str) -> Any:
7499
"""
75100
Deserialize the string version to the value stored internally.
76101
@@ -102,34 +127,41 @@ def deserialize_field(field, value):
102127
return value
103128

104129

105-
class XmlMixin:
130+
def own_metadata(block: XBlock) -> dict[str, Any]:
106131
"""
107-
Class containing XML parsing functionality shared between XBlock and XModuleDescriptor.
132+
Return a JSON-friendly dictionary that contains only non-inherited field
133+
keys, mapped to their serialized values
108134
"""
109-
resources_dir = None
135+
return block.get_explicitly_set_fields_by_scope(Scope.settings)
136+
110137

138+
class LegacyXmlMixin:
139+
"""
140+
Class containing XML parsing functionality for the extracted XBlocks.
141+
NOTE: This is a temporary solution, we don't want new XBlocks to use this mixin.
142+
It will be removed in the future improvements.
143+
"""
111144
# Extension to append to filename paths
112145
filename_extension = 'xml'
113146

114-
xml_attributes = Dict(help="Map of unhandled xml attributes, used only for storage between import and export",
115-
default={}, scope=Scope.settings)
116-
117-
metadata_to_strip = ('data_dir',
118-
'tabs', 'grading_policy',
119-
'discussion_blackouts',
120-
# VS[compat]
121-
# These attributes should have been removed from here once all 2012-fall courses imported into
122-
# the CMS and "inline" OLX format deprecated. But, it never got deprecated. Moreover, it's
123-
# widely used to this date. So, we still have to strip them. Also, removing of "filename"
124-
# changes OLX returned by `/api/olx-export/v1/xblock/{block_id}/`, which indicates that some
125-
# places in the platform rely on it.
126-
'course', 'org', 'url_name', 'filename',
127-
# Used for storing xml attributes between import and export, for roundtrips
128-
'xml_attributes',
129-
# Used by _import_xml_node_to_parent in cms/djangoapps/contentstore/helpers.py to prevent
130-
# XmlMixin from treating some XML nodes as "pointer nodes".
131-
"x-is-pointer-node",
132-
)
147+
xml_attributes = Dict(
148+
help="Map of unhandled xml attributes, used only for storage between import and export",
149+
default={},
150+
scope=Scope.settings
151+
)
152+
153+
metadata_to_strip = (
154+
'data_dir',
155+
'tabs',
156+
'grading_policy',
157+
'discussion_blackouts',
158+
'course',
159+
'org',
160+
'url_name',
161+
'filename',
162+
'xml_attributes',
163+
"x-is-pointer-node",
164+
)
133165

134166
# This is a categories to fields map that contains the block category specific fields which should not be
135167
# cleaned and/or override while adding xml to node.
@@ -197,7 +229,7 @@ def load_file(cls, filepath, fs, def_id): # pylint: disable=invalid-name
197229
try:
198230
with fs.open(filepath) as xml_file:
199231
return cls.file_to_xml(xml_file)
200-
except Exception as err: # lint-amnesty, pylint: disable=broad-except
232+
except Exception as err:
201233
# Add info about where we are, but keep the traceback
202234
raise Exception(f'Unable to load file contents at path {filepath} for item {def_id}: {err}') from err
203235

@@ -291,7 +323,7 @@ def apply_policy(cls, metadata, policy):
291323
metadata[attr] = value
292324

293325
@classmethod
294-
def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-statements
326+
def parse_xml(cls, node, runtime, keys):
295327
"""
296328
Use `node` to construct a new block.
297329
@@ -306,14 +338,15 @@ def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-statements
306338
Returns (XBlock): The newly parsed XBlock
307339
308340
"""
309-
from xmodule.modulestore.xml import XMLImportingModuleStoreRuntime # done here to avoid circular import
310-
311341
if keys is None:
312342
# Passing keys=None is against the XBlock API but some platform tests do it.
313343
def_id = runtime.id_generator.create_definition(node.tag, node.get('url_name'))
314344
keys = ScopeIds(None, node.tag, def_id, runtime.id_generator.create_usage(def_id))
315345
aside_children = []
316346

347+
# Let the runtime construct the block. It will have a proper, inheritance-aware field data store.
348+
xblock = runtime.construct_xblock_from_class(cls, keys)
349+
317350
# VS[compat]
318351
# In 2012, when the platform didn't have CMS, and all courses were handwritten XML files, problem tags
319352
# contained XML problem descriptions withing themselves. Later, when Studio has been created, and "pointer" tags
@@ -358,30 +391,19 @@ def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-statements
358391
# Set/override any metadata specified by policy
359392
cls.apply_policy(metadata, runtime.get_policy(keys.usage_id))
360393

361-
field_data = {**metadata, **definition, "children": children}
362-
field_data['xml_attributes']['filename'] = definition.get('filename', ['', None]) # for git link
363-
if "filename" in field_data:
364-
del field_data["filename"] # filename should only be in xml_attributes.
365-
366-
if isinstance(runtime, XMLImportingModuleStoreRuntime):
367-
# we shouldn't be instantiating our own field data instance here, but there are complex
368-
# inter-depenencies between this mixin and XMLImportingModuleStoreRuntime that currently
369-
# seem to require it for proper metadata inheritance.
370-
kvs = InheritanceKeyValueStore(initial_values=field_data)
371-
field_data = KvsFieldData(kvs)
372-
xblock = runtime.construct_xblock_from_class(cls, keys, field_data)
373-
else:
374-
# The "normal" / new way to set field data:
375-
xblock = runtime.construct_xblock_from_class(cls, keys)
376-
for (key, value_jsonish) in field_data.items():
377-
if key in cls.fields:
378-
setattr(xblock, key, cls.fields[key].from_json(value_jsonish))
379-
elif key == 'children':
380-
xblock.children = value_jsonish
381-
else:
382-
log.warning(
383-
"Imported %s XBlock does not have field %s found in XML.", xblock.scope_ids.block_type, key,
384-
)
394+
field_data = {**metadata, **definition}
395+
396+
for field_name, value in field_data.items():
397+
# The 'xml_attributes' field has a special setter logic in its Field class,
398+
# so we must handle it carefully to avoid duplicating data.
399+
if field_name == "xml_attributes":
400+
# The 'filename' attribute is specially handled for git links.
401+
value["filename"] = definition.get("filename", ["", None])
402+
xblock.xml_attributes.update(value)
403+
elif field_name in xblock.fields:
404+
setattr(xblock, field_name, value)
405+
406+
xblock.children = children
385407

386408
if aside_children:
387409
cls.add_applicable_asides_to_block(xblock, runtime, aside_children)
@@ -399,17 +421,6 @@ def add_applicable_asides_to_block(cls, block, runtime, aside_children):
399421
if aside.scope_ids.block_type in asides_tags:
400422
block.add_aside(aside)
401423

402-
@classmethod
403-
def parse_xml_new_runtime(cls, node, runtime, keys):
404-
"""
405-
This XML lives within Learning Core and the new runtime doesn't need this
406-
legacy XModule code. Use the "normal" XBlock parsing code.
407-
"""
408-
try:
409-
return super().parse_xml_new_runtime(node, runtime, keys)
410-
except AttributeError:
411-
return super().parse_xml(node, runtime, keys)
412-
413424
@classmethod
414425
def load_definition_xml(cls, node, runtime, def_id):
415426
"""
@@ -427,23 +438,15 @@ def _format_filepath(cls, category, name):
427438
def export_to_file(self):
428439
"""If this returns True, write the definition of this block to a separate
429440
file.
430-
431-
NOTE: Do not override this without a good reason. It is here
432-
specifically for customtag...
433441
"""
434442
return True
435443

436444
def add_xml_to_node(self, node):
437445
"""
438446
For exporting, set data on `node` from ourselves.
439447
"""
440-
# Get the definition
441-
# if node.tag == 'lti':
442-
# import pdb ; pdb.set_trace()
443448
xml_object = self.definition_to_xml(self.runtime.export_fs)
444449

445-
# If xml_object is None, we don't know how to serialize this node, but
446-
# we shouldn't crash out the whole export for it.
447450
if xml_object is None:
448451
return
449452

@@ -482,11 +485,7 @@ def add_xml_to_node(self, node):
482485
if self.export_to_file():
483486
# Write the definition to a file
484487
url_path = name_to_pathname(self.url_name)
485-
# if folder is course then create file with name {course_run}.xml
486-
filepath = self._format_filepath(
487-
self.category,
488-
self.location.run if self.category == 'course' else url_path,
489-
)
488+
filepath = self._format_filepath(self.category, url_path)
490489
self.runtime.export_fs.makedirs(os.path.dirname(filepath), recreate=True)
491490
with self.runtime.export_fs.open(filepath, 'wb') as fileobj:
492491
ElementTree(xml_object).write(fileobj, pretty_print=True, encoding='utf-8')
@@ -499,16 +498,9 @@ def add_xml_to_node(self, node):
499498
node.attrib.update(xml_object.attrib)
500499
node.extend(xml_object)
501500

502-
# Do not override an existing value for the course.
503501
if not node.get('url_name'):
504502
node.set('url_name', self.url_name)
505503

506-
# Special case for course pointers:
507-
if self.category == 'course':
508-
# add org and course attributes on the pointer tag
509-
node.set('org', self.location.org)
510-
node.set('course', self.location.course)
511-
512504
def definition_to_xml(self, resource_fs):
513505
"""
514506
Return a new etree Element object created from this modules definition.
@@ -522,5 +514,5 @@ def non_editable_metadata_fields(self):
522514
Return a list of all metadata fields that cannot be edited.
523515
"""
524516
non_editable_fields = super().non_editable_metadata_fields
525-
non_editable_fields.append(XmlMixin.xml_attributes)
517+
non_editable_fields.append(LegacyXmlMixin.xml_attributes)
526518
return non_editable_fields

0 commit comments

Comments
 (0)