Skip to content

Commit 05e3053

Browse files
committed
feat: add support for pointer tag OLX export/import format in HTML block
1 parent a13fccb commit 05e3053

File tree

1 file changed

+14
-160
lines changed

1 file changed

+14
-160
lines changed

xblocks_contrib/html/html.py

Lines changed: 14 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"""
55

66
import copy
7-
import datetime
87
import json
98
import logging
109
import os
@@ -14,7 +13,6 @@
1413
from html.parser import HTMLParser
1514

1615
from django.conf import settings
17-
from django.core.serializers.json import DjangoJSONEncoder
1816
from django.utils.translation import gettext_noop as _
1917
from fs.errors import ResourceNotFound
2018
from lxml import etree
@@ -27,6 +25,17 @@
2725
from xblock.fields import Boolean, Dict, Scope, ScopeIds, String, UserScope
2826
from xblock.utils.resources import ResourceLoader
2927

28+
from xblocks_contrib.common.xml_utils import (
29+
apply_pointer_attributes,
30+
deserialize_field,
31+
format_filepath,
32+
is_pointer_tag,
33+
load_definition_xml,
34+
name_to_pathname,
35+
own_metadata,
36+
serialize_field,
37+
)
38+
3039
log = logging.getLogger(__name__)
3140
resource_loader = ResourceLoader(__name__)
3241

@@ -37,27 +46,6 @@
3746
EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding="utf-8")
3847

3948

40-
class EdxJSONEncoder(DjangoJSONEncoder):
41-
"""
42-
Custom JSONEncoder that handles ``Location`` and ``datetime.datetime`` objects.
43-
Encodes ``Location`` as its URL string form, and ``datetime.datetime`` as an ISO 8601 string.
44-
"""
45-
46-
def default(self, o):
47-
if isinstance(o, (CourseKey, UsageKey)):
48-
return str(o)
49-
elif isinstance(o, datetime.datetime):
50-
if o.tzinfo is not None:
51-
if o.utcoffset() is None:
52-
return o.isoformat() + "Z"
53-
else:
54-
return o.isoformat()
55-
else:
56-
return o.isoformat()
57-
else:
58-
return super().default(o)
59-
60-
6149
class MLStripper(HTMLParser):
6250
"helper function for html_to_text below"
6351

@@ -164,95 +152,6 @@ def stringify_children(node):
164152
return "".join([part for part in parts if part])
165153

166154

167-
def name_to_pathname(name):
168-
"""
169-
Convert a location name for use in a path: replace ':' with '/'.
170-
This allows users of the xml format to organize content into directories
171-
"""
172-
return name.replace(":", "/")
173-
174-
175-
def is_pointer_tag(xml_obj):
176-
"""
177-
Check if xml_obj is a pointer tag: <blah url_name="something" />.
178-
No children, one attribute named url_name, no text.
179-
180-
Special case for course roots: the pointer is
181-
<course url_name="something" org="myorg" course="course">
182-
183-
xml_obj: an etree Element
184-
185-
Returns a bool.
186-
"""
187-
if xml_obj.tag != "course":
188-
expected_attr = {"url_name"}
189-
else:
190-
expected_attr = {"url_name", "course", "org"}
191-
192-
actual_attr = set(xml_obj.attrib.keys())
193-
194-
has_text = xml_obj.text is not None and len(xml_obj.text.strip()) > 0
195-
196-
return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text
197-
198-
199-
def serialize_field(value):
200-
"""
201-
Return a string version of the value (where value is the JSON-formatted, internally stored value).
202-
203-
If the value is a string, then we simply return what was passed in.
204-
Otherwise, we return json.dumps on the input value.
205-
"""
206-
if isinstance(value, str):
207-
return value
208-
elif isinstance(value, datetime.datetime):
209-
if value.tzinfo is not None and value.utcoffset() is None:
210-
return value.isoformat() + "Z"
211-
return value.isoformat()
212-
213-
return json.dumps(value, cls=EdxJSONEncoder)
214-
215-
216-
def deserialize_field(field, value):
217-
"""
218-
Deserialize the string version to the value stored internally.
219-
220-
Note that this is not the same as the value returned by from_json, as model types typically store
221-
their value internally as JSON. By default, this method will return the result of calling json.loads
222-
on the supplied value, unless json.loads throws a TypeError, or the type of the value returned by json.loads
223-
is not supported for this class (from_json throws an Error). In either of those cases, this method returns
224-
the input value.
225-
"""
226-
try:
227-
deserialized = json.loads(value)
228-
if deserialized is None:
229-
return deserialized
230-
try:
231-
field.from_json(deserialized)
232-
return deserialized
233-
except (ValueError, TypeError):
234-
# Support older serialized version, which was just a string, not result of json.dumps.
235-
# If the deserialized version cannot be converted to the type (via from_json),
236-
# just return the original value. For example, if a string value of '3.4' was
237-
# stored for a String field (before we started storing the result of json.dumps),
238-
# then it would be deserialized as 3.4, but 3.4 is not supported for a String
239-
# field. Therefore field.from_json(3.4) will throw an Error, and we should
240-
# actually return the original value of '3.4'.
241-
return value
242-
243-
except (ValueError, TypeError):
244-
# Support older serialized version.
245-
return value
246-
247-
248-
def own_metadata(block):
249-
"""
250-
Return a JSON-friendly dictionary that contains only non-inherited field
251-
keys, mapped to their serialized values
252-
"""
253-
return block.get_explicitly_set_fields_by_scope(Scope.settings)
254-
255-
256155
@XBlock.needs("i18n")
257156
# We 'want' the user service, but we don't strictly 'need' it.
258157
# This makes our block more resilient. It won't crash in test environments
@@ -600,31 +499,6 @@ def clean_metadata_from_xml(cls, xml_object, excluded_fields=()):
600499
):
601500
del xml_object.attrib[field_name]
602501

603-
@classmethod
604-
def file_to_xml(cls, file_object):
605-
"""
606-
Used when this module wants to parse a file object to xml
607-
that will be converted to the definition.
608-
609-
Returns an lxml Element
610-
"""
611-
return etree.parse(file_object, parser=EDX_XML_PARSER).getroot() # CHANGEE
612-
613-
@classmethod
614-
def load_file(cls, filepath, fs, def_id):
615-
"""
616-
Open the specified file in fs, and call cls.file_to_xml on it,
617-
returning the lxml object.
618-
619-
Add details and reraise on error.
620-
"""
621-
try:
622-
with fs.open(filepath) as xml_file:
623-
return cls.file_to_xml(xml_file)
624-
except Exception as err:
625-
# Add info about where we are, but keep the traceback
626-
raise Exception(f"Unable to load file contents at path {filepath} for item {def_id}: {err}") from err
627-
628502
# NOTE: html descriptors are special. We do not want to parse and
629503
# export them ourselves, because that can break things (e.g. lxml
630504
# adds body tags when it exports, but they should just be html
@@ -769,7 +643,7 @@ def parse_xml(cls, node, runtime, keys):
769643
if is_pointer_tag(node):
770644
# new style:
771645
# read the actual definition file--named using url_name.replace(':','/')
772-
definition_xml, filepath = cls.load_definition_xml(node, runtime, keys.def_id)
646+
definition_xml, filepath = load_definition_xml(node, runtime, keys.def_id)
773647
aside_children = runtime.parse_asides(definition_xml, keys.def_id, keys.usage_id, runtime.id_generator)
774648
else:
775649
filepath = None
@@ -847,21 +721,6 @@ def parse_xml_new_runtime(cls, node, runtime, keys):
847721
cls._set_field_if_present(block, name, value, {})
848722
return block
849723

850-
@classmethod
851-
def load_definition_xml(cls, node, runtime, def_id):
852-
"""
853-
Loads definition_xml stored in a dedicated file
854-
"""
855-
url_name = node.get("url_name")
856-
filepath = cls._format_filepath(node.tag, name_to_pathname(url_name))
857-
definition_xml = cls.load_file(filepath, runtime.resources_fs, def_id)
858-
return definition_xml, filepath
859-
860-
@classmethod
861-
def _format_filepath(cls, category, name):
862-
"""Formats a path to an XML definition file."""
863-
return f"{category}/{name}.{cls.filename_extension}"
864-
865724
def export_to_file(self):
866725
"""If this returns True, write the definition of this block to a separate
867726
file.
@@ -907,7 +766,7 @@ def add_xml_to_node(self, node):
907766

908767
if self.export_to_file():
909768
url_path = name_to_pathname(self.url_name)
910-
filepath = self._format_filepath(
769+
filepath = format_filepath(
911770
self.category, self.location.run if self.category == "course" else url_path
912771
)
913772
self.runtime.export_fs.makedirs(os.path.dirname(filepath), recreate=True)
@@ -921,12 +780,7 @@ def add_xml_to_node(self, node):
921780
node.attrib.update(xml_object.attrib)
922781
node.extend(xml_object)
923782

924-
if not node.get("url_name"):
925-
node.set("url_name", self.url_name)
926-
927-
if self.category == "course":
928-
node.set("org", self.location.org)
929-
node.set("course", self.location.course)
783+
apply_pointer_attributes(node, self)
930784

931785
def definition_to_xml(self, resource_fs):
932786
"""

0 commit comments

Comments
 (0)