Skip to content

Commit c43cd0f

Browse files
committed
chore: add pointer tag parsing logic to LTIBlock
1 parent 290d22d commit c43cd0f

File tree

2 files changed

+232
-1
lines changed

2 files changed

+232
-1
lines changed

xblocks_contrib/lti/lti.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
from xblockutils.resources import ResourceLoader
8585
from xblockutils.studio_editable import StudioEditableXBlockMixin
8686

87-
from .lti_2_util import LTI20BlockMixin, LTIError
87+
from .lti_2_util import LTI20BlockMixin, LTIError, LTIXmlMixin
8888

8989
# The anonymous user ID for the user in the course.
9090
ATTR_KEY_ANONYMOUS_USER_ID = 'edx-platform.anonymous_user_id'
@@ -290,6 +290,7 @@ class LTIFields:
290290
class LTIBlock(
291291
LTIFields,
292292
LTI20BlockMixin,
293+
LTIXmlMixin,
293294
StudioEditableXBlockMixin,
294295
XBlock,
295296
):

xblocks_contrib/lti/lti_2_util.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
import base64
8+
import copy
89
import hashlib
910
import json
1011
import logging
@@ -14,9 +15,22 @@
1415
from urllib import parse
1516

1617
from django.conf import settings
18+
from lxml import etree
1719
from oauthlib.oauth1 import Client
1820
from webob import Response
1921
from xblock.core import XBlock
22+
from xblock.fields import Dict, Scope, ScopeIds
23+
24+
from xblocks_contrib.common.xml_utils import (
25+
apply_pointer_attributes,
26+
deserialize_field,
27+
format_filepath,
28+
is_pointer_tag,
29+
load_definition_xml,
30+
name_to_pathname,
31+
own_metadata,
32+
serialize_field,
33+
)
2034

2135
log = logging.getLogger(__name__)
2236

@@ -391,3 +405,219 @@ def parse_lti_2_0_result_json(self, json_str):
391405
raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from
392406

