""" View validation code (using assertions, not the RNG schema). """ import ast import collections import logging import os import re from functools import partial from lxml import etree from odoo import tools from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) _validators = collections.defaultdict(list) _relaxng_cache = {} # attributes in views that may contain references to field names ATTRS_WITH_FIELD_NAMES = { 'context', 'domain', 'decoration-bf', 'decoration-it', 'decoration-danger', 'decoration-info', 'decoration-muted', 'decoration-primary', 'decoration-success', 'decoration-warning', } READONLY = re.compile(r"\breadonly\b") def _get_attrs_symbols(): """ Return a set of predefined symbols for evaluating attrs. """ return { 'True', 'False', 'None', # those are identifiers in Python 2.7 'self', 'parent', 'id', 'uid', 'context', 'context_today', 'active_id', 'active_ids', 'allowed_company_ids', 'current_company_id', 'active_model', 'time', 'datetime', 'relativedelta', 'current_date', 'abs', 'len', 'bool', 'float', 'str', 'unicode', } def _view_is_editable(node): """ Return whether the node is an editable view. """ return node.tag == 'form' or node.tag == 'tree' and node.get('editable') def field_is_editable(field, node): """ Return whether a field is editable (not always readonly). """ return ( (not field.readonly or READONLY.search(str(field.states or ""))) and (node.get('readonly') != "1" or READONLY.search(node.get('attrs') or "")) ) def get_attrs_field_names(env, arch, model, editable): """ Retrieve the field names appearing in context, domain and attrs, and return a list of triples ``(field_name, attr_name, attr_value)``. """ VIEW_TYPES = {item[0] for item in type(env['ir.ui.view']).type.selection} symbols = _get_attrs_symbols() | {None} result = [] def get_name(node): """ return the name from an AST node, or None """ if isinstance(node, ast.Name): return node.id def get_subname(get, node): """ return the subfield name from an AST node, or None """ if isinstance(node, ast.Attribute) and get(node.value) == 'parent': return node.attr def process_expr(expr, get, key, val): """ parse `expr` and collect triples """ for node in ast.walk(ast.parse(expr.strip(), mode='eval')): name = get(node) if name not in symbols: result.append((name, key, val)) def process_attrs(expr, get, key, val): """ parse `expr` and collect field names in lhs of conditions. """ for domain in safe_eval(expr).values(): if not isinstance(domain, list): continue for arg in domain: if isinstance(arg, (tuple, list)): process_expr(str(arg[0]), get, key, expr) def process(node, model, editable, get=get_name): """ traverse `node` and collect triples """ if node.tag in VIEW_TYPES: # determine whether this view is editable editable = editable and _view_is_editable(node) elif node.tag in ('field', 'groupby'): # determine whether the field is editable field = model._fields.get(node.get('name')) if field: editable = editable and field_is_editable(field, node) for key, val in node.items(): if not val: continue if key in ATTRS_WITH_FIELD_NAMES: process_expr(val, get, key, val) elif key == 'attrs': process_attrs(val, get, key, val) if node.tag in ('field', 'groupby') and field and field.relational: if editable and not node.get('domain'): domain = field._description_domain(env) # process the field's domain as if it was in the view if isinstance(domain, str): process_expr(domain, get, 'domain', domain) # retrieve subfields of 'parent' model = env[field.comodel_name] get = partial(get_subname, get) for child in node: if node.tag == 'search' and child.tag == 'searchpanel': # searchpanel part has to be validated independently continue process(child, model, editable, get) process(arch, model, editable) return result def valid_view(arch, **kwargs): for pred in _validators[arch.tag]: check = pred(arch, **kwargs) if not check: _logger.error("Invalid XML: %s", pred.__doc__) return False if check == "Warning": _logger.warning("Invalid XML: %s", pred.__doc__) return "Warning" 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.error(tools.ustr(error)) result = False return result return True @validate('search') def valid_searchpanel(arch, **kwargs): """ There must be at most one ``searchpanel`` node in search view archs. """ return len(arch.xpath('/search/searchpanel')) <= 1 @validate('search') def valid_searchpanel_domain_select(arch, **kwargs): """ In the searchpanel, the attribute ``domain`` can only be used on ``field`` nodes with ``select`` attribute set to ``multi``. """ for child in arch.xpath('/search/searchpanel/field'): if child.get('domain') and child.get('select') != 'multi': return False return True @validate('search') def valid_searchpanel_domain_fields(arch, **kwargs): """ In the searchpanel, fields used in the ``domain`` attribute must be present inside the ``searchpanel`` node with ``select`` attribute not set to ``multi``. """ searchpanel = arch.xpath('/search/searchpanel') if searchpanel: env = kwargs['env'] model = kwargs['model'] attrs_fields = [r[0] for r in get_attrs_field_names(env, searchpanel[0], env[model], False)] non_multi_fields = [ c.get('name') for c in arch.xpath('/search/searchpanel/field') if c.get('select') != 'multi' ] return len(set(attrs_fields) - set(non_multi_fields)) == 0 return True @validate('form') def valid_page_in_book(arch, **kwargs): """A `page` node must be below a `notebook` node.""" return not arch.xpath('//page[not(ancestor::notebook)]') @validate('graph') def valid_field_in_graph(arch, **kwargs): """ Children of ``graph`` can only be ``field`` """ return all( child.tag == 'field' for child in arch.xpath('/graph/*') ) @validate('tree') def valid_field_in_tree(arch, **kwargs): """ Children of ``tree`` view must be ``field`` or ``button`` or ``control`` or ``groupby``.""" return all( child.tag in ('field', 'button', 'control', 'groupby') for child in arch.xpath('/tree/*') ) @validate('form', 'graph', 'tree', 'activity') def valid_att_in_field(arch, **kwargs): """ ``field`` nodes must all have a ``@name`` """ return not arch.xpath('//field[not(@name)]') @validate('form') def valid_att_in_label(arch, **kwargs): """ ``label`` nodes must have a ``@for`` """ return not arch.xpath('//label[not(@for) and not(descendant::input)]') @validate('form') def valid_att_in_form(arch, **kwargs): return True @validate('form') def valid_type_in_colspan(arch, **kwargs): """A `colspan` attribute must be an `integer` type.""" return all( attrib.isdigit() for attrib in arch.xpath('//@colspan') ) @validate('form') def valid_type_in_col(arch, **kwargs): """A `col` attribute must be an `integer` type.""" return all( attrib.isdigit() for attrib in arch.xpath('//@col') ) @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_alternative_image_text(arch, **kwargs): """An `img` tag must have an alt value.""" if arch.xpath('//img[not(@alt or @t-att-alt or @t-attf-alt)]'): return "Warning" return True @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_alternative_icon_text(arch, **kwargs): """An icon with fa- class or in a button must have aria-label in its tag, parents, descendants or have text.""" valid_aria_attrs = { 'aria-label', 'aria-labelledby', 't-att-aria-label', 't-attf-aria-label', 't-att-aria-labelledby', 't-attf-aria-labelledby'} valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'} valid_attrs = valid_aria_attrs | valid_t_attrs valid_aria_attrs_xpath = ' or '.join('@' + attr for attr in valid_aria_attrs) valid_attrs_xpath = ' or '.join('@' + attr for attr in valid_attrs) # Select elements with class beginning by 'fa-' xpath = '(//*[contains(concat(" ", @class), " fa-")' xpath += ' or contains(concat(" ", @t-att-class), " fa-")' xpath += ' or contains(concat(" ", @t-attf-class), " fa-")]' xpath += ' | //button[@icon])' # Elements with accessibility or string attrs are good xpath += '[not(' + valid_attrs_xpath + ')]' # And we ignore all elements with describing in children xpath += '[not(//*[' + valid_attrs_xpath + '])]' # Aria label can be on ancestors xpath += '[not(ancestor[' + valid_aria_attrs_xpath + '])]' # Labels provide text by definition xpath += '[not(descendant-or-self::label)]' # Buttons can have a string attribute xpath += '[not(descendant-or-self::button[@string])]' # Fields have labels xpath += '[not(descendant-or-self::field)]' # And finally, if there is some text, it's good too xpath += '[not(descendant-or-self::*[text()])]' # Following or preceding text xpath += '[not(preceding-sibling::text()[normalize-space()])]' xpath += '[not(following-sibling::text()[normalize-space()])]' # Following or preceding text in span xpath += '[not(preceding-sibling::span[text()])]' xpath += '[not(following-sibling::span[text()])]' if arch.xpath(xpath): return "Warning" return True @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_title_icon(arch, **kwargs): """An icon with fa- class or in a button must have title in its tag, parents, descendants or have text.""" valid_title_attrs = {'title', 't-att-title', 't-attf-title'} valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'} valid_attrs = valid_title_attrs | valid_t_attrs valid_title_attrs_xpath = ' or '.join('@' + attr for attr in valid_title_attrs) valid_attrs_xpath = ' or '.join('@' + attr for attr in valid_attrs) # Select elements with class beginning by 'fa-' xpath = '(//*[contains(concat(" ", @class), " fa-")' xpath += ' or contains(concat(" ", @t-att-class), " fa-")' xpath += ' or contains(concat(" ", @t-attf-class), " fa-")]' xpath += ' | //button[@icon])' # Elements with accessibility or string attrs are good xpath += '[not(' + valid_attrs_xpath + ')]' # And we ignore all elements with describing in children xpath += '[not(//*[' + valid_attrs_xpath + '])]' # Aria label can be on ancestors xpath += '[not(ancestor[' + valid_title_attrs_xpath + '])]' # Labels provide text by definition xpath += '[not(descendant-or-self::label)]' # Buttons can have a string attribute xpath += '[not(descendant-or-self::button[@string])]' # Fields have labels xpath += '[not(descendant-or-self::field)]' # And finally, if there is some text, it's good too xpath += '[not(descendant-or-self::*[text()])]' # Following or preceding text xpath += '[not(preceding-sibling::text()[normalize-space()])]' xpath += '[not(following-sibling::text()[normalize-space()])]' # Following or preceding text in span xpath += '[not(preceding-sibling::span[text()])]' xpath += '[not(following-sibling::span[text()])]' if arch.xpath(xpath): return "Warning" return True @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_simili_button(arch, **kwargs): """A simili button must be tagged with "role='button'".""" # Select elements with class 'btn' xpath = '//a[contains(concat(" ", @class), " btn")' xpath += ' or contains(concat(" ", @t-att-class), " btn")' xpath += ' or contains(concat(" ", @t-attf-class), " btn")]' xpath += '[not(@role="button")]' if arch.xpath(xpath): return "Warning" return True @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_simili_dropdown(arch, **kwargs): """A simili dropdown must be tagged with "role='menu'".""" xpath = '//*[contains(concat(" ", @class, " "), " dropdown-menu ")' xpath += ' or contains(concat(" ", @t-att-class, " "), " dropdown-menu ")' xpath += ' or contains(concat(" ", @t-attf-class, " "), " dropdown-menu ")]' xpath += '[not(@role="menu")]' if arch.xpath(xpath): return "Warning" return True @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_simili_progressbar(arch, **kwargs): """A simili progressbar must be tagged with "role='progressbar'" and have aria-valuenow, aria-valuemin and aria-valuemax attributes.""" # Select elements with class 'btn' xpath = '//*[contains(concat(" ", @class, " "), " o_progressbar ")' xpath += ' or contains(concat(" ", @t-att-class, " "), " o_progressbar ")' xpath += ' or contains(concat(" ", @t-attf-class, " "), " o_progressbar ")]' xpath += '[not(self::progress)]' xpath += '[not(@role="progressbar")]' xpath += '[not(@aria-valuenow or @t-att-aria-valuenow or @t-attf-aria-valuenow)]' xpath += '[not(@aria-valuemin or @t-att-aria-valuemin or @t-attf-aria-valuemin)]' xpath += '[not(@aria-valuemax or @t-att-aria-valuemax or @t-attf-aria-valuemax)]' if arch.xpath(xpath): return "Warning" return True @validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity') def valid_dialog(arch, **kwargs): """A dialog must use role="dialog" and its header, body and footer contents must use
,
and