diff --git a/addons/web/controllers/__init__.py b/addons/web/controllers/__init__.py index dd76bbf7057..ceb32b0072c 100644 --- a/addons/web/controllers/__init__.py +++ b/addons/web/controllers/__init__.py @@ -4,6 +4,7 @@ from . import action from . import binary from . import database from . import dataset +from . import domain from . import export from . import home from . import pivot diff --git a/addons/web/controllers/domain.py b/addons/web/controllers/domain.py new file mode 100644 index 00000000000..50890895c59 --- /dev/null +++ b/addons/web/controllers/domain.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http, _ +from odoo.http import Controller, request +from odoo.exceptions import ValidationError +from odoo.tools.misc import mute_logger + +class Domain(Controller): + + @http.route('/web/domain/validate', type='json', auth="user") + def validate(self, model, domain): + """ Parse `domain` and verify that it can be used to search on `model` + :return: True when the domain is valid, otherwise False + :raises ValidationError: if `model` is invalid + """ + Model = request.env.get(model) + if Model is None: + raise ValidationError(_('Invalid model: %s', model)) + try: + # go through the motions of preparing the final SQL for the domain, + # so that anything invalid will raise an exception. + query = Model.sudo()._search(domain) + sql, params = query.select() + + # Execute the search in EXPLAIN mode, to have the query parser + # verify it. EXPLAIN will make sure the query is never actually executed + # An alternative to EXPLAIN would be a LIMIT 0 clause, but the semantics + # of a falsy `limit` parameter when calling _search() do not permit it. + with mute_logger('odoo.sql_db'): + request.env.cr.execute(f"EXPLAIN {sql}", params) + return True + except Exception: # pylint: disable=broad-except + return False diff --git a/addons/web/static/src/views/fields/domain/domain_field.js b/addons/web/static/src/views/fields/domain/domain_field.js index 6a710ae3cfc..4456fc3d890 100644 --- a/addons/web/static/src/views/fields/domain/domain_field.js +++ b/addons/web/static/src/views/fields/domain/domain_field.js @@ -27,6 +27,7 @@ export class DomainField extends Component { }; setup() { + this.rpc = useService("rpc"); this.orm = useService("orm"); this.addDialog = useOwnedDialogs(); @@ -50,8 +51,12 @@ export class DomainField extends Component { if (this.isDebugEdited) { const props = this.props; ev.detail.proms.push( - this.checkProps(props).then(() => { - if (!this.state.isValid) { + this.quickValidityCheck(props).then((isValid) => { + if (isValid) { + this.isDebugEdited = false; // will allow the count to be loaded if needed + } else { + this.state.isValid = false; + this.state.recordCount = 0; props.record.setInvalidField(props.name); } }) @@ -157,6 +162,18 @@ export class DomainField extends Component { }); } + async quickValidityCheck(props) { + const resModel = this.getResModel(props); + if (!resModel) { + return false; + } + const domain = this.getEvaluatedDomain(props); + if (domain.isInvalid) { + return false; + } + return this.rpc("/web/domain/validate", { model: resModel, domain }); + } + update(domain, isDebugEdited = false) { this.isDebugEdited = isDebugEdited; return this.props.record.update({ [this.props.name]: domain }); diff --git a/addons/web/static/tests/views/fields/domain_field_tests.js b/addons/web/static/tests/views/fields/domain_field_tests.js index b81c8be99c3..444707b2f1f 100644 --- a/addons/web/static/tests/views/fields/domain_field_tests.js +++ b/addons/web/static/tests/views/fields/domain_field_tests.js @@ -497,6 +497,9 @@ QUnit.module("Fields", (hooks) => { if (method === "search_count") { assert.step(JSON.stringify(args[0])); } + if (route === "/web/domain/validate") { + return true; + } }, }); @@ -562,6 +565,9 @@ QUnit.module("Fields", (hooks) => { if (method === "write") { throw new Error("should not save"); } + if (route === "/web/domain/validate") { + return false; + } }, }); @@ -735,6 +741,9 @@ QUnit.module("Fields", (hooks) => { if (method === "write") { assert.strictEqual(args[1].foo, rawDomain); } + if (route === "/web/domain/validate") { + return true; + } }, }); @@ -942,4 +951,42 @@ QUnit.module("Fields", (hooks) => { await click(target, ".modal-footer .btn-primary"); assert.containsOnce(target, ".modal", "the domain is invalid: the dialog is not closed"); }); + + QUnit.test( + "quick check on save if domain has been edited via the debug input", + async function (assert) { + patchWithCleanup(odoo, { debug: true }); + serverData.models.partner.fields.display_name.default = "[['id', '=', False]]"; + await makeView({ + type: "form", + resModel: "partner", + serverData, + arch: ` +
`, + mockRPC: (route, args) => { + if (route === "/web/domain/validate") { + assert.step(route); + assert.deepEqual(args, { + domain: [["id", "!=", false]], + model: "partner", + }); + return true; + } + }, + }); + assert.strictEqual( + target.querySelector(".o_domain_show_selection_button").textContent.trim(), + "0 record(s)" + ); + await editInput(target, ".o_domain_debug_input", "[['id', '!=', False]]"); + await click(target, "button.o_form_button_save"); + assert.verifySteps(["/web/domain/validate"]); + assert.strictEqual( + target.querySelector(".o_domain_show_selection_button").textContent.trim(), + "6 record(s)" + ); + } + ); }); diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index 2c19c17978c..7420ed38091 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -16,5 +16,6 @@ from . import test_assets from . import test_assets_xml from . import test_login from . import test_web_search_read +from . import test_domain from . import test_web_redirect from . import test_res_users diff --git a/addons/web/tests/test_domain.py b/addons/web/tests/test_domain.py new file mode 100644 index 00000000000..34b8dddcb55 --- /dev/null +++ b/addons/web/tests/test_domain.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger + +@tagged('post_install', '-at_install') +class DomainTest(HttpCase): + + def test_domain_validate(self): + self.authenticate("demo", "demo") + + with mute_logger('odoo.http'): + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'i', 'domain':[]}}), + ) + self.assertEqual(resp.json()['error']['data']['message'], "Invalid model: i") + + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'res.users', 'domain':[]}}), + ) + self.assertEqual(resp.json()['result'], True) + + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'res.users', 'domain':[('name', 'ilike', 'ad')]}}), + ) + self.assertEqual(resp.json()['result'], True) + + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'res.users', 'domain':[('hop')]}}), + ) + self.assertEqual(resp.json()['result'], False)