328 lines
14 KiB
Python
328 lines
14 KiB
Python
|
import copy
|
||
|
import itertools
|
||
|
import logging
|
||
|
import re
|
||
|
|
||
|
from lxml import etree
|
||
|
from lxml.builder import E
|
||
|
|
||
|
from odoo.tools.translate import LazyTranslate
|
||
|
from odoo.exceptions import ValidationError
|
||
|
from .misc import SKIPPED_ELEMENT_TYPES, html_escape
|
||
|
|
||
|
__all__ = []
|
||
|
|
||
|
_lt = LazyTranslate('base')
|
||
|
_logger = logging.getLogger(__name__)
|
||
|
RSTRIP_REGEXP = re.compile(r'\n[ \t]*$')
|
||
|
|
||
|
# attribute names that contain Python expressions
|
||
|
PYTHON_ATTRIBUTES = {'readonly', 'required', 'invisible', 'column_invisible', 't-if', 't-elif'}
|
||
|
|
||
|
|
||
|
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 ''
|
||
|
fallback_text = None if spec.text is None else ''
|
||
|
parent.text = ((parent.text or '').rstrip() + text) or fallback_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':
|
||
|
tail = child.tail
|
||
|
child = extract(child)
|
||
|
child.tail = tail
|
||
|
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(_lt("Invalid Expression while parsing xpath “%s”", 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(
|
||
|
_lt("Invalid specification for moved nodes: “%s”", 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(
|
||
|
_lt("Element “%s” 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(_lt("Invalid mode attribute: “%s”", mode))
|
||
|
elif pos == 'attributes':
|
||
|
for child in spec.getiterator('attribute'):
|
||
|
# The element should only have attributes:
|
||
|
# - name (mandatory),
|
||
|
# - add, remove, separator
|
||
|
# - any attribute that starts with data-oe-*
|
||
|
unknown = [
|
||
|
key
|
||
|
for key in child.attrib
|
||
|
if key not in ('name', 'add', 'remove', 'separator')
|
||
|
and not key.startswith('data-oe-')
|
||
|
]
|
||
|
if unknown:
|
||
|
raise ValueError(_lt(
|
||
|
"Invalid attributes %s in element <attribute>",
|
||
|
", ".join(map(repr, unknown)),
|
||
|
))
|
||
|
|
||
|
attribute = child.get('name')
|
||
|
value = None
|
||
|
|
||
|
if child.get('add') or child.get('remove'):
|
||
|
if child.text:
|
||
|
raise ValueError(_lt(
|
||
|
"Element <attribute> with 'add' or 'remove' cannot contain text %s",
|
||
|
repr(child.text),
|
||
|
))
|
||
|
value = node.get(attribute, '')
|
||
|
add = child.get('add', '')
|
||
|
remove = child.get('remove', '')
|
||
|
separator = child.get('separator')
|
||
|
|
||
|
if attribute in PYTHON_ATTRIBUTES or attribute.startswith('decoration-'):
|
||
|
# attribute containing a python expression
|
||
|
separator = separator.strip()
|
||
|
if separator not in ('and', 'or'):
|
||
|
raise ValueError(_lt(
|
||
|
"Invalid separator %(separator)s for python expression %(expression)s; "
|
||
|
"valid values are 'and' and 'or'",
|
||
|
separator=repr(separator), expression=repr(attribute),
|
||
|
))
|
||
|
if remove:
|
||
|
if re.match(rf'^\(*{remove}\)*$', value):
|
||
|
value = ''
|
||
|
else:
|
||
|
patterns = [
|
||
|
f"({remove}) {separator} ",
|
||
|
f" {separator} ({remove})",
|
||
|
f"{remove} {separator} ",
|
||
|
f" {separator} {remove}",
|
||
|
]
|
||
|
for pattern in patterns:
|
||
|
index = value.find(pattern)
|
||
|
if index != -1:
|
||
|
value = value[:index] + value[index + len(pattern):]
|
||
|
break
|
||
|
if add:
|
||
|
value = f"({value}) {separator} ({add})" if value else add
|
||
|
else:
|
||
|
if separator is None:
|
||
|
separator = ','
|
||
|
elif separator == ' ':
|
||
|
separator = None # squash spaces
|
||
|
values = (s.strip() for s in value.split(separator))
|
||
|
to_add = filter(None, (s.strip() for s in add.split(separator)))
|
||
|
to_remove = {s.strip() for s in remove.split(separator)}
|
||
|
value = (separator or ' ').join(itertools.chain(
|
||
|
(v for v in values if v and v not in to_remove),
|
||
|
to_add
|
||
|
))
|
||
|
else:
|
||
|
value = child.text or ''
|
||
|
|
||
|
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(_lt("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(
|
||
|
_lt("Element '%s' cannot be located in parent view", tag)
|
||
|
)
|
||
|
|
||
|
return source
|