393407
return score, json_obj.get('comment', "")
408+
409+
410+
class LTIXmlMixin:
411+
"""
412+
A mixin class to add XML parsing functionality to LTIBlock.
413+
"""
414+
415+
metadata_to_strip = (
416+
'data_dir',
417+
'tabs', 'grading_policy',
418+
'discussion_blackouts',
419+
# VS[compat]
420+
# These attributes should have been removed from here once all 2012-fall courses imported into
421+
# the CMS and "inline" OLX format deprecated. But, it never got deprecated. Moreover, it's
422+
# widely used to this date. So, we still have to strip them. Also, removing of "filename"
423+
# changes OLX returned by `/api/olx-export/v1/xblock/{block_id}/`, which indicates that some
424+
# places in the platform rely on it.
425+
'course', 'org', 'url_name', 'filename',
426+
# Used for storing xml attributes between import and export, for roundtrips
427+
'xml_attributes',
428+
# Used by _import_xml_node_to_parent in cms/djangoapps/contentstore/helpers.py to prevent
429+
# XmlMixin from treating some XML nodes as "pointer nodes".
430+
"x-is-pointer-node",
431+
)
432+
433+
xml_attributes = Dict(
434+
help="Map of unhandled xml attributes, used only for storage between import and export",
435+
default={},
436+
scope=Scope.settings
437+
)
438+
439+
@classmethod
440+
def apply_policy(cls, metadata, policy):
441+
"""
442+
Add the keys in policy to metadata, after processing them
443+
through the attrmap. Updates the metadata dict in place.
444+
"""
445+
for attr, value in policy.items():
446+
if attr not in cls.fields:
447+
# Store unknown attributes coming from policy.json
448+
# in such a way that they will export to xml unchanged
449+
metadata['xml_attributes'][attr] = value
450+
else:
451+
metadata[attr] = value
452+
453+
@staticmethod
454+
def _get_metadata_from_xml(xml_object, remove=True):
455+
"""
456+
Extract the metadata from the XML.
457+
"""
458+
meta = xml_object.find('meta')
459+
if meta is None:
460+
return ''
461+
dmdata = meta.text
462+
if remove:
463+
xml_object.remove(meta)
464+
return dmdata
465+
466+
@classmethod
467+
def clean_metadata_from_xml(cls, xml_object, excluded_fields=()):
468+
"""
469+
Remove any attribute named for a field with scope Scope.settings from the supplied
470+
xml_object
471+
"""
472+
for field_name, field in cls.fields.items():
473+
if (field.scope == Scope.settings
474+
and field_name not in excluded_fields
475+
and xml_object.get(field_name) is not None):
476+
del xml_object.attrib[field_name]
477+
478+
@classmethod
479+
def load_definition(cls, xml_object):
480+
"""
481+
Load a block from the specified xml_object.
482+
483+
Args:
484+
xml_object: an lxml.etree._Element containing the definition to load
485+
"""
486+
487+
filename = xml_object.get('filename')
488+
definition_xml = copy.deepcopy(xml_object)
489+
filepath = ''
490+
491+
definition_metadata = cls._get_metadata_from_xml(definition_xml)
492+
cls.clean_metadata_from_xml(definition_xml)
493+
494+
if len(xml_object) == 0 and len(list(xml_object.items())) == 0:
495+
definition, children = {'data': ''}, []
496+
else:
497+
definition, children = {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}, []
498+
499+
if definition_metadata:
500+
definition['definition_metadata'] = definition_metadata
501+
definition['filename'] = [filepath, filename]
502+
503+
return definition, children
504+
505+
@classmethod
506+
def load_metadata(cls, xml_object):
507+
"""
508+
Read the metadata attributes from this xml_object.
509+
510+
Returns a dictionary {key: value}.
511+
"""
512+
metadata = {'xml_attributes': {}}
513+
for attr, val in xml_object.attrib.items():
514+
515+
if attr in cls.metadata_to_strip:
516+
# don't load these
517+
continue
518+
519+
if attr not in cls.fields:
520+
metadata['xml_attributes'][attr] = val
521+
else:
522+
metadata[attr] = deserialize_field(cls.fields[attr], val)
523+
return metadata
524+
525+
@classmethod
526+
def parse_xml(cls, node, runtime, keys):
527+
"""
528+
Use `node` to construct a new block.
529+
530+
Arguments:
531+
node (etree.Element): The xml node to parse into an xblock.
532+
533+
runtime (:class:`.Runtime`): The runtime to use while parsing.
534+
535+
keys (:class:`.ScopeIds`): The keys identifying where this block
536+
will store its data.
537+
538+
Returns (XBlock): The newly parsed XBlock
539+
540+
"""
541+
import pdb ; pdb.set_trace()
542+
if keys is None:
543+
# Passing keys=None is against the XBlock API but some platform tests do it.
544+
def_id = runtime.id_generator.create_definition(node.tag, node.get("url_name"))
545+
keys = ScopeIds(None, node.tag, def_id, runtime.id_generator.create_usage(def_id))
546+
aside_children = []
547+
548+
# Let the runtime construct the block. It will have a proper, inheritance-aware field data store.
549+
block = runtime.construct_xblock_from_class(cls, keys)
550+
551+
# VS[compat]
552+
# In 2012, when the platform didn't have CMS, and all courses were handwritten XML files, problem tags
553+
# contained XML problem descriptions withing themselves. Later, when Studio has been created, and "pointer" tags
554+
# became the preferred problem format, edX has to add this compatibility code to 1) support both pre- and
555+
# post-Studio course formats simulteneously, and 2) be able to migrate 2012-fall courses to Studio. Old style
556+
# support supposed to be removed, but the deprecation process have never been initiated, so this
557+
# compatibility must stay, probably forever.
558+
if is_pointer_tag(node):
559+
# new style:
560+
# read the actual definition file--named using url_name.replace(':','/')
561+
definition_xml, filepath = load_definition_xml(node, runtime, keys.def_id)
562+
aside_children = runtime.parse_asides(definition_xml, keys.def_id, keys.usage_id, runtime.id_generator)
563+
else:
564+
filepath = None
565+
definition_xml = node
566+
567+
# Removes metadata
568+
definition, children = cls.load_definition(definition_xml)
569+
570+
# VS[compat]
571+
# Make Ike's github preview links work in both old and new file layouts.
572+
if is_pointer_tag(node):
573+
# new style -- contents actually at filepath
574+
definition["filename"] = [filepath, filepath]
575+
576+
metadata = cls.load_metadata(definition_xml)
577+
578+
# move definition metadata into dict
579+
dmdata = definition.get("definition_metadata", "")
580+
if dmdata:
581+
metadata["definition_metadata_raw"] = dmdata
582+
try:
583+
metadata.update(json.loads(dmdata))
584+
except Exception as err: # lint-amnesty, pylint: disable=broad-except
585+
log.debug("Error in loading metadata %r", dmdata, exc_info=True)
586+
metadata["definition_metadata_err"] = str(err)
587+
588+
definition_aside_children = definition.pop("aside_children", None)
589+
if definition_aside_children:
590+
aside_children.extend(definition_aside_children)
591+
592+
# Set/override any metadata specified by policy
593+
cls.apply_policy(metadata, runtime.get_policy(keys.usage_id))
594+
595+
field_data = {**metadata, **definition}
596+
597+
for field_name, value in field_data.items():
598+
# The 'xml_attributes' field has a special setter logic in its Field class,
599+
# so we must handle it carefully to avoid duplicating data.
600+
if field_name == "xml_attributes":
601+
# The 'filename' attribute is specially handled for git links.
602+
value["filename"] = definition.get("filename", ["", None])
603+
block.xml_attributes.update(value)
604+
elif field_name in block.fields:
605+
setattr(block, field_name, value)
606+
607+
block.children = children
608+
609+
if aside_children:
610+
cls.add_applicable_asides_to_block(block, runtime, aside_children)
611+
612+
return block
613+
614+
@classmethod
615+
def add_applicable_asides_to_block(cls, block, runtime, aside_children):
616+
"""
617+
Add asides to the block. Moved this out of the parse_xml method to use it in the VideoBlock.parse_xml
618+
"""
619+
asides_tags = [aside_child.tag for aside_child in aside_children]
620+
asides = runtime.get_asides(block)
621+
for aside in asides:
622+
if aside.scope_ids.block_type in asides_tags:
623+
block.add_aside(aside)

0 commit comments

Comments
 (0)