Skip to content

Commit 9b1c2d2

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

File tree

2 files changed

+227
-1
lines changed

2 files changed

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

0 commit comments

Comments
 (0)