Files
odoo_source/odoo/tools/view_validation.py
T
Gorash ba1a5509fa [IMP] base: Remove context dependencies from get_views method
In this viewtiverse, the heroes remove the context dependencies for
`get_views`, from the views and python fields (such as domain). To reduce
inconsistencies and the number of rpc.

Current issues:
* There may be inconsistencies in views at the JavaScript level. Some
overrides modify the behavior of get_views or domains on fields via
context keys, therefore by changing the action, the rendering may be
different. However, these views are cached. However, the cache key
(Javascript) does not reflect the entire context, and requires additional
post-processing from the server.
* Multiple rpc for the same rendering. get_views being dependent on the
context, as soon as it changes, a new rpc is performed. In most cases,
when JavaScript needs the same view, there is no change depending on the
context, the rpc is useless.
* Inconsistency when rendering subviews, some views could be different
depending on the context, this context can be modified in the view itself
via the context attributes. However, the JavaScript client does not redo
an rpc for each change of these sub-contexts. Therefore the result may be
inconsistent.

Solution:
Limit as much as possible the number of context keys provided when calling
get_views, and use the context provided as a cache key. The authorized
keys are 'lang' and '*_view_ref'. For the cache key, options are added in
the get_views method.
Instead of using the context, it is inserted into python expressions.
This will be evaluated by JavaScript and thus avoids inconsistencies.

task-3414108
task-3414068

closes odoo/odoo#135145

Related: odoo/enterprise#47584
Signed-off-by: Raphael Collet <rco@odoo.com>
2023-09-21 16:52:10 +00:00

322 lines
12 KiB
Python

