diff --git a/corehq/apps/app_manager/models.py b/corehq/apps/app_manager/models.py index 408bd3447f37..4f9354488536 100644 --- a/corehq/apps/app_manager/models.py +++ b/corehq/apps/app_manager/models.py @@ -546,6 +546,21 @@ def _apply_updates_to_mappings(current_mappings, updates): if all_missing_mappings: raise MissingPropertyMapException(*all_missing_mappings) + def get_mappings(self): + mappings = {} + self.make_multi() + + for (case_property, updates) in self.update_multi.items(): + mappings[case_property] = [self.update_to_json(update) for update in updates] + + return mappings + + def update_to_json(self, update): + json = update.to_json() + if 'doc_type' in json: + del json['doc_type'] + return json + class PreloadAction(FormAction): @@ -655,6 +670,34 @@ def has_name_update(self): def _update_has_name(self, update): return bool(update.question_path) + def get_assigned_names(self): + questions = [] + if self.name_update_multi: + questions.extend(self.name_update_multi) + + if self.name_update: + questions.append(self.name_update) + + return [question.question_path for question in questions if question.question_path] + + def assign_name_update(self, question_path): + self.name_update = ConditionalCaseUpdate(question_path=question_path) + self.name_update_multi = [] + + def get_mappings(self): + mappings = {} + self.make_multi() + + mappings['name'] = [self.update_to_json(update) for update in self.name_update_multi] + + return mappings + + def update_to_json(self, update): + json = update.to_json() + if 'doc_type' in json: + del json['doc_type'] + return json + class OpenSubCaseAction(FormAction, IndexedSchema): @@ -697,6 +740,33 @@ class FormActionsDiff(DocumentSchema): open_case = SchemaProperty(OpenCaseDiff) update_case = SchemaProperty(UpdateCaseDiff) + @classmethod + def parse_universal_diff(cls, universal_diff_json, is_registration=False): + open_diff = OpenCaseDiff() + update_diff = UpdateCaseDiff(universal_diff_json) + + if is_registration: + if 'name' in update_diff.add: + name_additions = update_diff.add['name'] + open_diff.add = name_additions + del update_diff.add['name'] + + if 'name' in update_diff.update: + name_updates = update_diff.update['name'] + open_diff.update = name_updates + del update_diff.update['name'] + + if 'name' in update_diff.delete: + name_deletions = update_diff.delete['name'] + open_diff.delete = name_deletions + del update_diff.delete['name'] + + diff = FormActionsDiff() + diff.open_case = open_diff + diff.update_case = update_diff + + return diff + class FormActions(UpdateableDocument): open_case = SchemaProperty(OpenCaseAction) @@ -767,6 +837,17 @@ def make_single(self, allow_conflicts=True): else: self.update_case.make_single() + def get_mappings(self): + mappings = {} + + if self.open_case: + mappings.update(self.open_case.get_mappings()) + + if self.update_case: + mappings.update(self.update_case.get_mappings()) + + return mappings + class CaseIndex(DocumentSchema): tag = StringProperty() @@ -1726,6 +1807,9 @@ def get_case_updates_for_case_type(self, case_type): """ return self.get_case_updates().get(case_type, []) + def get_source_with_mappings(self): + return self.source + class JRResourceProperty(StringProperty): @@ -2113,6 +2197,14 @@ def get_contributed_case_relationships(self): (parent_case_type, subcase.reference_id or 'parent')) return case_relationships_by_child_type + def get_source_with_mappings(self): + if not self._parent.case_type: + return self.source + + xform = XForm(self.source) + xform.add_case_mappings(self) + return xform.render_pretty().decode('utf-8') + class GraphAnnotations(IndexedSchema): display_text = DictProperty() diff --git a/corehq/apps/app_manager/tests/test_models.py b/corehq/apps/app_manager/tests/test_models.py index 6b4572d8a3ec..19dafb481233 100644 --- a/corehq/apps/app_manager/tests/test_models.py +++ b/corehq/apps/app_manager/tests/test_models.py @@ -160,6 +160,48 @@ def test_has_name_update_requires_name_update_multi_to_have_a_path(self): self.assertFalse(action.has_name_update()) + def test_get_assigned_names_spans_update_and_update_multi(self): + action = OpenCaseAction({ + 'name_update': {'question_path': 'one'}, + 'name_update_multi': [{'question_path': 'two'}] + }) + + self.assertEqual(set(action.get_assigned_names()), {'one', 'two'}) + + def test_get_assigned_names_ignores_empty_values(self): + action = OpenCaseAction({ + 'name_update': {'question_path': None} + }) + + self.assertEqual(set(action.get_assigned_names()), set()) + + def test_assign_name_update_sets_name_update(self): + action = OpenCaseAction() + action.assign_name_update('name_question') + + self.assertEqual(action.name_update.question_path, 'name_question') + + def test_assign_name_update_removes_update_multi(self): + action = OpenCaseAction({ + 'name_update_multi': [{'question_path': 'one'}, {'question_path': 'two'}] + }) + action.assign_name_update('three') + + self.assertEqual(action.name_update_multi, []) + + def test_get_mappings_serializes_name_updates(self): + action = OpenCaseAction({ + 'name_update_multi': [{'question_path': 'one'}, {'question_path': 'two'}] + }) + + json = action.get_mappings() + self.assertEqual(json, { + 'name': [ + {'question_path': 'one', 'update_mode': 'always'}, + {'question_path': 'two', 'update_mode': 'always'} + ] + }) + class OpenCaseAction_ApplyUpdates_Tests(SimpleTestCase): def test_no_changes(self): @@ -391,6 +433,37 @@ def test_get_property_names_returns_keys_from_update_multi(self): self.assertEqual(action.get_property_names(), {'one'}) + def test_get_mappings_serializes_updates(self): + action = UpdateCaseAction({ + 'update_multi': { + 'one': [{'question_path': '/A/'}, {'question_path': '/B/'}], + 'two': [{'question_path': '/C/'}], + } + }) + + json = action.get_mappings() + + self.assertEqual(json, { + 'one': [ + {'question_path': '/A/', 'update_mode': 'always'}, + {'question_path': '/B/', 'update_mode': 'always'} + ], + 'two': [{'question_path': '/C/', 'update_mode': 'always'}] + }) + + def test_get_mappings_removes_doc_type(self): + action = UpdateCaseAction({ + 'update': { + 'one': {'question_path': '/A/', 'update_mode': 'edit', 'doc_type': 'TestDoc'}, + } + }) + + json = action.get_mappings() + + self.assertEqual(json, { + 'one': [{'question_path': '/A/', 'update_mode': 'edit'}] + }) + class UpdateCaseAction_ApplyUpdates_Tests(SimpleTestCase): def test_no_changes(self): @@ -759,6 +832,54 @@ def test_json_construction(self): assert diff.open_case.add[0].question_path == 'one' assert diff.update_case.delete['case_two'][0].question_path == 'two' + def test_parse_universal_diff_creates_diff_object(self): + universal_json = { + 'add': { + 'prop1': [{'question_path': 'one'}] + }, + 'update': { + 'prop2': [{'question_path': 'two'}] + }, + 'delete': { + 'prop3': [{'question_path': 'three'}] + } + } + + diff = FormActionsDiff.parse_universal_diff(universal_json) + + assert len(diff.update_case.add['prop1']) == 1 + assert diff.update_case.add['prop1'][0].question_path == 'one' + + assert len(diff.update_case.update['prop2']) == 1 + assert diff.update_case.update['prop2'][0].question_path == 'two' + + assert len(diff.update_case.delete['prop3']) == 1 + assert diff.update_case.delete['prop3'][0].question_path == 'three' + + def test_parse_universal_diff_non_registration_name_stays_in_update(self): + universal_json = { + 'add': { + 'name': [{'question_path': 'one'}] + } + } + + diff = FormActionsDiff.parse_universal_diff(universal_json) + + assert diff.update_case.add['name'][0].question_path == 'one' + assert 'name' not in diff.open_case.add + + def test_parse_universal_diff_registration_name_is_in_open_case(self): + universal_json = { + 'add': { + 'name': [{'question_path': 'one'}] + } + } + + diff = FormActionsDiff.parse_universal_diff(universal_json, is_registration=True) + + assert diff.open_case.add[0].question_path == 'one' + assert 'name' not in diff.update_case.add + class FormActionTests(SimpleTestCase): def test_get_action_properties_for_name_update(self): diff --git a/corehq/apps/app_manager/tests/test_xform.py b/corehq/apps/app_manager/tests/test_xform.py index 799b42270244..da605f107ea1 100644 --- a/corehq/apps/app_manager/tests/test_xform.py +++ b/corehq/apps/app_manager/tests/test_xform.py @@ -1,4 +1,5 @@ from unittest.mock import patch +from lxml import etree from django.test import SimpleTestCase @@ -9,7 +10,8 @@ XFormValidationError, XFormValidationFailed, ) -from ..xform import parse_xml, validate_xform +from corehq.apps.app_manager.models import Form, FormActions +from ..xform import parse_xml, validate_xform, XForm class ParseXMLTests(SimpleTestCase): @@ -106,3 +108,225 @@ def test_successful(self, mock_validate_form): validate_xform(xml) except XFormValidationFailed as e: self.fail(f"validate_xform raised {e} unexpectedly") + + +class XForm_CreateCaseMappingsTests(SimpleTestCase): + def test_creates_mappings(self): + actions = FormActions({ + 'update_case': { + 'update': { + 'one': {'question_path': 'q1'}, + 'two': {'question_path': 'q2'} + } + } + }) + + form = Form(actions=actions) + + tree = XForm.create_case_mappings(form) + rendered_tree = etree.tostring(tree, encoding='unicode', pretty_print=True).strip() + + assert rendered_tree == """ + + + + + + + + +""".strip() + + def test_creates_open_case_mappings(self): + actions = FormActions({ + 'open_case': { + 'name_update': {'question_path': 'name'} + } + }) + + form = Form(actions=actions) + + tree = XForm.create_case_mappings(form) + rendered_tree = etree.tostring(tree, encoding='unicode', pretty_print=True).strip() + + assert rendered_tree == """ + + + + + +""".strip() + + def test_uses_name_from_open_case(self): + actions = FormActions({ + 'open_case': { + 'name_update': {'question_path': 'open_case_name'} + }, + 'update_case': { + 'update': { + 'name': {'question_path': 'update_case_name'} + } + } + }) + + form = Form(actions=actions) + + tree = XForm.create_case_mappings(form) + rendered_tree = etree.tounicode(tree, pretty_print=True).strip() + + assert rendered_tree == """ + + + + + +""".strip() + + +class XForm_AddCaseMappingsTests(SimpleTestCase): + def test_adds_case_mappings(self): + actions = FormActions({ + 'update_case': { + 'update': { + 'one': {'question_path': 'q1'} + } + } + }) + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + + assert xform.find('case_mappings') is not None + + def test_handles_no_mappings(self): + actions = FormActions() + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + + mappings_node = xform.find('case_mappings') + assert len(mappings_node) == 0 + + def test_does_not_create_duplicate_mapping_nodes(self): + actions = FormActions({ + 'update_case': { + 'update': { + 'one': {'question_path': 'q1'} + } + } + }) + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + xform.add_case_mappings(form) + + case_mapping_nodes = xform.findall('{f}case_mappings') + assert len(case_mapping_nodes) == 1 + + def _create_minimal_xform(self): + return XForm(''' + + + + + + + + + +''') + + +class XForm_GetFormActionsTests(SimpleTestCase): + def test_no_mappings_returns_empty_dict(self): + xform = self._create_minimal_xform() + assert xform.get_form_actions() == {} + + def test_returns_update_mappings(self): + actions = FormActions({ + 'update_case': { + 'update': { + 'one': {'question_path': 'q1'}, + 'two': {'question_path': 'q2'}, + } + } + }) + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + + mappings = xform.get_form_actions() + + case_updates = mappings['update_case']['update_multi'] + + assert set(case_updates.keys()) == {'one', 'two'} + assert case_updates['one'] == [{'question_path': 'q1', 'update_mode': 'always'}] + assert case_updates['two'] == [{'question_path': 'q2', 'update_mode': 'always'}] + + def test_name_updates_for_registration_forms_use_open_case(self): + actions = FormActions({ + 'open_case': { + 'name_update': {'question_path': 'test_name'} + } + }) + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + + mappings = xform.get_form_actions(for_registration_form=True) + + name_updates = mappings['open_case']['name_update_multi'] + + assert name_updates == [{'question_path': 'test_name', 'update_mode': 'always'}] + assert 'update_case' not in mappings + + def test_name_updates_for_update_forms_use_update_case(self): + actions = FormActions({ + 'update_case': { + 'update': {'name': {'question_path': 'test_name'}}, + } + }) + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + + mappings = xform.get_form_actions(for_registration_form=False) + + case_updates = mappings['update_case']['update_multi'] + + assert case_updates['name'] == [{'question_path': 'test_name', 'update_mode': 'always'}] + assert 'open_case' not in mappings + + def test_can_be_turned_into_form_actions_object(self): + actions = FormActions({ + 'update_case': { + 'update': {'name': {'question_path': 'test_name'}}, + } + }) + form = Form(actions=actions) + xform = self._create_minimal_xform() + + xform.add_case_mappings(form) + + mappings = xform.get_form_actions() + result = FormActions(mappings) + assert result.update_case.update_multi['name'][0].question_path == 'test_name' + + def _create_minimal_xform(self): + return XForm(''' + + + + + + + + + +''') diff --git a/corehq/apps/app_manager/util.py b/corehq/apps/app_manager/util.py index 55b7acc8e59e..4910e3994fef 100644 --- a/corehq/apps/app_manager/util.py +++ b/corehq/apps/app_manager/util.py @@ -151,26 +151,32 @@ def generate_xmlns(): return str(uuid.uuid4()).upper() -def save_xform(app, form, xml): - - def change_xmlns(xform, old_xmlns, new_xmlns): +def save_xform(app, form, xml, mapping_diff=None): + def update_xmlns(xform, old_xmlns, new_xmlns): data = xform.data_node.render().decode('utf-8') data = data.replace(old_xmlns, new_xmlns, 1) xform.instance_node.remove(xform.data_node.xml) xform.instance_node.append(parse_xml(data)) - return xform.render() + + source_xml = xml try: xform = XForm(xml, domain=app.domain) except XFormException: pass else: + changed = False + if mapping_diff: + form.actions = form.actions.with_updates({}, mapping_diff) + changed = True + GENERIC_XMLNS = "http://www.w3.org/2002/xforms" uid = generate_xmlns() tag_xmlns = xform.data_node.tag_xmlns new_xmlns = form.xmlns or "http://openrosa.org/formdesigner/%s" % uid if not tag_xmlns or tag_xmlns == GENERIC_XMLNS: # no xmlns - xml = change_xmlns(xform, GENERIC_XMLNS, new_xmlns) + update_xmlns(xform, GENERIC_XMLNS, new_xmlns) + changed = True else: forms = [form_ for form_ in app.get_xmlns_map().get(tag_xmlns, []) @@ -180,23 +186,28 @@ def change_xmlns(xform, old_xmlns, new_xmlns): new_xmlns = "http://openrosa.org/formdesigner/%s" % uid # form most likely created by app.copy_form(...) # or form is being updated with source copied from other form - xml = change_xmlns(xform, tag_xmlns, new_xmlns) + update_xmlns(xform, tag_xmlns, new_xmlns) + changed = True + + if changed: + # form.source needs to persist the XML without case mappings (because the mappings + # are persisted in FormActions, instead), + # but keep a separate copy of the xml with mapping data for SHA comparisons + xml = xform.render_pretty() + xform.remove_case_mappings() + source_xml = xform.render_pretty() - form.source = xml.decode('utf-8') + form.source = source_xml.decode('utf-8') - from corehq.apps.app_manager.models import ConditionalCaseUpdate if form.is_registration_form(): # For registration forms, assume that the first question is the # case name unless something else has been specified questions = form.get_questions([app.default_language]) - if hasattr(form.actions, 'open_case'): - path = getattr(form.actions.open_case.name_update, 'question_path', None) - if path: - name_questions = [q for q in questions if q['value'] == path] - if not len(name_questions): - path = None - if not path and len(questions): - form.actions.open_case.name_update = ConditionalCaseUpdate(question_path=questions[0]['value']) + if questions and hasattr(form.actions, 'open_case'): + names = form.actions.open_case.get_assigned_names() + assigns_name = any(q for q in questions if q['value'] in names) + if not assigns_name: + form.actions.open_case.assign_name_update(questions[0]['value']) return xml diff --git a/corehq/apps/app_manager/views/formdesigner.py b/corehq/apps/app_manager/views/formdesigner.py index 182cf83e1791..f1120fe3cec0 100644 --- a/corehq/apps/app_manager/views/formdesigner.py +++ b/corehq/apps/app_manager/views/formdesigner.py @@ -20,6 +20,7 @@ send_hubspot_form, ) from corehq.apps.app_manager import add_ons +from corehq.apps.app_manager.app_schemas.case_properties import get_all_case_properties_for_case_type from corehq.apps.app_manager.app_schemas.casedb_schema import get_casedb_schema, get_registry_schema from corehq.apps.app_manager.app_schemas.session_schema import ( get_session_schema, @@ -36,7 +37,8 @@ AppManagerException, FormNotFoundException, ) -from corehq.apps.app_manager.models import ModuleNotFoundException +from corehq.apps.app_manager.helpers.validators import load_case_reserved_words +from corehq.apps.app_manager.models import ModuleNotFoundException, AdvancedForm from corehq.apps.app_manager.templatetags.xforms_extras import translate from corehq.apps.app_manager.util import ( app_callout_templates, @@ -234,7 +236,7 @@ def _get_base_vellum_options(request, domain, form, displayLang): :param displayLang: --> derived from the base context """ app = form.get_app() - return { + options = { 'intents': { 'templates': next(app_callout_templates), }, @@ -254,6 +256,22 @@ def _get_base_vellum_options(request, domain, form, displayLang): }, } + has_vellum_case_mapping = toggles.FORMBUILDER_SAVE_TO_CASE.enabled_for_request(request) + is_advanced_form = isinstance(form, AdvancedForm) + + case_type = form.get_module().case_type + if case_type and has_vellum_case_mapping and not is_advanced_form: + case_properties = get_all_case_properties_for_case_type(domain, case_type) + mappings = form.actions.get_mappings() + options['caseManagement'] = { + 'mappings': mappings, + 'properties': case_properties, + 'view_form_url': reverse('view_form', args=[domain, app.id, form.unique_id]), + 'reserved_words': load_case_reserved_words(), + } + + return options + def _get_vellum_core_context(request, domain, app, module, form, lang): """ diff --git a/corehq/apps/app_manager/views/forms.py b/corehq/apps/app_manager/views/forms.py index 3a240075cbf4..c63c34945f87 100644 --- a/corehq/apps/app_manager/views/forms.py +++ b/corehq/apps/app_manager/views/forms.py @@ -304,7 +304,7 @@ def should_edit(attribute): return attribute in request.POST if 'sha1' in request.POST and (should_edit("xform") or "xform" in request.FILES): - conflict = _get_xform_conflict_response(form, request.POST['sha1']) + conflict = _get_xform_conflict_response(form.source, request.POST['sha1']) if conflict is not None: return conflict @@ -336,7 +336,9 @@ def should_edit(attribute): if xform: if isinstance(xform, str): xform = xform.encode('utf-8') - save_xform(app, form, xform) + + case_update_diff = _get_case_update_diff(request, form) + save_xform(app, form, xform, case_update_diff) else: raise Exception("You didn't select a form to upload") except Exception as e: @@ -523,15 +525,18 @@ def patch_xform(request, domain, app_id, form_unique_id): app = get_app(domain, app_id) form = app.get_form(form_unique_id) + existing_xml = form.source - conflict = _get_xform_conflict_response(form, sha1_checksum) + conflict = _get_xform_conflict_response(existing_xml, sha1_checksum) if conflict is not None: return conflict - xml = apply_patch(patch, form.source) + xml = apply_patch(patch, existing_xml) + + case_update_diff = _get_case_update_diff(request, form) try: - xml = save_xform(app, form, xml.encode('utf-8')) + xml = save_xform(app, form, xml.encode('utf-8'), case_update_diff) except XFormException: return JsonResponse({'status': 'error'}, status=HttpResponseBadRequest.status_code) @@ -551,8 +556,19 @@ def apply_patch(patch, text): return dmp.patch_apply(dmp.patch_fromText(patch), text)[0] -def _get_xform_conflict_response(form, sha1_checksum): - form_xml = form.source +def _get_case_update_diff(request, form): + update_diff = None + + uses_vellum_case_mapping = toggles.FORMBUILDER_SAVE_TO_CASE.enabled_for_request(request) + if uses_vellum_case_mapping and 'mapping_diff' in request.POST: + diff_json = json.loads(request.POST['mapping_diff']) + is_registration_form = form.is_registration_form() + update_diff = FormActionsDiff.parse_universal_diff(diff_json, is_registration=is_registration_form) + + return update_diff + + +def _get_xform_conflict_response(form_xml, sha1_checksum): if hashlib.sha1(form_xml.encode('utf-8')).hexdigest() != sha1_checksum: return json_response({'status': 'conflict', 'xform': form_xml}) return None diff --git a/corehq/apps/app_manager/xform.py b/corehq/apps/app_manager/xform.py index 03437fdafd5a..1e8f16322a1e 100644 --- a/corehq/apps/app_manager/xform.py +++ b/corehq/apps/app_manager/xform.py @@ -2147,9 +2147,120 @@ def get_scheduler_case_updates(self): raise Exception('Scheduler case updates have not yet been populated') return self._scheduler_case_updates + @property + def case_mappings_xml_node(self): + path = '{f}case_mappings'.format(**self.namespaces) + return self.xml.find(path) + def _add_scheduler_case_update(self, case_type, case_property): self._scheduler_case_updates[case_type].add(case_property) + def add_case_mappings(self, form): + mapping_element = self.create_case_mappings(form) + existing_mappings = self.case_mappings_xml_node + if existing_mappings is None: + self.xml.append(mapping_element) + else: + self.xml.replace(existing_mappings, mapping_element) + + @classmethod + def create_case_mappings(cls, form): + root = _make_elem('{f}case_mappings') + + # TODO: add non-mutating functionality to form actions to grab + # the multi version without mutating the base object + form.actions.open_case.make_multi() + name_mapping = _make_elem('{f}mapping', {'property': 'name'}) + for question in form.actions.open_case.name_update_multi: + if question.question_path: + name_mapping.append(cls._get_update_xml(question)) + if len(name_mapping): + root.append(name_mapping) + + form.actions.update_case.make_multi() + for case_property, questions in form.actions.update_case.update_multi.items(): + if case_property == 'name' and len(name_mapping): + # skip name mappings if already provided by open_case + continue + mapping = _make_elem('{f}mapping', {'property': case_property}) + root.append(mapping) + for question in questions: + mapping.append(cls._get_update_xml(question)) + + return root + + def get_form_actions(self, for_registration_form=False): + root = self.case_mappings_xml_node + if root is None: + return {} + + name_update_multi = [] + update_case_mappings = {} + + for mapping_node in root: + prop = mapping_node.attrib['property'] + questions = [] + + for question_node in mapping_node: + question = question_node.attrib + question_dict = dict(question.items()) + questions.append(question_dict) + + if prop == 'name' and for_registration_form: + name_update_multi = questions + else: + update_case_mappings[prop] = questions + + actions = {} + if name_update_multi: + actions['open_case'] = { + 'name_update_multi': name_update_multi + } + + if update_case_mappings: + actions['update_case'] = { + 'update_multi': update_case_mappings + } + + return actions + + def remove_case_mappings(self): + ''' + Removes the case mappings node and returns a boolean indicating whether + the tree was changed + ''' + mappings_node = self.case_mappings_xml_node + if mappings_node is not None: + self.xml.remove(mappings_node) + return True + + return False + + @classmethod + def _get_update_xml(cls, update): + EXCLUDED_PROPERTIES = ['doc_type'] + properties = {k: v for (k, v) in update.items() if k not in EXCLUDED_PROPERTIES} + # return ET.Element('question', **properties) + return _make_elem('{f}question', properties) + + def render_pretty(self): + ''' + This returns a tab-indented version of the xml as a string. + This is mostly a hackish method of creating a string that exactly mimics + Vellum's formatting. If the Vellum formatting were to change, this would + also need to change. + ''' + ET.indent(self.xml, space='\t') + encoding = "UTF-8" + output = ET.tostring(self.xml, encoding=encoding).decode(encoding) + + # Vellum uses an additional space after self-closing elements + output = re.sub(r'(?', ' />', output) + + xml_declaration = f'' + + return (xml_declaration + '\n' + output).encode(encoding) + VELLUM_TYPES = { "AndroidIntent": {