1
2from lxml import etree
3from lxml.builder import E
4import copy
5import itertools
6import logging
7
8from odoo.tools.translate import _
9from odoo.tools import SKIPPED_ELEMENT_TYPES
10_logger = logging.getLogger(__name__)
11
12
13def add_text_before(node, text):
14    """ Add text before ``node`` in its XML tree. """
15    if text is None:
16        return
17    prev = node.getprevious()
18    if prev is not None:
19        prev.tail = (prev.tail or "") + text
20    else:
21        parent = node.getparent()
22        parent.text = (parent.text or "") + text
23
24
25def add_text_inside(node, text):
26    """ Add text inside ``node``. """
27    if text is None:
28        return
29    if len(node):
30        node[-1].tail = (node[-1].tail or "") + text
31    else:
32        node.text = (node.text or "") + text
33
34
35def remove_element(node):
36    """ Remove ``node`` but not its tail, from its XML tree. """
37    add_text_before(node, node.tail)
38    node.tail = None
39    node.getparent().remove(node)
40
41
42def locate_node(arch, spec):
43    """ Locate a node in a source (parent) architecture.
44
45    Given a complete source (parent) architecture (i.e. the field
46    `arch` in a view), and a 'spec' node (a node in an inheriting
47    view that specifies the location in the source view of what
48    should be changed), return (if it exists) the node in the
49    source view matching the specification.
50
51    :param arch: a parent architecture to modify
52    :param spec: a modifying node in an inheriting view
53    :return: a node in the source matching the spec
54    """
55    if spec.tag == 'xpath':
56        expr = spec.get('expr')
57        try:
58            xPath = etree.ETXPath(expr)
59        except etree.XPathSyntaxError:
60            _logger.error("XPathSyntaxError while parsing xpath %r", expr)
61            raise
62        nodes = xPath(arch)
63        return nodes[0] if nodes else None
64    elif spec.tag == 'field':
65        # Only compare the field name: a field can be only once in a given view
66        # at a given level (and for multilevel expressions, we should use xpath
67        # inheritance spec anyway).
68        for node in arch.iter('field'):
69            if node.get('name') == spec.get('name'):
70                return node
71        return None
72
73    for node in arch.iter(spec.tag):
74        if isinstance(node, SKIPPED_ELEMENT_TYPES):
75            continue
76        if all(node.get(attr) == spec.get(attr) for attr in spec.attrib
77               if attr not in ('position', 'version')):
78            # Version spec should match parent's root element's version
79            if spec.get('version') and spec.get('version') != arch.get('version'):
80                return None
81            return node
82    return None
83
84
85def apply_inheritance_specs(source, specs_tree, inherit_branding=False, pre_locate=lambda s: True):
86    """ Apply an inheriting view (a descendant of the base view)
87
88    Apply to a source architecture all the spec nodes (i.e. nodes
89    describing where and what changes to apply to some parent
90    architecture) given by an inheriting view.
91
92    :param Element source: a parent architecture to modify
93    :param pre_locate: function that is executed before locating a node.
94                        This function receives an arch as argument.
95                        This is required by studio to properly handle group_ids.
96    :return: a modified source where the specs are applied
97    :rtype: Element
98    """
99    # Queue of specification nodes (i.e. nodes describing where and
100    # changes to apply to some parent architecture).
101    specs = specs_tree if isinstance(specs_tree, list) else [specs_tree]
102
103    def extract(spec):
104        """
105        Utility function that locates a node given a specification, remove
106        it from the source and returns it.
107        """
108        if len(spec):
109            raise ValueError(
110                _("Invalid specification for moved nodes: '%s'") %
111                etree.tostring(spec)
112            )
113        pre_locate(spec)
114        to_extract = locate_node(source, spec)
115        if to_extract is not None:
116            remove_element(to_extract)
117            return to_extract
118        else:
119            raise ValueError(
120                _("Element '%s' cannot be located in parent view") %
121                etree.tostring(spec)
122            )
123
124    while len(specs):
125        spec = specs.pop(0)
126        if isinstance(spec, SKIPPED_ELEMENT_TYPES):
127            continue
128        if spec.tag == 'data':
129            specs += [c for c in spec]
130            continue
131        pre_locate(spec)
132        node = locate_node(source, spec)
133        if node is not None:
134            pos = spec.get('position', 'inside')
135            if pos == 'replace':
136                for loc in spec.xpath(".//*[text()='$0']"):
137                    loc.text = ''
138                    loc.append(copy.deepcopy(node))
139                if node.getparent() is None:
140                    spec_content = None
141                    comment = None
142                    for content in spec:
143                        if content.tag is not etree.Comment:
144                            spec_content = content
145                            break
146                        else:
147                            comment = content
148                    source = copy.deepcopy(spec_content)
149                    # only keep the t-name of a template root node
150                    t_name = node.get('t-name')
151                    if t_name:
152                        source.set('t-name', t_name)
153                    if comment is not None:
154                        text = source.text
155                        source.text = None
156                        comment.tail = text
157                        source.insert(0, comment)
158                else:
159                    replaced_node_tag = None
160                    for child in spec:
161                        if child.get('position') == 'move':
162                            child = extract(child)
163                        if inherit_branding and not replaced_node_tag and child.tag is not etree.Comment:
164                            # To make a correct branding, we need to
165                            # - know exactly which node has been replaced
166                            # - store it before anything else has altered the Tree
167                            # Do it exactly here :D
168                            child.set('meta-oe-xpath-replacing', node.tag)
169                            # We just store the replaced node tag on the first
170                            # child of the xpath replacing it
171                            replaced_node_tag = node.tag
172                        node.addprevious(child)
173                    node.getparent().remove(node)
174            elif pos == 'attributes':
175                for child in spec.getiterator('attribute'):
176                    attribute = child.get('name')
177                    value = child.text or ''
178                    if child.get('add') or child.get('remove'):
179                        assert not child.text
180                        separator = child.get('separator', ',')
181                        if separator == ' ':
182                            separator = None    # squash spaces
183                        to_add = (
184                            s for s in (s.strip() for s in child.get('add', '').split(separator))
185                            if s
186                        )
187                        to_remove = {s.strip() for s in child.get('remove', '').split(separator)}
188                        values = (s.strip() for s in node.get(attribute, '').split(separator))
189                        value = (separator or ' ').join(itertools.chain(
190                            (v for v in values if v not in to_remove),
191                            to_add
192                        ))
193                    if value:
194                        node.set(attribute, value)
195                    elif attribute in node.attrib:
196                        del node.attrib[attribute]
197            elif pos == 'inside':
198                add_text_inside(node, spec.text)
199                for child in spec:
200                    if child.get('position') == 'move':
201                        child = extract(child)
202                    node.append(child)
203            elif pos == 'after':
204                # add a sentinel element right after node, insert content of
205                # spec before the sentinel, then remove the sentinel element
206                sentinel = E.sentinel()
207                node.addnext(sentinel)
208                add_text_before(sentinel, spec.text)
209                for child in spec:
210                    if child.get('position') == 'move':
211                        child = extract(child)
212                    sentinel.addprevious(child)
213                remove_element(sentinel)
214            elif pos == 'before':
215                add_text_before(node, spec.text)
216                for child in spec:
217                    if child.get('position') == 'move':
218                        child = extract(child)
219                    node.addprevious(child)
220            else:
221                raise ValueError(
222                    _("Invalid position attribute: '%s'") %
223                    pos
224                )
225
226        else:
227            attrs = ''.join([
228                ' %s="%s"' % (attr, spec.get(attr))
229                for attr in spec.attrib
230                if attr != 'position'
231            ])
232            tag = "<%s%s>" % (spec.tag, attrs)
233            raise ValueError(
234                _("Element '%s' cannot be located in parent view", tag)
235            )
236
237    return source
238