Odoo18-Base/odoo/tools/template_inheritance.py
2025-01-06 10:57:38 +07:00

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