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
47import copy
58import datetime
69import json
710import logging
811import os
12+ from typing import Any
913
14+ from django .core .serializers .json import DjangoJSONEncoder
1015from lxml import etree
1116from 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
1822log = logging .getLogger (__name__ )
1923
2024# assume all XML files are persisted as utf-8.
2125EDX_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