267 lines
11 KiB
Python
267 lines
11 KiB
Python
|
|
||
|
from lxml import etree
|
||
|
from lxml.builder import E
|
||
|
import copy
|
||
|
import itertools
|
||
|
import logging
|
||
|
import re
|
||
|
|
||
|
from odoo.tools.translate import _
|
||
|
from odoo.tools import SKIPPED_ELEMENT_TYPES, html_escape
|
||
|
from odoo.exceptions import ValidationError
|
||
|
|
||
|
_logger = logging.getLogger(__name__)
|
||
|
RSTRIP_REGEXP = re.compile(r'\n[ \t]*$')
|
||
|
|
||
|
def add_stripped_items_before(node, spec, extract):
|
||
|
text = spec.text or ''
|
||
|
|
||
|
before_text = ''
|
||
|
prev = node.getprevious()
|
||
|
if prev is None:
|
||
|
parent = node.getparent()
|
||
|
result = parent.text and RSTRIP_REGEXP.search(parent.text)
|
||
|
before_text = result.group(0) if result else ''
|
||
|
parent.text = (parent.text or '').rstrip() + text
|
||
|
else:
|
||
|
result = prev.tail and RSTRIP_REGEXP.search(prev.tail)
|
||
|
before_text = result.group(0) if result else ''
|
||
|
prev.tail = (prev.tail or '').rstrip() + text
|
||
|
|
||
|
if len(spec) > 0:
|
||
|
spec[-1].tail = (spec[-1].tail or "").rstrip() + before_text
|
||
|
else:
|
||
|
spec.text = (spec.text or "").rstrip() + before_text
|
||
|
|
||
|
for child in spec:
|
||
|
if child.get('position') == 'move':
|
||
|
child = extract(child)
|
||
|
node.addprevious(child)
|
||
|
|
||
|
|
||
|
def add_text_before(node, text):
|
||
|
""" Add text before ``node`` in its XML tree. """
|
||
|
if text is None:
|
||
|
return
|
||
|
prev = node.getprevious()
|
||
|
if prev is not None:
|
||
|
prev.tail = (prev.tail or "") + text
|
||
|
else:
|
||
|
parent = node.getparent()
|
||
|
parent.text = (parent.text or "").rstrip() + text
|
||
|
|
||
|
|
||
|
def remove_element(node):
|
||
|
""" Remove ``node`` but not its tail, from its XML tree. """
|
||
|
add_text_before(node, node.tail)
|
||
|
node.tail = None
|
||
|
node.getparent().remove(node)
|
||
|
|
||
|
|
||
|
def locate_node(arch, spec):
|
||
|
""" Locate a node in a source (parent) architecture.
|
||
|
|
||
|
Given a complete source (parent) architecture (i.e. the field
|
||
|
`arch` in a view), and a 'spec' node (a node in an inheriting
|
||
|
view that specifies the location in the source view of what
|
||
|
should be changed), return (if it exists) the node in the
|
||
|
source view matching the specification.
|
||
|
|
||
|
:param arch: a parent architecture to modify
|
||
|
:param spec: a modifying node in an inheriting view
|
||
|
:return: a node in the source matching the spec
|
||
|
"""
|
||
|
if spec.tag == 'xpath':
|
||
|
expr = spec.get('expr')
|
||
|
try:
|
||
|
xPath = etree.ETXPath(expr)
|
||
|
except etree.XPathSyntaxError as e:
|
||
|
raise ValidationError(_("Invalid Expression while parsing xpath %r", expr)) from e
|
||
|
nodes = xPath(arch)
|
||
|
return nodes[0] if nodes else None
|
||
|
elif spec.tag == 'field':
|
||
|
# Only compare the field name: a field can be only once in a given view
|
||
|
# at a given level (and for multilevel expressions, we should use xpath
|
||
|
# inheritance spec anyway).
|
||
|
for node in arch.iter('field'):
|
||
|
if node.get('name') == spec.get('name'):
|
||
|
return node
|
||
|
return None
|
||
|
|
||
|
for node in arch.iter(spec.tag):
|
||
|
if all(node.get(attr) == spec.get(attr) for attr in spec.attrib if attr != 'position'):
|
||
|
return node
|
||
|
return None
|
||
|
|
||
|
|
||
|
def apply_inheritance_specs(source, specs_tree, inherit_branding=False, pre_locate=lambda s: True):
|
||
|
""" Apply an inheriting view (a descendant of the base view)
|
||
|
|
||
|
Apply to a source architecture all the spec nodes (i.e. nodes
|
||
|
describing where and what changes to apply to some parent
|
||
|
architecture) given by an inheriting view.
|
||
|
|
||
|
:param Element source: a parent architecture to modify
|
||
|
:param Element specs_tree: a modifying architecture in an inheriting view
|
||
|
:param bool inherit_branding:
|
||
|
:param pre_locate: function that is executed before locating a node.
|
||
|
This function receives an arch as argument.
|
||
|
This is required by studio to properly handle group_ids.
|
||
|
:return: a modified source where the specs are applied
|
||
|
:rtype: Element
|
||
|
"""
|
||
|
# Queue of specification nodes (i.e. nodes describing where and
|
||
|
# changes to apply to some parent architecture).
|
||
|
specs = specs_tree if isinstance(specs_tree, list) else [specs_tree]
|
||
|
|
||
|
def extract(spec):
|
||
|
"""
|
||
|
Utility function that locates a node given a specification, remove
|
||
|
it from the source and returns it.
|
||
|
"""
|
||
|
if len(spec):
|
||
|
raise ValueError(
|
||
|
_("Invalid specification for moved nodes: %r", etree.tostring(spec, encoding='unicode'))
|
||
|
)
|
||
|
pre_locate(spec)
|
||
|
to_extract = locate_node(source, spec)
|
||
|
if to_extract is not None:
|
||
|
remove_element(to_extract)
|
||
|
return to_extract
|
||
|
else:
|
||
|
raise ValueError(
|
||
|
_("Element %r cannot be located in parent view", etree.tostring(spec, encoding='unicode'))
|
||
|
)
|
||
|
|
||
|
while len(specs):
|
||
|
spec = specs.pop(0)
|
||
|
if isinstance(spec, SKIPPED_ELEMENT_TYPES):
|
||
|
continue
|
||
|
if spec.tag == 'data':
|
||
|
specs += [c for c in spec]
|
||
|
continue
|
||
|
pre_locate(spec)
|
||
|
node = locate_node(source, spec)
|
||
|
if node is not None:
|
||
|
pos = spec.get('position', 'inside')
|
||
|
if pos == 'replace':
|
||
|
mode = spec.get('mode', 'outer')
|
||
|
if mode == "outer":
|
||
|
for loc in spec.xpath(".//*[text()='$0']"):
|
||
|
loc.text = ''
|
||
|
loc.append(copy.deepcopy(node))
|
||
|
if node.getparent() is None:
|
||
|
spec_content = None
|
||
|
comment = None
|
||
|
for content in spec:
|
||
|
if content.tag is not etree.Comment:
|
||
|
spec_content = content
|
||
|
break
|
||
|
else:
|
||
|
comment = content
|
||
|
source = copy.deepcopy(spec_content)
|
||
|
# only keep the t-name of a template root node
|
||
|
t_name = node.get('t-name')
|
||
|
if t_name:
|
||
|
source.set('t-name', t_name)
|
||
|
if comment is not None:
|
||
|
text = source.text
|
||
|
source.text = None
|
||
|
comment.tail = text
|
||
|
source.insert(0, comment)
|
||
|
else:
|
||
|
# TODO ideally the notion of 'inherit_branding' should
|
||
|
# not exist in this function. Given the current state of
|
||
|
# the code, it is however necessary to know where nodes
|
||
|
# were removed when distributing branding. As a stable
|
||
|
# fix, this solution was chosen: the location is marked
|
||
|
# with a "ProcessingInstruction" which will not impact
|
||
|
# the "Element" structure of the resulting tree.
|
||
|
# Exception: if we happen to replace a node that already
|
||
|
# has xpath branding (root level nodes), do not mark the
|
||
|
# location of the removal as it will mess up the branding
|
||
|
# of siblings elements coming from other views, after the
|
||
|
# branding is distributed (and those processing instructions
|
||
|
# removed).
|
||
|
if inherit_branding and not node.get('data-oe-xpath'):
|
||
|
node.addprevious(etree.ProcessingInstruction('apply-inheritance-specs-node-removal', node.tag))
|
||
|
|
||
|
for child in spec:
|
||
|
if child.get('position') == 'move':
|
||
|
child = extract(child)
|
||
|
node.addprevious(child)
|
||
|
node.getparent().remove(node)
|
||
|
elif mode == "inner":
|
||
|
# Replace the entire content of an element
|
||
|
for child in node:
|
||
|
node.remove(child)
|
||
|
node.text = None
|
||
|
|
||
|
for child in spec:
|
||
|
node.append(copy.deepcopy(child))
|
||
|
node.text = spec.text
|
||
|
|
||
|
else:
|
||
|
raise ValueError(_("Invalid mode attribute:") + " '%s'" % mode)
|
||
|
elif pos == 'attributes':
|
||
|
for child in spec.getiterator('attribute'):
|
||
|
attribute = child.get('name')
|
||
|
value = child.text or ''
|
||
|
if child.get('add') or child.get('remove'):
|
||
|
assert not child.text
|
||
|
separator = child.get('separator', ',')
|
||
|
if separator == ' ':
|
||
|
separator = None # squash spaces
|
||
|
to_add = (
|
||
|
s for s in (s.strip() for s in child.get('add', '').split(separator))
|
||
|
if s
|
||
|
)
|
||
|
to_remove = {s.strip() for s in child.get('remove', '').split(separator)}
|
||
|
values = (s.strip() for s in node.get(attribute, '').split(separator))
|
||
|
value = (separator or ' ').join(itertools.chain(
|
||
|
(v for v in values if v not in to_remove),
|
||
|
to_add
|
||
|
))
|
||
|
if value:
|
||
|
node.set(attribute, value)
|
||
|
elif attribute in node.attrib:
|
||
|
del node.attrib[attribute]
|
||
|
elif pos == 'inside':
|
||
|
# add a sentinel element at the end, insert content of spec
|
||
|
# before the sentinel, then remove the sentinel element
|
||
|
sentinel = E.sentinel()
|
||
|
node.append(sentinel)
|
||
|
add_stripped_items_before(sentinel, spec, extract)
|
||
|
remove_element(sentinel)
|
||
|
elif pos == 'after':
|
||
|
# add a sentinel element right after node, insert content of
|
||
|
# spec before the sentinel, then remove the sentinel element
|
||
|
sentinel = E.sentinel()
|
||
|
node.addnext(sentinel)
|
||
|
if node.tail is not None: # for lxml >= 5.1
|
||
|
sentinel.tail = node.tail
|
||
|
node.tail = None
|
||
|
add_stripped_items_before(sentinel, spec, extract)
|
||
|
remove_element(sentinel)
|
||
|
elif pos == 'before':
|
||
|
add_stripped_items_before(node, spec, extract)
|
||
|
|
||
|
else:
|
||
|
raise ValueError(
|
||
|
_("Invalid position attribute: '%s'") %
|
||
|
pos
|
||
|
)
|
||
|
|
||
|
else:
|
||
|
attrs = ''.join([
|
||
|
' %s="%s"' % (attr, html_escape(spec.get(attr)))
|
||
|
for attr in spec.attrib
|
||
|
if attr != 'position'
|
||
|
])
|
||
|
tag = "<%s%s>" % (spec.tag, attrs)
|
||
|
raise ValueError(
|
||
|
_("Element '%s' cannot be located in parent view", tag)
|
||
|
)
|
||
|
|
||
|
return source
|