Coverage for adhoc-cicd-odoo-odoo / odoo / tools / template_inheritance.py: 79%
198 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1import copy
2import itertools
3import logging
4import re
6from lxml import etree
7from lxml.builder import E
9from odoo.tools.translate import LazyTranslate
10from odoo.exceptions import ValidationError
11from .misc import SKIPPED_ELEMENT_TYPES, html_escape
13__all__ = []
15_lt = LazyTranslate('base')
16_logger = logging.getLogger(__name__)
17RSTRIP_REGEXP = re.compile(r'\n[ \t]*$')
19# attribute names that contain Python expressions
20PYTHON_ATTRIBUTES = {'readonly', 'required', 'invisible', 'column_invisible', 't-if', 't-elif'}
23def add_stripped_items_before(node, spec, extract):
24 text = spec.text or ''
26 before_text = ''
27 prev = next((n for n in node.itersiblings(preceding=True) if not (n.tag == etree.ProcessingInstruction and n.target == "apply-inheritance-specs-node-removal")), None)
28 if prev is None:
29 parent = node.getparent()
30 result = parent.text and RSTRIP_REGEXP.search(parent.text)
31 before_text = result.group(0) if result else ''
32 fallback_text = None if spec.text is None else ''
33 parent.text = ((parent.text or '').rstrip() + text) or fallback_text
34 else:
35 result = prev.tail and RSTRIP_REGEXP.search(prev.tail)
36 before_text = result.group(0) if result else ''
37 prev.tail = (prev.tail or '').rstrip() + text
39 if len(spec) > 0:
40 spec[-1].tail = (spec[-1].tail or "").rstrip() + before_text
41 else:
42 spec.text = (spec.text or "").rstrip() + before_text
44 for child in spec:
45 if child.get('position') == 'move':
46 tail = child.tail
47 child = extract(child)
48 child.tail = tail
49 node.addprevious(child)
52def add_text_before(node, text):
53 """ Add text before ``node`` in its XML tree. """
54 if text is None:
55 return
56 prev = node.getprevious()
57 if prev is not None:
58 prev.tail = (prev.tail or "") + text
59 else:
60 parent = node.getparent()
61 parent.text = (parent.text or "").rstrip() + text
64def remove_element(node):
65 """ Remove ``node`` but not its tail, from its XML tree. """
66 add_text_before(node, node.tail)
67 node.tail = None
68 node.getparent().remove(node)
71def locate_node(arch, spec):
72 """ Locate a node in a source (parent) architecture.
74 Given a complete source (parent) architecture (i.e. the field
75 `arch` in a view), and a 'spec' node (a node in an inheriting
76 view that specifies the location in the source view of what
77 should be changed), return (if it exists) the node in the
78 source view matching the specification.
80 :param arch: a parent architecture to modify
81 :param spec: a modifying node in an inheriting view
82 :return: a node in the source matching the spec
83 """
84 if spec.tag == 'xpath':
85 expr = spec.get('expr')
86 if expr is None: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 raise ValidationError(_lt("Missing 'expr' attribute in xpath specification"))
88 try:
89 xPath = etree.ETXPath(expr)
90 except etree.XPathSyntaxError as e:
91 raise ValidationError(_lt("Invalid Expression while parsing xpath “%s”", expr)) from e
92 nodes = xPath(arch)
93 return nodes[0] if nodes else None
94 elif spec.tag == 'field':
95 # Only compare the field name: a field can be only once in a given view
96 # at a given level (and for multilevel expressions, we should use xpath
97 # inheritance spec anyway).
98 for node in arch.iter('field'): 98 ↛ 101line 98 didn't jump to line 101 because the loop on line 98 didn't complete
99 if node.get('name') == spec.get('name'):
100 return node
101 return None
103 for node in arch.iter(spec.tag): 103 ↛ 106line 103 didn't jump to line 106 because the loop on line 103 didn't complete
104 if all(node.get(attr) == spec.get(attr) for attr in spec.attrib if attr != 'position'):
105 return node
106 return None
109def apply_inheritance_specs(source, specs_tree, inherit_branding=False, pre_locate=None):
110 """ Apply an inheriting view (a descendant of the base view)
112 Apply to a source architecture all the spec nodes (i.e. nodes
113 describing where and what changes to apply to some parent
114 architecture) given by an inheriting view.
116 :param Element source: a parent architecture to modify
117 :param Element specs_tree: a modifying architecture in an inheriting view
118 :param bool inherit_branding:
119 :param pre_locate: function that is executed before locating a node.
120 This function receives an arch as argument.
121 This is required by studio to properly handle group_ids.
122 :return: a modified source where the specs are applied
123 :rtype: Element
124 """
125 # Queue of specification nodes (i.e. nodes describing where and
126 # changes to apply to some parent architecture).
127 specs = specs_tree if isinstance(specs_tree, list) else [specs_tree]
128 pre_locate = pre_locate or (lambda _: True)
130 def extract(spec):
131 """
132 Utility function that locates a node given a specification, remove
133 it from the source and returns it.
134 """
135 if len(spec): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 raise ValueError(
137 _lt("Invalid specification for moved nodes: “%s”", etree.tostring(spec, encoding='unicode'))
138 )
139 pre_locate(spec)
140 to_extract = locate_node(source, spec)
141 if to_extract is not None: 141 ↛ 145line 141 didn't jump to line 145 because the condition on line 141 was always true
142 remove_element(to_extract)
143 return to_extract
144 else:
145 raise ValueError(
146 _lt("Element “%s” cannot be located in parent view", etree.tostring(spec, encoding='unicode'))
147 )
149 while len(specs):
150 spec = specs.pop(0)
151 if isinstance(spec, SKIPPED_ELEMENT_TYPES):
152 continue
153 if spec.tag == 'data':
154 specs += [c for c in spec]
155 continue
156 pre_locate(spec)
157 node = locate_node(source, spec)
158 if node is not None: 158 ↛ 336line 158 didn't jump to line 336 because the condition on line 158 was always true
159 pos = spec.get('position', 'inside')
160 if pos == 'replace':
161 mode = spec.get('mode', 'outer')
162 if mode == "outer":
163 for loc in spec.xpath(".//*[text()='$0']"):
164 loc.text = ''
165 copied_node = copy.deepcopy(node)
166 # TODO: Remove 'inherit_branding' logic if possible;
167 # currently needed to track node removal for branding
168 # distribution. Avoid marking root nodes to prevent
169 # sibling branding issues.
170 if inherit_branding: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 copied_node.set('data-oe-no-branding', '1')
172 loc.append(copied_node)
173 if node.getparent() is None: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 spec_content = None
175 comment = None
176 for content in spec:
177 if content.tag is not etree.Comment:
178 spec_content = content
179 break
180 else:
181 comment = content
182 source = copy.deepcopy(spec_content)
183 # only keep the t-name of a template root node
184 t_name = node.get('t-name')
185 if t_name:
186 source.set('t-name', t_name)
187 if comment is not None:
188 text = source.text
189 source.text = None
190 comment.tail = text
191 source.insert(0, comment)
192 else:
193 # TODO ideally the notion of 'inherit_branding' should
194 # not exist in this function. Given the current state of
195 # the code, it is however necessary to know where nodes
196 # were removed when distributing branding. As a stable
197 # fix, this solution was chosen: the location is marked
198 # with a "ProcessingInstruction" which will not impact
199 # the "Element" structure of the resulting tree.
200 # Exception: if we happen to replace a node that already
201 # has xpath branding (root level nodes), do not mark the
202 # location of the removal as it will mess up the branding
203 # of siblings elements coming from other views, after the
204 # branding is distributed (and those processing instructions
205 # removed).
206 if inherit_branding and not node.get('data-oe-xpath'): 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 node.addprevious(etree.ProcessingInstruction('apply-inheritance-specs-node-removal', node.tag))
209 for child in spec:
210 if child.get('position') == 'move': 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 child = extract(child)
212 node.addprevious(child)
213 node.getparent().remove(node)
214 elif mode == "inner": 214 ↛ 234line 214 didn't jump to line 234 because the condition on line 214 was always true
215 # use a sentinel to keep the existing children nodes, so
216 # that one can move existing children nodes inside the new
217 # content of the node (with position="move")
218 sentinel = E.sentinel()
219 if len(node) > 0: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 node[0].addprevious(sentinel)
221 else:
222 node.append(sentinel)
223 # fill the node with the spec *before* the sentinel
224 # remove node.text before that operation, otherwise it will
225 # be merged with the new content's text
226 node.text = None
227 add_stripped_items_before(sentinel, copy.deepcopy(spec), extract)
228 # now remove the old content and the sentinel
229 for child in reversed(node): 229 ↛ 149line 229 didn't jump to line 149 because the loop on line 229 didn't complete
230 node.remove(child)
231 if child == sentinel: 231 ↛ 229line 231 didn't jump to line 229 because the condition on line 231 was always true
232 break
233 else:
234 raise ValueError(_lt("Invalid mode attribute: “%s”", mode))
235 elif pos == 'attributes':
236 for child in spec.getiterator('attribute'):
237 # The element should only have attributes:
238 # - name (mandatory),
239 # - add, remove, separator
240 # - any attribute that starts with data-oe-*
241 unknown = [
242 key
243 for key in child.attrib
244 if key not in ('name', 'add', 'remove', 'separator')
245 and not key.startswith('data-oe-')
246 ]
247 if unknown: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 raise ValueError(_lt(
249 "Invalid attributes %s in element <attribute>",
250 ", ".join(map(repr, unknown)),
251 ))
253 attribute = child.get('name')
254 value = None
256 if child.get('add') or child.get('remove'):
257 if child.text: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 raise ValueError(_lt(
259 "Element <attribute> with 'add' or 'remove' cannot contain text %s",
260 repr(child.text),
261 ))
262 value = node.get(attribute, '')
263 add = child.get('add', '')
264 remove = child.get('remove', '')
265 separator = child.get('separator')
267 if attribute in PYTHON_ATTRIBUTES or attribute.startswith('decoration-'):
268 # attribute containing a python expression
269 separator = separator.strip()
270 if separator not in ('and', 'or'): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 raise ValueError(_lt(
272 "Invalid separator %(separator)s for python expression %(expression)s; "
273 "valid values are 'and' and 'or'",
274 separator=repr(separator), expression=repr(attribute),
275 ))
276 if remove:
277 if re.match(rf'^\(*{remove}\)*$', value): 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 value = ''
279 else:
280 patterns = [
281 f"({remove}) {separator} ",
282 f" {separator} ({remove})",
283 f"{remove} {separator} ",
284 f" {separator} {remove}",
285 ]
286 for pattern in patterns: 286 ↛ 291line 286 didn't jump to line 291 because the loop on line 286 didn't complete
287 index = value.find(pattern)
288 if index != -1:
289 value = value[:index] + value[index + len(pattern):]
290 break
291 if add:
292 value = f"({value}) {separator} ({add})" if value else add
293 else:
294 if separator is None:
295 separator = ','
296 elif separator == ' ':
297 separator = None # squash spaces
298 values = (s.strip() for s in value.split(separator))
299 to_add = filter(None, (s.strip() for s in add.split(separator)))
300 to_remove = {s.strip() for s in remove.split(separator)}
301 value = (separator or ' ').join(itertools.chain(
302 (v for v in values if v and v not in to_remove),
303 to_add
304 ))
305 else:
306 value = child.text or ''
308 if value:
309 node.set(attribute, value)
310 elif attribute in node.attrib:
311 del node.attrib[attribute]
312 elif pos == 'inside':
313 # add a sentinel element at the end, insert content of spec
314 # before the sentinel, then remove the sentinel element
315 sentinel = E.sentinel()
316 node.append(sentinel)
317 add_stripped_items_before(sentinel, spec, extract)
318 remove_element(sentinel)
319 elif pos == 'after':
320 # add a sentinel element right after node, insert content of
321 # spec before the sentinel, then remove the sentinel element
322 sentinel = E.sentinel()
323 node.addnext(sentinel)
324 if node.tail is not None: # for lxml >= 5.1
325 sentinel.tail = node.tail
326 node.tail = None
327 add_stripped_items_before(sentinel, spec, extract)
328 remove_element(sentinel)
329 elif pos == 'before': 329 ↛ 333line 329 didn't jump to line 333 because the condition on line 329 was always true
330 add_stripped_items_before(node, spec, extract)
332 else:
333 raise ValueError(_lt("Invalid position attribute: '%s'", pos))
335 else:
336 attrs = ''.join([
337 ' %s="%s"' % (attr, html_escape(spec.get(attr)))
338 for attr in spec.attrib
339 if attr != 'position'
340 ])
341 tag = "<%s%s>" % (spec.tag, attrs)
342 raise ValueError(
343 _lt("Element '%s' cannot be located in parent view", tag)
344 )
346 return source