""" View validation code (using assertions, not the RNG schema). """
import ast
import collections
import logging
import os
import re
from lxml import etree
from odoo import tools
from odoo.osv.expression import DOMAIN_OPERATORS
_logger = logging.getLogger(__name__)
_validators = collections.defaultdict(list)
_relaxng_cache = {}
READONLY = re.compile(r"\breadonly\b")
# predefined symbols for evaluating attributes (invisible, readonly...)
IGNORED_IN_EXPRESSION = {
'True', 'False', 'None', # those are identifiers in Python 2.7
'self',
'uid',
'context',
'context_today',
'active_id',
'active_ids',
'allowed_company_ids',
'current_company_id',
'active_model',
'time',
'datetime',
'relativedelta',
'current_date',
'today',
'now',
'abs',
'len',
'bool',
'float',
'str',
'unicode',
}
def get_domain_value_names(domain):
""" Return all field name used by this domain
eg: [
('id', 'in', [1, 2, 3]),
('field_a', 'in', parent.truc),
('field_b', 'in', context.get('b')),
(1, '=', 1),
bool(context.get('c')),
]
returns {'id', 'field_a', 'field_b'}, {'parent', 'parent.truc', 'context'}
:param domain: list(tuple) or str
:return: set(str), set(str)
"""
contextual_values = set()
field_names = set()
try:
if isinstance(domain, list):
for leaf in domain:
if leaf in DOMAIN_OPERATORS or leaf in (True, False):
# "&", "|", "!", True, False
continue
left, _operator, _right = leaf
if isinstance(left, str):
field_names.add(left)
elif left not in (1, 0):
# deprecate: True leaf and False leaf
raise ValueError()
elif isinstance(domain, str):
def extract_from_domain(ast_domain):
if isinstance(ast_domain, ast.IfExp):
# [] if condition else []
extract_from_domain(ast_domain.body)
extract_from_domain(ast_domain.orelse)
return
if isinstance(ast_domain, ast.BoolOp):
# condition and []
# this formating don't check returned domain syntax
for value in ast_domain.values:
if isinstance(value, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
extract_from_domain(value)
else:
contextual_values.update(_get_expression_contextual_values(value))
return
if isinstance(ast_domain, ast.BinOp):
# [] + []
# this formating don't check returned domain syntax
if isinstance(ast_domain.left, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
extract_from_domain(ast_domain.left)
else:
contextual_values.update(_get_expression_contextual_values(ast_domain.left))
if isinstance(ast_domain.right, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
extract_from_domain(ast_domain.right)
else:
contextual_values.update(_get_expression_contextual_values(ast_domain.right))
return
for ast_item in ast_domain.elts:
if isinstance(ast_item, ast.Constant):
# "&", "|", "!", True, False
if ast_item.value not in DOMAIN_OPERATORS and ast_item.value not in (True, False):
raise ValueError()
elif isinstance(ast_item, (ast.List, ast.Tuple)):
left, _operator, right = ast_item.elts
contextual_values.update(_get_expression_contextual_values(right))
if isinstance(left, ast.Constant) and isinstance(left.value, str):
field_names.add(left.value)
elif isinstance(left, ast.Constant) and left.value in (1, 0):
# deprecate: True leaf (1, '=', 1) and False leaf (0, '=', 1)
pass
elif isinstance(right, ast.Constant) and right.value == 1:
# deprecate: True/False leaf (py expression, '=', 1)
contextual_values.update(_get_expression_contextual_values(left))
else:
raise ValueError()
else:
raise ValueError()
expr = domain.strip()
item_ast = ast.parse(f"({expr})", mode='eval').body
if isinstance(item_ast, ast.Name):
# domain="other_field_domain"
contextual_values.update(_get_expression_contextual_values(item_ast))
else:
extract_from_domain(item_ast)
except ValueError:
raise ValueError("Wrong domain formatting.") from None
value_names = set()
for name in contextual_values:
if name == 'parent':
continue
root = name.split('.')[0]
if root not in IGNORED_IN_EXPRESSION:
value_names.add(name if root == 'parent' else root)
return field_names, value_names
def _get_expression_contextual_values(item_ast):
""" Return all contextual value this ast
eg: ast from '''(
id in [1, 2, 3]
and field_a in parent.truc
and field_b in context.get('b')
or (
True
and bool(context.get('c'))
)
)
returns {'parent', 'parent.truc', 'context', 'bool'}
:param item_ast: ast
:return: set(str)
"""
if isinstance(item_ast, ast.Constant):
return set()
if isinstance(item_ast, ast.Str):
return set()
if isinstance(item_ast, (ast.List, ast.Tuple)):
values = set()
for item in item_ast.elts:
values |= _get_expression_contextual_values(item)
return values
if isinstance(item_ast, ast.Name):
return {item_ast.id}
if isinstance(item_ast, ast.Attribute):
values = _get_expression_contextual_values(item_ast.value)
if len(values) == 1:
path = sorted(list(values)).pop()
values = {f"{path}.{item_ast.attr}"}
return values
return values
if isinstance(item_ast, ast.Index): # deprecated python ast class for Subscript key
return _get_expression_contextual_values(item_ast.value)
if isinstance(item_ast, ast.Subscript):
values = _get_expression_contextual_values(item_ast.value)
values |= _get_expression_contextual_values(item_ast.slice)
return values
if isinstance(item_ast, ast.Compare):
values = _get_expression_contextual_values(item_ast.left)
for sub_ast in item_ast.comparators:
values |= _get_expression_contextual_values(sub_ast)
return values
if isinstance(item_ast, ast.BinOp):
values = _get_expression_contextual_values(item_ast.left)
values |= _get_expression_contextual_values(item_ast.right)
return values
if isinstance(item_ast, ast.BoolOp):
values = set()
for ast_value in item_ast.values:
values |= _get_expression_contextual_values(ast_value)
return values
if isinstance(item_ast, ast.UnaryOp):
return _get_expression_contextual_values(item_ast.operand)
if isinstance(item_ast, ast.Call):
values = _get_expression_contextual_values(item_ast.func)
for ast_arg in item_ast.args:
values |= _get_expression_contextual_values(ast_arg)
return values
if isinstance(item_ast, ast.IfExp):
values = _get_expression_contextual_values(item_ast.test)
values |= _get_expression_contextual_values(item_ast.body)
values |= _get_expression_contextual_values(item_ast.orelse)
return values
if isinstance(item_ast, ast.Dict):
values = set()
for item in item_ast.keys:
values |= _get_expression_contextual_values(item)
for item in item_ast.values:
values |= _get_expression_contextual_values(item)
return values
raise ValueError(f"Undefined item {item_ast!r}.")
def get_expression_field_names(expression):
""" Return all field name used by this expression
eg: expression = '''(
id in [1, 2, 3]
and field_a in parent.truc.id
and field_b in context.get('b')
or (True and bool(context.get('c')))
)
returns {'parent', 'parent.truc', 'parent.truc.id', 'context', 'context.get'}
:param expression: str
:param ignored: set contains the value name to ignore.
Add '.' to ignore attributes (eg: {'parent.'} will
ignore 'parent.truc' and 'parent.truc.id')
:return: set(str)
"""
item_ast = ast.parse(expression.strip(), mode='eval').body
contextual_values = _get_expression_contextual_values(item_ast)
value_names = set()
for name in contextual_values:
if name == 'parent':
continue
root = name.split('.')[0]
if root not in IGNORED_IN_EXPRESSION:
value_names.add(name if root == 'parent' else root)
return value_names
def get_dict_asts(expr):
""" Check that the given string or AST node represents a dict expression
where all keys are string literals, and return it as a dict mapping string
keys to the AST of values.
"""
if isinstance(expr, str):
expr = ast.parse(expr.strip(), mode='eval').body
if not isinstance(expr, ast.Dict):
raise ValueError("Non-dict expression")
if not all(isinstance(key, ast.Str) for key in expr.keys):
raise ValueError("Non-string literal dict key")
return {key.s: val for key, val in zip(expr.keys, expr.values)}
def _check(condition, explanation):
if not condition:
raise ValueError("Expression is not a valid domain: %s" % explanation)
def valid_view(arch, **kwargs):
for pred in _validators[arch.tag]:
check = pred(arch, **kwargs)
if not check:
_logger.warning("Invalid XML: %s", pred.__doc__)
return False
return True
def validate(*view_types):
""" Registers a view-validation function for the specific view types
"""
def decorator(fn):
for arch in view_types:
_validators[arch].append(fn)
return fn
return decorator
def relaxng(view_type):
""" Return a validator for the given view type, or None. """
if view_type not in _relaxng_cache:
with tools.file_open(os.path.join('base', 'rng', '%s_view.rng' % view_type)) as frng:
try:
relaxng_doc = etree.parse(frng)
_relaxng_cache[view_type] = etree.RelaxNG(relaxng_doc)
except Exception:
_logger.exception('Failed to load RelaxNG XML schema for views validation')
_relaxng_cache[view_type] = None
return _relaxng_cache[view_type]
@validate('calendar', 'graph', 'pivot', 'search', 'tree', 'activity')
def schema_valid(arch, **kwargs):
""" Get RNG validator and validate RNG file."""
validator = relaxng(arch.tag)
if validator and not validator.validate(arch):
result = True
for error in validator.error_log:
_logger.warning(tools.ustr(error))
result = False
return result
return True