summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXavier-Do <xdo@odoo.com>2019-10-24 12:48:19 +0000
committerRaphael Collet <rco@odoo.com>2019-11-13 16:11:46 +0000
commitaae1d578296875a641d1142c0f73279fc134a151 (patch)
tree5aab227cfe57fc42a83b7133453c94db091cfff0
parentb63e82bebda948d58e5adffe2f5aa7ea047c1514 (diff)
[REF] base: refactor check_xml and read_combined
View creation/edition represent a important part of an install and a lot of possible view errors are not detected, like fields used in domain filters. Some part of the code a difficult to maintain, and view checks are splitted in multiple places. This commit aims at refactoring view validation by regrouping most part of the logic in ir_ui_view and trying to optimize the overall process. Since most of the lines were touched, this task was also an opportunity to modernize the API. Main changes on method `check_xml`: - extract node processign and validation to individual postprocessor - add validation for filter node, buttons, ... - fix accessibility checks (and improve their performance) - move xpath check to specific Python node validator - clarify error messages (wip to continue) Main changes on method `read_combined`: - optimize the search for inheriting views in a single query doing the whole recursive search Indeed, after removing xpath validations, `get_inheriting_views_arch` was the most expensive method in `check_xml`, spending most of the time in `search` because of recursive calls to retrieve children views. The view Backend Assets is a good example of the latter point, since a line is added in the view for almost every module. 70 views (community) are added at first level, `get_inheriting_views_arch` is efficient and returns all 70 views. Then at the second level, the method `get_inheriting_views_arch` is called 70 times for nothing. 70 calls to `search` (squared/2 since each view is checked independently) are almost useless. The same case applies to the settings view. As a result, the average module installation time is 25% faster, and the average time spent in `read_combined` is almost divided by 2.
-rw-r--r--addons/mail/__manifest__.py2
-rw-r--r--addons/website/models/ir_ui_view.py22
-rw-r--r--addons/website/models/website.py5
-rw-r--r--odoo/addons/base/models/ir_ui_view.py1085
-rw-r--r--odoo/addons/base/models/res_partner.py2
-rw-r--r--odoo/addons/base/tests/__init__.py1
-rw-r--r--odoo/addons/base/tests/test_view_validation.py189
-rw-r--r--odoo/addons/base/tests/test_views.py609
-rw-r--r--odoo/fields.py6
-rw-r--r--odoo/models.py6
-rw-r--r--odoo/tools/view_validation.py438
11 files changed, 1421 insertions, 944 deletions
diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index dd37b6055c4..eab82754cb7 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -20,6 +20,7 @@
'views/mail_message_views.xml',
'views/mail_mail_views.xml',
'views/mail_followers_views.xml',
+ 'views/mail_moderation_views.xml',
'views/mail_channel_views.xml',
'views/mail_shortcode_views.xml',
'views/mail_activity_views.xml',
@@ -35,7 +36,6 @@
'views/mail_templates.xml',
'wizard/email_template_preview_view.xml',
'views/mail_template_views.xml',
- 'views/mail_moderation_views.xml',
'views/ir_actions_views.xml',
'views/ir_model_views.xml',
'views/res_partner_views.xml',
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 89458b4a5b6..a765cdd2064 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -251,32 +251,24 @@ class View(models.Model):
return view_id if view_id._name == 'ir.ui.view' else self.env['ir.ui.view']
@api.model
- def _get_inheriting_views_arch_website(self, view_id):
- return self.env['website'].browse(self._context.get('website_id'))
-
- @api.model
- def _get_inheriting_views_arch_domain(self, view_id, model):
- domain = super(View, self)._get_inheriting_views_arch_domain(view_id, model)
- current_website = self._get_inheriting_views_arch_website(view_id)
+ def _get_inheriting_views_arch_domain(self, model):
+ domain = super(View, self)._get_inheriting_views_arch_domain(model)
+ current_website = self.env['website'].browse(self._context.get('website_id'))
website_views_domain = current_website.website_domain()
# when rendering for the website we have to include inactive views
# we will prefer inactive website-specific views over active generic ones
if current_website:
domain = [leaf for leaf in domain if 'active' not in leaf]
-
return expression.AND([website_views_domain, domain])
@api.model
- def get_inheriting_views_arch(self, view_id, model):
+ def get_inheriting_views_arch(self, model):
if not self._context.get('website_id'):
- return super(View, self).get_inheriting_views_arch(view_id, model)
-
- inheriting_views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(view_id, model)
+ return super(View, self).get_inheriting_views_arch(model)
+ views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(model)
# prefer inactive website-specific views over active generic ones
- inheriting_views = self.browse([view[1] for view in inheriting_views]).filter_duplicate().filtered('active')
-
- return [(view.arch, view.id) for view in inheriting_views]
+ return views.filter_duplicate().filtered('active')
@api.model
@tools.ormcache_context('self.env.uid', 'self.env.su', 'xml_id', keys=('website_id',))
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index ae7dd1a6556..9dd2c8c9be1 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -185,9 +185,8 @@ class Website(models.Model):
attachments_to_unlink.unlink()
return super(Website, self).unlink()
- @api.model
- def create_and_redirect_to_theme(self, vals):
- self.browse(vals)._force()
+ def create_and_redirect_to_theme(self):
+ self._force()
action = self.env.ref('website.theme_install_kanban_action')
return action.read()[0]
diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py
index 9b1baa2bd3e..63174ef1bf4 100644
--- a/odoo/addons/base/models/ir_ui_view.py
+++ b/odoo/addons/base/models/ir_ui_view.py
@@ -1,18 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
import collections
import datetime
import fnmatch
+import inspect
import json
import logging
import re
import time
import uuid
-import itertools
from dateutil.relativedelta import relativedelta
from difflib import HtmlDiff
-from operator import itemgetter
import werkzeug
from lxml import etree
@@ -20,25 +21,23 @@ from lxml.etree import LxmlError
from lxml.builder import E
from odoo import api, fields, models, tools, _
-from odoo.exceptions import ValidationError
+from odoo.exceptions import ValidationError, AccessError
from odoo.http import request
from odoo.modules.module import get_resource_from_path, get_resource_path
from odoo.tools import config, ConstantMapping, pycompat, apply_inheritance_specs, locate_node
from odoo.tools.convert import _fix_multiple_roots
from odoo.tools.json import scriptsafe as json_scriptsafe
from odoo.tools.safe_eval import safe_eval
-from odoo.tools.view_validation import valid_view, get_attrs_field_names, field_is_editable
+from odoo.tools.view_validation import valid_view, get_variable_names, get_domain_identifiers, get_dict_asts
from odoo.tools.translate import xml_translate, TRANSLATED_ATTRS
from odoo.tools.image import image_data_uri
+from odoo.models import check_method_name
+from odoo.osv.expression import expression
_logger = logging.getLogger(__name__)
MOVABLE_BRANDING = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpath', 'data-oe-source-id']
-# First sort criterion for inheritance is priority, second is chronological order of installation
-# Note: natural _order has `name`, but only because that makes list browsing easier
-INHERIT_ORDER = 'priority,id'
-
def transfer_field_to_modifiers(field, modifiers):
default_values = {}
@@ -46,7 +45,7 @@ def transfer_field_to_modifiers(field, modifiers):
for attr in ('invisible', 'readonly', 'required'):
state_exceptions[attr] = []
default_values[attr] = bool(field.get(attr))
- for state, modifs in field.get("states",{}).items():
+ for state, modifs in field.get("states", {}).items():
for modif in modifs:
if default_values[modif[0]] != modif[1]:
state_exceptions[modif[0]].append(state)
@@ -58,7 +57,7 @@ def transfer_field_to_modifiers(field, modifiers):
modifiers[attr] = default_value
-def transfer_node_to_modifiers(node, modifiers, context=None, in_tree_view=False):
+def transfer_node_to_modifiers(node, modifiers, context=None, current_node_path=None):
# Don't deal with groups, it is done by check_group().
# Need the context to evaluate the invisible attribute on tree views.
# For non-tree views, the context shouldn't be given.
@@ -75,7 +74,7 @@ def transfer_node_to_modifiers(node, modifiers, context=None, in_tree_view=False
for a in ('invisible', 'readonly', 'required'):
if node.get(a):
v = bool(safe_eval(node.get(a), {'context': context or {}}))
- if in_tree_view and a == 'invisible':
+ if 'tree' in (current_node_path or ()) and a == 'invisible':
# Invisible in a tree view has a specific meaning, make it a
# new key in the modifiers attribute.
modifiers['column_invisible'] = v
@@ -133,7 +132,7 @@ class ViewCustom(models.Model):
return [(rec.id, rec.user_id.name) for rec in self]
@api.model
- def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
if name:
view_ids = self._search([('user_id', operator, name)] + (args or []), limit=limit, access_rights_uid=name_get_uid)
return models.lazy_name_get(self.browse(view_ids).with_user(name_get_uid))
@@ -237,8 +236,6 @@ class View(models.Model):
help="ID of the view defined in xml file")
groups_id = fields.Many2many('res.groups', 'ir_ui_view_group_rel', 'view_id', 'group_id',
string='Groups', help="If this field is empty, the view applies to all users. Otherwise, the view applies to the users of those groups only.")
- model_ids = fields.One2many('ir.model.data', 'res_id', string="Models", domain=[('model', '=', 'ir.ui.view')], auto_join=True)
-
mode = fields.Selection([('primary', "Base view"), ('extension', "Extension View")],
string="View inheritance mode", default='primary', required=True,
help="""Only applies if this view inherits from an other one (inherit_id is not False/Null).
@@ -351,7 +348,7 @@ actual arch.
match = TRANSLATED_ATTRS_RE.search(node.get('expr', ''))
if match:
message = "View inheritance may not use attribute %r as a selector." % match.group(1)
- self.raise_view_error(message, self.id)
+ self.handle_view_error(message)
if WRONGCLASS.search(node.get('expr', '')):
_logger.warning(
"Error-prone use of @class in view %s (%s): use the "
@@ -362,20 +359,13 @@ actual arch.
for attr in TRANSLATED_ATTRS:
if node.get(attr):
message = "View inheritance may not use attribute %r as a selector." % attr
- self.raise_view_error(message, self.id)
+ self.handle_view_error(message)
return True
- def _check_groups_validity(self, view, view_name):
- for node in view.xpath('//*[@groups]'):
- for group in node.get('groups').replace('!', '').split(','):
- if not self.env.ref(group.strip(), raise_if_not_found=False):
- _logger.warning("The group %s defined in view %s does not exist!", group, view_name)
-
@api.constrains('arch_db')
def _check_xml(self):
# Sanity checks: the view should not break anything upon rendering!
# Any exception raised below will cause a transaction rollback.
- self = self.with_context(check_field_names=True)
for view in self:
if not view.arch:
continue
@@ -385,12 +375,12 @@ actual arch.
view_arch_utf8 = view_def['arch']
if view.type != 'qweb':
view_doc = etree.fromstring(view_arch_utf8)
- self._check_groups_validity(view_doc, view.name)
# verify that all fields used are valid, etc.
try:
- self.postprocess_and_fields(view.model, view_doc, view.id)
+ view.postprocess_and_fields(view_doc, validate=True)
except ValueError as e:
raise ValidationError("%s\n\n%s" % (_("Error while validating view"), tools.ustr(e)))
+
# RNG-based validation is not possible anymore with 7.0 forms
view_docs = [view_doc]
if view_docs[0].tag == 'data':
@@ -526,53 +516,77 @@ actual arch.
# Inheritance mecanism
#------------------------------------------------------
@api.model
- def _get_inheriting_views_arch_domain(self, view_id, model):
+ def _get_inheriting_views_arch_domain(self, model):
return [
- ['inherit_id', '=', view_id],
['model', '=', model],
['mode', '=', 'extension'],
['active', '=', True],
]
- @api.model
- def get_inheriting_views_arch(self, view_id, model):
- """Retrieves the architecture of views that inherit from the given view, from the sets of
- views that should currently be used in the system. During the module upgrade phase it
+ def get_inheriting_views_arch(self, model):
+ """Retrieves the sets of views that should currently be used in the
+ system in the right order. During the module upgrade phase it
may happen that a view is present in the database but the fields it relies on are not
fully loaded yet. This method only considers views that belong to modules whose code
is already loaded. Custom views defined directly in the database are loaded only
after the module initialization phase is completely finished.
- :param int view_id: id of the view whose inheriting views should be retrieved
:param str model: model identifier of the inheriting views.
- :rtype: list of tuples
- :return: [(view_arch,view_id), ...]
+ :return: list of ir.ui.view
"""
- user_groups = self.env.user.groups_id
- conditions = self._get_inheriting_views_arch_domain(view_id, model)
-
- if self.pool._init and not self._context.get('load_all_views'):
- # Module init currently in progress, only consider views from
- # modules whose code is already loaded
-
- # Search terms inside an OR branch in a domain
- # cannot currently use relationships that are
- # not required. The root cause is the INNER JOIN
- # used to implement it.
- modules = tuple(self.pool._init_modules) + (self._context.get('install_module'),)
- views = self.search(conditions + [('model_ids.module', 'in', modules)])
- views_cond = [('id', 'in', list(self._context.get('check_view_ids') or (0,)) + views.ids)]
- views = self.search(conditions + views_cond, order=INHERIT_ORDER)
- else:
- views = self.search(conditions, order=INHERIT_ORDER)
+ self.ensure_one()
+ self.check_access_rights('read')
- return [(view.arch, view.id)
- for view in views.sudo()
- if not view.groups_id or (view.groups_id & user_groups)]
+ # retrieve all the views transitively inheriting from view_id
+ domain = self._get_inheriting_views_arch_domain(model)
+ e = expression(domain, self.env['ir.ui.view'])
+ where_clause, where_params = e.to_sql()
+ query = """
+ WITH RECURSIVE ir_ui_view_inherits AS (
+ SELECT id, inherit_id, priority
+ FROM ir_ui_view
+ WHERE inherit_id = %s AND {where_clause}
+ UNION
+ SELECT iuv.id, iuv.inherit_id, iuv.priority
+ FROM ir_ui_view iuv
+ INNER JOIN ir_ui_view_inherits iuvi ON iuvi.id = iuv.inherit_id
+ WHERE {sub_where_clause}
+ )
+ SELECT id
+ FROM ir_ui_view_inherits
+ ORDER BY priority, id;
+ """.format(
+ where_clause=where_clause,
+ sub_where_clause=where_clause.replace('ir_ui_view', 'iuv'),
+ )
+ self.env.cr.execute(query, [self.id] + where_params + where_params)
+ view_ids = [r[0] for r in self.env.cr.fetchall()]
- @api.model
- def raise_view_error(self, message, view_id):
- view = self.browse(view_id)
+ if self.pool._init and not self._context.get('load_all_views'):
+ # check that all found ids have a corresponding xml_id in a loaded module
+ check_view_ids = self._context.get('check_view_ids') or []
+ ids_to_check = [vid for vid in view_ids if vid not in check_view_ids]
+ if ids_to_check:
+ loaded_modules = tuple(self.pool._init_modules) + (self._context.get('install_module'),)
+ query = """
+ SELECT res_id FROM ir_model_data
+ WHERE res_id IN %s AND model = 'ir.ui.view' AND module IN %s
+ """
+ self.env.cr.execute(query, [tuple(ids_to_check), loaded_modules])
+ valid_view_ids = [r[0] for r in self.env.cr.fetchall()] + check_view_ids
+ view_ids = [vid for vid in view_ids if vid in valid_view_ids]
+
+ def accessible(view):
+ return not view.groups_id or (view.groups_id & self.env.user.groups_id)
+
+ return self.browse(view_ids).sudo().filtered(accessible)
+
+ def handle_view_error(self, message, raise_exception=True):
+ """ Handle a view error by raising an exception or logging a warning,
+ depending on the value of `raise_exception`.
+ """
+ if self:
+ self.ensure_one()
not_avail = _('n/a')
message = (
"%(msg)s\n\n" +
@@ -580,15 +594,18 @@ actual arch.
"\n[view_id: %(viewid)s, xml_id: %(xmlid)s, "
"model: %(model)s, parent_id: %(parent)s]"
) % {
- 'view_name': view.name or not_avail,
- 'viewid': view_id or not_avail,
- 'xmlid': view.xml_id or not_avail,
- 'model': view.model or not_avail,
- 'parent': view.inherit_id.id or not_avail,
+ 'view_name': self.name or not_avail,
+ 'viewid': self.id or not_avail,
+ 'xmlid': self.xml_id or not_avail,
+ 'model': self.model or not_avail,
+ 'parent': self.inherit_id.id or not_avail,
'msg': message,
}
- _logger.info(message)
- raise ValueError(message)
+ if raise_exception:
+ _logger.info(message)
+ raise ValueError(message) from None
+ else:
+ _logger.warning(message)
def locate_node(self, arch, spec):
""" Locate a node in a source (parent) architecture.
@@ -605,20 +622,20 @@ actual arch.
"""
return locate_node(arch, spec)
- def inherit_branding(self, specs_tree, view_id, root_id):
+ def inherit_branding(self, specs_tree):
for node in specs_tree.iterchildren(tag=etree.Element):
xpath = node.getroottree().getpath(node)
if node.tag == 'data' or node.tag == 'xpath' or node.get('position') or node.get('t-field'):
- self.inherit_branding(node, view_id, root_id)
+ self.inherit_branding(node)
else:
- node.set('data-oe-id', str(view_id))
+ node.set('data-oe-id', str(self.id))
node.set('data-oe-xpath', xpath)
node.set('data-oe-model', 'ir.ui.view')
node.set('data-oe-field', 'arch')
return specs_tree
@api.model
- def apply_inheritance_specs(self, source, specs_tree, inherit_id, pre_locate=lambda s: True):
+ def apply_inheritance_specs(self, source, specs_tree, pre_locate=lambda s: True):
""" Apply an inheriting view (a descendant of the base view)
Apply to a source architecture all the spec nodes (i.e. nodes
@@ -627,7 +644,6 @@ actual arch.
:param Element source: a parent architecture to modify
:param Elepect specs_tree: a modifying architecture in an inheriting view
- :param inherit_id: the database id of specs_arch
:param (optional) pre_locate: function that is execute before locating a node.
This function receives an arch as argument.
:return: a modified source where the specs are applied
@@ -636,33 +652,37 @@ actual arch.
# Queue of specification nodes (i.e. nodes describing where and
# changes to apply to some parent architecture).
try:
- source = apply_inheritance_specs(source, specs_tree,
- inherit_branding=self._context.get('inherit_branding'),
- pre_locate=pre_locate)
+ source = apply_inheritance_specs(
+ source, specs_tree,
+ inherit_branding=self._context.get('inherit_branding'),
+ pre_locate=pre_locate,
+ )
except ValueError as e:
- self.raise_view_error(str(e), inherit_id)
+ self.handle_view_error(str(e))
return source
- @api.model
- def apply_view_inheritance(self, source, source_id, model, root_id=None):
+ def apply_view_inheritance(self, source, model):
""" Apply all the (directly and indirectly) inheriting views.
:param source: a parent architecture to modify (with parent modifications already applied)
- :param source_id: the database view_id of the parent view
:param model: the original model for which we create a view (not
necessarily the same as the source's model); only the inheriting
views with that specific model will be applied.
:return: a modified source where all the modifying architecture are applied
"""
- if root_id is None:
- root_id = source_id
- sql_inherit = self.get_inheriting_views_arch(source_id, model)
- for (specs, view_id) in sql_inherit:
- specs_tree = etree.fromstring(specs.encode('utf-8'))
+ inherit_tree = collections.defaultdict(list)
+ for view in self.get_inheriting_views_arch(model):
+ inherit_tree[view.inherit_id].append(view)
+ return self._apply_view_inheritance(source, inherit_tree)
+
+ def _apply_view_inheritance(self, source, inherit_tree):
+ # recursively apply inheritance following the given inheritance tree
+ for view in inherit_tree[self]:
+ arch_tree = etree.fromstring(view.arch.encode('utf-8'))
if self._context.get('inherit_branding'):
- self.inherit_branding(specs_tree, view_id, root_id)
- source = self.apply_inheritance_specs(source, specs_tree, view_id)
- source = self.apply_view_inheritance(source, view_id, model, root_id=root_id)
+ view.inherit_branding(arch_tree)
+ source = view.apply_inheritance_specs(source, arch_tree)
+ source = view._apply_view_inheritance(source, inherit_tree)
return source
def read_combined(self, fields=None):
@@ -706,171 +726,76 @@ actual arch.
arch_tree = view_arch
else:
if self._context.get('inherit_branding'):
- self.inherit_branding(view_arch, root.id, root.id)
+ root.inherit_branding(view_arch)
parent_view = root.inherit_id.read_combined(fields=fields)
arch_tree = etree.fromstring(parent_view['arch'])
- arch_tree = self.apply_inheritance_specs(arch_tree, view_arch, parent_view['id'])
+ arch_tree = self.browse(parent_view['id']).apply_inheritance_specs(arch_tree, view_arch)
# and apply inheritance
- arch = self.apply_view_inheritance(arch_tree, root.id, self.model)
+ arch = root.apply_view_inheritance(arch_tree, self.model)
return dict(view_data, arch=etree.tostring(arch, encoding='unicode'))
- def _apply_group(self, model, node, modifiers, fields):
- """Apply group restrictions, may be set at view level or model level::
- * at view level this means the element should be made invisible to
- people who are not members
- * at model level (exclusively for fields, obviously), this means
- the field should be completely removed from the view, as it is
- completely unavailable for non-members
-
- :return: True if field should be included in the result of fields_view_get
+ def _apply_groups(self, node, name_manager, node_info):
+ #pylint: disable=unused-argument
+ """ Apply group restrictions: elements with a 'groups' attribute should
+ be made invisible to people who are not members.
"""
- Model = self.env[model]
-
- field_name = None
- if node.tag == "field":
- field_name = node.get("name")
- elif node.tag == "label":
- field_name = node.get("for")
- if field_name and field_name in Model._fields:
- field = Model._fields[field_name]
- if field.groups and not self.user_has_groups(groups=field.groups):
- node.getparent().remove(node)
- fields.pop(field_name, None)
- # no point processing view-level ``groups`` anymore, return
- return False
if node.get('groups'):
can_see = self.user_has_groups(groups=node.get('groups'))
if not can_see:
node.set('invisible', '1')
- modifiers['invisible'] = True
+ node_info['modifiers']['invisible'] = True
if 'attrs' in node.attrib:
del node.attrib['attrs'] # avoid making field visible later
del node.attrib['groups']
- return True
#------------------------------------------------------
# Postprocessing: translation, groups and modifiers
#------------------------------------------------------
# TODO: remove group processing from ir_qweb
#------------------------------------------------------
- @api.model
- def postprocess(self, model, node, view_id, in_tree_view, model_fields):
- """Return the description of the fields in the node.
+ def postprocess_and_fields(self, node, model=None, validate=False):
+ """ Return an architecture and a description of all the fields.
- In a normal call to this method, node is a complete view architecture
- but it is actually possible to give some sub-node (this is used so
- that the method can call itself recursively).
+ The field description combines the result of fields_get() and
+ postprocess().
- Originally, the field descriptions are drawn from the node itself.
- But there is now some code calling fields_get() in order to merge some
- of those information in the architecture.
+ :param self: the view to postprocess
+ :param node: the architecture as an etree
+ :param model: the view's reference model
+ :param validate: whether the view must be validated
+ :return: a tuple (arch, fields) where arch is the given node as a
+ string and fields is the description of all the fields.
"""
- result = False
- fields = {}
- children = True
- modifiers = {}
- Model = self.env[model]
+ if self:
+ self.ensure_one()
+ model = model or self.model
- if node.tag == 'field':
- if node.get('name'):
- attrs = {}
- field = Model._fields.get(node.get('name'))
- if field:
- editable = self.env.context.get('view_is_editable', True) and field_is_editable(field, node)
- children = False
- views = {}
- for f in node:
- if f.tag in ('form', 'tree', 'graph', 'kanban', 'calendar'):
- node.remove(f)
- xarch, xfields = self.with_context(
- base_model_name=model,
- view_is_editable=editable,
- ).postprocess_and_fields(field.comodel_name, f, view_id)
- views[str(f.tag)] = {
- 'arch': xarch,
- 'fields': xfields,
- }
- attrs = {'views': views}
- if field.comodel_name in self.env and field.type in ('many2one', 'many2many'):
- Comodel = self.env[field.comodel_name]
- node.set('can_create', 'true' if Comodel.check_access_rights('create', raise_exception=False) else 'false')
- node.set('can_write', 'true' if Comodel.check_access_rights('write', raise_exception=False) else 'false')
- fields[node.get('name')] = attrs
-
- field = model_fields.get(node.get('name'))
- if field:
- transfer_field_to_modifiers(field, modifiers)
+ arch, name_manager = self._postprocess_view(node, model, validate=validate)
+ # name_manager.final_check()
+ return arch, name_manager.available_fields
- elif node.tag == 'groupby':
- # groupby nodes should be considered as nested view because they may
- # contain fields on the comodel
- field = Model._fields.get(node.get('name'))
- if field:
- if field.type != 'many2one':
- self.raise_view_error(_("'groupby' tags can only target many2one (%(field)s)") % dict(field=field.name), view_id)
- attrs = fields.setdefault(node.get('name'), {})
- children = False
- # move all children nodes into a new node <groupby>
- groupby_node = E.groupby()
- for child in list(node):
- node.remove(child)
- groupby_node.append(child)
- # validate the new node as a nested view, and associate it to the field
- xarch, xfields = self.with_context(
- base_model_name=model,
- view_is_editable=False,
- ).postprocess_and_fields(field.comodel_name, groupby_node, view_id)
- attrs['views'] = {'groupby': {
- 'arch': xarch,
- 'fields': xfields,
- }}
-
- elif node.tag in ('form', 'tree'):
- result = Model.view_header_get(False, node.tag)
- if result:
- node.set('string', result)
- in_tree_view = node.tag == 'tree'
-
- elif node.tag == 'calendar':
- for additional_field in ('date_start', 'date_delay', 'date_stop', 'color', 'all_day'):
- if node.get(additional_field):
- fields[node.get(additional_field).split('.', 1)[0]] = {}
- for f in node:
- if f.tag == 'filter':
- fields[f.get('name')] = {}
-
- elif node.tag == 'search':
- searchpanel = [c for c in node if c.tag == 'searchpanel']
- if searchpanel:
- self.with_context(
- base_model_name=model,
- check_field_names=False, # field validation is a bit more tricky and done apart
- view_is_editable=False,
- ).postprocess_and_fields(model, searchpanel[0], view_id)
-
- if not self._apply_group(model, node, modifiers, fields):
- # node must be removed, no need to proceed further with its children
- return fields
-
- # The view architeture overrides the python model.
- # Get the attrs before they are (possibly) deleted by check_group below
- transfer_node_to_modifiers(node, modifiers, self._context, in_tree_view)
+ def _postprocess_view(self, node, model, validate=True, editable=True):
- for f in node:
- if node.tag == 'search' and f.tag == 'searchpanel':
- # searchpanel part has to be validated independently
- continue
- if children or (node.tag == 'field' and f.tag in ('filter', 'separator')):
- fields.update(self.postprocess(model, f, view_id, in_tree_view, model_fields))
+ if model not in self.env:
+ self.handle_view_error(_('Model not found: %(model)s') % dict(model=model))
+
+ self._postprocess_on_change(model, node)
- transfer_modifiers_to_node(modifiers, node)
- return fields
+ name_manager = NameManager(validate, self.env[model])
+ self.postprocess(node, [], editable, name_manager)
- def add_on_change(self, model_name, arch):
+ name_manager.check_view_fields(self)
+ name_manager.update_view_fields()
+
+ self._postprocess_access_rights(model, node)
+
+ return etree.tostring(node, encoding="unicode").replace('\t', ''), name_manager
+
+ def _postprocess_on_change(self, model_name, arch):
""" Add attribute on_change="1" on fields that are dependencies of
computed fields on the same view.
"""
@@ -898,62 +823,6 @@ actual arch.
if not node.get('on_change'):
node.set('on_change', '1')
- return arch
-
- @api.model
- def postprocess_and_fields(self, model, node, view_id):
- """ Return an architecture and a description of all the fields.
-
- The field description combines the result of fields_get() and
- postprocess().
-
- :param node: the architecture as as an etree
- :return: a tuple (arch, fields) where arch is the given node as a
- string and fields is the description of all the fields.
-
- """
- fields = {}
- if model not in self.env:
- self.raise_view_error(_('Model not found: %(model)s') % dict(model=model), view_id)
- Model = self.env[model]
- fields = Model.fields_get(None)
-
- node = self.add_on_change(model, node)
-
- attrs_fields = []
- if self.env.context.get('check_field_names'):
- editable = self.env.context.get('view_is_editable', True)
- attrs_fields = get_attrs_field_names(self.env, node, Model, editable)
-
- fields_def = self.postprocess(model, node, view_id, False, fields)
- self._postprocess_access_rights(model, node)
-
- arch = etree.tostring(node, encoding="unicode").replace('\t', '')
- for k in list(fields):
- if k not in fields_def:
- del fields[k]
- for field in fields_def:
- if field in fields:
- fields[field].update(fields_def[field])
- else:
- message = _("Field `%(field_name)s` does not exist") % dict(field_name=field)
- self.raise_view_error(message, view_id)
-
- missing = [item for item in attrs_fields if item[0] not in fields]
- if missing:
- msg_lines = []
- msg_fmt = _("Field %r used in attributes must be present in view but is missing:")
- line_fmt = _(" - %r in %s=%r")
- for name, lines in itertools.groupby(sorted(missing), itemgetter(0)):
- if msg_lines:
- msg_lines.append("")
- msg_lines.append(msg_fmt % name)
- for line in lines:
- msg_lines.append(line_fmt % line)
- self.raise_view_error("\n".join(msg_lines), view_id)
-
- return arch, fields
-
def _postprocess_access_rights(self, model, node):
""" Compute and set on node access rights based on view type. Specific
views can add additional specific rights like creating columns for
@@ -968,19 +837,591 @@ actual arch.
not self._context.get(action, True) and is_base_model):
node.set(action, 'false')
- if node.tag in ('kanban',):
+ if node.tag == 'kanban':
group_by_name = node.get('default_group_by')
- if group_by_name in Model._fields:
- group_by_field = Model._fields[group_by_name]
- if group_by_field.type == 'many2one':
- group_by_model = Model.env[group_by_field.comodel_name]
- for action, operation in (('group_create', 'create'), ('group_delete', 'unlink'), ('group_edit', 'write')):
- if (not node.get(action) and
- not group_by_model.check_access_rights(operation, raise_exception=False) or
- not self._context.get(action, True) and is_base_model):
- node.set(action, 'false')
-
- return node
+ group_by_field = Model._fields.get(group_by_name)
+ if group_by_field and group_by_field.type == 'many2one':
+ group_by_model = Model.env[group_by_field.comodel_name]
+ for action, operation in (('group_create', 'create'), ('group_delete', 'unlink'), ('group_edit', 'write')):
+ if (not node.get(action) and
+ not group_by_model.check_access_rights(operation, raise_exception=False) or
+ not self._context.get(action, True) and is_base_model):
+ node.set(action, 'false')
+
+ def postprocess(self, node, current_node_path, editable, name_manager):
+ """ Process the given arch node, which may be the complete arch or some
+ subnode, and fill in the name manager with field information.
+ """
+ # compute default
+ tag = node.tag
+ parent = node.getparent()
+ node_info = dict(
+ modifiers={},
+ attr_model=name_manager.Model,
+ editable=editable,
+ )
+ current_node_path = current_node_path + [tag]
+
+ postprocessor = getattr(self, '_postprocess_tag_%s' % tag, False)
+ if postprocessor:
+ postprocessor(node, name_manager, node_info)
+ if node.getparent() is not parent:
+ # the node has been removed, stop processing here
+ return
+
+ elif tag in {item[0] for item in type(self.env['ir.ui.view']).type.selection}:
+ node_info['editable'] = False
+
+ if name_manager.validate:
+ # structure validation
+ validator = getattr(self, '_validate_tag_%s' % tag, False)
+ if validator:
+ validator(node, name_manager, node_info)
+ self._validate_attrs(node, name_manager, node_info)
+
+ self._apply_groups(node, name_manager, node_info)
+ transfer_node_to_modifiers(node, node_info['modifiers'], self._context, current_node_path)
+ transfer_modifiers_to_node(node_info['modifiers'], node)
+
+ # if present, iterate on node_info['children'] instead of node
+ for child in node_info.get('children', node):
+ self.postprocess(child, current_node_path, node_info['editable'], name_manager)
+
+ #------------------------------------------------------
+ # Specific node postprocessors
+ #------------------------------------------------------
+ def _postprocess_tag_calendar(self, node, name_manager, node_info):
+ for additional_field in ('date_start', 'date_delay', 'date_stop', 'color', 'all_day'):
+ if node.get(additional_field):
+ name_manager.has_field(node.get(additional_field).split('.', 1)[0], {})
+ for f in node:
+ if f.tag == 'filter':
+ name_manager.has_field(f.get('name'))
+ node_info['editable'] = False
+
+ def _postprocess_tag_field(self, node, name_manager, node_info):
+ if node.get('name'):
+ attrs = {'select': node.get('select')}
+ field = name_manager.Model._fields.get(node.get('name'))
+ if field:
+ # apply groups (no tested)
+ if field.groups and not self.user_has_groups(groups=field.groups):
+ node.getparent().remove(node)
+ # no point processing view-level ``groups`` anymore, return
+ return
+ node_info['editable'] = node_info['editable'] and field.is_editable() and (
+ node.get('readonly') not in ('1', 'True')
+ or get_dict_asts(node.get('attrs') or "{}")
+ )
+ if name_manager.validate:
+ name_manager.must_have_fields(
+ self._get_field_domain_variables(node, field, node_info['editable'])
+ )
+ views = {}
+ for child in node:
+ if child.tag in ('form', 'tree', 'graph', 'kanban', 'calendar'):
+ node.remove(child)
+ xarch, sub_name_manager = self.with_context(
+ base_model_name=name_manager.Model._name,
+ )._postprocess_view(
+ child, field.comodel_name, name_manager.validate,
+ editable=node_info['editable'],
+ )
+ name_manager.must_have_fields(sub_name_manager.mandatory_parent_fields)
+ views[child.tag] = {
+ 'arch': xarch,
+ 'fields': sub_name_manager.available_fields,
+ }
+ attrs['views'] = views
+ if field.comodel_name in self.env:
+ Comodel = self.env[field.comodel_name]
+ node_info['attr_model'] = Comodel
+ if field.type in ('many2one', 'many2many'):
+ can_create = Comodel.check_access_rights('create', raise_exception=False)
+ can_write = Comodel.check_access_rights('write', raise_exception=False)
+ node.set('can_create', 'true' if can_create else 'false')
+ node.set('can_write', 'true' if can_write else 'false')
+
+ name_manager.has_field(node.get('name'), attrs)
+ field = name_manager.fields_get.get(node.get('name'))
+ if field:
+ transfer_field_to_modifiers(field, node_info['modifiers'])
+
+ def _postprocess_tag_form(self, node, name_manager, node_info):
+ result = name_manager.Model.view_header_get(False, node.tag)
+ if result:
+ node.set('string', result)
+
+ def _postprocess_tag_groupby(self, node, name_manager, node_info):
+ # groupby nodes should be considered as nested view because they may
+ # contain fields on the comodel
+ name = node.get('name')
+ field = name_manager.Model._fields.get(name)
+ if not field or not field.comodel_name:
+ return
+ # move all children nodes into a new node <groupby>
+ groupby_node = E.groupby()
+ for child in list(node):
+ node.remove(child)
+ groupby_node.append(child)
+ # validate the new node as a nested view, and associate it to the field
+ xarch, sub_name_manager = self.with_context(
+ base_model_name=name_manager.Model._name,
+ )._postprocess_view(groupby_node, field.comodel_name, name_manager.validate, editable=False)
+ name_manager.has_field(name, {'views': {
+ 'groupby': {
+ 'arch': xarch,
+ 'fields': sub_name_manager.available_fields,
+ }
+ }})
+ name_manager.must_have_fields(sub_name_manager.mandatory_parent_fields)
+
+ def _postprocess_tag_label(self, node, name_manager, node_info):
+ if node.get('for'):
+ field = name_manager.Model._fields.get(node.get('for'))
+ if field and field.groups and not self.user_has_groups(groups=field.groups):
+ node.getparent().remove(node)
+
+ def _postprocess_tag_search(self, node, name_manager, node_info):
+ searchpanel = [child for child in node if child.tag == 'searchpanel']
+ if searchpanel:
+ self.with_context(
+ base_model_name=name_manager.Model._name,
+ )._postprocess_view(
+ searchpanel[0], name_manager.Model._name, name_manager.validate, editable=False,
+ )
+ node_info['children'] = [child for child in node if child.tag != 'searchpanel']
+ node_info['editable'] = False
+
+ def _postprocess_tag_tree(self, node, name_manager, node_info):
+ self._postprocess_tag_form(node, name_manager, node_info)
+ node_info['editable'] = node_info['editable'] and node.get('editable')
+
+ #------------------------------------------------------
+ # Node validator
+ #------------------------------------------------------
+ def _validate_tag_field(self, node, name_manager, node_info):
+ name = node.get('name')
+ if not name:
+ self.handle_view_error(_("Field tag must have a \"name\" attribute defined"))
+ field = name_manager.Model._fields.get(name)
+ if not field and name in name_manager.fields_get:
+ return
+ if not field:
+ msg = _('Field "%s" does not exist in model "%s"')
+ self.handle_view_error(msg % (node.get('name'), name_manager.Model._name))
+ if node.get('domain') and field.comodel_name not in self.env:
+ msg = _('Domain on field without comodel makes no sense for "%s" (domain:%s)')
+ self.handle_view_error(msg % (node.get('name'), node.get('domain')))
+
+ for attribute in ('invisible', 'readonly', 'required'):
+ val = node.get(attribute)
+ if val:
+ res = safe_eval(val, {'context': self._context})
+ if res not in (1, 0, True, False, None):
+ msg = _('Attribute %s evaluation must give a boolean, got %s')
+ self.handle_view_error(msg % (attribute, val))
+
+ def _validate_tag_button(self, node, name_manager, node_info):
+ name = node.get('name')
+ special = node.get('special')
+ type_ = node.get('type')
+ if special:
+ if special not in ('cancel', 'save', 'add'):
+ self.handle_view_error(_("Invalid special '%s' in button") % special)
+ elif type_:
+ if type_ == 'edit': # list_renderer, used in kanban view
+ return
+ elif not name:
+ self.handle_view_error(_("Button must have a name"))
+ elif type_ == 'object':
+ func = getattr(type(name_manager.Model), name, None)
+ if not func:
+ msg = _("%s is not a valid action on %s")
+ self.handle_view_error(msg % (name, name_manager.Model._name))
+ try:
+ check_method_name(name)
+ except AccessError:
+ msg = _("%s on %s is private and cannot be called from a button")
+ self.handle_view_error(msg % (name, name_manager.Model._name))
+ try:
+ inspect.signature(func).bind(self=name_manager.Model)
+ except TypeError:
+ msg = _("%s on %s has parameters and cannot be called from a button")
+ self.handle_view_error(msg % (name, name_manager.Model._name), raise_exception=False)
+ elif type_ == 'action':
+ # logic mimics /web/action/load behaviour
+ action = False
+ try:
+ action_id = int(name)
+ except ValueError:
+ model, action_id = self.env['ir.model.data'].xmlid_to_res_model_res_id(name, raise_if_not_found=False)
+ if not action_id:
+ msg = _("Invalid xmlid %s for button of type action.")
+ self.handle_view_error(msg % name)
+ if not issubclass(self.pool[model], self.pool['ir.actions.actions']):
+ msg = _("%s is of type %s, expected a subclass of ir.actions.actions")
+ self.handle_view_error(msg % (name, model))
+ action = self.env['ir.actions.actions'].browse(action_id).exists()
+ if not action:
+ msg = _("Action %s (id: %s) does not exist for button of type action.")
+ self.handle_view_error(msg % (name, action_id))
+
+ name_manager.has_action(name)
+ elif node.get('icon'):
+ description = 'A button with icon attribute (%s)' % node.get('icon')
+ self._validate_fa_class_accessibility(node, description)
+
+ def _validate_tag_graph(self, node, name_manager, node_info):
+ for child in node.iterchildren(tag=etree.Element):
+ if child.tag != 'field' and not isinstance(child, etree._Comment):
+ msg = _('A <graph> can only contains <field> nodes, found a <%s>')
+ self.handle_view_error(msg % child.tag)
+
+ def _validate_tag_groupby(self, node, name_manager, node_info):
+ # groupby nodes should be considered as nested view because they may
+ # contain fields on the comodel
+ name = node.get('name')
+ if name:
+ field = name_manager.Model._fields.get(name)
+ if field:
+ if field.type != 'many2one':
+ msg = _("field '%s' found in 'groupby' node can only be of type many2one, found %s")
+ self.handle_view_error(msg % (field.name, field.type))
+ name_manager.must_have_fields(
+ self._get_field_domain_variables(node, field, node_info['editable'])
+ )
+ else:
+ msg = _("field '%s' found in 'groupby' node does not exist in model %s")
+ self.handle_view_error(msg % (name, name_manager.Model._name))
+
+ def _validate_tag_tree(self, node, name_manager, node_info):
+ allowed_tags = ('field', 'button', 'control', 'groupby', 'widget')
+ for child in node.iterchildren(tag=etree.Element):
+ if child.tag not in allowed_tags and not isinstance(child, etree._Comment):
+ msg = _('Tree child can only be have one of %s tag (not %s)')
+ self.handle_view_error(msg % (', '.join(allowed_tags), child.tag))
+
+ def _validate_tag_search(self, node, name_manager, node_info):
+ if len([c for c in node if c.tag == 'searchpanel']) > 1:
+ self.handle_view_error(_('Search tag can only contains one search panel'))
+
+ def _validate_tag_searchpanel(self, node, name_manager, node_info):
+ for child in node.iterchildren(tag=etree.Element):
+ if child.get('domain') and child.get('select') != 'multi':
+ msg = _('Searchpanel item with select multi cannot have a domain.')
+ self.handle_view_error(msg)
+
+ def _validate_tag_label(self, node, name_manager, node_info):
+ # replace return not arch.xpath('//label[not(@for) and not(descendant::input)]')
+ for_ = node.get('for')
+ if not for_:
+ msg = _('Label tag must contain a "for". To match label style '
+ 'without corresponding field or button, use \'class="o_form_label"\'.')
+ self.handle_view_error(msg)
+ else:
+ name_manager.must_have_name(for_, 'label for') # this could be done in check_attr
+
+ def _validate_tag_page(self, node, name_manager, node_info):
+ if node.getparent() is None or node.getparent().tag != 'notebook':
+ self.handle_view_error(_('Page direct ancestor must be notebook'))
+
+ def _validate_tag_img(self, node, name_manager, node_info):
+ if not any(node.get(alt) for alt in self._att_list('alt')):
+ src = next((node.get(src) for src in self._att_list('src') if node.get(src)), "")
+ if src:
+ src = ' with src (%s)'
+ msg = _('<img> tag%s must contain an alt attribute')
+ self.handle_view_error(msg % src, raise_exception=False)
+
+ def _validate_tag_a(self, node, name_manager, node_info):
+ #('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
+ if any('btn' in node.get(cl, '') for cl in self._att_list('class')):
+ if node.get('role') != 'button':
+ msg = _('"<a>" tag with "btn" class must have "button" role')
+ self.handle_view_error(msg, raise_exception=False)
+
+ def _validate_tag_ul(self, node, name_manager, node_info):
+ self._check_dropdown_menu(node) # was applied to all node, but in practice, only used on div and ul
+
+ def _validate_tag_div(self, node, name_manager, node_info):
+ self._check_dropdown_menu(node)
+ self._check_progress_bar(node)
+
+ #------------------------------------------------------
+ # Validation tools
+ #------------------------------------------------------
+
+ def _check_dropdown_menu(self, node):
+ #('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
+ if any('dropdown-menu' in node.get(cl, '') for cl in self._att_list('class')):
+ if node.get('role') != 'menu':
+ msg = _('dropdown-menu class must have menu role')
+ self.handle_view_error(msg, raise_exception=False)
+
+ def _check_progress_bar(self, node):
+ if any('o_progressbar' in node.get(cl, '') for cl in self._att_list('class')):
+ if node.get('role') != 'progressbar':
+ msg = _('o_progressbar class must have progressbar role')
+ self.handle_view_error(msg, raise_exception=False)
+ if not any(node.get(at) for at in self._att_list('aria-valuenow')):
+ msg = _('o_progressbar class must have aria-valuenow attribute')
+ self.handle_view_error(msg, raise_exception=False)
+ if not any(node.get(at) for at in self._att_list('aria-valuemin')):
+ msg = _('o_progressbar class must have aria-valuemin attribute')
+ self.handle_view_error(msg, raise_exception=False)
+ if not any(node.get(at) for at in self._att_list('aria-valuemax')):
+ msg = _('o_progressbar class must have aria-valuemaxattribute')
+ self.handle_view_error(msg, raise_exception=False)
+
+ def _att_list(self, name):
+ return [name, 't-att-%s' % name, 't-attf-%s' % name]
+
+ def _validate_attrs(self, node, name_manager, node_info):
+ """ Generic validation of node attrs. """
+ Model = node_info['attr_model']
+
+ for attr, expr in node.items():
+ if attr == 'domain':
+ fields = self._get_server_domain_variables(expr, 'domain of <%s%s> ' % (node.tag, (' name="%s"' % node.get('name')) if node.get('name') else '' ), Model)
+ name_manager.must_have_fields(fields)
+
+ elif attr.startswith('decoration-'):
+ fields = dict.fromkeys(get_variable_names(expr), '%s=%s' % (attr, expr))
+ name_manager.must_have_fields(fields)
+
+ elif attr in ('attrs', 'context'):
+ for key, val_ast in get_dict_asts(expr).items():
+ if attr == 'attrs' and isinstance(val_ast, ast.List):
+ # domains in attrs are used for readonly, invisible, ...
+ # and thus are only executed client side
+ desc = '%s.%s' % (attr, key)
+ fields = self._get_client_domain_variables(val_ast, desc, expr)
+ name_manager.must_have_fields(fields)
+
+ elif key == 'group_by': # only in context
+ if not isinstance(val_ast, ast.Str):
+ msg = _('"group_by" value must be a string %s=%r')
+ self.handle_view_error(msg % (attr, expr))
+ group_by = val_ast.s
+ if not group_by.split(':')[0] in Model._fields:
+ msg = _('Unknow field "%s" in "group_by" value in %s=%r')
+ self.handle_view_error(msg % (group_by, attr, expr))
+
+ else:
+ use = '%s.%s (%s)' % (attr, key, expr)
+ fields = dict.fromkeys(get_variable_names(val_ast), use)
+ name_manager.must_have_fields(fields)
+
+ elif attr in ('col', 'colspan'):
+ # col check is mainly there for the tag 'group', but previous
+ # check was generic in view form
+ if not expr.isdigit():
+ self.handle_view_error(_('%r value must be an integer (%s)') % (attr, expr))
+
+ elif attr in ('class', 't-att-class', 't-attf-class'):
+ self._validate_classes(node, expr)
+
+ elif attr == 'groups':
+ key_description = '%s=%r' % (attr, expr)
+ for group in expr.replace('!', '').split(','):
+ # further improvement: add all groups to name_manager in
+ # order to batch check them at the end
+ if not self.env['ir.model.data'].xmlid_to_res_id(group.strip(), raise_if_not_found=False):
+ msg = _("The group %r defined in view does not exist!") % group
+ self.handle_view_error(msg, raise_exception=False)
+
+ elif attr == 'group':
+ msg = _("attribute 'group' is not valid. Did you mean 'groups'?")
+ self.handle_view_error(msg, raise_exception=False)
+
+ elif attr == 'data-toggle' and expr == 'tab':
+ if node.get('role') != 'tab':
+ msg = _('tab link (data-toggle="tab") must have "tab" role')
+ self.handle_view_error(msg, raise_exception=False)
+ aria_control = node.get('aria-controls') or node.get('t-att-aria-controls')
+ if not aria_control and not node.get('t-attf-aria-controls'):
+ msg = _('tab link (data-toggle="tab") must have "aria_control" defined')
+ self.handle_view_error(msg, raise_exception=False)
+ if aria_control and '#' in aria_control:
+ msg = _('aria-controls in tablink cannot contains "#"')
+ self.handle_view_error(msg, raise_exception=False)
+
+ elif attr == "role" and expr in ('presentation', 'none'):
+ msg = _("A role cannot be `none` or `presentation`. "
+ "All your elements must be accessible with screen readers, describe it.")
+ self.handle_view_error(msg, raise_exception=False)
+
+ def _validate_classes(self, node, expr):
+ """ Validate the classes present on node. """
+ classes = set(expr.split(' '))
+ # Be careful: not always true if it is an expression
+ # example: <div t-attf-class="{{!selection_mode ? 'oe_kanban_color_' + kanban_getcolor(record.color.raw_value) : ''}} oe_kanban_card oe_kanban_global_click oe_applicant_kanban oe_semantic_html_override">
+ if 'modal' in classes and node.get('role') != 'dialog':
+ msg = _('"modal" class should only be used with "dialog" role')
+ self.handle_view_error(msg, raise_exception=False)
+
+ if 'modal-header' in classes and node.tag != 'header':
+ msg = _('"modal-header" class should only be used in "header" tag')
+ self.handle_view_error(msg, raise_exception=False)
+
+ if 'modal-body' in classes and node.tag != 'main':
+ msg = _('"modal-body" class should only be used in "main" tag')
+ self.handle_view_error(msg, raise_exception=False)
+
+ if 'modal-footer' in classes and node.tag != 'footer':
+ msg = _('"modal-footer" class should only be used in "footer" tag')
+ self.handle_view_error(msg, raise_exception=False)
+
+ if 'tab-pane' in classes and node.get('role') != 'tabpanel':
+ msg = _('"tab-pane" class should only be used with "tabpanel" role')
+ self.handle_view_error(msg, raise_exception=False)
+
+ if 'nav-tabs' in classes and node.get('role') != 'tablist':
+ msg = _('A tab list with class nav-tabs must have role="tablist"')
+ self.handle_view_error(msg, raise_exception=False)
+
+ if any(klass.startswith('alert-') for klass in classes):
+ if (
+ node.get('role') not in ('alert', 'alertdialog', 'status')
+ and 'alert-link' not in classes
+ ):
+ msg = _("An alert (class alert-*) must have an alert, alertdialog or "
+ "status role or an alert-link class. Please use alert and "
+ "alertdialog only for what expects to stop any activity to "
+ "be read immediately.")
+ self.handle_view_error(msg, raise_exception=False)
+
+ if any(klass.startswith('fa-') for klass in classes):
+ description = 'A <%s> with fa class (%s)' % (node.tag, expr)
+ self._validate_fa_class_accessibility(node, description)
+
+ if any(klass.startswith('btn') for klass in classes):
+ if node.tag in ('a', 'button', 'select'):
+ pass
+ elif node.tag == 'input' and node.get('type') in ('button', 'submit', 'reset'):
+ pass
+ elif any(klass in classes for klass in ('btn-group', 'btn-toolbar', 'btn-ship')):
+ pass
+ else:
+ msg = _("A simili button must be in tag a/button/select or tag `input` "
+ "with type button/submit/reset or have class in "
+ "btn-group/btn-toolbar/btn-ship")
+ self.handle_view_error(msg, raise_exception=False)
+
+ def _validate_fa_class_accessibility(self, node, description):
+ valid_aria_attrs = set(
+ self._att_list('title')
+ + self._att_list('aria-label')
+ + self._att_list('aria-labelledby')
+ )
+ valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'}
+
+ ## Following or preceding text
+ if (node.tail or '').strip() or (node.getparent().text or '').strip():
+ # text<i class="fa-..."/> or <i class="fa-..."/>text or
+ return
+
+ ## Following or preceding text in span
+ def has_text(elem):
+ if elem is None:
+ return False
+ if elem.tag == 'span' and elem.text:
+ return True
+ if elem.tag == 't' and (elem.get('t-esc') or elem.get('t-raw')):
+ return True
+ return False
+
+ if has_text(node.getnext()) or has_text(node.getprevious()):
+ return
+
+ ## Aria label can be on ancestors
+ def has_title_or_aria_label(node):
+ return any(node.get(attr) for attr in valid_aria_attrs)
+
+ parent = node.getparent()
+ while parent is not None:
+ if has_title_or_aria_label(parent):
+ return
+ parent = parent.getparent()
+
+ ## And we ignore all elements with describing in children
+ def contains_description(node, depth=0):
+ if depth > 2:
+ _logger.warning('excessive depth in fa')
+ if any(node.get(attr) for attr in valid_t_attrs):
+ return True
+ if has_title_or_aria_label(node):
+ return True
+ if node.tag in ('label', 'field'):
+ return True
+ if node.tag == 'button' and node.get('string'):
+ return True
+ if node.text: # not sure, does it match *[text()]
+ return True
+ return any(contains_description(child, depth+1) for child in node)
+
+ if contains_description(node):
+ return
+
+ msg = _('%s must have title in its tag, parents, descendants or have text')
+ self.handle_view_error(msg % description, raise_exception=False)
+
+ def _get_client_domain_variables(self, domain, key, expr):
+ """ Returns all field and variable names present in the given domain
+ (to be used client-side).
+ """
+ try:
+ (field_names, var_names) = get_domain_identifiers(domain)
+ except ValueError:
+ msg = _('Invalid domain format while checking %s in %s') % (expr, key)
+ self.handle_view_error(msg)
+
+ return dict.fromkeys(field_names | var_names, '%s (%s)' % (key, expr))
+
+ def _get_server_domain_variables(self, domain, key, Model):
+ """ Returns all the variable names present in the given domain (to be
+ used server-side).
+ """
+ try:
+ (field_names, var_names) = get_domain_identifiers(domain)
+ except ValueError:
+ msg = _('Invalid domain format while checking %s in %s')
+ self.handle_view_error(msg % (domain, key))
+
+ # checking field names
+ for name_seq in field_names:
+ fnames = name_seq.split('.')
+ model = Model
+ try:
+ for fname in fnames:
+ if not isinstance(model, models.BaseModel):
+ msg = _('Trying to access "%s" on %s in path %r in %s=%r')
+ self.handle_view_error(msg % (fname, model, name_seq, key, domain))
+ field = model._fields[fname]
+ if not field._description_searchable:
+ msg = _('Unsearchable field "%s" in path %r in %s=%r')
+ self.handle_view_error(msg % (field, name_seq, key, domain))
+ model = model[fname]
+ except KeyError:
+ msg = _('Unknow field "%s.%s" in %s%r')
+ self.handle_view_error(msg % (model._name, fname, key, domain))
+
+ return dict.fromkeys(var_names, "%s (%s)" % (key, domain))
+
+ def _get_field_domain_variables(self, node, field, editable):
+ """ Return the variable names present in the field's domain, if no
+ domain is given on the node itself.
+ """
+ if editable and not node.get('domain') and field.relational:
+ domain = field._description_domain(self.env)
+ if isinstance(domain, str):
+ return self._get_server_domain_variables(
+ domain,
+ 'field %s default domain' % field.name,
+ self.env[field.comodel_name],
+ )
+ return {}
#------------------------------------------------------
# QWeb template views
@@ -1208,7 +1649,7 @@ actual arch.
try:
view._check_xml()
except Exception as e:
- self.raise_view_error("Can't validate view:\n%s" % e, view.id)
+ view.handle_view_error("Can't validate view:\n%s" % e)
class ResetViewArchWizard(models.TransientModel):
@@ -1283,3 +1724,73 @@ class ResetViewArchWizard(models.TransientModel):
self.ensure_one()
self.view_id.reset_arch(self.reset_mode)
return {'type': 'ir.actions.act_window_close'}
+
+
+class NameManager:
+ """ An object that manages all the named elements in a view. """
+
+ def __init__(self, validate, Model):
+ self.available_fields = collections.defaultdict(dict)
+ self.mandatory_fields = dict()
+ self.mandatory_parent_fields = dict()
+ self.available_actions = set()
+ self.mandatory_names = dict()
+ self.validate = validate
+ self.Model = Model
+ self.fields_get = self.Model.fields_get()
+
+ def has_field(self, name, info=()):
+ self.available_fields[name].update(info)
+
+ def has_action(self, name):
+ self.available_actions.add(name)
+
+ def must_have_field(self, name, use):
+ if name.startswith('parent.'):
+ self.mandatory_parent_fields[name[7:]] = use
+ else:
+ self.mandatory_fields[name] = use
+
+ def must_have_fields(self, name_uses):
+ for name, use in name_uses.items():
+ self.must_have_field(name, use)
+
+ def must_have_name(self, name, use):
+ self.mandatory_names[name] = use
+
+ def final_check(self):
+ if self.mandatory_fields:
+ msg = []
+ for field in self.mandatory_fields:
+ msg.append(str(field))
+ _logger.error("All parent.field should have been consummed at root level. \n %s", '\n'.join(msg))
+
+ def check_view_fields(self, view):
+ if not self.validate:
+ return
+
+ for action, use in self.mandatory_names.items():
+ if action not in self.available_actions and action not in self.available_fields:
+ view.handle_view_error("Name '%s' used in '%s' must be present in view but is missing." % (action, use))
+
+ for field_name in self.available_fields:
+ if field_name not in self.fields_get:
+ message = _("Field `%s` does not exist") % field_name
+ view.handle_view_error(message)
+
+ for field, use in self.mandatory_fields.items():
+ if field == 'id': # always available
+ continue
+ if "." in field:
+ view.handle_view_error('Invalid composed field %s in %s' % (field, use))
+ corresponding_field = self.available_fields.get(str(field))
+ if corresponding_field is None:
+ view.handle_view_error('Field %s used in %s must be present in view but is missing.' % (field, use))
+ if corresponding_field.get('select') == 'multi': # mainly for searchpanel, but can be a generic behaviour.
+ view.handle_view_error('Field %s used in %s is present in view but is in select multi.' % (field, use))
+
+ def update_view_fields(self):
+ for field_name, field_infos in self.available_fields.items():
+ model_field_infos = self.fields_get.get(field_name)
+ if model_field_infos:
+ field_infos.update(model_field_infos)
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index 2e5b7e77697..90393b0148d 100644
--- a/odoo/addons/base/models/res_partner.py
+++ b/odoo/addons/base/models/res_partner.py
@@ -59,7 +59,7 @@ class FormatAddressMixin(models.AbstractModel):
#(e.g fields not present on the model). In that case we just return arch
if self._name != 'res.partner':
try:
- self.env['ir.ui.view'].postprocess_and_fields(self._name, sub_view_node, None)
+ self.env['ir.ui.view'].postprocess_and_fields(sub_view_node, model=self._name)
except ValueError:
return arch
address_node.getparent().replace(address_node, sub_view_node)
diff --git a/odoo/addons/base/tests/__init__.py b/odoo/addons/base/tests/__init__.py
index e22f21be75c..f9e248f4544 100644
--- a/odoo/addons/base/tests/__init__.py
+++ b/odoo/addons/base/tests/__init__.py
@@ -35,7 +35,6 @@ from . import test_search
from . import test_translate
#import test_uninstall # loop
from . import test_user_has_group
-from . import test_view_validation
from . import test_views
from . import test_xmlrpc
from . import test_res_partner
diff --git a/odoo/addons/base/tests/test_view_validation.py b/odoo/addons/base/tests/test_view_validation.py
deleted file mode 100644
index 6a9d701cdb3..00000000000
--- a/odoo/addons/base/tests/test_view_validation.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from lxml import etree
-import io
-
-from odoo.tests.common import BaseCase
-from odoo.tools.view_validation import (
- valid_page_in_book, valid_att_in_form, valid_type_in_colspan,
- valid_type_in_col, valid_att_in_field, valid_att_in_label,
- valid_field_in_graph, valid_field_in_tree, valid_alternative_image_text,
- valid_simili_button,
- valid_simili_progressbar, valid_dialog, valid_simili_dropdown,
- valid_focusable_button, valid_prohibited_none_role, valid_simili_tabpanel,
- valid_simili_tab, valid_simili_tablist, valid_alerts
-)
-
-invalid_form = etree.parse(io.BytesIO(b'''\
-<form>
- <label></label>
- <ul class="dropdown-menu"><li/><li/></ul>
- <div role="presentation"/>
- <group>
- <div>
- <page></page>
- <label colspan="True" string=""></label>
- <field></field>
- </div>
- </group>
- <notebook>
- <page>
- <group col="Two">
- <div>
- <div class="o_progressbar">100%</div>
- <label string=""></label>
- <img/>
- <span class="fa fa-warning"/>
- <field colspan="Five"> </field>
- </div>
- </group>
- <a class="btn"/>
- <div class="btn"/>
- <div class="tab-pane"/>
- </page>
- </notebook>
- <div class="modal"/>
- <a data-toggle="tab"/>
- <div class="nav-tabs"/>
- <div class="alert alert-success"/>
-</form>
-''')).getroot()
-
-valid_form = etree.parse(io.BytesIO(b'''\
-<form string="">
- <field name=""></field>
- <field name=""></field>
- <ul class="dropdown-menu" role="menu"></ul>
- <notebook>
- <page>
- <field name=""></field>
- <label for="" string=""></label>
- <field name=""></field>
- </page>
- <page>
- <group colspan="5" col="2">
- <div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemin="0" aria-valuemax="100">14%</div>
- <label for=""></label>
- <label for="" string="" colspan="5"></label>
- <img alt="Test image"/>
- <span class="fa fa-success" aria-label="Test span" title="Test span"/>
- <a class="fa fa-success"><span aria-label="test" title="test"/></a>
- <a class="btn" role="button"/>
- <i class="fa fa-check"/> Test icon
- <i class="fa fa-check"/>
- </group>
- </page>
- </notebook>
- <div role="dialog" class="modal">
- <header class="modal-header"/>
- <main class="modal-body"/>
- <i class="fa fa-check"/> <span>Test</span>
- <footer class="modal-footer"/>
- </div>
- <div class="tab-pane" role="tabpanel"/>
- <a data-toggle="tab" role="tab" aria-selected="true" aria-controls="test"/>
- <div class="nav-tabs" role="tablist"/>
- <div class="alert alert-success" role="alert"/>
- <div class="alert alert-success" role="alertdialog"/>
- <div class="alert alert-success" role="status"/>
-</form>
-''')).getroot()
-
-invalid_graph = etree.parse(io.BytesIO(b'''\
-<graph>
- <label/>
- <group>
- <div>
- <field></field>
- <field></field>
- </div>
- </group>
-</graph>
-''')).getroot()
-
-valid_graph = etree.parse(io.BytesIO(b'''\
-<graph string="">
- <field name=""></field>
- <field name=""></field>
-</graph>
-''')).getroot()
-
-invalid_tree = etree.parse(io.BytesIO(b'''\
-<tree>
- <group>
- <div>
- <field></field>
- <field></field>
- </div>
- </group>
-</tree>
-''')).getroot()
-
-valid_tree = etree.parse(io.BytesIO(b'''\
-<tree string="">
- <field name=""></field>
- <field name=""></field>
- <button/>
- <field name=""></field>
-</tree>
-''')).getroot()
-
-
-class TestViewValidation(BaseCase):
- """ Test the view validation code (but not the views themselves). """
-
- def test_page_validation(self):
- assert not valid_page_in_book(invalid_form)
- assert valid_page_in_book(valid_form)
-
- def test_all_field_validation(self):
- assert not valid_att_in_field(invalid_form)
- assert valid_att_in_field(valid_form)
-
- def test_all_label_validation(self):
- assert not valid_att_in_label(invalid_form)
- assert valid_att_in_label(valid_form)
-
- def test_form_string_validation(self):
- assert valid_att_in_form(valid_form)
-
- def test_graph_validation(self):
- assert not valid_field_in_graph(invalid_graph)
- assert valid_field_in_graph(valid_graph)
-
- def test_tree_validation(self):
- assert not valid_field_in_tree(invalid_tree)
- assert valid_field_in_tree(valid_tree)
-
- def test_colspan_datatype_validation(self):
- assert not valid_type_in_colspan(invalid_form)
- assert valid_type_in_colspan(valid_form)
-
- def test_col_datatype_validation(self):
- assert not valid_type_in_col(invalid_form)
- assert valid_type_in_col(valid_form)
-
- def test_a11y_validation(self):
- assert valid_alternative_image_text(invalid_form) == "Warning"
- assert valid_alternative_image_text(valid_form) is True
- assert valid_simili_button(invalid_form) == "Warning"
- assert valid_simili_button(valid_form) is True
- assert valid_dialog(invalid_form) == "Warning"
- assert valid_dialog(valid_form) is True
- assert valid_simili_dropdown(invalid_form) == "Warning"
- assert valid_simili_dropdown(valid_form) is True
- assert valid_simili_progressbar(invalid_form) == "Warning"
- assert valid_simili_progressbar(valid_form) is True
- assert valid_simili_tabpanel(invalid_form) == "Warning"
- assert valid_simili_tabpanel(valid_form) is True
- assert valid_simili_tablist(invalid_form) == "Warning"
- assert valid_simili_tablist(valid_form) is True
- assert valid_simili_tab(invalid_form) == "Warning"
- assert valid_simili_tab(valid_form) is True
- assert valid_focusable_button(invalid_form) == "Warning"
- assert valid_focusable_button(valid_form) is True
- assert valid_prohibited_none_role(invalid_form) == "Warning"
- assert valid_prohibited_none_role(valid_form) is True
- assert valid_alerts(invalid_form) == "Warning"
- assert valid_alerts(valid_form) is True
diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py
index 942ff2422db..84c08d6603a 100644
--- a/odoo/addons/base/tests/test_views.py
+++ b/odoo/addons/base/tests/test_views.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import ast
from functools import partial
import logging
@@ -10,7 +11,7 @@ from psycopg2 import IntegrityError
from odoo.exceptions import ValidationError
from odoo.tests import common
-from odoo.tools import mute_logger
+from odoo.tools import mute_logger, view_validation
from odoo.addons.base.models.ir_ui_view import (
transfer_field_to_modifiers, transfer_node_to_modifiers, simplify_modifiers,
)
@@ -171,7 +172,7 @@ class TestViewInheritance(ViewCase):
'inherit_id': parent,
'priority': 5, # higher than default views
})
- self.view_ids[name] = view.id
+ self.view_ids[name] = view
return view
def setUp(self):
@@ -182,42 +183,35 @@ class TestViewInheritance(ViewCase):
self.model = 'ir.ui.view.custom'
self.view_ids = {}
- a = self.makeView("A")
- a1 = self.makeView("A1", a.id)
- a11 = self.makeView("A11", a1.id)
- self.makeView("A111", a11.id)
- self.makeView("A12", a1.id)
- a2 = self.makeView("A2", a.id)
- self.makeView("A21", a2.id)
- a22 = self.makeView("A22", a2.id)
- self.makeView("A221", a22.id)
-
- b = self.makeView('B', arch=self.arch_for("B", 'tree'))
- self.makeView('B1', b.id, arch=self.arch_for("B1", 'tree', parent=b))
- c = self.makeView('C', arch=self.arch_for("C", 'tree'))
- c.write({'priority': 1})
+ self.a = self.makeView("A")
+ self.a1 = self.makeView("A1", self.a.id)
+ self.a2 = self.makeView("A2", self.a.id)
+ self.a11 = self.makeView("A11", self.a1.id)
+ self.a11.mode = 'primary'
+ self.makeView("A111", self.a11.id)
+ self.makeView("A12", self.a1.id)
+ self.makeView("A21", self.a2.id)
+ self.a22 = self.makeView("A22", self.a2.id)
+ self.makeView("A221", self.a22.id)
+
+ self.b = self.makeView('B', arch=self.arch_for("B", 'tree'))
+ self.makeView('B1', self.b.id, arch=self.arch_for("B1", 'tree', parent=self.b))
+ self.c = self.makeView('C', arch=self.arch_for("C", 'tree'))
+ self.c.write({'priority': 1})
def test_get_inheriting_views_arch(self):
self.assertEqual(
- self.View.get_inheriting_views_arch(self.view_ids['A'], self.model), [
- (self.arch_for('A1', parent=True), self.view_ids['A1']),
- (self.arch_for('A2', parent=True), self.view_ids['A2']),
- ])
-
- self.assertEqual(
- self.View.get_inheriting_views_arch(self.view_ids['A21'], self.model),
- [])
-
- self.assertEqual(
- self.View.get_inheriting_views_arch(self.view_ids['A11'], self.model),
- [(self.arch_for('A111', parent=True), self.view_ids['A111'])])
+ self.view_ids['A'].get_inheriting_views_arch(self.model),
+ self.view_ids['A1'] | self.view_ids['A2'] | self.view_ids['A12'] | self.view_ids['A21'] | self.view_ids['A22'] | self.view_ids['A221'])
+ self.assertEqual(self.view_ids['A21'].get_inheriting_views_arch(self.model), self.View)
+ self.assertEqual(self.view_ids['A11'].get_inheriting_views_arch(self.model), self.view_ids['A111'])
def test_default_view(self):
default = self.View.default_view(model=self.model, view_type='form')
- self.assertEqual(default, self.view_ids['A'])
+ self.assertEqual(default, self.view_ids['A'].id)
default_tree = self.View.default_view(model=self.model, view_type='tree')
- self.assertEqual(default_tree, self.view_ids['C'])
+ self.assertEqual(default_tree, self.view_ids['C'].id)
def test_no_default_view(self):
self.assertFalse(self.View.default_view(model='does.not.exist', view_type='form'))
@@ -260,7 +254,7 @@ class TestApplyInheritanceSpecs(ViewCase):
E.field(name="replacement"),
name="target", position="replace")
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
self.assertEqual(
self.base_arch,
@@ -269,7 +263,7 @@ class TestApplyInheritanceSpecs(ViewCase):
def test_delete(self):
spec = E.field(name="target", position="replace")
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
self.assertEqual(
self.base_arch,
@@ -280,7 +274,7 @@ class TestApplyInheritanceSpecs(ViewCase):
E.field(name="inserted"),
name="target", position="after")
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
self.assertEqual(
self.base_arch,
@@ -295,7 +289,7 @@ class TestApplyInheritanceSpecs(ViewCase):
E.field(name="inserted"),
name="target", position="before")
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
self.assertEqual(
self.base_arch,
@@ -308,8 +302,8 @@ class TestApplyInheritanceSpecs(ViewCase):
default = E.field(E.field(name="inserted"), name="target")
spec = E.field(E.field(name="inserted 2"), name="target", position='inside')
- self.View.apply_inheritance_specs(self.base_arch, default, None)
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, default)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
self.assertEqual(
self.base_arch,
@@ -328,7 +322,7 @@ class TestApplyInheritanceSpecs(ViewCase):
E.field(E.field(name="inserted 3"), name="target"),
)
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
self.assertEqual(
self.base_arch,
@@ -348,7 +342,7 @@ class TestApplyInheritanceSpecs(ViewCase):
name="target", position="serious_series")
with self.assertRaises(ValueError):
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_incorrect_version(self):
@@ -359,14 +353,14 @@ class TestApplyInheritanceSpecs(ViewCase):
foo="42", version="7.0")
with self.assertRaises(ValueError):
- self.View.apply_inheritance_specs(arch, spec, None)
+ self.View.apply_inheritance_specs(arch, spec)
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_target_not_found(self):
spec = E.field(name="targut")
with self.assertRaises(ValueError):
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
class TestApplyInheritanceWrapSpecs(ViewCase):
@@ -375,7 +369,7 @@ class TestApplyInheritanceWrapSpecs(ViewCase):
self.base_arch = E.template(E.div(E.p("Content")))
def apply_spec(self, spec):
- self.View.apply_inheritance_specs(self.base_arch, spec, None)
+ self.View.apply_inheritance_specs(self.base_arch, spec)
def test_replace(self):
spec = E.xpath(
@@ -404,7 +398,7 @@ class TestApplyInheritanceMoveSpecs(ViewCase):
)
def apply_spec(self, arch, spec):
- self.View.apply_inheritance_specs(arch, spec, None)
+ self.View.apply_inheritance_specs(arch, spec)
def test_move_replace(self):
spec = E.xpath(
@@ -1039,7 +1033,7 @@ class TestViews(ViewCase):
<form string="Base title">
<separator name="separator" string="Separator" colspan="4"/>
<footer>
- <button name="action_next" type="object" string="Next button" class="btn-primary"/>
+ <button name="action_archive" type="object" string="Next button" class="btn-primary"/>
<button string="Skip" special="cancel" class="btn-secondary"/>
</footer>
</form>
@@ -1056,7 +1050,7 @@ class TestViews(ViewCase):
</form>
<footer position="replace">
<footer>
- <button name="action_next" type="object" string="New button"/>
+ <button name="action_archive" type="object" string="New button"/>
</footer>
</footer>
<separator name="separator" position="replace">
@@ -1091,7 +1085,7 @@ class TestViews(ViewCase):
E.form(
E.p("Replacement data"),
E.footer(
- E.button(name="action_next", type="object", string="New button"),
+ E.button(name="action_archive", type="object", string="New button"),
thing="bob lolo bibi and co", otherthing="lolo"
),
string="Replacement title"))
@@ -1164,7 +1158,7 @@ class TestViews(ViewCase):
<form string="Base title">
<separator name="separator" string="Separator" colspan="4"/>
<footer>
- <button name="action_next" type="object" string="Next button" class="btn-primary"/>
+ <button name="action_archive" type="object" string="Next button" class="btn-primary"/>
<button string="Skip" special="cancel" class="btn-secondary"/>
</footer>
</form>
@@ -1181,7 +1175,7 @@ class TestViews(ViewCase):
</form>
<footer position="replace">
<footer>
- <button name="action_next" type="object" string="New button"/>
+ <button name="action_unarchive" type="object" string="New button"/>
</footer>
</footer>
<separator name="separator" position="replace">
@@ -1213,7 +1207,7 @@ class TestViews(ViewCase):
E.form(
E.p("Replacement data"),
E.footer(
- E.button(name="action_next", type="object", string="New button")),
+ E.button(name="action_unarchive", type="object", string="New button")),
string="Replacement title"
))
@@ -1229,6 +1223,7 @@ class TestViews(ViewCase):
transfer_field_to_modifiers(what, modifiers)
simplify_modifiers(modifiers)
assert modifiers == expected, "%s != %s" % (modifiers, expected)
+
_test_modifiers('<field name="a"/>', {})
_test_modifiers('<field name="a" invisible="1"/>', {"invisible": True})
_test_modifiers('<field name="a" readonly="1"/>', {"readonly": True})
@@ -1237,12 +1232,22 @@ class TestViews(ViewCase):
_test_modifiers('<field name="a" readonly="0"/>', {})
_test_modifiers('<field name="a" required="0"/>', {})
# TODO: Order is not guaranteed
- _test_modifiers('<field name="a" invisible="1" required="1"/>',
- {"invisible": True, "required": True})
- _test_modifiers('<field name="a" invisible="1" required="0"/>', {"invisible": True})
- _test_modifiers('<field name="a" invisible="0" required="1"/>', {"required": True})
- _test_modifiers("""<field name="a" attrs="{'invisible': [['b', '=', 'c']]}"/>""",
- {"invisible": [["b", "=", "c"]]})
+ _test_modifiers(
+ '<field name="a" invisible="1" required="1"/>',
+ {"invisible": True, "required": True},
+ )
+ _test_modifiers(
+ '<field name="a" invisible="1" required="0"/>',
+ {"invisible": True},
+ )
+ _test_modifiers(
+ '<field name="a" invisible="0" required="1"/>',
+ {"required": True},
+ )
+ _test_modifiers(
+ """<field name="a" attrs="{'invisible': [['b', '=', 'c']]}"/>""",
+ {"invisible": [["b", "=", "c"]]},
+ )
# The dictionary is supposed to be the result of fields_get().
_test_modifiers({}, {})
@@ -1251,17 +1256,17 @@ class TestViews(ViewCase):
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_invalid_field(self):
- with self.assertRaises(ValidationError):
- self.View.create({
- 'name': 'invalid field',
- 'model': 'ir.ui.view',
- 'arch': """
- <form string="View">
- <field name="name"/>
- <field name="not_a_field"/>
- </form>
- """,
- })
+ self.assertInvalid("""
+ <form string="View">
+ <field name="name"/>
+ <field name="not_a_field"/>
+ </form>
+ """, 'Field "not_a_field" does not exist in model "ir.ui.view"')
+ self.assertInvalid("""
+ <form string="View">
+ <field/>
+ </form>
+ """, 'Field tag must have a "name" attribute defined')
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_invalid_subfield(self):
@@ -1408,6 +1413,33 @@ class TestViews(ViewCase):
})
@mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_domain_id_case(self):
+ # id is read by default and should be usable in domains
+ self.assertValid("""
+ <form string="View">
+ <field name="inherit_id" domain="[('id', '=', False)]"/>
+ </form>
+ """)
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_domain_boolean_case(self):
+ arch = """
+ <form string="View">
+ %s
+ <field name="inherit_id" domain="[(%s, '=', %s)]"/>
+ </form>
+ """
+ self.assertValid(arch % ('', '1', '1'))
+ self.assertValid(arch % ('', '0', '1'))
+ # self.assertInvalid(arch % ('', '1', '0'))
+ self.assertValid(arch % ('<field name="name"/>', '0 if name else 1', '1'))
+ # self.assertInvalid(arch % ('<field name="name"/><field name="type"/>', "'tata' if name else 'tutu'", 'type'), 'xxxx')
+ self.assertInvalid(
+ arch % ('', '0 if name else 1', '1'),
+ """Field name used in domain of <field name="inherit_id"> ([(0 if name else 1, '=', 1)]) must be present in view but is missing""",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_in_view(self):
arch = """
<form string="View">
@@ -1427,6 +1459,39 @@ class TestViews(ViewCase):
'arch': arch % '',
})
+ def test_domain_unknown_field(self):
+ self.assertInvalid("""
+ <form string="View">
+ <field name="name"/>
+ <field name="inherit_id" domain="[('invalid_field', '=', 'res.users')]"/>
+ </form>
+ """,
+ '''Unknow field "ir.ui.view.invalid_field" in domain of <field name="inherit_id"> "[('invalid_field', '=', 'res.users')]"''',
+ )
+
+ def test_domain_field_searchable(self):
+ arch = """
+ <form string="View">
+ <field name="name"/>
+ <field name="inherit_id" domain="[('%s', '=', 'test')]"/>
+ </form>
+ """
+ # computed field with a search method
+ self.assertValid(arch % 'model_data_id')
+ # computed field, not stored, no search
+ self.assertInvalid(
+ arch % 'xml_id',
+ '''Unsearchable field "ir.ui.view.xml_id" in path 'xml_id' in domain of <field name="inherit_id"> ="[('xml_id', '=', 'test')]"''',
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_domain_field_no_comodel(self):
+ self.assertInvalid("""
+ <form string="View">
+ <field name="name" domain="[('test', '=', 'test')]"/>
+ </form>
+ """, "Domain on field without comodel makes no sense for \"name\" (domain:[('test', '=', 'test')])")
+
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_in_subview(self):
arch = """
@@ -1468,25 +1533,30 @@ class TestViews(ViewCase):
<field name="name"/>%s
<field name="inherit_id" domain="[('model', '=', parent.model)]"/>
</form>
- </field>
+ </field>%s
</form>
"""
self.View.create({
'name': 'valid domain',
'model': 'ir.ui.view',
- 'arch': arch % ('<field name="model"/>', ''),
+ 'arch': arch % ('<field name="model"/>', '', ''),
+ })
+ self.View.create({
+ 'name': 'valid domain',
+ 'model': 'ir.ui.view',
+ 'arch': arch % ('', '', '<field name="model"/>'),
})
with self.assertRaises(ValidationError):
self.View.create({
'name': 'valid domain',
'model': 'ir.ui.view',
- 'arch': arch % ('', ''),
+ 'arch': arch % ('', '', ''),
})
with self.assertRaises(ValidationError):
self.View.create({
'name': 'valid domain',
'model': 'ir.ui.view',
- 'arch': arch % ('', '<field name="model"/>'),
+ 'arch': arch % ('', '<field name="model"/>', ''),
})
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -1667,6 +1737,119 @@ class TestViews(ViewCase):
})
@mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_modifier_attribute_is_boolean(self):
+ arch = """
+ <form string="View">
+ <field name="name" readonly="%s"/>
+ </form>
+ """
+ self.assertValid(arch % '1')
+ self.assertValid(arch % '0')
+ self.assertValid(arch % 'True')
+ self.assertInvalid(
+ arch % "[('model', '=', '1')]",
+ "Attribute readonly evaluation must give a boolean, got [('model', '=', '1')]",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_domain_in_filter(self):
+ arch = """
+ <search string="Search">
+ <field name="%s"/>
+ <filter string="Dummy" name="draft" domain="[('%s', '=', 'dummy')]"/>
+ </search>
+ """
+ self.assertValid(arch % ('name', 'name'))
+ self.assertValid(arch % ('name', 'inherit_children_ids.name'))
+ self.assertInvalid(
+ arch % ('invalid_field', 'name'),
+ 'Field "invalid_field" does not exist in model "ir.ui.view"',
+ )
+ self.assertInvalid(
+ arch % ('name', 'invalid_field'),
+ """Unknow field "ir.ui.view.invalid_field" in domain of <filter name="draft"> "[('invalid_field', '=', 'dummy')]""",
+ )
+ self.assertInvalid(
+ arch % ('name', 'inherit_children_ids.invalid_field'),
+ """Unknow field "ir.ui.view.invalid_field" in domain of <filter name="draft"> "[('inherit_children_ids.invalid_field', '=', 'dummy')]""",
+ )
+ # todo add check for non searchable fields and group by
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_group_by_in_filter(self):
+ arch = """
+ <search string="Search">
+ <filter string="Date" name="month" domain="[]" context="{'group_by':'%s'}"/>
+ </search>
+ """
+ self.assertValid(arch % 'name')
+ self.assertInvalid(
+ arch % 'invalid_field',
+ """Unknow field "invalid_field" in "group_by" value in context="{'group_by':'invalid_field'}""",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_domain_invalid_in_filter(self):
+ # invalid domain: it should be a list of tuples
+ self.assertInvalid(
+ """ <search string="Search">
+ <filter string="Dummy" name="draft" domain="['name', '=', 'dummy']"/>
+ </search>
+ """,
+ """Invalid domain format while checking ['name', '=', 'dummy'] in domain of <filter name="draft">""",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_searchpanel(self):
+ arch = """
+ <search>
+ %s
+ <searchpanel>
+ %s
+ <field select="multi" name="groups_id" domain="[['%s', '=', %s]]"/>
+ </searchpanel>
+ </search>
+ """
+ self.assertValid(arch % ('', '<field name="inherit_id"/>', 'view_access', 'inherit_id'))
+ self.assertInvalid(
+ arch % ('<field name="inherit_id"/>', '', 'view_access', 'inherit_id'),
+ """Field inherit_id used in domain of <field name="groups_id"> ([['view_access', '=', inherit_id]]) must be present in view but is missing.""",
+ )
+ self.assertInvalid(
+ arch % ('', '<field name="inherit_id"/>', 'view_access', 'view_access'),
+ """Field view_access used in domain of <field name="groups_id"> ([['view_access', '=', view_access]]) must be present in view but is missing.""",
+ )
+ self.assertInvalid(
+ arch % ('', '<field name="inherit_id"/>', 'inherit_id', 'inherit_id'),
+ """Unknow field "res.groups.inherit_id" in domain of <field name="groups_id"> "[['inherit_id', '=', inherit_id]]""",
+ )
+ self.assertInvalid(
+ arch % ('', '<field name="inherit_id" select="multi"/>', 'view_access', 'inherit_id'),
+ """Field inherit_id used in domain of <field name="groups_id"> ([['view_access', '=', inherit_id]]) is present in view but is in select multi.""",
+ )
+
+ arch = """
+ <search>
+ <searchpanel>
+ <field name="inherit_id"/>
+ </searchpanel>
+ <searchpanel>
+ <field name="inherit_id"/>
+ </searchpanel>
+ </search>
+ """
+ self.assertInvalid(arch, "Search tag can only contains one search panel")
+
+ def test_groups_field(self):
+ arch = """
+ <form string="View">
+ <field name="name" groups="%s"/>
+ </form>
+ """
+ self.assertValid(arch % 'base.group_no_one')
+ self.assertWarning(arch % 'base.dummy')
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_attrs_field(self):
arch = """
<form string="View">
@@ -1688,6 +1871,21 @@ class TestViews(ViewCase):
})
@mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_attrs_invalid_domain(self):
+ arch = """
+ <form string="View">
+ <field name="name"/>
+ <field name="model"/>
+ <field name="inherit_id"
+ attrs="{'readonly': [('model', 'ir.ui.view')]}"/>
+ </form>
+ """
+ self.assertInvalid(
+ arch,
+ """Invalid domain format while checking {'readonly': [('model', 'ir.ui.view')]} in attrs.readonly""",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_attrs_subfield(self):
arch = """
<form string="View">
@@ -1751,27 +1949,60 @@ class TestViews(ViewCase):
'arch': arch % ('', '<field name="model"/>'),
})
+ def test_button(self):
+ arch = """
+ <form>
+ <button type="object" name="%s"/>
+ </form>
+ """
+ self.assertValid(arch % 'action_archive', name='valid button name')
+ self.assertInvalid(
+ arch % 'wtfzzz', 'wtfzzz is not a valid action on ir.ui.view',
+ name='button name is not even a method',
+ )
+ self.assertInvalid(
+ arch % '_check_xml',
+ '_check_xml on ir.ui.view is private and cannot be called from a button',
+ name='button name is a private method',
+ )
+ self.assertWarning(arch % 'postprocess_and_fields', name='button name is a method that requires extra arguments')
+ arch = """
+ <form>
+ <button type="action" name="%s"/>
+ </form>
+ """
+ self.assertInvalid(arch % 0, 'Action 0 (id: 0) does not exist for button of type action.')
+ self.assertInvalid(arch % 'base.random_xmlid', 'Invalid xmlid base.random_xmlid for button of type action')
+ self.assertInvalid('<form><button type="action"/></form>', 'Button must have a name')
+ self.assertInvalid('<form><button special="dummy"/></form>', "Invalid special 'dummy' in button")
+ self.assertValid(arch % 'base.action_server_module_immediate_install')
+ self.assertInvalid(arch % 'base.partner_root', "base.partner_root is of type res.partner, expected a subclass of ir.actions.actions")
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_tree(self):
+ arch = """
+ <tree>
+ <field name="name"/>
+ <button type='object' name="action_archive"/>
+ %s
+ </tree>
+ """
+ self.assertValid(arch % '')
+ self.assertInvalid(arch % '<group/>', "Tree child can only be have one of field, button, control, groupby, widget tag (not group)")
+
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_tree_groupby(self):
arch = """
<tree>
<field name="name"/>
<groupby name="%s">
- <button type="object" name="method1"/>
+ <button type="object" name="action_archive"/>
</groupby>
</tree>
"""
- self.View.create({
- 'name': 'valid groupby',
- 'model': 'ir.ui.view',
- 'arch': arch % ('model_data_id'),
- })
- with self.assertRaises(ValidationError):
- self.View.create({
- 'name': 'invalid groupby',
- 'model': 'ir.ui.view',
- 'arch': arch % ('type'),
- })
+ self.assertValid(arch % ('model_data_id'))
+ self.assertInvalid(arch % ('type'), "field 'type' found in 'groupby' node can only be of type many2one, found selection")
+ self.assertInvalid(arch % ('dummy'), "field 'dummy' found in 'groupby' node does not exist in model ir.ui.view")
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_tree_groupby_many2one(self):
@@ -1781,7 +2012,7 @@ class TestViews(ViewCase):
%s
<groupby name="model_data_id">
%s
- <button type="object" name="method" attrs="{'invisible': [('noupdate', '=', True)]}" string="Button1"/>
+ <button type="object" name="action_archive" attrs="{'invisible': [('noupdate', '=', True)]}" string="Button1"/>
</groupby>
</tree>
"""
@@ -1809,6 +2040,183 @@ class TestViews(ViewCase):
'arch': arch % ('', '<field name="noupdate"/><field name="fake_field"/>'),
})
+ def test_for_in_label(self):
+ self.assertValid('<form><field name="model"/><label for="model"/></form>')
+ self.assertInvalid(
+ '<form><field name="model"/><label/></form>',
+ """Label tag must contain a "for". To match label style without corresponding field or button, use 'class="o_form_label"'""",
+ )
+ self.assertInvalid(
+ '<form><label for="model"/></form>',
+ "Name 'model' used in 'label for' must be present in view but is missing.",
+ )
+
+ def test_col_colspan_numerical(self):
+ self.assertValid('<form><group col="5"></group></form>')
+ self.assertInvalid(
+ '<form><group col="alpha"></group></form>',
+ "'col' value must be an integer (alpha)",
+ )
+ self.assertValid('<form><div colspan="5"></div></form>')
+ self.assertInvalid(
+ '<form><div colspan="alpha"></div></form>',
+ "'colspan' value must be an integer (alpha)",
+ )
+
+ def test_valid_alerts(self):
+ self.assertValid('<form><div class="alert alert-success" role="alert"/></form>')
+ self.assertValid('<form><div class="alert alert-success" role="alertdialog"/></form>')
+ self.assertValid('<form><div class="alert alert-success" role="status"/></form>')
+ self.assertWarning('<form><div class="alert alert-success"/></form>')
+
+ def test_valid_prohibited_none_role(self):
+ self.assertWarning('<form><div role="none"/></form>')
+ self.assertWarning('<form><div role="presentation"/></form>')
+
+ def test_valid_alternative_image_text(self):
+ self.assertValid('<form><img src="a" alt="a image"></img></form>')
+ self.assertWarning('<form><img src="a"></img></form>')
+
+ def test_valid_accessibility_icon_text(self):
+ self.assertWarning(
+ '<form><span class="fa fa-warning"/></form>',
+ 'A <span> with fa class (fa fa-warning) must have title in its tag, parents, descendants or have text'
+ )
+ self.assertWarning(
+ '<form><button icon="fa-warning"/></form>',
+ 'A button with icon attribute (fa-warning) must have title in its tag, parents, descendants or have text'
+ )
+ self.assertValid('<form><button icon="fa-warning"/>text</form>')
+ self.assertValid('<form><span class="fa fa-warning"/>text</form>')
+ self.assertValid('<form>text<span class="fa fa-warning"/></form>')
+ self.assertValid('<form><span class="fa fa-warning">text</span></form>')
+ self.assertValid('<form><span title="text" class="fa fa-warning"/></form>')
+ self.assertValid('<form><span aria-label="text" class="fa fa-warning"/></form>')
+
+ def test_valid_simili_button(self):
+ self.assertWarning('<form><a class="btn"/></form>')
+ self.assertValid('<form><a class="btn" role="button"/></form>')
+
+ def test_valid_dialog(self):
+ self.assertWarning('<form><div class="modal"/></form>')
+ self.assertValid('<form><div role="dialog" class="modal"></div></form>')
+ self.assertWarning('<form><div class="modal-header"/></form>')
+ self.assertValid('<form><header class="modal-header"/></form>')
+ self.assertWarning('<form><div class="modal-footer"/></form>')
+ self.assertValid('<form><footer class="modal-footer"/></form>')
+ self.assertWarning('<form><div class="modal-body"/></form>')
+ self.assertValid('<form><main class="modal-body"/></form>')
+
+ def test_valid_simili_dropdown(self):
+ self.assertValid('<form><ul class="dropdown-menu" role="menu"></ul></form>')
+ self.assertWarning('<form><ul class="dropdown-menu"></ul></form>')
+
+ def test_valid_simili_progressbar(self):
+ self.assertValid('<form><div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemin="0" aria-valuemax="100">14%</div></form>')
+ self.assertWarning('<form><div class="o_progressbar" aria-valuenow="14" aria-valuemin="0" aria-valuemax="100">14%</div></form>')
+ self.assertWarning('<form><div class="o_progressbar" role="progressbar" aria-valuemin="0" aria-valuemax="100">14%</div></form>')
+ self.assertWarning('<form><div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemax="100">14%</div></form>')
+ self.assertWarning('<form><div class="o_progressbar" role="progressbar" aria-valuenow="14" aria-valuemin="0" >14%</div></form>')
+
+ def test_valid_simili_tabpanel(self):
+ self.assertValid('<form><div class="tab-pane" role="tabpanel"/></form>')
+ self.assertWarning('<form><div class="tab-pane"/></form>')
+
+ def test_valid_simili_tablist(self):
+ self.assertValid('<form><div class="nav-tabs" role="tablist"/></form>')
+ self.assertWarning('<form><div class="nav-tabs"/></form>')
+
+ def test_valid_simili_tab(self):
+ self.assertValid('<form><a data-toggle="tab" role="tab" aria-controls="test"/></form>')
+ self.assertWarning('<form><a data-toggle="tab" aria-controls="test"/></form>')
+ self.assertWarning('<form><a data-toggle="tab" role="tab"/></form>')
+ self.assertWarning('<form><a data-toggle="tab" role="tab" aria-controls="#test"/></form>')
+
+ def test_valid_focusable_button(self):
+ self.assertValid('<form><a class="btn" role="button"/></form>')
+ self.assertValid('<form><button class="btn" role="button"/></form>')
+ self.assertValid('<form><select class="btn" role="button"/></form>')
+ self.assertValid('<form><input type="button" class="btn" role="button"/></form>')
+ self.assertValid('<form><input type="submit" class="btn" role="button"/></form>')
+ self.assertValid('<form><input type="reset" class="btn" role="button"/></form>')
+ self.assertValid('<form><div type="reset" class="btn btn-group" role="button"/></form>')
+ self.assertValid('<form><div type="reset" class="btn btn-toolbar" role="button"/></form>')
+ self.assertValid('<form><div type="reset" class="btn btn-ship" role="button"/></form>')
+ self.assertWarning('<form><div class="btn" role="button"/></form>')
+ self.assertWarning('<form><input type="email" class="btn" role="button"/></form>')
+
+ def test_address_view(self):
+ # pe_partner_address_form
+ address_arch = """<form><div class="o_address_format"><field name="parent_name"/></div></form>"""
+ address_view = self.View.create({
+ 'name': 'view',
+ 'model': 'res.partner',
+ 'arch': address_arch,
+ 'priority': 900,
+ })
+
+ # view can be created without address_view
+ form_arch = """<form><field name="id"/><div class="o_address_format"><field name="street"/></div></form>"""
+ partner_view = self.View.create({
+ 'name': 'view',
+ 'model': 'res.partner',
+ 'arch': form_arch,
+ })
+
+ # default view, no address_view defined
+ arch = self.env['res.partner'].fields_view_get(view_id=partner_view.id)['arch']
+ self.assertIn('"street"', arch)
+ self.assertNotIn('"parent_name"', arch)
+
+ # custom view, address_view defined
+ self.env.company.country_id.address_view_id = address_view
+ arch = self.env['res.partner'].fields_view_get(view_id=partner_view.id)['arch']
+ self.assertNotIn('"street"', arch)
+ self.assertIn('"parent_name"', arch)
+ # weird result: <form> inside a <form>
+ self.assertRegex(arch, r"<form>.*<form>.*</form>.*</form>")
+
+ def test_graph_fields(self):
+ self.assertValid('<graph string="Graph"><field name="model" type="row"/><field name="inherit_id" type="measure"/></graph>')
+ self.assertInvalid(
+ '<graph string="Graph"><label for="model"/><field name="model" type="row"/><field name="inherit_id" type="measure"/></graph>',
+ 'A <graph> can only contains <field> nodes, found a <label>'
+ )
+
+ def assertValid(self, arch, name='valid view'):
+ self.View.create({
+ 'name': name,
+ 'model': 'ir.ui.view',
+ 'arch': arch,
+ })
+
+ def assertInvalid(self, arch, expected_message=None, name='invalid view'):
+ with self.assertRaises(ValidationError) as catcher, mute_logger('odoo.addons.base.models.ir_ui_view'):
+ self.View.create({
+ 'name': name,
+ 'model': 'ir.ui.view',
+ 'arch': arch,
+ })
+ message = str(catcher.exception.args[0])
+ self.assertIn('Error context:\nView `%s`' % name, message)
+ if expected_message:
+ self.assertIn(expected_message, message)
+ else:
+ _logger.warning(message)
+
+ def assertWarning(self, arch, expected_message=None, name='invalid view'):
+ with self.assertLogs('odoo.addons.base.models.ir_ui_view', level="WARNING") as log_catcher:
+ self.View.create({
+ 'name': name,
+ 'model': 'ir.ui.view',
+ 'arch': arch,
+ })
+ self.assertEqual(len(log_catcher.output), 1, "Exactly one warning should be logged")
+ message = log_catcher.output[0]
+ self.assertIn('Error context:\nView `%s`' % name, message)
+ if expected_message:
+ self.assertIn(expected_message, message)
+
class ViewModeField(ViewCase):
"""
@@ -2293,6 +2701,43 @@ class TestQWebRender(ViewCase):
self.assertNotEqual(content1, content3)
+class TestValidationTools(common.BaseCase):
+
+ def test_get_domain_idents(self):
+ res = view_validation.get_domain_identifiers("['|', ('model', '=', parent.model or need_model), ('need_model', '=', False)]")
+ self.assertEqual(res, ({'model', 'need_model'}, {'parent.model', 'need_model'}))
+
+ def test_process_2_level_parents(self):
+ res = view_validation.get_domain_identifiers("['|', ('model', '=', parent.parent.model)]")
+ self.assertEqual(res, ({'model'}, {'parent.parent.model'}))
+
+ def test_get_dict_asts(self):
+ res = view_validation.get_dict_asts("{'test': False, 'required': [('model', '!=', False)], 'invisible': ['|', ('model', '=', parent.model or need_model), ('need_model', '=', False)]}")
+ self.assertEqual(set(res.keys()), set(['test', 'required', 'invisible']))
+ self.assertIsInstance(res['test'], ast.NameConstant)
+ self.assertIsInstance(res['required'], ast.List)
+ self.assertIsInstance(res['invisible'], ast.List)
+ self.assertEqual(view_validation.get_domain_identifiers(res['invisible']), ({'model', 'need_model'}, {'parent.model', 'need_model'}))
+
+ def test_get_expression_identities(self):
+ self.assertEqual(
+ view_validation.get_variable_names("context_today().strftime('%Y-%m-%d')"),
+ set(),
+ )
+ self.assertEqual(
+ view_validation.get_variable_names("field and field[0] or not field2"),
+ {'field', 'field2'},
+ )
+ self.assertEqual(
+ view_validation.get_variable_names("context_today().strftime('%Y-%m-%d') or field"),
+ {'field'},
+ )
+ self.assertEqual(
+ view_validation.get_variable_names("(datetime.datetime.combine(context_today(), datetime.time(x,y,z)).to_utc()).strftime('%Y-%m-%d %H:%M:%S')"),
+ {'x', 'y', 'z'},
+ )
+
+
@common.tagged('post_install', '-at_install', '-standard', 'migration')
class TestAllViews(common.TransactionCase):
def test_views(self):
diff --git a/odoo/fields.py b/odoo/fields.py
index f94676b4303..7da2b234a8b 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -731,6 +731,12 @@ class Field(MetaField('DummyField', (object,), {})):
return field_help.get(self.name) or self.help
return self.help
+ def is_editable(self):
+ """ Return whether the field can be editable in a view. """
+ return not self.readonly or self.states and any(
+ 'readonly' in item for items in self.states.values() for item in items
+ )
+
############################################################################
#
# Conversion of values
diff --git a/odoo/models.py b/odoo/models.py
index be614498fe4..96f41eecc1d 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -1495,17 +1495,17 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
* if some tag other than 'position' is found in parent view
:raise Invalid ArchitectureError: if there is view type other than form, tree, calendar, search etc defined on the structure
"""
- View = self.env['ir.ui.view']
+ view = self.env['ir.ui.view'].browse(view_id)
# Get the view arch and all other attributes describing the composition of the view
result = self._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
# Override context for postprocessing
if view_id and result.get('base_model', self._name) != self._name:
- View = View.with_context(base_model_name=result['base_model'])
+ view = view.with_context(base_model_name=result['base_model'])
# Apply post processing, groups and modifiers etc...
- xarch, xfields = View.postprocess_and_fields(self._name, etree.fromstring(result['arch']), view_id)
+ xarch, xfields = view.postprocess_and_fields(etree.fromstring(result['arch']), model=self._name)
result['arch'] = xarch
result['fields'] = xfields
diff --git a/odoo/tools/view_validation.py b/odoo/tools/view_validation.py
index 46921fee839..89bd11145e8 100644
--- a/odoo/tools/view_validation.py
+++ b/odoo/tools/view_validation.py
@@ -6,10 +6,8 @@ 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__)
@@ -17,20 +15,6 @@ _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")
@@ -39,7 +23,6 @@ def _get_attrs_symbols():
return {
'True', 'False', 'None', # those are identifiers in Python 2.7
'self',
- 'parent',
'id',
'uid',
'context',
@@ -62,90 +45,88 @@ def _get_attrs_symbols():
}
-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)``.
+def get_variable_names(expr):
+ """ Return the subexpressions of the kind "VARNAME(.ATTNAME)*" in the given
+ string or AST node.
"""
- VIEW_TYPES = {item[0] for item in type(env['ir.ui.view']).type.selection}
- symbols = _get_attrs_symbols() | {None}
- result = []
+ IGNORED = _get_attrs_symbols()
+ names = set()
- def get_name(node):
- """ return the name from an AST node, or None """
+ def get_name_seq(node):
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)
+ return [node.id]
+ elif isinstance(node, ast.Attribute):
+ left = get_name_seq(node.value)
+ return left and left + [node.attr]
+
+ def process(node):
+ seq = get_name_seq(node)
+ if seq and seq[0] not in IGNORED:
+ names.add('.'.join(seq))
+ else:
+ for child in ast.iter_child_nodes(node):
+ process(child)
+
+ if isinstance(expr, str):
+ expr = ast.parse(expr.strip(), mode='eval').body
+ process(expr)
+
+ return 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 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)
+ 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)}
- 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 get_domain_identifiers(expr):
+ """ Check that the given string or AST node represents a domain expression,
+ and return a pair of sets ``(fields, vars)`` where ``fields`` are the field
+ names on the left-hand side of conditions, and ``vars`` are the variable
+ names on the right-hand side of conditions.
+ """
+ if not expr: # case of expr=""
+ return (set(), set())
+ if isinstance(expr, str):
+ expr = ast.parse(expr.strip(), mode='eval').body
+
+ def check(condition):
+ if not condition:
+ raise ValueError("Expression is not a valid domain")
+
+ check(isinstance(expr, ast.List))
+ fnames = set()
+ vnames = set()
+ leaves = list(ast.iter_child_nodes(expr))
+ check(isinstance(leaves.pop(), ast.Load))
+ for leaf in leaves:
+ if isinstance(leaf, ast.Str):
+ # note: this doesn't check the and/or structure
+ check(leaf.s in ('&', '|', '!'))
+ else:
+ check(isinstance(leaf, (ast.List, ast.Tuple)))
+ tuple_ = list(ast.iter_child_nodes(leaf))
+ check(isinstance(tuple_.pop(), ast.Load) and len(tuple_) == 3)
+ lhs, operator, rhs = tuple_
+ if isinstance(lhs, ast.Str):
+ fnames.add(lhs.s)
+ else:
+ # limitation: we do not list fnames in this case
+ vnames.update(get_variable_names(lhs))
+ check(isinstance(operator, ast.Str))
+ vnames.update(get_variable_names(rhs))
+
+ return (fnames, vnames)
def valid_view(arch, **kwargs):
@@ -194,270 +175,3 @@ def schema_valid(arch, **kwargs):
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_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 <header/>, <main/> and <footer/>."""
- # Select elements with class 'btn'
- xpath = '//*[contains(concat(" ", @class, " "), " modal ")'
- xpath += ' or contains(concat(" ", @t-att-class, " "), " modal ")'
- xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal ")]'
- xpath += '[not(@role="dialog")]'
- if arch.xpath(xpath):
- return "Warning"
-
- xpath = '//*[contains(concat(" ", @class, " "), " modal-header ")'
- xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-header ")'
- xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-header ")]'
- xpath += '[not(self::header)]'
- if arch.xpath(xpath):
- return "Warning"
-
- xpath = '//*[contains(concat(" ", @class, " "), " modal-body ")'
- xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-body ")'
- xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-body ")]'
- xpath += '[not(self::main)]'
- if arch.xpath(xpath):
- return "Warning"
-
- xpath = '//*[contains(concat(" ", @class, " "), " modal-footer ")'
- xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-footer ")'
- xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-footer ")]'
- xpath += '[not(self::footer)]'
- if arch.xpath(xpath):
- return "Warning"
- return True
-
-
-@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
-def valid_simili_tabpanel(arch, **kwargs):
- """A tab panel with tab-pane class must have role="tabpanel"."""
- # Select elements with class 'btn'
- xpath = '//*[contains(concat(" ", @class, " "), " tab-pane ")'
- xpath += ' or contains(concat(" ", @t-att-class, " "), " tab-pane ")'
- xpath += ' or contains(concat(" ", @t-attf-class, " "), " tab-pane ")]'
- xpath += '[not(@role="tabpanel")]'
- if arch.xpath(xpath):
- return "Warning"
- return True
-
-
-@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
-def valid_simili_tab(arch, **kwargs):
- """A tab link must have role="tab", a link to an id (without #) by aria-controls."""
- # Select elements with class 'btn'
- xpath = '//*[@data-toggle="tab"]'
- xpath += '[not(@role="tab")'
- xpath += 'or not(@aria-controls or @t-att-aria-controls or @t-attf-aria-controls)'
- xpath += 'or contains(@aria-controls, "#") or contains(@t-att-aria-controls, "#")]'
- if arch.xpath(xpath):
- return "Warning"
- return True
-
-
-@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
-def valid_simili_tablist(arch, **kwargs):
- """A tab list with class nav-tabs must have role="tablist"."""
- # Select elements with class 'btn'
- xpath = '//*[contains(concat(" ", @class, " "), " nav-tabs ")'
- xpath += ' or contains(concat(" ", @t-att-class, " "), " nav-tabs ")'
- xpath += ' or contains(concat(" ", @t-attf-class, " "), " nav-tabs ")]'
- xpath += '[not(@role="tablist")]'
- if arch.xpath(xpath):
- return "Warning"
- return True
-
-
-@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
-def valid_focusable_button(arch, **kwargs):
- """A simili button must be with a `button`, an `input` (with type `button`, `submit` or `reset`) or a `a` tag."""
- xpath = '//*[contains(concat(" ", @class), " btn")'
- xpath += ' or contains(concat(" ", @t-att-class), " btn")'
- xpath += ' or contains(concat(" ", @t-attf-class), " btn")]'
- xpath += '[not(self::a)]'
- xpath += '[not(self::button)]'
- xpath += '[not(self::select)]'
- xpath += '[not(self::input[@type="button"])]'
- xpath += '[not(self::input[@type="submit"])]'
- xpath += '[not(self::input[@type="reset"])]'
- xpath += '[not(contains(@class, "btn-group"))]'
- xpath += '[not(contains(@t-att-class, "btn-group"))]'
- xpath += '[not(contains(@t-attf-class, "btn-group"))]'
- xpath += '[not(contains(@class, "btn-toolbar"))]'
- xpath += '[not(contains(@t-att-class, "btn-toolbar"))]'
- xpath += '[not(contains(@t-attf-class, "btn-toolbar"))]'
- xpath += '[not(contains(@class, "btn-ship"))]'
- xpath += '[not(contains(@t-att-class, "btn-ship"))]'
- xpath += '[not(contains(@t-attf-class, "btn-ship"))]'
- if arch.xpath(xpath):
- return "Warning"
- return True
-
-
-@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
-def valid_prohibited_none_role(arch, **kwargs):
- """A role can't be `none` or `presentation`. All your elements must be accessible with screen readers, describe it."""
- xpath = '//*[@role="none" or @role="presentation"]'
- if arch.xpath(xpath):
- return "Warning"
- return True
-
-
-@validate('calendar', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree', 'activity')
-def valid_alerts(arch, **kwargs):
- """An alert (class alert-*) must have an alert, alertdialog or status role. Please use alert and alertdialog only for what expects to stop any activity to be read immediatly."""
- xpath = '//*[contains(concat(" ", @class), " alert-")'
- xpath += ' or contains(concat(" ", @t-att-class), " alert-")'
- xpath += ' or contains(concat(" ", @t-attf-class), " alert-")]'
- xpath += '[not(contains(@class, "alert-link") or contains(@t-att-class, "alert-link")'
- xpath += ' or contains(@t-attf-class, "alert-link"))]'
- xpath += '[not(@role="alert")]'
- xpath += '[not(@role="alertdialog")]'
- xpath += '[not(@role="status")]'
- if arch.xpath(xpath):
- return "Warning"
- return True