|
5 | 5 |
|
6 | 6 |
|
7 | 7 | import base64 |
| 8 | +import copy |
8 | 9 | import hashlib |
9 | 10 | import json |
10 | 11 | import logging |
|
14 | 15 | from urllib import parse |
15 | 16 |
|
16 | 17 | from django.conf import settings |
| 18 | +from lxml import etree |
17 | 19 | from oauthlib.oauth1 import Client |
18 | 20 | from webob import Response |
19 | 21 | 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 | +) |
20 | 34 |
|
21 | 35 | log = logging.getLogger(__name__) |
22 | 36 |
|
@@ -391,3 +405,219 @@ def parse_lti_2_0_result_json(self, json_str): |
391 | 405 | raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from |
392 | 406 |
|
393 | 407 | 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