[REF] base: Update modifier syntax (invisible, required, readonly)
Goal:
* Simplified modifiers to only have one way to define modifiers;
* Remove states attributes on python field;
* Use python expression in view `required`, `readonly`, `invisible`;
* More accurate validation of xml views.
This commit change the syntax to python expression. The next commit
will update/convert all xml views.
Before this commit:
* the `required`, `readonly` and `invisible` attributes can only have
values of `True`, `False`, 1, 0 or a python expression to use the
context;
* the `attrs` attribute define a dict. The key of this dict was
`required`, `readonly` and `invisible` and the values are the domain or
a string representing a domain to be evaluate as python expression.
This python expressions was evaluate by the javascript with view fields
and other contextual values as: context, uid, parent, active_id,
active_ids, active_model, allowed_company_ids, current_company_id.
* the `states` attribute in the view was a comma separated list of the
state. This list was combined with the `invisible` attribute;
* the `invisible` attribute on python field is used as default value;
* the `states` attribute on python field was dictionnary with state as
key and list of tuple. This structure was combined with `readonly` view
attribute.
* After combining, the resulting domains of the different attributes
`required`, `readonly` and `invisible` are evaluated with the values of
the fields. The `invisible` attributes is splitted into two use:
`invisible` and `column_invisible`.
After this commit:
* The attributes `required`, `readonly`, `invisible` and
`column_invisible` define python expression. This python expressions
are evaluate by the javascript with view fields and other contextual
values as: context, uid, parent, active_id, active_ids, active_model,
allowed_company_ids, current_company_id.
The domains can contains contextual value and will be evaluate by the
javascript.
```xml
<field name="field_a" readonly="not context.get('show_a')" attrs="{'readonly': [('field_b', '!=', False), ('field_c', '=', parent.c)]}"/>
<field name="field_b" states="draft"/>
```
will be replaced by
```xml
<field name="field_a" readonly="not context.get('show_a') or field_b and field_c == parent.c"/>
<field name="field_b" invisible="state != 'draft'"/>
```
Some inherited views will be modified differently in order to maintain
the previous behavior:
```xml
<field name="field_a" readonly="not context.get('show_a')" attrs="{'invisible': [('field_b', '!=', False)]}">
```
```xml
<field name="field_a" position="attributes">
<attribute name="attrs">{'readonly': [('field_c', '=', False)], 'invisible': [('field_d', '!=', '3')]}<attribute>
</field>
```
will be replaced by
```xml
<field name="field_a" readonly="not context.get('show_a')" invisible="field_b">
```
```xml
<field name="field_a" position="attributes">
<attribute name="readonly" add="(not field_c)" separator=" or "/>
<attribute name="invisible">field_d != 3<attribute>
</field>
```
Validation:
A stricter control is made on the level of the attributes (modifiers)
and the fields necessary for these. The use of the previous attributes
'attr' and 'states' triggers an error (these no longer exist after the
application of the migration script)
task-2495504
Part-of: odoo/odoo#104741
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# <field name="parent_id"
|
||||
# attrs="{
|
||||
# 'invisible': [
|
||||
# '|',
|
||||
# '&', ('is_company','=', True),('parent_id', '=', False),
|
||||
# ('company_name', '!=', False),('company_name', '!=', '')
|
||||
# ]
|
||||
# }"
|
||||
# invisible="(is_company and not parent_id or company_name) and company_name != ''"
|
||||
# />
|
||||
partner_form.parent_id = contact_company_1
|
||||
partner_form.company_type = 'company'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
# <div class="o_row" attrs="{'invisible': ['|', ('state', '=', 'draft'), ('product_tracking', 'in', ('none', False))]}">
|
||||
# <div class="o_row" invisible="state == 'draft' or product_tracking in ('none', False)">
|
||||
mo = mo_form.save()
|
||||
mo.action_confirm()
|
||||
mo_form = Form(mo)
|
||||
|
||||
@@ -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
|
||||
# <field name="lot_producing_id" attrs="{'invisible': [('product_tracking', 'in', ('none', False))]}"/>
|
||||
# <field name="lot_producing_id" invisible="product_tracking in ('none', False)"/>
|
||||
p_final.tracking = 'lot'
|
||||
|
||||
self.assertEqual(len(mo), 1, 'MO should have been created')
|
||||
|
||||
@@ -437,8 +437,8 @@ class TestMultiCompanyProject(TestMultiCompanyCommon):
|
||||
# <field name="parent_id" groups="base.group_no_one"/>
|
||||
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):
|
||||
# <field name="parent_id" groups="base.group_no_one"/>
|
||||
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'):
|
||||
|
||||
@@ -44,11 +44,11 @@ class TestPurchaseOrderReport(AccountTestInvoicingCommon):
|
||||
# <field name="invoice_vendor_bill_id" position="after">
|
||||
# <field name="purchase_id" invisible="1"/>
|
||||
# <label for="purchase_vendor_bill_id" string="Auto-Complete" class="oe_edit_only"
|
||||
# attrs="{'invisible': ['|', ('state','!=','draft'), ('move_type', '!=', 'in_invoice')]}" />
|
||||
# invisible="state != 'draft' or move_type != 'in_invoice'" />
|
||||
# <field name="purchase_vendor_bill_id" nolabel="1"
|
||||
# attrs="{'invisible': ['|', ('state','!=','draft'), ('move_type', '!=', 'in_invoice')]}"
|
||||
# invisible="state != 'draft' or move_type != 'in_invoice'"
|
||||
# class="oe_edit_only"
|
||||
# domain="partner_id and [('company_id', '=', company_id), ('partner_id.commercial_partner_id', '=', commercial_partner_id)] or [('company_id', '=', company_id)]"
|
||||
# domain="('company_id', '=', company_id), ('partner_id.commercial_partner_id', '=', commercial_partner_id)] if partner_id else [('company_id', '=', company_id)]"
|
||||
# placeholder="Select a purchase order or an old bill"
|
||||
# context="{'show_total_amount': True}"
|
||||
# options="{'no_create': True, 'no_open': True}"/>
|
||||
|
||||
@@ -144,10 +144,10 @@ class TestPurchaseOrder(ValuationReconciliationTestCommon):
|
||||
# Not supposed to see/change the purchase order of a refund invoice by default
|
||||
# <field name="purchase_id" invisible="1"/>
|
||||
# <label for="purchase_vendor_bill_id" string="Auto-Complete" class="oe_edit_only"
|
||||
# attrs="{'invisible': ['|', ('state','!=','draft'), ('move_type', '!=', 'in_invoice')]}" />
|
||||
# invisible="state != 'draft' or move_type != 'in_invoice'" />
|
||||
# <field name="purchase_vendor_bill_id" nolabel="1"
|
||||
# attrs="{'invisible': ['|', ('state','!=','draft'), ('move_type', '!=', 'in_invoice')]}"
|
||||
move_form._view['modifiers']['purchase_id']['invisible'] = False
|
||||
# invisible="state != 'draft' or move_type != 'in_invoice'"
|
||||
move_form._view['modifiers']['purchase_id']['invisible'] = 'False'
|
||||
move_form.purchase_id = self.po
|
||||
self.invoice = move_form.save()
|
||||
move_form = Form(self.invoice)
|
||||
|
||||
@@ -565,10 +565,10 @@ class TestStockValuationWithCOA(AccountTestInvoicingCommon):
|
||||
# Not supposed to see/change the purchase order of a refund invoice by default
|
||||
# <field name="purchase_id" invisible="1"/>
|
||||
# <label for="purchase_vendor_bill_id" string="Auto-Complete" class="oe_edit_only"
|
||||
# attrs="{'invisible': ['|', ('state','!=','draft'), ('move_type', '!=', 'in_invoice')]}" />
|
||||
# invisible="state != 'draft' or move_type != 'in_invoice'" />
|
||||
# <field name="purchase_vendor_bill_id" nolabel="1"
|
||||
# attrs="{'invisible': ['|', ('state','!=','draft'), ('move_type', '!=', 'in_invoice')]}"
|
||||
move_form._view['modifiers']['purchase_id']['invisible'] = False
|
||||
# invisible="state != 'draft' or move_type != 'in_invoice'"
|
||||
move_form._view['modifiers']['purchase_id']['invisible'] = 'False'
|
||||
move_form.purchase_id = po2
|
||||
with move_form.invoice_line_ids.edit(0) as line_form:
|
||||
line_form.quantity = 10
|
||||
|
||||
@@ -444,8 +444,8 @@ class TestProjectBilling(TestCommonSaleTimesheet):
|
||||
# As the behavior of the test is to check the partner on the project
|
||||
# is set to the partner of the order line, temporary make the field visible
|
||||
# even if it's not the case in the reality, in the web client
|
||||
# {'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]}
|
||||
project_form._view['modifiers']['sale_line_employee_ids']['invisible'] = False
|
||||
# not allow_billable or not partner_id
|
||||
project_form._view['modifiers']['sale_line_employee_ids']['invisible'] = 'False'
|
||||
with project_form.sale_line_employee_ids.new() as mapping_form:
|
||||
mapping_form.employee_id = self.employee_manager
|
||||
mapping_form.sale_line_id = self.so.order_line[:1]
|
||||
|
||||
@@ -2345,7 +2345,7 @@ class TestSinglePicking(TestStockCommon):
|
||||
), view='stock.view_picking_form')
|
||||
receipt_form.partner_id = partner
|
||||
receipt_form.picking_type_id = picking_type
|
||||
# <field name="location_id" attrs="{'invisible': [('picking_type_code', '=', 'incoming')]}"
|
||||
# <field name="location_id" invisible="picking_type_code' == 'incoming'"
|
||||
receipt_form.location_dest_id = stock_location
|
||||
receipt = receipt_form.save()
|
||||
with receipt_form.move_line_nosuggest_ids.new() as move_line:
|
||||
|
||||
@@ -830,9 +830,9 @@ class TestPacking(TestPackingCommon):
|
||||
package = self.env["stock.quant.package"].create({"name": "Src Pack"})
|
||||
self.env['stock.quant']._update_available_quantity(self.productA, self.stock_location, 100, package_id=package)
|
||||
# Required for `package_level_ids_details` to be visible in the view
|
||||
# <page string="Detailed Operations" attrs="{'invisible': [('show_operations', '=', False)]}">
|
||||
# <page string="Detailed Operations" invisible="not show_operations">
|
||||
# <field name="package_level_ids_details"
|
||||
# attrs="{'invisible': ['|', ('picking_type_entire_packs', '=', False), ('show_operations', '=', False)]}"
|
||||
# invisible="not picking_type_entire_packs or not show_operations"
|
||||
self.warehouse.out_type_id.show_operations = True
|
||||
self.warehouse.out_type_id.show_entire_packs = True
|
||||
picking = self.env['stock.picking'].create({
|
||||
@@ -983,9 +983,9 @@ class TestPacking(TestPackingCommon):
|
||||
picking.button_validate()
|
||||
|
||||
# Required for `package_level_ids_details` to be visible in the view
|
||||
# <page string="Detailed Operations" attrs="{'invisible': [('show_operations', '=', False)]}">
|
||||
# <page string="Detailed Operations" invisible="not show_operations">
|
||||
# <field name="package_level_ids_details"
|
||||
# attrs="{'invisible': ['|', ('picking_type_entire_packs', '=', False), ('show_operations', '=', False)]}"
|
||||
# invisible="not picking_type_entire_packs or not show_operations"
|
||||
delivery_type.show_operations = True
|
||||
delivery_type.show_entire_packs = True
|
||||
picking, _, _ = create_picking(delivery_type, delivery_type.default_location_src_id, self.customer_location)
|
||||
|
||||
@@ -1186,7 +1186,7 @@ class TestReports(TestReportsCommon):
|
||||
move_line.product_id = self.product
|
||||
move_line.quantity_done = 3
|
||||
delivery_by_date_priority = delivery_form.save()
|
||||
# <field name="priority" attrs="{'invisible': [('name','=','/')]}"/>
|
||||
# <field name="priority" invisible="name == '/'"/>
|
||||
# The priority field is not visible until the name is set,
|
||||
# which is done after a first save / the `create`
|
||||
delivery_form.priority = '1'
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestSurveyInvite(common.TestSurveyCommon, MailCommon):
|
||||
# By default, `<field name="emails"/>` 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <data> 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.
|
||||
<field name="date_approve" invisible="context.get('quotation_only', False)"
|
||||
|
||||
For such modifiers, which cannot be cached, the modifier has been stored under it's non-evaluated expression,
|
||||
along with a temporary technical attribute `context-dependent-modifiers`
|
||||
to tell which modifier should be evaluated after retrieving the view from the cache.
|
||||
e.g.
|
||||
<field
|
||||
name="date_approve"
|
||||
modifiers="{"invisible": "context.get('quotation_only')"}"
|
||||
context-dependent-modifiers="invisible"/>
|
||||
"""
|
||||
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 <field name="{name}">' 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 <filter name="{name}">' if name else 'domain of <filter>'
|
||||
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>
|
||||
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.",
|
||||
|
||||
@@ -40,19 +40,6 @@
|
||||
</rng:optional>
|
||||
</rng:define>
|
||||
|
||||
<rng:define name="modifiable">
|
||||
<rng:optional>
|
||||
<!-- @modifiers contains a JSON map unifying the various
|
||||
modifier attributes: @readonly, @required, @invisible.
|
||||
Each attribute is a key, mapped to a JSON list representing
|
||||
a condition expressed as an OpenERP `domain` filter
|
||||
Only some of the modifier keys make sense on some
|
||||
elements, for example <filter> and <group> only support
|
||||
`invisible`. -->
|
||||
<rng:attribute name="modifiers"/>
|
||||
</rng:optional>
|
||||
</rng:define>
|
||||
|
||||
<rng:define name="access_rights">
|
||||
<rng:optional>
|
||||
<rng:attribute name="groups"/>
|
||||
@@ -95,7 +82,6 @@
|
||||
<rng:element name="label">
|
||||
<rng:ref name="overload"/>
|
||||
<rng:ref name="access_rights"/>
|
||||
<rng:ref name="modifiable"/>
|
||||
<rng:optional><rng:attribute name="invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="align"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="nolabel"/></rng:optional>
|
||||
@@ -147,7 +133,6 @@
|
||||
<rng:element name="separator">
|
||||
<rng:ref name="overload"/>
|
||||
<rng:ref name="access_rights"/>
|
||||
<rng:ref name="modifiable"/>
|
||||
<rng:optional><rng:attribute name="invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="name"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="colspan"/></rng:optional>
|
||||
@@ -208,9 +193,7 @@
|
||||
<rng:attribute name="name" />
|
||||
<rng:ref name="overload"/>
|
||||
<rng:ref name="access_rights"/>
|
||||
<rng:ref name="modifiable"/>
|
||||
<rng:optional><rng:attribute name="domain_filter"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="attrs"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="class"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="string"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="completion"/></rng:optional>
|
||||
@@ -224,6 +207,7 @@
|
||||
<rng:optional><rng:attribute name="on_change"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="domain"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="filter_domain"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="column_invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="password"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="comparator"/></rng:optional>
|
||||
@@ -246,7 +230,6 @@
|
||||
<rng:optional><rng:attribute name="view_mode"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="widget"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="context"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="states"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="digits"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="icon"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="mode"/></rng:optional>
|
||||
@@ -312,12 +295,9 @@
|
||||
<rng:element name="group">
|
||||
<rng:ref name="overload"/>
|
||||
<rng:ref name="access_rights"/>
|
||||
<rng:ref name="modifiable"/>
|
||||
<rng:optional><rng:attribute name="attrs"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="colspan"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="rowspan"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="expand"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="states"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="string"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="fill"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="height"/></rng:optional>
|
||||
@@ -336,14 +316,12 @@
|
||||
<rng:element name="button">
|
||||
<rng:ref name="overload"/>
|
||||
<rng:ref name="access_rights"/>
|
||||
<rng:ref name="modifiable"/>
|
||||
<rng:optional><rng:attribute name="attrs"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="column_invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="disabled"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="name" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="icon" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="string" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="states" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="type" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="special" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="align" /></rng:optional>
|
||||
@@ -380,9 +358,7 @@
|
||||
<rng:element name="filter">
|
||||
<rng:ref name="overload"/>
|
||||
<rng:ref name="access_rights"/>
|
||||
<rng:ref name="modifiable"/>
|
||||
<rng:attribute name="name"/>
|
||||
<rng:optional><rng:attribute name="attrs"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="icon"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="invisible"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="separator" /></rng:optional>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
# <group col="4" attrs="{'invisible': [('sel_groups_1_9_10', '!=', 1)]}" groups="base.group_no_one" class="o_label_nowrap">
|
||||
# <group col="4" invisible="sel_groups_1_9_10 != 1" groups="base.group_no_one" class="o_label_nowrap">
|
||||
with self.debug_mode():
|
||||
user_form = Form(self.env['res.users'], view='base.view_users_form')
|
||||
user_form.name = "Test"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ access_test_testing_utilities_m2o,access_test_testing_utilities_m2o,model_test_t
|
||||
access_test_testing_utilities_default,access_test_testing_utilities_default,model_test_testing_utilities_default,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_parent,access_test_testing_utilities_parent,model_test_testing_utilities_parent,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_sub,access_test_testing_utilities_sub,model_test_testing_utilities_sub,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_ref,access_test_testing_utilities_ref,model_test_testing_utilities_ref,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_ref_sub,access_test_testing_utilities_ref_sub,model_test_testing_utilities_ref_sub,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test_testing_utilities_sub2,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,base.group_user,1,0,0,0
|
||||
access_test_testing_utilities_recursive,access_test_testing_utilities_recursive,model_test_testing_utilities_recursive,base.group_user,1,0,0,0
|
||||
|
||||
|
@@ -139,7 +139,7 @@
|
||||
<field name="f2" attrs="{'readonly': [['f1', '=', '63'], ['f3', '=', 5]]}"/>
|
||||
<field name="f3"/>
|
||||
<field name="f4"/>
|
||||
<field name="f5" attrs="{'readonly': 1}"/>
|
||||
<field name="f5" readonly="True"/>
|
||||
<field name="f6" readonly="1" force_save="1"/>
|
||||
</form>
|
||||
</field>
|
||||
@@ -233,6 +233,54 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="o2m_modifier_ref" model="ir.ui.view">
|
||||
<field name="name">Modifier check</field>
|
||||
<field name="model">test_testing_utilities.ref</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<field name="value"/>
|
||||
<field name="subs"
|
||||
invisible="value == 666"
|
||||
context="{
|
||||
'tree_view_ref': 'test_testing_utilities.o2m_modifier_ref_sub_tree',
|
||||
'hide_c': True
|
||||
}"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="o2m_modifier_ref_sub_form" model="ir.ui.view">
|
||||
<field name="name">Sub form view</field>
|
||||
<field name="model">test_testing_utilities.ref.sub</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<field name="c"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="o2m_modifier_ref_sub_wrong" model="ir.ui.view">
|
||||
<field name="name">Sub tree view</field>
|
||||
<field name="model">test_testing_utilities.ref.sub</field>
|
||||
<field name="priority">1</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="top">
|
||||
<field name="c"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record id="o2m_modifier_ref_sub_tree" model="ir.ui.view">
|
||||
<field name="name">Sub tree view</field>
|
||||
<field name="model">test_testing_utilities.ref.sub</field>
|
||||
<field name="priority">1000</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom">
|
||||
<field name="a"/>
|
||||
<field name="b" readonly="parent.value == 42"/>
|
||||
<field name="c" invisible="context.get('hide_c')"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="o2m_widget_m2m" model="ir.ui.view">
|
||||
<field name="name">Treat o2m as m2m</field>
|
||||
<field name="model">test_testing_utilities.parent</field>
|
||||
@@ -246,7 +294,7 @@
|
||||
</record>
|
||||
|
||||
<record id="attrs_using_m2m" model="ir.ui.view">
|
||||
<field name="name">Check attr domain using an m2m value</field>
|
||||
<field name="name">Check readonly using an m2m value</field>
|
||||
<field name="model">test_testing_utilities.e</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
@@ -255,4 +303,5 @@
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -171,6 +171,22 @@ class O2MSub(models.Model):
|
||||
if self.has_parent:
|
||||
self.value = self.parent_id.value
|
||||
|
||||
class O2MRef(models.Model):
|
||||
_name = 'test_testing_utilities.ref'
|
||||
_description = 'Testing Utilities ref'
|
||||
|
||||
value = fields.Integer(default=1)
|
||||
subs = fields.One2many('test_testing_utilities.ref.sub', 'parent_id')
|
||||
|
||||
class O2MRefSub(models.Model):
|
||||
_name = 'test_testing_utilities.ref.sub'
|
||||
_description = 'Testing Utilities Subtraction'
|
||||
|
||||
a = fields.Integer()
|
||||
b = fields.Integer()
|
||||
c = fields.Integer()
|
||||
parent_id = fields.Many2one('test_testing_utilities.ref')
|
||||
|
||||
class O2MDefault(models.Model):
|
||||
_name = 'test_testing_utilities.default'
|
||||
_description = 'Testing Utilities Default'
|
||||
|
||||
@@ -58,7 +58,7 @@ class Root(models.Model):
|
||||
E.field(name='unit_factor'),
|
||||
E.field(name='quantity_done'),
|
||||
E.field(
|
||||
{'name': 'move_line_ids', 'invisible': '1'},
|
||||
{'name': 'move_line_ids', 'column_invisible': '1'},
|
||||
E.tree(
|
||||
E.field(name='qty_done', invisible='1'),
|
||||
E.field(name='product_id', invisible='1'),
|
||||
|
||||
@@ -533,7 +533,7 @@ class TestO2M(TransactionCase):
|
||||
def test_o2m_self_recursive(self):
|
||||
Form(self.env['test_testing_utilities.recursive'], view='test_testing_utilities.o2m_recursive_relation_view')
|
||||
|
||||
def test_o2m_attrs(self):
|
||||
def test_o2m_readonly(self):
|
||||
Model = self.env['test_testing_utilities.parent']
|
||||
with Form(Model, view='test_testing_utilities.o2m_modifier') as form:
|
||||
with form.subs.new() as line:
|
||||
@@ -543,7 +543,7 @@ class TestO2M(TransactionCase):
|
||||
with self.assertRaises(AssertionError):
|
||||
line.value = 7
|
||||
|
||||
def test_o2m_attrs_parent(self):
|
||||
def test_o2m_readonly_parent(self):
|
||||
Model = self.env['test_testing_utilities.parent']
|
||||
with Form(Model, view='test_testing_utilities.o2m_modifier_parent') as form:
|
||||
with form.subs.new() as line:
|
||||
@@ -554,6 +554,25 @@ class TestO2M(TransactionCase):
|
||||
with self.assertRaises(AssertionError):
|
||||
line.value = 7
|
||||
|
||||
def test_o2m_external_readonly_parent(self):
|
||||
Model = self.env['test_testing_utilities.ref']
|
||||
with Form(Model, view='test_testing_utilities.o2m_modifier_ref') as form:
|
||||
with form.subs.new() as line:
|
||||
line.a = 1
|
||||
line.b = 2
|
||||
# readonly from context
|
||||
# with self.assertRaises(AssertionError): # this part must raise but the context attributes on x2m field is not used. To fix.
|
||||
# line.c = 3
|
||||
# will hide 'subs' field
|
||||
form.value = 666
|
||||
with self.assertRaisesRegex(AssertionError, 'invisible'):
|
||||
line.a = 4
|
||||
# this makes 'has_parent' readonly on lines
|
||||
form.value = 42
|
||||
line.a = 5
|
||||
with self.assertRaisesRegex(AssertionError, 'readonly'):
|
||||
line.b = 6
|
||||
|
||||
def test_o2m_widget(self):
|
||||
create = self.env['test_testing_utilities.sub'].create
|
||||
a, b, c = create({'v': 1}), create({'v': 2}), create({'v': 3})
|
||||
|
||||
+4
-3
@@ -140,8 +140,6 @@ class Field(MetaField('DummyField', (object,), {})):
|
||||
|
||||
:param str help: the tooltip of the field seen by users
|
||||
|
||||
:param invisible: whether the field is invisible (boolean, by default ``False``)
|
||||
|
||||
:param bool readonly: whether the field is readonly (default: ``False``)
|
||||
|
||||
This only has an impact on the UI. Any field assignation in code will work
|
||||
@@ -539,6 +537,10 @@ class Field(MetaField('DummyField', (object,), {})):
|
||||
self.setup_related(model)
|
||||
else:
|
||||
self.setup_nonrelated(model)
|
||||
|
||||
if self.states:
|
||||
warnings.warn(f'"states" property on the field "{self}" is no longer used', DeprecationWarning)
|
||||
|
||||
self._setup_done = True
|
||||
|
||||
#
|
||||
@@ -864,7 +866,6 @@ class Field(MetaField('DummyField', (object,), {})):
|
||||
_description_company_dependent = property(attrgetter('company_dependent'))
|
||||
_description_readonly = property(attrgetter('readonly'))
|
||||
_description_required = property(attrgetter('required'))
|
||||
_description_states = property(attrgetter('states'))
|
||||
_description_groups = property(attrgetter('groups'))
|
||||
_description_change_default = property(attrgetter('change_default'))
|
||||
_description_group_operator = property(attrgetter('group_operator'))
|
||||
|
||||
+1
-1
@@ -5781,7 +5781,7 @@ class BaseModel(metaclass=MetaModel):
|
||||
|
||||
if comparator == '=':
|
||||
ok = value in data
|
||||
elif comparator in ('!=', '<>'):
|
||||
elif comparator == '!=':
|
||||
ok = value not in data
|
||||
elif comparator == '=?':
|
||||
ok = not value or (value in data)
|
||||
|
||||
@@ -212,7 +212,8 @@ def normalize_domain(domain):
|
||||
else:
|
||||
expected += op_arity.get(token, 0) - 1
|
||||
result.append(token)
|
||||
assert expected == 0, 'This domain is syntactically not correct: %s' % (domain)
|
||||
if expected:
|
||||
raise ValueError(f'Domain {domain} is syntactically not correct.')
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+78
-117
@@ -5,9 +5,8 @@ view for server-side unit tests.
|
||||
"""
|
||||
import ast
|
||||
import collections
|
||||
import json
|
||||
import itertools
|
||||
import logging
|
||||
import operator
|
||||
import time
|
||||
from datetime import datetime, date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -17,12 +16,12 @@ from lxml import etree
|
||||
import odoo
|
||||
from odoo.models import BaseModel
|
||||
from odoo.fields import Command
|
||||
from odoo.osv import expression
|
||||
from odoo.osv.expression import normalize_domain, TRUE_LEAF, FALSE_LEAF
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
MODIFIER_ALIASES = {'1': 'True', '0': 'False'}
|
||||
|
||||
|
||||
class Form:
|
||||
""" Server-side form view implementation (partial)
|
||||
@@ -131,7 +130,7 @@ class Form:
|
||||
# self._view = {
|
||||
# 'tree': view_arch_etree,
|
||||
# 'fields': {field_name: field_info},
|
||||
# 'modifiers': {field_name: {modifier: domain}},
|
||||
# 'modifiers': {field_name: {modifier: expression}},
|
||||
# 'contexts': {field_name: field_context_str},
|
||||
# 'onchange': onchange_spec,
|
||||
# }
|
||||
@@ -147,11 +146,11 @@ class Form:
|
||||
def _process_view(self, tree, model, level=2):
|
||||
""" Post-processes to augment the view_get with:
|
||||
* an id field (may not be present if not in the view but needed)
|
||||
* pre-processed modifiers (map of modifier name to json-loaded domain)
|
||||
* pre-processed modifiers (map of modifier name to json-loaded expresion)
|
||||
* pre-processed onchanges list
|
||||
"""
|
||||
fields = {'id': {'type': 'id'}}
|
||||
modifiers = {'id': {'required': [FALSE_LEAF], 'readonly': [TRUE_LEAF]}}
|
||||
modifiers = {'id': {'required': 'False', 'readonly': 'True'}}
|
||||
contexts = {}
|
||||
view = {
|
||||
'tree': tree,
|
||||
@@ -159,18 +158,6 @@ class Form:
|
||||
'modifiers': modifiers,
|
||||
'contexts': contexts,
|
||||
}
|
||||
# pre-resolve modifiers & bind to arch toplevel
|
||||
eval_context = {
|
||||
"uid": self._env.user.id,
|
||||
"tz": self._env.user.tz,
|
||||
"lang": self._env.user.lang,
|
||||
"datetime": datetime,
|
||||
"context_today": lambda: odoo.fields.Date.context_today(self._env.user),
|
||||
"relativedelta": relativedelta,
|
||||
"current_date": time.strftime("%Y-%m-%d"),
|
||||
"allowed_company_ids": [self._env.user.company_id.id],
|
||||
"context": {},
|
||||
}
|
||||
# retrieve <field> nodes at the current level
|
||||
flevel = tree.xpath('count(ancestor::field)')
|
||||
daterange_field_names = {}
|
||||
@@ -178,45 +165,47 @@ class Form:
|
||||
field_name = node.get('name')
|
||||
|
||||
# add field_info into fields
|
||||
field_info = self._models_info[model._name].get(field_name) or {'type': None}
|
||||
field_info = self._models_info.get(model._name, {}).get(field_name) or {'type': None}
|
||||
fields[field_name] = field_info
|
||||
|
||||
# determine modifiers
|
||||
field_modifiers = {}
|
||||
for modifier, domain in json.loads(node.get('modifiers', '{}')).items():
|
||||
if isinstance(domain, int):
|
||||
domain = [TRUE_LEAF] if domain else [FALSE_LEAF]
|
||||
elif isinstance(domain, str):
|
||||
domain = safe_eval(domain, eval_context)
|
||||
field_modifiers[modifier] = normalize_domain(domain)
|
||||
for attr in ('required', 'readonly', 'invisible', 'column_invisible'):
|
||||
# use python field attribute as default value
|
||||
default = attr in ('required', 'readonly') and field_info.get(attr, False)
|
||||
expr = node.get(attr) or str(default)
|
||||
field_modifiers[attr] = MODIFIER_ALIASES.get(expr, expr)
|
||||
|
||||
# Combine the field modifiers with its ancestor modifiers with an
|
||||
# OR: A field is invisible if its own invisible modifier is True OR
|
||||
# if one of its ancestor invisible modifier is True
|
||||
for ancestor in node.xpath(f'ancestor::*[@modifiers][count(ancestor::field) = {flevel}]'):
|
||||
ancestor_modifiers = json.loads(ancestor.get('modifiers'))
|
||||
if 'invisible' in ancestor_modifiers:
|
||||
domain = ancestor_modifiers['invisible']
|
||||
if isinstance(domain, int):
|
||||
domain = [TRUE_LEAF] if domain else [FALSE_LEAF]
|
||||
elif isinstance(domain, str):
|
||||
domain = safe_eval(domain, eval_context)
|
||||
domain = normalize_domain(domain)
|
||||
field_modifiers['invisible'] = expression.OR([
|
||||
domain,
|
||||
field_modifiers.get('invisible', [FALSE_LEAF]),
|
||||
])
|
||||
for ancestor in node.xpath(f'ancestor::*[@invisible][count(ancestor::field) = {flevel}]'):
|
||||
modifier = 'invisible'
|
||||
expr = ancestor.get(modifier)
|
||||
if expr == 'True' or field_modifiers[modifier] == 'True':
|
||||
field_modifiers[modifier] = 'True'
|
||||
if expr == 'False':
|
||||
field_modifiers[modifier] = field_modifiers[modifier]
|
||||
elif field_modifiers[modifier] == 'False':
|
||||
field_modifiers[modifier] = expr
|
||||
else:
|
||||
field_modifiers[modifier] = f'({expr}) or ({field_modifiers[modifier]})'
|
||||
|
||||
# merge field_modifiers into modifiers[field_name]
|
||||
if field_name in modifiers:
|
||||
# The field is several times in the view, combine the modifier
|
||||
# domains with an AND: a field is X if all occurences of the
|
||||
# expression with an AND: a field is X if all occurences of the
|
||||
# field in the view are X.
|
||||
for modifier in field_modifiers.keys() | modifiers[field_name].keys():
|
||||
field_modifiers[modifier] = expression.AND([
|
||||
modifiers[field_name].get(modifier, [FALSE_LEAF]),
|
||||
field_modifiers.get(modifier, [FALSE_LEAF]),
|
||||
])
|
||||
for modifier, expr in modifiers[field_name].items():
|
||||
if expr == 'False' or field_modifiers[modifier] == 'False':
|
||||
field_modifiers[modifier] = 'False'
|
||||
if expr == 'True':
|
||||
field_modifiers[modifier] = field_modifiers[modifier]
|
||||
elif field_modifiers[modifier] == 'True':
|
||||
field_modifiers[modifier] = expr
|
||||
else:
|
||||
field_modifiers[modifier] = f'({expr}) and ({field_modifiers[modifier]})'
|
||||
|
||||
|
||||
modifiers[field_name] = field_modifiers
|
||||
|
||||
@@ -237,7 +226,7 @@ class Form:
|
||||
# determine subview to use for edition
|
||||
if field_info['type'] == 'one2many':
|
||||
if level:
|
||||
field_info['invisible'] = field_modifiers.get('invisible') == [TRUE_LEAF]
|
||||
field_info['invisible'] = field_modifiers.get('invisible')
|
||||
field_info['edition_view'] = self._get_one2many_edition_view(field_info, node, level)
|
||||
else:
|
||||
# this trick enables the following invariant: every one2many
|
||||
@@ -262,7 +251,7 @@ class Form:
|
||||
for view_type in ['tree', 'form']:
|
||||
if view_type in views:
|
||||
continue
|
||||
if field_info['invisible']:
|
||||
if field_info['invisible'] == 'True':
|
||||
# add an empty view
|
||||
views[view_type] = etree.Element(view_type)
|
||||
continue
|
||||
@@ -349,72 +338,35 @@ class Form:
|
||||
if view is None:
|
||||
view = self._view
|
||||
|
||||
domain = view['modifiers'][field_name].get(modifier, False)
|
||||
if isinstance(domain, bool):
|
||||
return domain
|
||||
expr = view['modifiers'][field_name].get(modifier, False)
|
||||
if isinstance(expr, bool):
|
||||
return expr
|
||||
if expr in ('True', 'False'):
|
||||
return expr == 'True'
|
||||
|
||||
if vals is None:
|
||||
vals = self._values
|
||||
|
||||
stack = []
|
||||
for it in reversed(domain):
|
||||
if it == '!':
|
||||
stack.append(not stack.pop())
|
||||
elif it == '&':
|
||||
e1 = stack.pop()
|
||||
e2 = stack.pop()
|
||||
stack.append(e1 and e2)
|
||||
elif it == '|':
|
||||
e1 = stack.pop()
|
||||
e2 = stack.pop()
|
||||
stack.append(e1 or e2)
|
||||
elif isinstance(it, tuple):
|
||||
if it == TRUE_LEAF:
|
||||
stack.append(True)
|
||||
continue
|
||||
if it == FALSE_LEAF:
|
||||
stack.append(False)
|
||||
continue
|
||||
left, operator, right = it
|
||||
# hack-ish handling of parent.<field> modifiers
|
||||
if left.startswith('parent.'):
|
||||
left_value = vals['•parent•'][left[7:]]
|
||||
else:
|
||||
left_value = vals[left]
|
||||
# apparent artefact of JS data representation: m2m field
|
||||
# values are assimilated to lists of ids?
|
||||
# FIXME: SSF should do that internally, but the requirement
|
||||
# of recursively post-processing to generate lists of
|
||||
# commands on save (e.g. m2m inside an o2m) means the
|
||||
# data model needs proper redesign
|
||||
# we're looking up the "current view" so bits might be
|
||||
# missing when processing o2ms in the parent (see
|
||||
# values_to_save:1450 or so)
|
||||
left_field = view['fields'].get(left, {'type': None})
|
||||
if left_field['type'] == 'many2many':
|
||||
# field value should be [(6, _, ids)], we want just the ids
|
||||
left_value = left_value[0][2] if left_value else []
|
||||
stack.append(self._OPS[operator](left_value, right))
|
||||
else:
|
||||
raise ValueError(f"Unknown domain element {it!r}")
|
||||
assert len(stack) == 1
|
||||
return stack[0]
|
||||
eval_context = self._get_eval_context(vals)
|
||||
for name, desc in self._view['fields'].items():
|
||||
if name in eval_context and desc['type'] in ('one2many', 'many2many'):
|
||||
ids = set()
|
||||
for command in eval_context.get(name):
|
||||
if command[0] == Command.LINK:
|
||||
ids.add(command[1])
|
||||
elif command[0] == Command.CREATE:
|
||||
ids.add(command[1])
|
||||
elif command[0] == Command.UPDATE:
|
||||
ids.add(command[1])
|
||||
elif command[0] == Command.UNLINK:
|
||||
ids.remove(command[1])
|
||||
elif command[0] == Command.CLEAR:
|
||||
ids.clear()
|
||||
elif command[0] == Command.SET:
|
||||
ids = set(command[2])
|
||||
eval_context[name] = list(ids)
|
||||
|
||||
_OPS = {
|
||||
'=': operator.eq,
|
||||
'==': operator.eq,
|
||||
'!=': operator.ne,
|
||||
'<': operator.lt,
|
||||
'<=': operator.le,
|
||||
'>=': operator.ge,
|
||||
'>': operator.gt,
|
||||
'in': lambda a, b: (a in b) if isinstance(b, (tuple, list)) else (b in a),
|
||||
'not in': lambda a, b: (a not in b) if isinstance(b, (tuple, list)) else (b not in a),
|
||||
'like': lambda a, b: a and b and isinstance(a, str) and isinstance(b, str) and a in b,
|
||||
'ilike': lambda a, b: a and b and isinstance(a, str) and isinstance(b, str) and a.lower() in b.lower(),
|
||||
'not like': lambda a, b: a and b and isinstance(a, str) and isinstance(b, str) and a not in b,
|
||||
'not ilike': lambda a, b: a and b and isinstance(a, str) and isinstance(b, str) and a.lower() not in b.lower(),
|
||||
}
|
||||
return bool(safe_eval(expr, eval_context))
|
||||
|
||||
def _get_context(self, field_name):
|
||||
""" Return the context of a given field. """
|
||||
@@ -424,7 +376,7 @@ class Form:
|
||||
eval_context = self._get_eval_context()
|
||||
return safe_eval(context_str, eval_context)
|
||||
|
||||
def _get_eval_context(self):
|
||||
def _get_eval_context(self, values=None):
|
||||
""" Return the context dict to eval something. """
|
||||
context = {
|
||||
'id': self._record.id,
|
||||
@@ -434,10 +386,12 @@ class Form:
|
||||
'current_date': date.today().strftime("%Y-%m-%d"),
|
||||
**self._env.context,
|
||||
}
|
||||
if values is None:
|
||||
values = self._get_all_values()
|
||||
return {
|
||||
**context,
|
||||
'context': context,
|
||||
**self._get_all_values(),
|
||||
**values,
|
||||
}
|
||||
|
||||
def _get_all_values(self):
|
||||
@@ -571,7 +525,7 @@ class Form:
|
||||
vs._changed.update(vs)
|
||||
vs = self._get_values(
|
||||
mode, vs, subview,
|
||||
modifiers_values={'id': False, **vs, '•parent•': values},
|
||||
modifiers_values={'id': False, **vs, 'parent': Dotter(values)},
|
||||
# related o2m don't have a relation_field
|
||||
parent_link=field_info.get('relation_field'),
|
||||
)
|
||||
@@ -675,7 +629,7 @@ class Form:
|
||||
# TODO: simplistic, unlikely to work if e.g. there's a 5 inbetween other commands
|
||||
for command in value:
|
||||
if command[0] == Command.CREATE:
|
||||
result.append((Command.CREATE, 0, {
|
||||
result.append((Command.CREATE, virtual_id(), {
|
||||
key: self._cleanup_onchange(subfields[key], val, None)
|
||||
for key, val in command[2].items()
|
||||
if key in subfields
|
||||
@@ -754,12 +708,12 @@ class O2MForm(Form):
|
||||
object.__setattr__(self, '_record', model.browse(vals['id']))
|
||||
|
||||
def _get_modifier(self, field_name, modifier, *, view=None, vals=None):
|
||||
if vals is None:
|
||||
vals = {**self._values, '•parent•': self._proxy._form._values}
|
||||
if modifier != 'required' and self._proxy._form._get_modifier(self._proxy._field, modifier):
|
||||
return True
|
||||
return super()._get_modifier(field_name, modifier, view=view, vals=vals)
|
||||
|
||||
def _get_eval_context(self):
|
||||
eval_context = super()._get_eval_context()
|
||||
def _get_eval_context(self, values=None):
|
||||
eval_context = super()._get_eval_context(values)
|
||||
eval_context['parent'] = Dotter(self._proxy._form._values)
|
||||
return eval_context
|
||||
|
||||
@@ -776,7 +730,7 @@ class O2MForm(Form):
|
||||
field_value = proxy._form._values[proxy._field]
|
||||
values = self._get_save_values()
|
||||
if self._index is None:
|
||||
field_value.append((Command.CREATE, 0, values))
|
||||
field_value.append((Command.CREATE, virtual_id(), values))
|
||||
else:
|
||||
index = proxy._command_index(self._index)
|
||||
(cmd, id_, vs) = field_value[index]
|
||||
@@ -807,6 +761,13 @@ class O2MForm(Form):
|
||||
return values
|
||||
|
||||
|
||||
_virtual_id_counter = itertools.count()
|
||||
|
||||
|
||||
def virtual_id():
|
||||
return f'virtual_{next(_virtual_id_counter)}'
|
||||
|
||||
|
||||
class UpdateDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
+232
-90
@@ -8,6 +8,7 @@ import re
|
||||
|
||||
from lxml import etree
|
||||
from odoo import tools
|
||||
from odoo.osv.expression import DOMAIN_OPERATORS
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,63 +18,242 @@ _relaxng_cache = {}
|
||||
|
||||
READONLY = re.compile(r"\breadonly\b")
|
||||
|
||||
|
||||
def _get_attrs_symbols():
|
||||
""" Return a set of predefined symbols for evaluating attrs. """
|
||||
return {
|
||||
'True', 'False', 'None', # those are identifiers in Python 2.7
|
||||
'self',
|
||||
'id',
|
||||
'uid',
|
||||
'context',
|
||||
'context_today',
|
||||
'active_id',
|
||||
'active_ids',
|
||||
'allowed_company_ids',
|
||||
'current_company_id',
|
||||
'active_model',
|
||||
'time',
|
||||
'datetime',
|
||||
'relativedelta',
|
||||
'current_date',
|
||||
'today',
|
||||
'now',
|
||||
'abs',
|
||||
'len',
|
||||
'bool',
|
||||
'float',
|
||||
'str',
|
||||
'unicode',
|
||||
}
|
||||
# predefined symbols for evaluating attributes (invisible, readonly...)
|
||||
IGNORED_IN_EXPRESSION = {
|
||||
'True', 'False', 'None', # those are identifiers in Python 2.7
|
||||
'self',
|
||||
'uid',
|
||||
'context',
|
||||
'context_today',
|
||||
'active_id',
|
||||
'active_ids',
|
||||
'allowed_company_ids',
|
||||
'current_company_id',
|
||||
'active_model',
|
||||
'time',
|
||||
'datetime',
|
||||
'relativedelta',
|
||||
'current_date',
|
||||
'today',
|
||||
'now',
|
||||
'abs',
|
||||
'len',
|
||||
'bool',
|
||||
'float',
|
||||
'str',
|
||||
'unicode',
|
||||
}
|
||||
|
||||
|
||||
def get_variable_names(expr):
|
||||
""" Return the subexpressions of the kind "VARNAME(.ATTNAME)*" in the given
|
||||
string or AST node.
|
||||
def get_domain_value_names(domain):
|
||||
""" Return all field name used by this domain
|
||||
eg: [
|
||||
('id', 'in', [1, 2, 3]),
|
||||
('field_a', 'in', parent.truc),
|
||||
('field_b', 'in', context.get('b')),
|
||||
(1, '=', 1),
|
||||
bool(context.get('c')),
|
||||
]
|
||||
returns {'parent', 'parent.truc', 'context'}
|
||||
|
||||
:param domain: list(tuple) or str
|
||||
:return: set(str)
|
||||
"""
|
||||
IGNORED = _get_attrs_symbols()
|
||||
names = set()
|
||||
contextual_values = set()
|
||||
field_names = set()
|
||||
|
||||
def get_name_seq(node):
|
||||
if isinstance(node, ast.Name):
|
||||
return [node.id]
|
||||
elif isinstance(node, ast.Attribute):
|
||||
left = get_name_seq(node.value)
|
||||
return left and left + [node.attr]
|
||||
try:
|
||||
if isinstance(domain, list):
|
||||
for leaf in domain:
|
||||
if leaf in DOMAIN_OPERATORS or leaf in (True, False):
|
||||
# "&", "|", "!", True, False
|
||||
continue
|
||||
left, _operator, _right = leaf
|
||||
if isinstance(left, str):
|
||||
field_names.add(left)
|
||||
elif left not in (1, 0):
|
||||
# deprecate: True leaf and False leaf
|
||||
raise ValueError()
|
||||
|
||||
def process(node):
|
||||
seq = get_name_seq(node)
|
||||
if seq and seq[0] not in IGNORED:
|
||||
names.add('.'.join(seq))
|
||||
else:
|
||||
for child in ast.iter_child_nodes(node):
|
||||
process(child)
|
||||
elif isinstance(domain, str):
|
||||
def extract_from_domain(ast_domain):
|
||||
if isinstance(ast_domain, ast.IfExp):
|
||||
# [] if condition else []
|
||||
extract_from_domain(ast_domain.body)
|
||||
extract_from_domain(ast_domain.orelse)
|
||||
return
|
||||
if isinstance(ast_domain, ast.BoolOp):
|
||||
# condition and []
|
||||
# this formating don't check returned domain syntax
|
||||
for value in ast_domain.values:
|
||||
if isinstance(value, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
|
||||
extract_from_domain(value)
|
||||
else:
|
||||
contextual_values.update(_get_expression_contextual_values(value))
|
||||
return
|
||||
if isinstance(ast_domain, ast.BinOp):
|
||||
# [] + []
|
||||
# this formating don't check returned domain syntax
|
||||
if isinstance(ast_domain.left, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
|
||||
extract_from_domain(ast_domain.left)
|
||||
else:
|
||||
contextual_values.update(_get_expression_contextual_values(ast_domain.left))
|
||||
|
||||
if isinstance(expr, str):
|
||||
expr = ast.parse(expr.strip(), mode='eval').body
|
||||
process(expr)
|
||||
if isinstance(ast_domain.right, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
|
||||
extract_from_domain(ast_domain.right)
|
||||
else:
|
||||
contextual_values.update(_get_expression_contextual_values(ast_domain.right))
|
||||
return
|
||||
for ast_item in ast_domain.elts:
|
||||
if isinstance(ast_item, ast.Constant):
|
||||
# "&", "|", "!", True, False
|
||||
if ast_item.value not in DOMAIN_OPERATORS and ast_item.value not in (True, False):
|
||||
raise ValueError()
|
||||
elif isinstance(ast_item, (ast.List, ast.Tuple)):
|
||||
left, _operator, right = ast_item.elts
|
||||
contextual_values.update(_get_expression_contextual_values(right))
|
||||
if isinstance(left, ast.Constant) and isinstance(left.value, str):
|
||||
field_names.add(left.value)
|
||||
elif isinstance(left, ast.Constant) and left.value in (1, 0):
|
||||
# deprecate: True leaf (1, '=', 1) and False leaf (0, '=', 1)
|
||||
pass
|
||||
elif isinstance(right, ast.Constant) and right.value == 1:
|
||||
# deprecate: True/False leaf (py expression, '=', 1)
|
||||
contextual_values.update(_get_expression_contextual_values(left))
|
||||
else:
|
||||
raise ValueError()
|
||||
else:
|
||||
raise ValueError()
|
||||
|
||||
return names
|
||||
expr = domain.strip()
|
||||
item_ast = ast.parse(f"({expr})", mode='eval').body
|
||||
if isinstance(item_ast, ast.Name):
|
||||
# domain="other_field_domain"
|
||||
contextual_values.update(_get_expression_contextual_values(item_ast))
|
||||
else:
|
||||
extract_from_domain(item_ast)
|
||||
|
||||
except ValueError:
|
||||
raise ValueError("Wrong domain formatting.") from None
|
||||
|
||||
value_names = set()
|
||||
for name in contextual_values:
|
||||
if name == 'parent':
|
||||
continue
|
||||
root = name.split('.')[0]
|
||||
if root not in IGNORED_IN_EXPRESSION:
|
||||
value_names.add(name if root == 'parent' else root)
|
||||
return field_names, value_names
|
||||
|
||||
|
||||
def _get_expression_contextual_values(item_ast):
|
||||
""" Return all contextual value this ast
|
||||
|
||||
eg: ast from '''(
|
||||
id in [1, 2, 3]
|
||||
and field_a in parent.truc
|
||||
and field_b in context.get('b')
|
||||
or (
|
||||
True
|
||||
and bool(context.get('c'))
|
||||
)
|
||||
)
|
||||
returns {'parent', 'parent.truc', 'context', 'bool'}
|
||||
|
||||
:param item_ast: ast
|
||||
:return: set(str)
|
||||
"""
|
||||
|
||||
if isinstance(item_ast, ast.Constant):
|
||||
return set()
|
||||
if isinstance(item_ast, ast.Str):
|
||||
return set()
|
||||
if isinstance(item_ast, (ast.List, ast.Tuple)):
|
||||
values = set()
|
||||
for item in item_ast.elts:
|
||||
values |= _get_expression_contextual_values(item)
|
||||
return values
|
||||
if isinstance(item_ast, ast.Name):
|
||||
return {item_ast.id}
|
||||
if isinstance(item_ast, ast.Attribute):
|
||||
values = _get_expression_contextual_values(item_ast.value)
|
||||
if len(values) == 1:
|
||||
path = sorted(list(values)).pop()
|
||||
values = {f"{path}.{item_ast.attr}"}
|
||||
return values
|
||||
return values
|
||||
if isinstance(item_ast, ast.Index): # deprecated python ast class for Subscript key
|
||||
return _get_expression_contextual_values(item_ast.value)
|
||||
if isinstance(item_ast, ast.Subscript):
|
||||
values = _get_expression_contextual_values(item_ast.value)
|
||||
values |= _get_expression_contextual_values(item_ast.slice)
|
||||
return values
|
||||
if isinstance(item_ast, ast.Compare):
|
||||
values = _get_expression_contextual_values(item_ast.left)
|
||||
for sub_ast in item_ast.comparators:
|
||||
values |= _get_expression_contextual_values(sub_ast)
|
||||
return values
|
||||
if isinstance(item_ast, ast.BinOp):
|
||||
values = _get_expression_contextual_values(item_ast.left)
|
||||
values |= _get_expression_contextual_values(item_ast.right)
|
||||
return values
|
||||
if isinstance(item_ast, ast.BoolOp):
|
||||
values = set()
|
||||
for ast_value in item_ast.values:
|
||||
values |= _get_expression_contextual_values(ast_value)
|
||||
return values
|
||||
if isinstance(item_ast, ast.UnaryOp):
|
||||
return _get_expression_contextual_values(item_ast.operand)
|
||||
if isinstance(item_ast, ast.Call):
|
||||
values = _get_expression_contextual_values(item_ast.func)
|
||||
for ast_arg in item_ast.args:
|
||||
values |= _get_expression_contextual_values(ast_arg)
|
||||
return values
|
||||
if isinstance(item_ast, ast.IfExp):
|
||||
values = _get_expression_contextual_values(item_ast.test)
|
||||
values |= _get_expression_contextual_values(item_ast.body)
|
||||
values |= _get_expression_contextual_values(item_ast.orelse)
|
||||
return values
|
||||
if isinstance(item_ast, ast.Dict):
|
||||
values = set()
|
||||
for item in item_ast.keys:
|
||||
values |= _get_expression_contextual_values(item)
|
||||
for item in item_ast.values:
|
||||
values |= _get_expression_contextual_values(item)
|
||||
return values
|
||||
|
||||
raise ValueError(f"Undefined item {item_ast!r}.")
|
||||
|
||||
|
||||
def get_expression_field_names(expression):
|
||||
""" Return all field name used by this expression
|
||||
|
||||
eg: expression = '''(
|
||||
id in [1, 2, 3]
|
||||
and field_a in parent.truc.id
|
||||
and field_b in context.get('b')
|
||||
or (True and bool(context.get('c')))
|
||||
)
|
||||
returns {'parent', 'parent.truc', 'parent.truc.id', 'context', 'context.get'}
|
||||
|
||||
:param expression: str
|
||||
:param ignored: set contains the value name to ignore.
|
||||
Add '.' to ignore attributes (eg: {'parent.'} will
|
||||
ignore 'parent.truc' and 'parent.truc.id')
|
||||
:return: set(str)
|
||||
"""
|
||||
item_ast = ast.parse(expression.strip(), mode='eval').body
|
||||
contextual_values = _get_expression_contextual_values(item_ast)
|
||||
|
||||
value_names = set()
|
||||
for name in contextual_values:
|
||||
if name == 'parent':
|
||||
continue
|
||||
root = name.split('.')[0]
|
||||
if root not in IGNORED_IN_EXPRESSION:
|
||||
value_names.add(name if root == 'parent' else root)
|
||||
|
||||
return value_names
|
||||
|
||||
|
||||
def get_dict_asts(expr):
|
||||
@@ -96,52 +276,14 @@ def _check(condition, explanation):
|
||||
raise ValueError("Expression is not a valid domain: %s" % explanation)
|
||||
|
||||
|
||||
def get_domain_identifiers(expr):
|
||||
""" Check that the given string or AST node represents a domain expression,
|
||||
and return a pair of sets ``(fields, vars)`` where ``fields`` are the field
|
||||
names on the left-hand side of conditions, and ``vars`` are the variable
|
||||
names on the right-hand side of conditions.
|
||||
"""
|
||||
if not expr: # case of expr=""
|
||||
return (set(), set())
|
||||
if isinstance(expr, str):
|
||||
expr = ast.parse(expr.strip(), mode='eval').body
|
||||
|
||||
fnames = set()
|
||||
vnames = set()
|
||||
|
||||
if isinstance(expr, ast.List):
|
||||
for elem in expr.elts:
|
||||
if isinstance(elem, ast.Str):
|
||||
# note: this doesn't check the and/or structure
|
||||
_check(elem.s in ('&', '|', '!'),
|
||||
f"logical operators should be '&', '|', or '!', found {elem.s!r}")
|
||||
continue
|
||||
|
||||
if not isinstance(elem, (ast.List, ast.Tuple)):
|
||||
continue
|
||||
|
||||
_check(len(elem.elts) == 3,
|
||||
f"segments should have 3 elements, found {len(elem.elts)}")
|
||||
lhs, operator, rhs = elem.elts
|
||||
_check(isinstance(operator, ast.Str),
|
||||
f"operator should be a string, found {type(operator).__name__}")
|
||||
if isinstance(lhs, ast.Str):
|
||||
fnames.add(lhs.s)
|
||||
|
||||
vnames.update(get_variable_names(expr))
|
||||
|
||||
return (fnames, vnames)
|
||||
|
||||
|
||||
def valid_view(arch, **kwargs):
|
||||
for pred in _validators[arch.tag]:
|
||||
check = pred(arch, **kwargs)
|
||||
if not check:
|
||||
_logger.error("Invalid XML: %s", pred.__doc__)
|
||||
_logger.error("Invalid XML when check %s: %s", arch.tag, pred.__doc__)
|
||||
return False
|
||||
if check == "Warning":
|
||||
_logger.warning("Invalid XML: %s", pred.__doc__)
|
||||
_logger.warning("Invalid XML when check %s: %s", arch.tag, pred.__doc__)
|
||||
return "Warning"
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user