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) + self.ensure_one() + self.check_access_rights('read') + + # 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()] 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 + # 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] - # 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) + def accessible(view): + return not view.groups_id or (view.groups_id & self.env.user.groups_id) - return [(view.arch, view.id) - for view in views.sudo() - if not view.groups_id or (view.groups_id & user_groups)] + return self.browse(view_ids).sudo().filtered(accessible) - @api.model - def raise_view_error(self, message, view_id): - view = self.browse(view_id) + 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 + arch, name_manager = self._postprocess_view(node, model, validate=validate) + # name_manager.final_check() + return arch, name_manager.available_fields - field = model_fields.get(node.get('name')) - if field: - transfer_field_to_modifiers(field, modifiers) + def _postprocess_view(self, node, model, validate=True, editable=True): - 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_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, - }} + if model not in self.env: + self.handle_view_error(_('Model not found: %(model)s') % dict(model=model)) - 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' + self._postprocess_on_change(model, node) - 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')] = {} + name_manager = NameManager(validate, self.env[model]) + self.postprocess(node, [], editable, name_manager) - 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) + name_manager.check_view_fields(self) + name_manager.update_view_fields() - if not self._apply_group(model, node, modifiers, fields): - # node must be removed, no need to proceed further with its children - return fields + self._postprocess_access_rights(model, node) - # 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) + return etree.tostring(node, encoding="unicode").replace('\t', ''), name_manager - 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)) - - transfer_modifiers_to_node(modifiers, node) - return fields - - def add_on_change(self, model_name, arch): + 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') + 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') - return node + 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_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 can only contains 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 = _(' 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 = _('"" 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:
+ 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 or 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'''\ -
- - -
- -
- - - -
-
- - - -
-
100%
- - - - -
-
-
-
-
- - -