|
- 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 = ''
- copied_node = copy.deepcopy(node)
- # TODO: Remove 'inherit_branding' logic if possible;
- # currently needed to track node removal for branding
- # distribution. Avoid marking root nodes to prevent
- # sibling branding issues.
- if inherit_branding:
- copied_node.set('data-oe-no-branding', '1')
- loc.append(copied_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
|