diff --git a/addons/account/tests/test_account_move_in_refund.py b/addons/account/tests/test_account_move_in_refund.py
index 6c3396ec4a7..cbbed7a03ba 100644
--- a/addons/account/tests/test_account_move_in_refund.py
+++ b/addons/account/tests/test_account_move_in_refund.py
@@ -785,13 +785,13 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
# https://github.com/odoo/odoo/blob/385884afd31f25d61e99d139ecd4c574d99a1863/addons/purchase/views/account_move_views.xml#L26
self.env.user.groups_id -= self.env.ref('purchase.group_purchase_manager')
self.env.user.groups_id -= self.env.ref('purchase.group_purchase_user')
- # 'invisible': ['|', ('state', '!=', 'draft'), ('move_type', '!=', 'in_invoice')]
+ # invisible="state != 'draft' or move_type != 'in_invoice'"
# This is an in_refund invoice, `invoice_vendor_bill_id` is not supposed to be visible
# and therefore not supposed to be changed.
view = self.env.ref('account.view_move_form')
tree = etree.fromstring(view.arch)
for node in tree.xpath('//field[@name="invoice_vendor_bill_id"]'):
- del node.attrib['attrs']
+ del node.attrib['invisible']
view.arch = etree.tostring(tree)
move_form = Form(self.invoice)
diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py
index f8788814d6d..70eff449d73 100644
--- a/addons/account/tests/test_account_move_out_invoice.py
+++ b/addons/account/tests/test_account_move_out_invoice.py
@@ -3334,7 +3334,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon):
# Quick edit total amount not activated yet
# As quick edit total is not yet activated, it's invisible by default in the view
- move_form._view['modifiers']['quick_edit_total_amount']['invisible'] = False
+ move_form._view['modifiers']['quick_edit_total_amount']['invisible'] = 'False'
move_form.quick_edit_total_amount = 100.0
invoice = move_form.save()
self.assertEqual(invoice.amount_total, 0.0)
diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py
index cd0d3cf6191..660b11da9a3 100644
--- a/addons/base_import/models/base_import.py
+++ b/addons/base_import/models/base_import.py
@@ -285,13 +285,7 @@ class Import(models.TransientModel):
if field.get('deprecated', False) is not False:
continue
if field.get('readonly'):
- states = field.get('states')
- if not states:
- continue
- # states = {state: [(attr, value), (attr2, value2)], state2:...}
- if not any(attr == 'readonly' and value is False
- for attr, value in itertools.chain.from_iterable(states.values())):
- continue
+ continue
field_value = {
'id': name,
'name': name,
diff --git a/addons/crm/tests/test_res_partner.py b/addons/crm/tests/test_res_partner.py
index b80849e3962..c95387de848 100644
--- a/addons/crm/tests/test_res_partner.py
+++ b/addons/crm/tests/test_res_partner.py
@@ -55,13 +55,7 @@ class TestPartner(TestCrmCommon):
# So, set a temporary `parent_id` before setting the contact as company
# to make `parent_id` visible in the interface while being a company
#
partner_form.parent_id = contact_company_1
partner_form.company_type = 'company'
diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py
index ebff50fcc8a..aabba6eb897 100644
--- a/addons/hr_holidays/tests/test_leave_requests.py
+++ b/addons/hr_holidays/tests/test_leave_requests.py
@@ -282,8 +282,8 @@ class TestLeaveRequests(TestHrHolidaysCommon):
# 06/16/2022 08:00:00 as date_from and 06/15/2022 17:00:00 as date_to
# Bug reported to the rd-fun-vidange channel to the dev who introduced the bug
# https://discord.com/channels/678381219515465750/687337760452902925/986918361768263710
- leave_form._view['modifiers']['date_from']['invisible'] = False
- leave_form._view['modifiers']['date_to']['invisible'] = False
+ leave_form._view['modifiers']['date_from']['invisible'] = 'False'
+ leave_form._view['modifiers']['date_to']['invisible'] = 'False'
leave_form.date_from = datetime(2019, 5, 6, 0, 0, 0)
leave_form.date_to = datetime(2019, 5, 6, 23, 59, 59)
leave = leave_form.save()
diff --git a/addons/http_routing/tests/test_res_lang.py b/addons/http_routing/tests/test_res_lang.py
new file mode 100644
index 00000000000..80c41592036
--- /dev/null
+++ b/addons/http_routing/tests/test_res_lang.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import TransactionCase, tagged, Form
+
+@tagged('-at_install', 'post_install')
+class TestFormCreate(TransactionCase):
+
+ def test_create_res_lang(self):
+ lang_form = Form(self.env['res.lang'])
+ lang_form.url_code = 'LANG'
+ lang_form.name = 'a lang name'
+ lang_form.code = 'a lang code'
+ lang_form.save()
diff --git a/addons/mrp/tests/test_multicompany.py b/addons/mrp/tests/test_multicompany.py
index aeca55a0560..fda22d07180 100644
--- a/addons/mrp/tests/test_multicompany.py
+++ b/addons/mrp/tests/test_multicompany.py
@@ -135,7 +135,7 @@ class TestMrpMulticompany(common.TransactionCase):
mo_form = Form(self.env['mrp.production'].with_user(self.user_a))
mo_form.product_id = product
# The mo must be confirmed, no longer in draft, in order for `lot_producing_id` to be visible in the view
- #
+ #
mo = mo_form.save()
mo.action_confirm()
mo_form = Form(mo)
diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py
index 5c856d27d5b..9621e64e044 100644
--- a/addons/mrp/tests/test_order.py
+++ b/addons/mrp/tests/test_order.py
@@ -627,7 +627,7 @@ class TestMrpOrder(TestMrpCommon):
mo, _, p_final, p1, p2 = self.generate_mo(tracking_base_1='lot', qty_base_1=10, qty_final=1)
# Required for `lot_producing_id` to be visible in the view
- #
+ #
p_final.tracking = 'lot'
self.assertEqual(len(mo), 1, 'MO should have been created')
diff --git a/addons/project/tests/test_multicompany.py b/addons/project/tests/test_multicompany.py
index d5465acfe3a..1eeab0ac91e 100644
--- a/addons/project/tests/test_multicompany.py
+++ b/addons/project/tests/test_multicompany.py
@@ -437,8 +437,8 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
#
view = self.env.ref('project.view_task_form2').sudo()
tree = etree.fromstring(view.arch)
- for node in tree.xpath('//field[@name="parent_id"][@attrs]'):
- node.attrib.pop('attrs')
+ for node in tree.xpath('//field[@name="parent_id"][@invisible]'):
+ node.attrib.pop('invisible')
view.arch = etree.tostring(tree)
with self.debug_mode():
with Form(self.task_2) as task_form:
@@ -457,8 +457,8 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
#
view = self.env.ref('project.view_task_form2').sudo()
tree = etree.fromstring(view.arch)
- for node in tree.xpath('//field[@name="parent_id"][@attrs]'):
- node.attrib.pop('attrs')
+ for node in tree.xpath('//field[@name="parent_id"][@invisible]'):
+ node.attrib.pop('invisible')
view.arch = etree.tostring(tree)
with self.sudo('employee-a'):
diff --git a/addons/purchase/tests/test_purchase_order_report.py b/addons/purchase/tests/test_purchase_order_report.py
index fca8f5e3d78..2f060a7b571 100644
--- a/addons/purchase/tests/test_purchase_order_report.py
+++ b/addons/purchase/tests/test_purchase_order_report.py
@@ -44,11 +44,11 @@ class TestPurchaseOrderReport(AccountTestInvoicingCommon):
#
#
#
+ # invisible="state != 'draft' or move_type != 'in_invoice'" />
#
diff --git a/addons/purchase_stock/tests/test_purchase_order.py b/addons/purchase_stock/tests/test_purchase_order.py
index 8d25ec146b5..e6df2fff0ef 100644
--- a/addons/purchase_stock/tests/test_purchase_order.py
+++ b/addons/purchase_stock/tests/test_purchase_order.py
@@ -144,10 +144,10 @@ class TestPurchaseOrder(ValuationReconciliationTestCommon):
# Not supposed to see/change the purchase order of a refund invoice by default
#
#
+ # invisible="state != 'draft' or move_type != 'in_invoice'" />
#
#
+ # invisible="state != 'draft' or move_type != 'in_invoice'" />
#
+ #
#
+ #
#
+ #
# The priority field is not visible until the name is set,
# which is done after a first save / the `create`
delivery_form.priority = '1'
diff --git a/addons/survey/tests/test_survey_invite.py b/addons/survey/tests/test_survey_invite.py
index e1899ede5ad..2d564a19aca 100644
--- a/addons/survey/tests/test_survey_invite.py
+++ b/addons/survey/tests/test_survey_invite.py
@@ -27,7 +27,7 @@ class TestSurveyInvite(common.TestSurveyCommon, MailCommon):
# By default, `` is invisible when `survey_users_login_required` is True,
# making it normally impossible to change by the user in the web client by default.
# For tests `test_survey_invite_authentication_nosignup` and `test_survey_invite_token_internal`
- tree.xpath('//field[@name="emails"]')[0].attrib.pop('attrs')
+ tree.xpath('//field[@name="emails"]')[0].attrib.pop('invisible', None)
view.arch = etree.tostring(tree)
return res
diff --git a/addons/web/controllers/export.py b/addons/web/controllers/export.py
index 9fbef242da4..f84b152f029 100644
--- a/addons/web/controllers/export.py
+++ b/addons/web/controllers/export.py
@@ -332,10 +332,7 @@ class Export(http.Controller):
if exclude and field_name in exclude:
continue
if field.get('readonly'):
- # If none of the field's states unsets readonly, skip the field
- if all(dict(attrs).get('readonly', True)
- for attrs in field.get('states', {}).values()):
- continue
+ continue
if not field.get('exportable', True):
continue
diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py
index 7a5d46ac792..94a600d3d6a 100644
--- a/odoo/addons/base/models/ir_ui_view.py
+++ b/odoo/addons/base/models/ir_ui_view.py
@@ -25,7 +25,7 @@ from odoo.modules.module import get_resource_from_path, get_resource_path
from odoo.tools import config, ConstantMapping, get_diff, pycompat, apply_inheritance_specs, locate_node, str2bool
from odoo.tools.convert import _fix_multiple_roots
from odoo.tools import safe_eval, lazy, lazy_property, frozendict
-from odoo.tools.view_validation import valid_view, get_variable_names, get_domain_identifiers, get_dict_asts
+from odoo.tools.view_validation import valid_view, get_domain_value_names, get_expression_field_names, get_dict_asts
from odoo.tools.translate import xml_translate, TRANSLATED_ATTRS
from odoo.models import check_method_name
from odoo.osv.expression import expression
@@ -33,6 +33,7 @@ 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']
+VIEW_MODIFIERS = ('column_invisible', 'invisible', 'readonly', 'required')
# Some views have a js compiler that generates an owl template from the arch. In that template,
# `__comp__` is a reserved keyword giving access to the component instance (e.g. the form renderer
@@ -64,87 +65,6 @@ def att_names(name):
yield f"t-attf-{name}"
-def transfer_field_to_modifiers(field, modifiers, attributes):
- default_values = {}
- state_exceptions = {}
- for attr in attributes:
- state_exceptions[attr] = []
- default_values[attr] = bool(field.get(attr))
- for state, modifs in field.get("states", {}).items():
- for modif in modifs:
- if modif[0] in attributes and default_values[modif[0]] != modif[1]:
- state_exceptions[modif[0]].append(state)
-
- for attr, default_value in default_values.items():
- if state_exceptions[attr]:
- modifiers[attr] = [("state", "not in" if default_value else "in", state_exceptions[attr])]
- else:
- modifiers[attr] = default_value
-
-
-def transfer_node_to_modifiers(node, modifiers):
- # Don't deal with groups, it is done by check_group().
- attrs = node.attrib.pop('attrs', None)
- if attrs:
- modifiers.update(ast.literal_eval(attrs.strip()))
- for a in ('invisible', 'readonly', 'required'):
- if a in modifiers and isinstance(modifiers[a], int):
- modifiers[a] = bool(modifiers[a])
-
- states = node.attrib.pop('states', None)
- if states:
- states = states.split(',')
- if 'invisible' in modifiers and isinstance(modifiers['invisible'], list):
- # TODO combine with AND or OR, use implicit AND for now.
- modifiers['invisible'].append(('state', 'not in', states))
- else:
- modifiers['invisible'] = [('state', 'not in', states)]
-
- context_dependent_modifiers = {}
- for attr in ('invisible', 'readonly', 'required'):
- value_str = node.attrib.pop(attr, None)
- if value_str:
-
- if (attr == 'invisible'
- and any(parent.tag == 'tree' for parent in node.iterancestors())
- and not any(parent.tag == 'header' for parent in node.iterancestors())):
- # Invisible in a tree view has a specific meaning, make it a
- # new key in the modifiers attribute.
- attr = 'column_invisible'
-
- # TODO: for invisible="context.get('...')", delegate to the web client.
- try:
- # most (~95%) elements are 1/True/0/False
- value = str2bool(value_str)
- except ValueError:
- # if str2bool fails, it means it's something else than 1/True/0/False,
- # meaning most-likely `context.get('...')`,
- # which should be evaluated after retrieving the view arch from the cache
- context_dependent_modifiers[attr] = value_str
- continue
-
- if value or (attr not in modifiers or not isinstance(modifiers[attr], list)):
- # Don't set the attribute to False if a dynamic value was
- # provided (i.e. a domain from attrs or states).
- modifiers[attr] = value
-
- if context_dependent_modifiers:
- node.set('context-dependent-modifiers', json.dumps(context_dependent_modifiers))
-
-
-def simplify_modifiers(modifiers):
- for a in ('column_invisible', 'invisible', 'readonly', 'required'):
- if a in modifiers and not modifiers[a]:
- del modifiers[a]
-
-
-def transfer_modifiers_to_node(modifiers, node):
- if modifiers:
- simplify_modifiers(modifiers)
- if modifiers:
- node.set('modifiers', json.dumps(modifiers))
-
-
@lazy
def keep_query():
mod = odoo.addons.base.models.ir_qweb
@@ -451,6 +371,7 @@ actual arch.
err = ValidationError(_(
"Error while parsing or validating view:\n\n%(error)s",
error=tools.ustr(e),
+ view=self.key or self.id,
)).with_traceback(e.__traceback__)
err.context = getattr(e, 'context', None)
raise err from None
@@ -459,6 +380,15 @@ actual arch.
# verify that all fields used are valid, etc.
view._validate_view(combined_arch, view.model)
combined_archs = [combined_arch]
+
+ if combined_arch.xpath('//*[@attrs]') or combined_arch.xpath('//*[@states]'):
+ view_name = f'{view.name} ({view.xml_id})' if view.xml_id else view.name
+ err = ValidationError(_('Since 17.0, the "attrs" and "states" attributes are no longer used.\nView: %(name)s in %(file)s',
+ name=view_name, file=view.arch_fs
+ ))
+ err.context = {'name': 'invalid view'}
+ raise err
+
if combined_archs[0].tag == 'data':
# A element is a wrapper for multiple root nodes
combined_archs = combined_archs[0]
@@ -467,23 +397,30 @@ actual arch.
del node.attrib['__validate__']
check = valid_view(view_arch, env=self.env, model=view.model)
if not check:
- view_name = ('%s (%s)' % (view.name, view.xml_id)) if view.xml_id else view.name
+ view_name = f'{view.name} ({view.xml_id})' if view.xml_id else view.name
raise ValidationError(_(
'Invalid view %(name)s definition in %(file)s',
name=view_name, file=view.arch_fs
))
if check == "Warning":
- view_name = ('%s (%s)' % (view.name, view.xml_id)) if view.xml_id else view.name
+ view_name = f'{view.name} ({view.xml_id})' if view.xml_id else view.name
_logger.warning('Invalid view %s definition in %s \n%s', view_name, view.arch_fs, view.arch)
except ValueError as e:
- lines = etree.tostring(combined_arch, encoding='unicode').splitlines(keepends=True)
- fivelines = "".join(lines[max(0, e.context["line"]-3):e.context["line"]+2])
- err = ValidationError(_(
- "Error while validating view near:\n\n%(fivelines)s\n%(error)s",
- fivelines=fivelines, error=tools.ustr(e),
- ))
- err.context = e.context
- raise err.with_traceback(e.__traceback__) from None
+ if hasattr(e, 'context'):
+ lines = etree.tostring(combined_arch, encoding='unicode').splitlines(keepends=True)
+ fivelines = "".join(lines[max(0, e.context["line"]-3):e.context["line"]+2])
+ err = ValidationError(_(
+ "Error while validating view near:\n\n%(fivelines)s\n%(error)s",
+ fivelines=fivelines, error=tools.ustr(e),
+ ))
+ err.context = e.context
+ raise err.with_traceback(e.__traceback__) from None
+ else:
+ err = ValidationError(_(
+ "Error while validating view (%(view)s):\n\n%(error)s", view=self.key or self.id, error=tools.ustr(e.__context__),
+ ))
+ err.context = {'name': 'invalid view'}
+ raise err.with_traceback(e.__context__.__traceback__) from None
return True
@@ -1121,31 +1058,6 @@ actual arch.
return tree
- def _postprocess_context_dependent(self, tree):
- """
- Evaluate the modifiers which depends on the context after retrieving the view from the cache.
-
- e.g.
-
- """
- for node in tree.xpath('//*[@context-dependent-modifiers]'):
- modifiers = json.loads(node.attrib.pop('modifiers', '{}'))
- for attr, value in json.loads(node.attrib.pop('context-dependent-modifiers')).items():
- value = bool(safe_eval.safe_eval(value, {'context': self._context}))
- if value or (attr not in modifiers or not isinstance(modifiers[attr], list)):
- modifiers[attr] = value
- transfer_modifiers_to_node(modifiers, node)
- return tree
-
def _postprocess_view(self, node, model_name, editable=True, parent_name_manager=None, **options):
""" Process the given architecture, modifying it in-place to add and
remove stuff.
@@ -1170,7 +1082,6 @@ actual arch.
root_info = {
'view_type': root.tag,
'view_editable': editable and self._editable_node(root, name_manager),
- 'view_modifiers_from_model': self._modifiers_from_model(root),
'mobile': options.get('mobile'),
}
@@ -1182,7 +1093,7 @@ actual arch.
# compute default
tag = node.tag
had_parent = node.getparent() is not None
- node_info = dict(root_info, modifiers={}, editable=editable and self._editable_node(node, name_manager))
+ node_info = dict(root_info, editable=editable and self._editable_node(node, name_manager))
# tag-specific postprocessing
postprocessor = getattr(self, f"_postprocess_tag_{tag}", None)
@@ -1192,9 +1103,6 @@ actual arch.
# the node has been removed, stop processing here
continue
- transfer_node_to_modifiers(node, node_info['modifiers'])
- transfer_modifiers_to_node(node_info['modifiers'], node)
-
# if present, iterate on node_info['children'] instead of node
for child in reversed(node_info.get('children', node)):
stack.append((child, node_info['editable']))
@@ -1294,7 +1202,7 @@ actual arch.
node_info.get('view_type') == 'form'
and field.type in ('one2many', 'many2many')
and not node.get('widget')
- and not node.get('invisible')
+ and node.get('invisible') not in ('1', 'True')
and not name_manager.parent
):
# Embed kanban/tree/form views for visible x2many fields in form views
@@ -1315,10 +1223,6 @@ actual arch.
name_manager.has_field(node, node.get('name'), attrs)
- field_info = name_manager.field_info.get(node.get('name'))
- if field_info:
- transfer_field_to_modifiers(field_info, node_info['modifiers'], node_info['view_modifiers_from_model'])
-
def _postprocess_tag_form(self, node, name_manager, node_info):
result = name_manager.model.view_header_get(False, node.tag)
if result:
@@ -1379,10 +1283,7 @@ actual arch.
def _editable_tag_field(self, node, name_manager):
field = name_manager.model._fields.get(node.get('name'))
- return field is None or field.is_editable() and (
- node.get('readonly') not in ('1', 'True')
- or get_dict_asts(node.get('attrs') or "{}")
- )
+ return field is None or field.is_editable() and node.get('readonly') not in ('1', 'True')
def _onchange_able_view(self, node):
func = getattr(self, f"_onchange_able_view_{node.tag}", None)
@@ -1399,7 +1300,7 @@ actual arch.
return True
def _modifiers_from_model(self, node):
- modifier_names = ['invisible']
+ modifier_names = []
if node.tag in ('kanban', 'tree', 'form'):
modifier_names += ['readonly', 'required']
return modifier_names
@@ -1456,7 +1357,7 @@ actual arch.
validator(node, name_manager, node_info)
if validate:
- self._validate_attrs(node, name_manager, node_info)
+ self._validate_attributes(node, name_manager, node_info)
for child in reversed(node):
stack.append((child, node_info['editable'], validate))
@@ -1534,11 +1435,14 @@ actual arch.
# dynamic domain: in [('foo', '=', bar)], field 'foo' must
# exist on the comodel and field 'bar' must be in the view
desc = (f'domain of ' if node.get('domain')
- else f"domain of field '{name}'")
- fnames, vnames = self._get_domain_identifiers(node, domain, desc)
- self._check_field_paths(node, fnames, field.comodel_name, f"{desc} ({domain})")
- if vnames:
- name_manager.must_have_fields(node, vnames, f"{desc} ({domain})")
+ else f"domain of python field {name!r}")
+ try:
+ self._validate_domain_identifiers(node, name_manager, domain, desc, field.comodel_name)
+ except ValueError as e:
+ if 'Modifier must be a domain' in str(e):
+ warnings.warn(f"Non-domain syntaxes are deprecated for attribute 'domain': {desc}\n{domain!r}", DeprecationWarning, 2)
+ else:
+ raise
elif validate and node.get('domain'):
msg = _(
@@ -1567,22 +1471,6 @@ actual arch.
name_manager.has_field(node, name, {'id': node.get('id'), 'select': node.get('select')})
- if validate:
- for attribute in ('invisible', 'readonly', 'required'):
- val = node.get(attribute)
- if val:
- try:
- # most (~95%) elements are 1/True/0/False
- res = str2bool(val)
- except ValueError:
- res = safe_eval.safe_eval(val, {'context': self._context})
- if res not in (1, 0, True, False, None):
- msg = _(
- 'Attribute %(attribute)s evaluation expects a boolean, got %(value)s',
- attribute=attribute, value=val,
- )
- self._raise_view_error(msg, node)
-
def _validate_tag_filter(self, node, name_manager, node_info):
if not node_info['validate']:
return
@@ -1590,10 +1478,7 @@ actual arch.
if domain:
name = node.get('name')
desc = f'domain of ' if name else 'domain of '
- fnames, vnames = self._get_domain_identifiers(node, domain, desc)
- self._check_field_paths(node, fnames, name_manager.model._name, f"{desc} ({domain})")
- if vnames:
- name_manager.must_have_fields(node, vnames, f"{desc} ({domain})")
+ self._validate_domain_identifiers(node, name_manager, domain, desc, name_manager.model._name)
def _validate_tag_button(self, node, name_manager, node_info):
if not node_info['validate']:
@@ -1656,11 +1541,8 @@ actual arch.
self._raise_view_error(msg, node)
domain = node_info['editable'] and field._description_domain(self.env)
if isinstance(domain, str):
- desc = f"domain of field '{name}'"
- fnames, vnames = self._get_domain_identifiers(node, domain, desc)
- self._check_field_paths(node, fnames, field.comodel_name, f"{desc} ({domain})")
- if vnames:
- name_manager.must_have_fields(node, vnames, f"{desc} ({domain})")
+ desc = f"domain of python field '{name}'"
+ self._validate_domain_identifiers(node, name_manager, domain, desc, field.comodel_name)
# move all children nodes into a new node
groupby_node = E.groupby(*node)
@@ -1756,25 +1638,24 @@ actual arch.
def _is_qweb_based_view(self, view_type):
return view_type in ("kanban", "gantt")
- def _validate_attrs(self, node, name_manager, node_info):
- """ Generic validation of node attrs. """
+ def _validate_attributes(self, node, name_manager, node_info):
+ """ Generic validation of node attributes. """
+
+ # python expression used in for readonly, invisible, ...
+ # and thus are only executed client side
+ for attr in VIEW_MODIFIERS:
+ py_expression = node.attrib.get(attr)
+ if py_expression:
+ self._validate_expression(node, name_manager, py_expression, f"modifier {attr!r}")
+
for attr, expr in node.items():
if attr in ('class', 't-att-class', 't-attf-class'):
self._validate_classes(node, expr)
- elif attr == 'attrs':
- for key, val_ast in get_dict_asts(expr).items():
- if isinstance(val_ast, ast.List):
- # domains in attrs are used for readonly, invisible, ...
- # and thus are only executed client side
- fnames, vnames = self._get_domain_identifiers(node, val_ast, attr, expr)
- name_manager.must_have_fields(node, fnames | vnames, f"attrs ({expr})")
- else:
- vnames = get_variable_names(val_ast)
- if vnames:
- name_manager.must_have_fields(node, vnames, f"attrs ({expr})")
-
elif attr == 'context':
+ vnames = get_expression_field_names(expr) - {'id'}
+ if vnames:
+ name_manager.must_have_fields(node, vnames, f"context ({expr})")
for key, val_ast in get_dict_asts(expr).items():
if key == 'group_by': # only in context
if not isinstance(val_ast, ast.Str):
@@ -1791,10 +1672,6 @@ actual arch.
field=fname, attribute=attr, value=expr,
)
self._raise_view_error(msg, node)
- else:
- vnames = get_variable_names(val_ast)
- if vnames:
- name_manager.must_have_fields(node, vnames, f"context ({expr})")
elif attr == 'groups':
for group in expr.replace('!', '').split(','):
@@ -1811,9 +1688,9 @@ actual arch.
)
elif attr.startswith('decoration-'):
- vnames = get_variable_names(expr)
+ vnames = get_expression_field_names(expr) - {'id'}
if vnames:
- name_manager.must_have_fields(node, vnames, f"{attr}={expr}")
+ name_manager.must_have_fields(node, vnames, f"{attr}={expr!r}")
elif attr == 'data-bs-toggle' and expr == 'tab':
if node.get('role') != 'tab':
@@ -1982,12 +1859,30 @@ actual arch.
if (not next(filter(lambda regex: re.match(regex, directive), allowed_directives), None)):
self._raise_view_error(_("Forbidden owl directive used in arch (%s).", directive), node)
- def _get_domain_identifiers(self, node, domain, use, expr=None):
+ def _validate_expression(self, node, name_manager, py_expression, use):
try:
- return get_domain_identifiers(domain)
- except ValueError:
- msg = _("Invalid domain format %(expr)s in %(use)s", expr=expr or domain, use=use)
- self._raise_view_error(msg, node)
+ if py_expression.lower() in ("0", "false", "1", "true"):
+ # most (~95%) elements are 1/True/0/False
+ return
+ fnames = get_expression_field_names(py_expression) - {"id"}
+ except (SyntaxError, ValueError, AttributeError) as e:
+ msg = _("Invalid %(use)s: %(expr)r\n%(error)s", use=use, expr=py_expression, error=e)
+ self._raise_view_error(msg, node, from_exception=e)
+ name_manager.must_have_fields(node, fnames, f"{use} ({py_expression})")
+
+ def _validate_domain_identifiers(self, node, name_manager, domain, use, model=None):
+ try:
+ fnames, vnames = get_domain_value_names(domain)
+ except (SyntaxError, ValueError, AttributeError) as e:
+ msg = _("Invalid %(use)s: %(expr)r\n%(error)s", use=use, expr=domain, error=e)
+ self._raise_view_error(msg, node, from_exception=e)
+
+ if model:
+ self._check_field_paths(node, fnames, model, f"{use} ({domain})")
+ else:
+ name_manager.must_have_fields(node, fnames, f"{use} ({domain})")
+
+ name_manager.must_have_fields(node, vnames, f"{use} ({domain})")
def _check_field_paths(self, node, field_paths, model_name, use):
""" Check whether the given field paths (dot-separated field names)
@@ -1996,6 +1891,8 @@ actual arch.
for field_path in field_paths:
names = field_path.split('.')
Model = self.pool[model_name]
+ if names[0] == 'parent':
+ continue
for index, name in enumerate(names):
if Model is None:
msg = _(
@@ -2707,7 +2604,6 @@ class Model(models.AbstractModel):
node = etree.fromstring(result['arch'])
node = self.env['ir.ui.view']._postprocess_access_rights(node)
- node = self.env['ir.ui.view']._postprocess_context_dependent(node)
result['arch'] = etree.tostring(node, encoding="unicode").replace('\t', '')
return result
@@ -2860,12 +2756,11 @@ class NameManager:
@lazy_property
def field_info(self):
- field_info = self.model.fields_get(attributes=['invisible', 'states', 'readonly', 'required'])
+ field_info = self.model.fields_get(attributes=['readonly', 'required'])
has_access = functools.partial(self.model.check_access_rights, raise_exception=False)
if not (has_access('write') or has_access('create')):
for info in field_info.values():
info['readonly'] = True
- info['states'] = {}
return field_info
def has_field(self, node, name, info=frozendict()):
@@ -3018,6 +2913,7 @@ class NameManager:
in available_combinations
for combination in available_combinations
)
+ and ((
# For all mandatory combinations, find an available combination
# which is included in the mandatory combination
# e.g.
@@ -3025,7 +2921,7 @@ class NameManager:
# available combination: A B
# The above is valid, the field will be available for users having both A and B groups
# and the field is mandatory only for users having A B and C groups.
- and not all(
+ not all(
any(
available_combination.issubset(mandatory_combination)
for available_combination in available_combinations
@@ -3060,6 +2956,13 @@ class NameManager:
)
) for mandatory_combination in mandatory_combinations
)
+ )
+ # if 'base.group_no_one' is in available_combinations the group
+ # must be in mandatory_combinations because depending of session
+ or (
+ {'base.group_no_one'} in available_combinations and
+ not all('base.group_no_one' in combination for combination in mandatory_combinations)
+ ))
):
msg = _(
"Field %(name)r used in %(use)s is restricted to the group(s) %(groups)s.",
diff --git a/odoo/addons/base/rng/common.rng b/odoo/addons/base/rng/common.rng
index 25fb98a9277..087fa6d0dbc 100644
--- a/odoo/addons/base/rng/common.rng
+++ b/odoo/addons/base/rng/common.rng
@@ -40,19 +40,6 @@
-
-
-
-
-
-
-
@@ -95,7 +82,6 @@
-
@@ -147,7 +133,6 @@
-
@@ -208,9 +193,7 @@
-
-
@@ -224,6 +207,7 @@
+
@@ -246,7 +230,6 @@
-
@@ -312,12 +295,9 @@
-
-
-
@@ -336,14 +316,12 @@
-
-
+
-
@@ -380,9 +358,7 @@
-
-
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index 032b789bbba..3c447653007 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -644,11 +644,51 @@ class TestExpression(SavepointCaseWithUserDemo):
self.assertEqual(users, b2, '(x =? id) failed')
def test_30_normalize_domain(self):
- norm_domain = domain = ['&', (1, '=', 1), ('a', '=', 'b')]
- self.assertEqual(norm_domain, expression.normalize_domain(domain), "Normalized domains should be left untouched")
- domain = [('x', 'in', ['y', 'z']), ('a.v', '=', 'e'), '|', '|', ('a', '=', 'b'), '!', ('c', '>', 'd'), ('e', '!=', 'f'), ('g', '=', 'h')]
- norm_domain = ['&', '&', '&'] + domain
- self.assertEqual(norm_domain, expression.normalize_domain(domain), "Non-normalized domains should be properly normalized")
+ normalize_domain = expression.normalize_domain
+
+ self.assertEqual(normalize_domain([]), [expression.TRUE_LEAF])
+ self.assertEqual(normalize_domain([expression.TRUE_LEAF]), [expression.TRUE_LEAF])
+ self.assertEqual(normalize_domain([expression.FALSE_LEAF]), [expression.FALSE_LEAF])
+ self.assertEqual(normalize_domain([('a', '=', 1)]), [('a', '=', 1)])
+ self.assertEqual(
+ normalize_domain([('a', '=', 1), ('b', '=', 2)]),
+ ['&', ('a', '=', 1), ('b', '=', 2)],
+ )
+ self.assertEqual(
+ normalize_domain(['|', ('a', '=', 1), ('b', '=', 2)]),
+ ['|', ('a', '=', 1), ('b', '=', 2)],
+ )
+ self.assertEqual(
+ normalize_domain(['|', ('a', '=', 1), ('b', '=', 2), ('c', '=', 3)]),
+ ['&', '|', ('a', '=', 1), ('b', '=', 2), ('c', '=', 3)],
+ )
+ self.assertEqual(
+ normalize_domain([('a', '=', 1), '|', ('b', '=', 2), ('c', '=', 3)]),
+ ['&', ('a', '=', 1), '|', ('b', '=', 2), ('c', '=', 3)],
+ )
+ self.assertEqual(
+ normalize_domain(['&', expression.TRUE_LEAF, ('a', '=', 1)]),
+ ['&', expression.TRUE_LEAF, ('a', '=', 1)],
+ )
+ domain = [
+ ('a', '=', 1),
+ ('b.z', '=', 2),
+ '|', '|', ('c', '=', 3), '!', ('d', '=', 4), ('e', '=', 5),
+ ('f', '=', 6),
+ ]
+ self.assertEqual(normalize_domain(domain), ['&', '&', '&'] + domain)
+
+ with self.assertRaises(ValueError):
+ normalize_domain(['&'])
+
+ with self.assertRaises(ValueError):
+ normalize_domain(['&', ('a', '=', 1)])
+
+ with self.assertRaises(ValueError):
+ normalize_domain([('a', '=', 1), '&', ('b', '=', 2)])
+
+ with self.assertRaises(ValueError):
+ normalize_domain([('a', '=', 1), '!'])
def test_35_negating_thruty_leafs(self):
self.assertEqual(expression.distribute_not(['!', '!', expression.TRUE_LEAF]), [expression.TRUE_LEAF], "distribute_not applied wrongly")
diff --git a/odoo/addons/base/tests/test_form_create.py b/odoo/addons/base/tests/test_form_create.py
index 48dbc36ab9a..36f6c9a8b92 100644
--- a/odoo/addons/base/tests/test_form_create.py
+++ b/odoo/addons/base/tests/test_form_create.py
@@ -69,6 +69,7 @@ class TestFormCreate(TransactionCase):
def test_create_res_lang(self):
lang_form = Form(self.env['res.lang'])
+ # lang_form.url_code = 'LANG' # invisible field, tested in http_routing
lang_form.name = 'a lang name'
lang_form.code = 'a lang code'
lang_form.save()
diff --git a/odoo/addons/base/tests/test_res_users.py b/odoo/addons/base/tests/test_res_users.py
index 59755db0a69..56a7020c968 100644
--- a/odoo/addons/base/tests/test_res_users.py
+++ b/odoo/addons/base/tests/test_res_users.py
@@ -330,7 +330,7 @@ class TestUsers2(TransactionCase):
user_groups_ids = [str(group_id) for group_id in sorted(user_groups.ids)]
group_field_name = f"sel_groups_{'_'.join(user_groups_ids)}"
- #
+ #
with self.debug_mode():
user_form = Form(self.env['res.users'], view='base.view_users_form')
user_form.name = "Test"
diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py
index dae4cf28f83..cdac518e261 100644
--- a/odoo/addons/base/tests/test_views.py
+++ b/odoo/addons/base/tests/test_views.py
@@ -15,10 +15,8 @@ from psycopg2.extras import Json
from odoo.exceptions import AccessError, ValidationError
from odoo.tests import common, tagged
-from odoo.tools import get_cache_key_counter, mute_logger, view_validation
-from odoo.addons.base.models.ir_ui_view import (
- transfer_field_to_modifiers, transfer_node_to_modifiers, simplify_modifiers, COMP_REGEX
-)
+from odoo.tools import get_cache_key_counter, mute_logger, view_validation, safe_eval
+from odoo.addons.base.models import ir_ui_view
_logger = logging.getLogger(__name__)
@@ -1815,73 +1813,6 @@ class TestViews(ViewCase):
string="Replacement title"
))
- def test_modifiers(self):
- def _test_modifiers(what, expected):
- modifiers = {}
- if isinstance(what, dict):
- transfer_field_to_modifiers(what, modifiers, ['invisible', 'readonly', 'required'])
- else:
- node = etree.fromstring(what) if isinstance(what, str) else what
- transfer_node_to_modifiers(node, modifiers)
- simplify_modifiers(modifiers)
- assert str(modifiers) == str(expected), "%s != %s" % (modifiers, expected)
-
- _test_modifiers('', {})
- _test_modifiers('', {"invisible": True})
- _test_modifiers('', {"readonly": True})
- _test_modifiers('', {"required": True})
- _test_modifiers('', {})
- _test_modifiers('', {})
- _test_modifiers('', {})
- _test_modifiers(
- """""",
- {"readonly": True},
- )
- _test_modifiers(
- """""",
- {},
- )
- # TODO: Order is not guaranteed
- _test_modifiers(
- '',
- {"invisible": True, "required": True},
- )
- _test_modifiers(
- '',
- {"invisible": True},
- )
- _test_modifiers(
- '',
- {"required": True},
- )
- _test_modifiers(
- """""",
- {"invisible": [["b", "=", "c"]]},
- )
-
- # fields in a tree view
- tree = etree.fromstring('''
-
-
-
-
-
-
-
-
-
- ''')
- _test_modifiers(tree[0][0], {"invisible": True})
- _test_modifiers(tree[1], {})
- _test_modifiers(tree[2], {})
- _test_modifiers(tree[3], {"column_invisible": True})
- _test_modifiers(tree[4], {"invisible": [['b', '=', 'c']]})
-
- # The dictionary is supposed to be the result of fields_get().
- _test_modifiers({}, {})
- _test_modifiers({"invisible": True}, {"invisible": True})
- _test_modifiers({"invisible": False}, {})
-
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_invalid_field(self):
self.assertInvalid("""
@@ -2026,11 +1957,11 @@ class TestViews(ViewCase):
self.assertValid(arch % ('', '1', '1'))
self.assertValid(arch % ('', '0', '1'))
# self.assertInvalid(arch % ('', '1', '0'))
- self.assertValid(arch % ('', '0 if name else 1', '1'))
+ self.assertValid(arch % ('', '1', '0 if name else 1'))
# self.assertInvalid(arch % ('', "'tata' if name else 'tutu'", 'type'), 'xxxx')
self.assertInvalid(
- arch % ('', '0 if name else 1', '1'),
- """Field 'name' used in domain of ([(0 if name else 1, '=', 1)]) must be present in view but is missing""",
+ arch % ('', '1', '0 if name else 1'),
+ """Field 'name' used in domain of ([(1, '=', 0 if name else 1)]) must be present in view but is missing""",
)
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -2141,7 +2072,7 @@ class TestViews(ViewCase):
self.assertValid(arch % '')
self.assertInvalid(
arch % '',
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
)
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -2163,11 +2094,11 @@ class TestViews(ViewCase):
self.assertValid(arch % ('', ''))
self.assertInvalid(
arch % ('', ''),
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
)
self.assertInvalid(
arch % ('', ''),
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
)
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -2189,11 +2120,11 @@ class TestViews(ViewCase):
self.assertValid(arch % ('', ''))
self.assertInvalid(
arch % ('', ''),
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', parent.model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', parent.model)]) must be present in view but is missing.""",
)
self.assertInvalid(
arch % ('', ''),
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', parent.model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', parent.model)]) must be present in view but is missing.""",
)
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -2215,7 +2146,7 @@ class TestViews(ViewCase):
self.assertValid(arch % '')
self.assertInvalid(
arch % ' editable="bottom"',
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
)
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -2259,65 +2190,9 @@ class TestViews(ViewCase):
self.assertValid(arch % ' readonly="1"')
self.assertInvalid(
arch % '',
- """Field 'model' used in domain of field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
+ """Field 'model' used in domain of python field 'inherit_id' ([('model', '=', model)]) must be present in view but is missing.""",
)
- @mute_logger('odoo.addons.base.models.ir_ui_view')
- def test_modifier_attribute_is_boolean(self):
- arch = """
-
- """
- self.assertValid(arch % '1')
- self.assertValid(arch % '0')
- self.assertValid(arch % 'True')
- self.assertInvalid(
- arch % "[('model', '=', '1')]",
- "Attribute readonly evaluation expects a boolean, got [('model', '=', '1')]",
- )
-
- def test_modifier_attribute_using_context(self):
- view = self.assertValid("""
-
- """)
-
- for context, expected in [
- ({}, {}),
- ({'foo': True}, {'invisible': True}),
- ({'bar': True}, {'readonly': True}),
- ({'baz': True}, {'required': True}),
- ({'foo': True, 'bar': True}, {'invisible': True, 'readonly': True}),
- ]:
- arch = self.View.with_context(**context).get_view(view.id)['arch']
- field_node = etree.fromstring(arch).xpath('//field[@name="name"]')[0]
- modifiers = json.loads(field_node.get('modifiers') or '{}')
- self.assertEqual(modifiers.get('invisible'), expected.get('invisible'))
- self.assertEqual(modifiers.get('readonly'), expected.get('readonly'))
- self.assertEqual(modifiers.get('required'), expected.get('required'))
-
- def test_modifier_attribute_priority(self):
- view = self.assertValid("""
-
- """)
- for context, expected in [
- ({}, [['type', '=', 'tree']]),
- ({'foo': True}, True)
- ]:
- arch = self.View.with_context(**context).get_view(view.id)['arch']
- field_node = etree.fromstring(arch).xpath('//field[@name="name"]')[0]
- modifiers = json.loads(field_node.get('modifiers') or '{}')
- self.assertEqual(modifiers.get('invisible'), expected)
-
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_in_filter(self):
arch = """
@@ -2363,7 +2238,7 @@ class TestViews(ViewCase):
""",
- """Invalid domain format ['name', '=', 'dummy'] in domain of """,
+ '''Invalid domain of : "['name', '=', 'dummy']"''',
)
@mute_logger('odoo.addons.base.models.ir_ui_view')
@@ -2373,26 +2248,26 @@ class TestViews(ViewCase):
%s
%s
-
+
"""
self.assertValid(arch % ('', '', 'view_access', 'inherit_id'))
self.assertInvalid(
arch % ('', '', 'view_access', 'inherit_id'),
- """Field 'inherit_id' used in domain of ([['view_access', '=', inherit_id]]) must be present in view but is missing.""",
+ """Field 'inherit_id' used in domain of ([('view_access', '=', inherit_id)]) must be present in view but is missing.""",
)
self.assertInvalid(
arch % ('', '', 'view_access', 'view_access'),
- """Field 'view_access' used in domain of ([['view_access', '=', view_access]]) must be present in view but is missing.""",
+ """Field 'view_access' used in domain of ([('view_access', '=', view_access)]) must be present in view but is missing.""",
)
self.assertInvalid(
arch % ('', '', 'inherit_id', 'inherit_id'),
- """Unknown field "res.groups.inherit_id" in domain of ([['inherit_id', '=', inherit_id]])""",
+ """Unknown field "res.groups.inherit_id" in domain of ([('inherit_id', '=', inherit_id)])""",
)
self.assertInvalid(
arch % ('', '', 'view_access', 'inherit_id'),
- """Field 'inherit_id' used in domain of ([['view_access', '=', inherit_id]]) is present in view but is in select multi.""",
+ """Field 'inherit_id' used in domain of ([('view_access', '=', inherit_id)]) is present in view but is in select multi.""",
)
arch = """
@@ -2416,84 +2291,6 @@ class TestViews(ViewCase):
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 = """
-
- """
- self.assertValid(arch % '')
- self.assertInvalid(
- arch % '',
- """Field 'model' used in attrs ({'readonly': [('model', '=', 'ir.ui.view')]}) must be present in view but is missing""",
- )
-
- @mute_logger('odoo.addons.base.models.ir_ui_view')
- def test_attrs_invalid_domain(self):
- arch = """
-
- """
- self.assertInvalid(
- arch,
- """Invalid domain format {'readonly': [('model', 'ir.ui.view')]} in attrs""",
- )
-
- @mute_logger('odoo.addons.base.models.ir_ui_view')
- def test_attrs_subfield(self):
- arch = """
-
- """
- self.assertValid(arch % ('', ''))
- self.assertInvalid(
- arch % ('', ''),
- """Field 'model' used in attrs ({'readonly': [('model', '=', 'ir.ui.view')]}) must be present in view but is missing.""",
- )
- self.assertInvalid(
- arch % ('', ''),
- """Field 'model' used in attrs ({'readonly': [('model', '=', 'ir.ui.view')]}) must be present in view but is missing.""",
- )
-
- @mute_logger('odoo.addons.base.models.ir_ui_view')
- def test_attrs_subfield_with_parent(self):
- arch = """
-
- %s
-
-
- %s
-
-
-
-
- """
- self.assertValid(arch % ('', ''))
- self.assertInvalid(
- arch % ('', ''),
- """Field 'model' used in attrs ({'readonly': [('parent.model', '=', 'ir.ui.view')]}) must be present in view but is missing.""",
- )
- self.assertInvalid(
- arch % ('', ''),
- """Field 'model' used in attrs ({'readonly': [('parent.model', '=', 'ir.ui.view')]}) must be present in view but is missing.""",
- )
-
def test_attrs_groups_behavior(self):
view = self.View.create({
'name': 'foo',
@@ -2531,14 +2328,14 @@ class TestViews(ViewCase):
def validate(arch, valid=False, parent=False):
parent = 'parent.' if parent else ''
if valid:
- self.assertValid(arch % {'attrs': f"""attrs="{{'invisible': [('{parent}name', '=', 'foo')]}}" """})
+ self.assertValid(arch % {'attrs': f"""invisible="{parent}name == 'foo'" """})
self.assertValid(arch % {'attrs': f"""domain="[('name', '!=', {parent}name)]" """})
self.assertValid(arch % {'attrs': f"""context="{{'default_name': {parent}name}}" """})
self.assertValid(arch % {'attrs': f"""decoration-info="{parent}name == 'foo'" """})
else:
self.assertInvalid(
- arch % {'attrs': f"""attrs="{{'invisible': [('{parent}name', '=', 'foo')]}}" """},
- f"""Field 'name' used in attrs ({{'invisible': [('{parent}name', '=', 'foo')]}}) is restricted to the group(s)""",
+ arch % {'attrs': f"""invisible="{parent}name == 'foo'" """},
+ f"""Field 'name' used in modifier 'invisible' ({parent}name == 'foo') is restricted to the group(s)""",
)
self.assertInvalid(
arch % {'attrs': f"""domain="[('name', '!=', {parent}name)]" """},
@@ -2550,7 +2347,7 @@ class TestViews(ViewCase):
)
self.assertInvalid(
arch % {'attrs': f"""decoration-info="{parent}name == 'foo'" """},
- f"""Field 'name' used in decoration-info={parent}name == 'foo' is restricted to the group(s)""",
+ f"""Field 'name' used in decoration-info="{parent}name == 'foo'" is restricted to the group(s)""",
)
@@ -2969,6 +2766,25 @@ class TestViews(ViewCase):
""", valid=True)
+ # Assert using a field restricted to a 'base.group_no_one' in another
+ # field with a group implied 'base.group_no_one' is invalid. The group
+ # 'base.group_no_one' must be in the view because it's depending of the
+ # session.
+ validate("""
+
+
+
+
+ """, valid=False)
+ validate("""
+
+
+
+
+
+
+ """, valid=True)
+
@mute_logger('odoo.addons.base.models.ir_ui_view')
def test_empty_groups_attrib(self):
"""Ensure we allow empty groups attribute"""
@@ -2986,7 +2802,7 @@ class TestViews(ViewCase):
nodes = tree.xpath("//field[@name='name' and not (@groups)]")
self.assertEqual(1, len(nodes))
- def test_attrs_groups_with_groups_in_model(self):
+ def test_invisible_groups_with_groups_in_model(self):
"""Tests the attrs is well processed to modifiers for a field node combining:
- a `groups` attribute on the field node in the view architecture
- a `groups` attribute on the field in the Python model
@@ -2999,14 +2815,14 @@ class TestViews(ViewCase):
'arch': """
+ """
+ self.assertInvalid(
+ arch,
+ """Field 'bidule' used in modifier 'readonly' (bidule.get('truc') or context.get('truc')) must be present in view but is missing.""",
+ )
+
+ arch = """
+
+
+
+
+
+ """
+ self.assertInvalid(
+ arch,
+ """must be present in view but is missing""",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_13_attrs_states_invisible_to_modifier(self):
+ view = self.View.create({
+ 'name': 'foo',
+ 'model': 'ir.module.module',
+ 'arch': """
+
+
+
+
+
+
+
+ """,
+ })
+ arch = self.env['ir.module.module'].get_view(view_id=view.id)['arch']
+ tree = etree.fromstring(arch)
+
+ invisible = tree.xpath('//group')[0].get('invisible')
+ self.assertEqual(invisible, "state != 'finished'")
+
+ invisible = tree.xpath('//field[@name="category_id"]')[0].get('invisible')
+ self.assertEqual(invisible, "not state")
+
+ invisible = tree.xpath('//field[@name="state"]')[0].get('invisible')
+ self.assertEqual(invisible, "name not in ['qweb-pdf', 'qweb-html', 'qweb-text']")
+
+ invisible = tree.xpath('//field[@name="name"]')[0].get('invisible')
+ self.assertEqual(invisible, "name != 'bidule' and category_id != uid and state not in ('draf', 'finished')")
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_14_attrs_subfield(self):
+ arch = """
+
+ %s
+
+
+ %s
+
+
+
+
+ """
+ self.assertValid(arch % ('', ''))
+ self.assertInvalid(
+ arch % ('', ''),
+ """Field 'model' used in modifier 'readonly' (model == 'ir.ui.view') must be present in view but is missing.""",
+ )
+ self.assertInvalid(
+ arch % ('', ''),
+ """Field 'model' used in modifier 'readonly' (model == 'ir.ui.view') must be present in view but is missing.""",
+ )
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_15_attrs_subfield_with_parent(self):
+ arch = """
+
+ %s
+
+
+ %s
+
+
+
+
+ """
+ self.assertValid(arch % ('', ''))
+ self.assertInvalid(
+ arch % ('', ''),
+ """Field 'model' used in modifier 'readonly' (parent.model == 'ir.ui.view') must be present in view but is missing.""",
+ )
+ self.assertInvalid(
+ arch % ('', ''),
+ """Field 'model' used in modifier 'readonly' (parent.model == 'ir.ui.view') must be present in view but is missing.""",
+ )
+
+ def test_16_attrs_groups_behavior(self):
+ view = self.View.create({
+ 'name': 'foo',
+ 'model': 'res.partner',
+ 'arch': """
+
+
+
+
+
+
+ """,
+ })
+ user_demo = self.env.ref('base.user_demo')
+ # Make sure demo doesn't have the base.group_system
+ self.assertFalse(self.env['res.partner'].with_user(user_demo).env.user.has_group('base.group_system'))
+ arch = self.env['res.partner'].with_user(user_demo).get_view(view_id=view.id)['arch']
+ tree = etree.fromstring(arch)
+ self.assertTrue(tree.xpath('//field[@name="name"]'))
+ self.assertFalse(tree.xpath('//field[@name="company_id"]'))
+ self.assertTrue(tree.xpath('//div[@id="foo"]'))
+ self.assertFalse(tree.xpath('//div[@id="bar"]'))
+
+ user_admin = self.env.ref('base.user_admin')
+ # Make sure admin has the base.group_system
+ self.assertTrue(self.env['res.partner'].with_user(user_admin).env.user.has_group('base.group_system'))
+ arch = self.env['res.partner'].with_user(user_admin).get_view(view_id=view.id)['arch']
+ tree = etree.fromstring(arch)
+ self.assertTrue(tree.xpath('//field[@name="name"]'))
+ self.assertTrue(tree.xpath('//field[@name="company_id"]'))
+ self.assertTrue(tree.xpath('//div[@id="foo"]'))
+ self.assertTrue(tree.xpath('//div[@id="bar"]'))
+
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
+ def test_17_attrs_groups_validation(self):
+ def validate(arch, valid=False, parent=False):
+ parent = 'parent.' if parent else ''
+ if valid:
+ self.assertValid(arch % {'attrs': f"""invisible="{parent}name == 'foo'" """})
+ self.assertValid(arch % {'attrs': f"""domain="[('name', '!=', {parent}name)]" """})
+ self.assertValid(arch % {'attrs': f"""context="{{'default_name': {parent}name}}" """})
+ self.assertValid(arch % {'attrs': f"""decoration-info="{parent}name == 'foo'" """})
+ else:
+ self.assertInvalid(
+ arch % {'attrs': f"""invisible="{parent}name == 'foo'" """},
+ f"""Field 'name' used in modifier 'invisible' ({parent}name == 'foo') is restricted to the group(s)""",
+ )
+ self.assertInvalid(
+ arch % {'attrs': f"""domain="[('name', '!=', {parent}name)]" """},
+ f"""Field 'name' used in domain of ([('name', '!=', {parent}name)]) is restricted to the group(s)""",
+ )
+ self.assertInvalid(
+ arch % {'attrs': f"""context="{{'default_name': {parent}name}}" """},
+ f"""Field 'name' used in context ({{'default_name': {parent}name}}) is restricted to the group(s)""",
+ )
+ self.assertInvalid(
+ arch % {'attrs': f"""decoration-info="{parent}name == 'foo'" """},
+ f"""Field 'name' used in decoration-info="{parent}name == 'foo'" is restricted to the group(s)""",
+ )
+
+
+ # Assert using a field restricted to a group
+ # in another field without the same group is invalid
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert using a parent field restricted to a group
+ # in a child field without the same group is invalid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=False, parent=True)
+
+ # Assert using a parent field restricted to a group
+ # in a child field with the same group is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True, parent=True)
+
+ # Assert using a parent field available for everyone
+ # in a child field restricted to a group is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True, parent=True)
+
+ # Assert using a field available for everyone
+ # in another field restricted to a group is valid
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field restricted to a group
+ # in another field with the same group is valid
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field available twice for 2 diffent groups
+ # in another field restricted to one of the 2 groups is valid
+ validate("""
+
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field restricted to a group only
+ # in other fields restricted to at least one different group is invalid
+ validate("""
+
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field available twice for 2 different groups
+ # in other fields restricted to the same 2 group is valid
+ validate("""
+
+
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field available for 2 diffent groups,
+ # in another field restricted to one of the 2 groups is valid
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field available for 1 group only
+ # in another field restricted 2 groups is invalid
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field restricted to a group
+ # in another field restricted to a group including the group for which the field is available is valid
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a parent field restricted to a group
+ # in a child field restricted to a group including the group for which the field is available is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True, parent=True)
+
+ # Assert using a field restricted to a group
+ # in another field restricted to a group not including the group for which the field is available is invalid
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert using a parent field restricted to a group
+ # in a child field restricted to a group not including the group for which the field is available is invalid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=False, parent=True)
+
+ # Assert using a field within a block restricted to a group
+ # in another field not restricted to the same group is invalid
+ validate("""
+
+
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field within a block restricted to a group
+ # in another field within the same block restricted to a group is valid
+ validate("""
+
+
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field within a block restricted to a group
+ # in another field within the same block restricted to a group and additional groups on the field node is valid
+ validate("""
+
+
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field within a block restricted to a group
+ # in another field within a block restricted to the same group is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field within a block restricted to a group
+ # in another field within a block restricted to a group including the group for which the field is available
+ # is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field within a block restricted to a group
+ # in another field within a block restricted to a group not including the group for which the field is available
+ # is invalid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=False)
+
+ # Assert using a parent field restricted to a group
+ # in a child field under a relational field restricted to the same group is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True, parent=True)
+
+ # Assert using a parent field restricted to a group
+ # in a child field under a relational field restricted
+ # to a group including the group for which the field is available is valid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=True, parent=True)
+
+ # Assert using a parent field restricted to a group
+ # in a child field under a relational field restricted
+ # to a group not including the group for which the field is available is invalid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=False, parent=True)
+
+ # Assert using a field restricted to users not having a group
+ # in another field not restricted to any group is invalid
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field not restricted to any group
+ # in another field restricted to users not having a group is valid
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field restricted to users not having multiple groups
+ # in another field restricted to users not having one of the group only is invalid
+ # e.g.
+ # if the user is portal, the field "name" will not be in the view
+ # but the field "inherit_id" where "name" is used will be in the view
+ # making it invalid.
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field restricted to users not having a group
+ # in another field restricted to users not having multiple group including the one above is valid
+ # e.g.
+ # if the user is portal, the field "name" will be in the view
+ # but the field "inherit_id" where "name" is used will not be in the view
+ # making it valid.
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field restricted to a non group
+ # in another field for which the non group is not implied is invalid
+ # e.g.
+ # if the user is employee, the field "name" will not be in the view
+ # but the field "inherit_id" where "name" is used will be in the view,
+ # making it invalid.
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field restricted to a non group
+ # in another field restricted to a non group implied in the non group of the available field is valid
+ # e.g.
+ # if the user is employee, the field "name" will be in the view
+ # but the field "inherit_id", where "name" is used, will not be in the view,
+ # therefore making it valid
+ validate("""
+
+
+
+
+ """, valid=True)
+
+ # Assert using a field restricted to non-admins, itself in a block restricted to employees,
+ # in another field restricted to a block restricted to employees
+ # is invalid
+ # e.g.
+ # if the user is admin, the field "name" will not be in the view
+ # but the field "inherit_id", where "name" is used, will be in the view,
+ # threfore making it invalid
+ validate("""
+
+
+
+
+
+
+
+
+ """, valid=False)
+
+ # Assert using a field restricted to a group
+ # in another field restricted the opposite group is invalid
+ # e.g.
+ # if the user is admin, the field "name" will be in the view
+ # but the field "inherit_id", where "name" is used, will not be in the view,
+ # therefore making it invalid
+ validate("""
+
+
+
+
+ """, valid=False)
+
+ # Assert having two times the same field with a mutually exclusive group
+ # and using that field in another field without any group is valid
+ validate("""
+
+
+
+
+
+ """, valid=True)
+
+ # Assert having two times the same field with a mutually exclusive group
+ # and using that field in another field using the group is valid
+ validate("""
+
+
+
+
+
+ """, valid=True)
+
+ # Assert having two times the same field with a mutually exclusive group
+ # and using that field in another field using the !group is valid
+ validate("""
+
+
+
+
+
+ """, valid=True)
+
+ # Assert having two times the same field with a mutually exclusive group
+ # and using that field in another field restricted to any other group is valid
+ validate("""
+