Coverage for adhoc-cicd-odoo-odoo / odoo / tools / view_validation.py: 79%

189 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:22 +0000

1""" View validation code (using assertions, not the RNG schema). """ 

2 

3import ast 

4import collections 

5import logging 

6import os 

7import re 

8 

9import odoo.orm.domains as domains 

10from lxml import etree 

11from odoo import tools 

12 

13_logger = logging.getLogger(__name__) 

14 

15 

16_validators = collections.defaultdict(list) 

17_relaxng_cache = {} 

18 

19READONLY = re.compile(r"\breadonly\b") 

20 

21# predefined symbols for evaluating attributes (invisible, readonly...) 

22IGNORED_IN_EXPRESSION = { 

23 'True', 'False', 'None', # those are identifiers in Python 2.7 

24 'self', 

25 'uid', 

26 'context', 

27 'context_today', 

28 'allowed_company_ids', 

29 'current_company_id', 

30 'time', 

31 'datetime', 

32 'relativedelta', 

33 'current_date', 

34 'today', 

35 'now', 

36 'abs', 

37 'len', 

38 'bool', 

39 'float', 

40 'str', 

41 'unicode', 

42 'set', 

43} 

44DOMAIN_OPERATORS = { 

45 domains.DomainNot.OPERATOR, 

46 domains.DomainAnd.OPERATOR, 

47 domains.DomainOr.OPERATOR, 

48} 

49 

50 

51def get_domain_value_names(domain): 

52 """ Return all field name used by this domain 

53 eg: [ 

54 ('id', 'in', [1, 2, 3]), 

55 ('field_a', 'in', parent.truc), 

56 ('field_b', 'in', context.get('b')), 

57 (1, '=', 1), 

58 bool(context.get('c')), 

59 ] 

60 returns {'id', 'field_a', 'field_b'}, {'parent', 'parent.truc', 'context'} 

61 

62 :param domain: list(tuple) or str 

63 :return: set(str), set(str) 

64 """ 

65 contextual_values = set() 

66 field_names = set() 

67 

68 try: 

69 if isinstance(domain, list): 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 for leaf in domain: 

71 if leaf in DOMAIN_OPERATORS or leaf in (True, False): 

72 # "&", "|", "!", True, False 

73 continue 

74 left, _operator, _right = leaf 

75 if isinstance(left, str): 

76 field_names.add(left) 

77 elif left not in (1, 0): 

78 # deprecate: True leaf and False leaf 

79 raise ValueError() 

80 

81 elif isinstance(domain, str): 81 ↛ 142line 81 didn't jump to line 142 because the condition on line 81 was always true

82 def extract_from_domain(ast_domain): 

83 if isinstance(ast_domain, ast.IfExp): 

84 # [] if condition else [] 

85 extract_from_domain(ast_domain.body) 

86 extract_from_domain(ast_domain.orelse) 

87 return 

88 if isinstance(ast_domain, ast.BoolOp): 

89 # condition and [] 

90 # this formating don't check returned domain syntax 

91 for value in ast_domain.values: 

92 if isinstance(value, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)): 

93 extract_from_domain(value) 

94 else: 

95 contextual_values.update(_get_expression_contextual_values(value)) 

96 return 

97 if isinstance(ast_domain, ast.BinOp): 

98 # [] + [] 

99 # this formating don't check returned domain syntax 

100 if isinstance(ast_domain.left, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)): 100 ↛ 103line 100 didn't jump to line 103 because the condition on line 100 was always true

101 extract_from_domain(ast_domain.left) 

102 else: 

103 contextual_values.update(_get_expression_contextual_values(ast_domain.left)) 

104 

105 if isinstance(ast_domain.right, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)): 105 ↛ 108line 105 didn't jump to line 108 because the condition on line 105 was always true

106 extract_from_domain(ast_domain.right) 

107 else: 

108 contextual_values.update(_get_expression_contextual_values(ast_domain.right)) 

109 return 

110 for ast_item in ast_domain.elts: 

111 if isinstance(ast_item, ast.Constant): 

112 # "&", "|", "!", True, False 

113 if ast_item.value not in DOMAIN_OPERATORS and ast_item.value not in (True, False): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 raise ValueError() 

