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

1import copy 

2import itertools 

3import logging 

4import re 

5 

6from lxml import etree 

7from lxml.builder import E 

8 

9from odoo.tools.translate import LazyTranslate 

10from odoo.exceptions import ValidationError 

11from .misc import SKIPPED_ELEMENT_TYPES, html_escape 

12 

13__all__ = [] 

14 

15_lt = LazyTranslate('base') 

16_logger = logging.getLogger(__name__) 

17RSTRIP_REGEXP = re.compile(r'\n[ \t]*$') 

18 

19# attribute names that contain Python expressions 

20PYTHON_ATTRIBUTES = {'readonly', 'required', 'invisible', 'column_invisible', 't-if', 't-elif'} 

21 

22 

23def add_stripped_items_before(node, spec, extract): 

24 text = spec.text or '' 

25 

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 

38 

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 

43 

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) 

50 

51 

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 

62 

63 

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) 

69 

70 

71def locate_node(arch, spec): 

72 """ Locate a node in a source (parent) architecture. 

73 

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. 

79 

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 

102 

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 

107 

108 

109def apply_inheritance_specs(source, specs_tree, inherit_branding=False, pre_locate=None): 

110 """ Apply an inheriting view (a descendant of the base view) 

111 

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. 

115 

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) 

129 

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 ) 

148 

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)) 

208 

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 )) 

252 

253 attribute = child.get('name') 

254 value = None 

255 

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') 

266 

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 '' 

307 

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) 

331 

332 else: 

333 raise ValueError(_lt("Invalid position attribute: '%s'", pos)) 

334 

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 ) 

345 

346 return source