115 elif isinstance(ast_item, (ast.List, ast.Tuple)): 115 ↛ 129line 115 didn't jump to line 129 because the condition on line 115 was always true

116 left, _operator, right = ast_item.elts 

117 contextual_values.update(_get_expression_contextual_values(right)) 

118 if isinstance(left, ast.Constant) and isinstance(left.value, str): 

119 field_names.add(left.value) 

120 elif isinstance(left, ast.Constant) and left.value in (1, 0): 120 ↛ 123line 120 didn't jump to line 123 because the condition on line 120 was always true

121 # deprecate: True leaf (1, '=', 1) and False leaf (0, '=', 1) 

122 pass 

123 elif isinstance(right, ast.Constant) and right.value == 1: 

124 # deprecate: True/False leaf (py expression, '=', 1) 

125 contextual_values.update(_get_expression_contextual_values(left)) 

126 else: 

127 raise ValueError() 

128 else: 

129 raise ValueError() 

130 

131 expr = domain.strip() 

132 item_ast = ast.parse(f"({expr})", mode='eval').body 

133 if isinstance(item_ast, ast.Name): 

134 # domain="other_field_domain" 

135 contextual_values.update(_get_expression_contextual_values(item_ast)) 

136 else: 

137 extract_from_domain(item_ast) 

138 

139 except ValueError: 

140 raise ValueError("Wrong domain formatting.") from None 

141 

142 value_names = set() 

143 for name in contextual_values: 

144 if name == 'parent': 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true

145 continue 

146 root = name.split('.')[0] 

147 if root not in IGNORED_IN_EXPRESSION: 

148 value_names.add(name if root == 'parent' else root) 

149 return field_names, value_names 

150 

151 

152def _get_expression_contextual_values(item_ast): 

153 """ Return all contextual value this ast 

154 

155 eg: ast from '''( 

156 id in [1, 2, 3] 

157 and field_a in parent.truc 

158 and field_b in context.get('b') 

159 or ( 

160 True 

161 and bool(context.get('c')) 

162 ) 

163 ) 

164 returns {'parent', 'parent.truc', 'context', 'bool'} 

165 

166 :param item_ast: ast 

167 :return: set(str) 

168 """ 

169 

170 if isinstance(item_ast, ast.Constant): 

171 return set() 

172 if isinstance(item_ast, (ast.List, ast.Tuple)): 

173 values = set() 

174 for item in item_ast.elts: 

175 values |= _get_expression_contextual_values(item) 

176 return values 

177 if isinstance(item_ast, ast.Name): 

178 return {item_ast.id} 

179 if isinstance(item_ast, ast.Attribute): 

180 values = _get_expression_contextual_values(item_ast.value) 

181 if len(values) == 1: 

182 path = sorted(list(values)).pop() 

183 values = {f"{path}.{item_ast.attr}"} 

184 return values 

185 return values 

186 if isinstance(item_ast, ast.Index): # deprecated python ast class for Subscript key 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 return _get_expression_contextual_values(item_ast.value) 

188 if isinstance(item_ast, ast.Subscript): 

189 values = _get_expression_contextual_values(item_ast.value) 

190 values |= _get_expression_contextual_values(item_ast.slice) 

191 return values 

192 if isinstance(item_ast, ast.Compare): 

193 values = _get_expression_contextual_values(item_ast.left) 

194 for sub_ast in item_ast.comparators: 

195 values |= _get_expression_contextual_values(sub_ast) 

196 return values 

197 if isinstance(item_ast, ast.BinOp): 

198 values = _get_expression_contextual_values(item_ast.left) 

199 values |= _get_expression_contextual_values(item_ast.right) 

200 return values 

201 if isinstance(item_ast, ast.BoolOp): 

202 values = set() 

203 for ast_value in item_ast.values: 

204 values |= _get_expression_contextual_values(ast_value) 

205 return values 

206 if isinstance(item_ast, ast.UnaryOp): 

207 return _get_expression_contextual_values(item_ast.operand) 

208 if isinstance(item_ast, ast.Call): 

209 values = _get_expression_contextual_values(item_ast.func) 

210 for ast_arg in item_ast.args: 

211 values |= _get_expression_contextual_values(ast_arg) 

212 return values 

213 if isinstance(item_ast, ast.IfExp): 

214 values = _get_expression_contextual_values(item_ast.test) 

215 values |= _get_expression_contextual_values(item_ast.body) 

216 values |= _get_expression_contextual_values(item_ast.orelse) 

217 return values 

218 if isinstance(item_ast, ast.Dict): 218 ↛ 226line 218 didn't jump to line 226 because the condition on line 218 was always true

219 values = set() 

220 for item in item_ast.keys: 

221 values |= _get_expression_contextual_values(item) 

222 for item in item_ast.values: 

223 values |= _get_expression_contextual_values(item) 

224 return values 

225 

226 raise ValueError(f"Undefined item {item_ast!r}.") 

227 

228 

229def get_expression_field_names(expression): 

230 """ Return all field name used by this expression 

231 

232 eg: expression = '''( 

233 id in [1, 2, 3] 

234 and field_a in parent.truc.id 

235 and field_b in context.get('b') 

236 or (True and bool(context.get('c'))) 

237 ) 

238 returns {'parent', 'parent.truc', 'parent.truc.id', 'context', 'context.get'} 

239 

240 :param expression: str 

241 :param ignored: set contains the value name to ignore. 

242 Add '.' to ignore attributes (eg: {'parent.'} will 

243 ignore 'parent.truc' and 'parent.truc.id') 

244 :return: set(str) 

245 """ 

246 if not expression: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 return set() 

248 item_ast = ast.parse(expression.strip(), mode='eval').body 

249 contextual_values = _get_expression_contextual_values(item_ast) 

250 

251 value_names = set() 

252 for name in contextual_values: 

253 if name == 'parent': 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 continue 

255 root = name.split('.')[0] 

256 if root not in IGNORED_IN_EXPRESSION: 

257 value_names.add(name if root == 'parent' else root) 

258 

259 return value_names 

260 

261 

262def get_dict_asts(expr): 

263 """ Check that the given string or AST node represents a dict expression 

264 where all keys are string literals, and return it as a dict mapping string 

265 keys to the AST of values. 

266 """ 

267 if isinstance(expr, str): 267 ↛ 270line 267 didn't jump to line 270 because the condition on line 267 was always true

268 expr = ast.parse(expr.strip(), mode='eval').body 

269 

270 if not isinstance(expr, ast.Dict): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 raise ValueError("Non-dict expression") 

272 if not all((isinstance(key, ast.Constant) and isinstance(key.value, str)) for key in expr.keys): 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 raise ValueError("Non-string literal dict key") 

274 return {key.value: val for key, val in zip(expr.keys, expr.values)} 

275 

276 

277def _check(condition, explanation): 

278 if not condition: 

279 raise ValueError("Expression is not a valid domain: %s" % explanation) 

280 

281 

282def valid_view(arch, **kwargs): 

283 for pred in _validators[arch.tag]: 

284 check = pred(arch, **kwargs) 

285 if not check: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 _logger.warning("Invalid XML: %s", pred.__doc__) 

287 return False 

288 return True 

289 

290 

291def validate(*view_types): 

292 """ Registers a view-validation function for the specific view types 

293 """ 

294 def decorator(fn): 

295 for arch in view_types: 

296 _validators[arch].append(fn) 

297 return fn 

298 return decorator 

299 

300 

301def relaxng(view_type): 

302 """ Return a validator for the given view type, or None. """ 

303 if view_type not in _relaxng_cache: 

304 with tools.file_open(os.path.join('base', 'rng', '%s_view.rng' % view_type)) as frng: 

305 try: 

306 relaxng_doc = etree.parse(frng) 

307 _relaxng_cache[view_type] = etree.RelaxNG(relaxng_doc) 

308 except Exception: 

309 _logger.exception('Failed to load RelaxNG XML schema for views validation') 

310 _relaxng_cache[view_type] = None 

311 return _relaxng_cache[view_type] 

312 

313 

314@validate('calendar', 'graph', 'pivot', 'search', 'list', 'activity') 

315def schema_valid(arch, **kwargs): 

316 """ Get RNG validator and validate RNG file.""" 

317 validator = relaxng(arch.tag) 

318 if validator and not validator.validate(arch): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true

319 for error in validator.error_log: 

320 _logger.warning("%s", error) 

321 return False 

322 return True