From a740989ba5758cc6bd0eb4fbe1ee4e410b4dda15 Mon Sep 17 00:00:00 2001 From: std-odoo Date: Wed, 24 Aug 2022 06:32:16 +0000 Subject: [PATCH] [MOV] web: move the domain selector component to web Purpose ======= For security reason access on ir.model is not granted to internal suers. Due to this constraint a component exists in spreadsheet to be able to select the models for which we have a read access on the records of this model. This is required for the properties fields feature, hence moving its code to web. Some renaming is performed to make it generic. This generates some changes in other addons, notably some class renaming.el. Task-2852259 Part-of: odoo/odoo#95184 --- addons/web/models/__init__.py | 1 + addons/web/models/ir_model.py | 34 +++ .../src/core/model_selector/model_selector.js | 74 ++++++ .../core/model_selector/model_selector.xml | 21 ++ .../static/tests/core/model_selector_test.js | 227 ++++++++++++++++++ addons/web/tests/__init__.py | 1 + addons/web/tests/test_ir_model.py | 65 +++++ 7 files changed, 423 insertions(+) create mode 100644 addons/web/models/ir_model.py create mode 100644 addons/web/static/src/core/model_selector/model_selector.js create mode 100644 addons/web/static/src/core/model_selector/model_selector.xml create mode 100644 addons/web/static/tests/core/model_selector_test.js create mode 100644 addons/web/tests/test_ir_model.py diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py index 3dbae07edb1..4c4136a1a1e 100644 --- a/addons/web/models/__init__.py +++ b/addons/web/models/__init__.py @@ -3,6 +3,7 @@ from . import ir_qweb_fields from . import ir_http +from . import ir_model from . import ir_ui_menu from . import models from . import base_document_layout diff --git a/addons/web/models/ir_model.py b/addons/web/models/ir_model.py new file mode 100644 index 00000000000..2787f8af63a --- /dev/null +++ b/addons/web/models/ir_model.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api + + +class IrModel(models.Model): + _inherit = "ir.model" + + @api.model + def display_name_for(self, models): + """ + Returns the display names from provided models which the current user can access. + The result is the same whether someone tries to access an inexistent model or a model they cannot access. + :models list(str): list of technical model names to lookup (e.g. `["res.partner"]`) + :return: list of dicts of the form `{ "model", "display_name" }` (e.g. `{ "model": "res_partner", "display_name": "Contact"}`) + """ + # Store accessible models in a temporary list in order to execute only one SQL query + accessible_models = [] + not_accessible_models = [] + for model in models: + if self._check_model_access(model): + accessible_models.append(model) + else: + not_accessible_models.append({"display_name": model, "model": model}) + records = self.env["ir.model"].sudo().search_read([("model", "in", accessible_models)], ["name", "model"]) + return [{ + "display_name": model["name"], + "model": model["model"], + } for model in records] + not_accessible_models + + @api.model + def _check_model_access(self, model): + return self.env.user._is_internal() and model in self.env and self.env[model].check_access_rights("read", raise_exception=False) diff --git a/addons/web/static/src/core/model_selector/model_selector.js b/addons/web/static/src/core/model_selector/model_selector.js new file mode 100644 index 00000000000..62b84c97d8b --- /dev/null +++ b/addons/web/static/src/core/model_selector/model_selector.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ + +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { useService } from "@web/core/utils/hooks"; +import { fuzzyLookup } from "@web/core/utils/search"; +import { _t } from "@web/core/l10n/translation"; + +const { Component, onWillStart } = owl; + +export class ModelSelector extends Component { + setup() { + this.orm = useService("orm"); + + onWillStart(async () => { + this.models = await this.orm.call("ir.model", "display_name_for", [this.props.models]); + this.models = this.models.map((record) => ({ + label: record.display_name, + technical: record.model, + classList: { + [`o_model_selector_${record.model}`]: 1, + }, + })); + }); + } + + get placeholder() { + return _t("Start typing to search more..."); + } + + get sources() { + return [this.optionsSource]; + } + get optionsSource() { + return { + placeholder: _t("Loading..."), + options: this.loadOptionsSource.bind(this), + }; + } + + onSelect(option) { + this.props.onModelSelected({ + label: option.label, + technical: option.technical, + }); + } + + filterModels(name) { + if (!name) { + return this.models.slice(0, 8); + } + return fuzzyLookup(name, this.models, (model) => model.technical + model.label).slice(0, 8); + } + + loadOptionsSource(request) { + const options = this.filterModels(request); + + if (!options.length) { + options.push({ + label: _t("No records"), + classList: "o_m2o_no_result", + unselectable: true, + }); + } + return options; + } +} + +ModelSelector.template = "web.ModelSelector"; +ModelSelector.components = { AutoComplete }; +ModelSelector.props = { + onModelSelected: Function, + value: { type: String, optional: true }, + models: Array, +}; diff --git a/addons/web/static/src/core/model_selector/model_selector.xml b/addons/web/static/src/core/model_selector/model_selector.xml new file mode 100644 index 00000000000..cffc987108a --- /dev/null +++ b/addons/web/static/src/core/model_selector/model_selector.xml @@ -0,0 +1,21 @@ + + + +
+ + + +
+
+
diff --git a/addons/web/static/tests/core/model_selector_test.js b/addons/web/static/tests/core/model_selector_test.js new file mode 100644 index 00000000000..a2e6ee4fb30 --- /dev/null +++ b/addons/web/static/tests/core/model_selector_test.js @@ -0,0 +1,227 @@ +/** @odoo-module */ + +import { browser } from "@web/core/browser/browser"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; +import { registry } from "@web/core/registry"; +import { ormService } from "@web/core/orm_service"; +import { ModelSelector } from "@web/core/model_selector/model_selector"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { click, editInput, getFixture, mount, patchWithCleanup } from "@web/../tests/helpers/utils"; + +registry + .category("mock_server") + .add("ir.model/display_name_for", function (route, args) { + const models = args.args[0]; + const records = this.models["ir.model"].records.filter((record) => + models.includes(record.model) + ); + return records.map((record) => ({ + model: record.model, + display_name: record.name, + })); + }); + + +const serviceRegistry = registry.category("services"); + +let env; +let fixture; + +async function mountModelSelector(models = [], value = undefined, onModelSelected = () => {}) { + await mount(ModelSelector, fixture, { + env, + props: { + models, + value, + onModelSelected, + }, + }); +} + +async function openAutocomplete(search = undefined) { + await click(fixture, ".o-autocomplete--input"); +} + +async function beforeEach() { + serviceRegistry.add("hotkey", hotkeyService); + serviceRegistry.add("orm", ormService); + env = await makeTestEnv({ + serverData: { + models: { + "ir.model": { + fields: { + name: { string: "Model Name", type: "char" }, + model: { string: "Model", type: "char" }, + }, + records: [ + { + id: 1, + name: "Model 1", + model: "model_1", + }, + { + id: 2, + name: "Model 2", + model: "model_2", + }, + { + id: 3, + name: "Model 3", + model: "model_3", + }, + { + id: 4, + name: "Model 4", + model: "model_4", + }, + { + id: 5, + name: "Model 5", + model: "model_5", + }, + { + id: 6, + name: "Model 6", + model: "model_6", + }, + { + id: 7, + name: "Model 7", + model: "model_7", + }, + { + id: 8, + name: "Model 8", + model: "model_8", + }, + { + id: 9, + name: "Model 9", + model: "model_9", + }, + { + id: 10, + name: "Model 10", + model: "model_10", + }, + ], + }, + }, + }, + }); + fixture = getFixture(); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); +} + +QUnit.module("web > model_selector", { beforeEach }); + +QUnit.test("model_selector: with no model", async function (assert) { + await mountModelSelector(); + await openAutocomplete(); + assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item"); + assert.strictEqual( + fixture.querySelector("li.o-autocomplete--dropdown-item").innerText, + "No records" + ); +}); + +QUnit.test("model_selector: displays model display names", async function (assert) { + await mountModelSelector(["model_1", "model_2", "model_3"]); + await openAutocomplete(); + assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 3); + const items = fixture.querySelectorAll("li.o-autocomplete--dropdown-item"); + assert.strictEqual(items[0].innerText, "Model 1"); + assert.strictEqual(items[1].innerText, "Model 2"); + assert.strictEqual(items[2].innerText, "Model 3"); +}); + +QUnit.test("model_selector: with 8 models", async function (assert) { + await mountModelSelector([ + "model_1", + "model_2", + "model_3", + "model_4", + "model_5", + "model_6", + "model_7", + "model_8", + ]); + await openAutocomplete(); + assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 8); +}); + +QUnit.test("model_selector: with more than 8 models", async function (assert) { + await mountModelSelector([ + "model_1", + "model_2", + "model_3", + "model_4", + "model_5", + "model_6", + "model_7", + "model_8", + "model_9", + "model_10", + ]); + await openAutocomplete(); + assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 8); +}); + +QUnit.test("model_selector: search content is not applied when opening the autocomplete", async function (assert) { + await mountModelSelector(["model_1", "model_2"], "_2"); + await openAutocomplete(); + assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 2); +}); + +QUnit.test("model_selector: with search matching some records on technical name", async function (assert) { + await mountModelSelector(["model_1", "model_2"]); + await openAutocomplete(); + await editInput(fixture, ".o-autocomplete--input", "_2"); + assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item"); + assert.strictEqual( + fixture.querySelector("li.o-autocomplete--dropdown-item").innerText, + "Model 2" + ); +}); + +QUnit.test("model_selector: with search matching some records on business name", async function (assert) { + await mountModelSelector(["model_1", "model_2"]); + await openAutocomplete(); + await editInput(fixture, ".o-autocomplete--input", " 2"); + assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item"); + assert.strictEqual( + fixture.querySelector("li.o-autocomplete--dropdown-item").innerText, + "Model 2" + ); +}); + +QUnit.test("model_selector: with search matching no record", async function (assert) { + await mountModelSelector(["model_1", "model_2"]); + await openAutocomplete("a random search query"); + await editInput(fixture, ".o-autocomplete--input", "a random search query"); + assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item"); + assert.strictEqual( + fixture.querySelector("li.o-autocomplete--dropdown-item").innerText, + "No records" + ); +}); + +QUnit.test("model_selector: select a model", async function (assert) { + await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1", (selected) => { + assert.step("model selected"); + assert.deepEqual(selected, { + label: "Model 2", + technical: "model_2", + }); + }); + await openAutocomplete(); + await click(fixture.querySelector(".o_model_selector_model_2")); + assert.verifySteps(["model selected"]); +}); + +QUnit.test("model_selector: with an initial value", async function (assert) { + await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1"); + assert.equal(fixture.querySelector(".o-autocomplete--input").value, "Model 1"); +}); diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index d2330ddc32f..35229ab1a12 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -3,6 +3,7 @@ from . import test_db_manager from . import test_health from . import test_image +from . import test_ir_model from . import test_js from . import test_menu from . import test_serving_base diff --git a/addons/web/tests/test_ir_model.py b/addons/web/tests/test_ir_model.py new file mode 100644 index 00000000000..8ba412761cb --- /dev/null +++ b/addons/web/tests/test_ir_model.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.tests.common import TransactionCase + +from odoo.tests import tagged +from odoo.tests.common import new_test_user + + +@tagged("post_install", "-at_install") +class IrModelAccessTest(TransactionCase): + + @classmethod + def setUpClass(cls): + super(IrModelAccessTest, cls).setUpClass() + + cls.env['ir.model.access'].create({ + 'name': "read", + 'model_id': cls.env['ir.model'].search([("model", "=", "res.company")]).id, + 'group_id': cls.env.ref("base.group_public").id, + 'perm_read': False, + }) + + cls.env['ir.model.access'].create({ + 'name': "read", + 'model_id': cls.env['ir.model'].search([("model", "=", "res.company")]).id, + 'group_id': cls.env.ref("base.group_portal").id, + 'perm_read': True, + }) + + cls.env['ir.model.access'].create({ + 'name': "read", + 'model_id': cls.env['ir.model'].search([("model", "=", "res.company")]).id, + 'group_id': cls.env.ref("base.group_user").id, + 'perm_read': True, + }) + + cls.portal_user = new_test_user( + cls.env, login="portalDude", groups="base.group_portal" + ) + cls.public_user = new_test_user( + cls.env, login="publicDude", groups="base.group_public" + ) + cls.spreadsheet_user = new_test_user( + cls.env, login="spreadsheetDude", groups="base.group_user" + ) + + def test_display_name_for(self): + # Internal User with access rights can access the business name + result = self.env['ir.model'].with_user(self.spreadsheet_user).display_name_for(["res.company"]) + self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}]) + # external user with access rights cannot access business name + result = self.env['ir.model'].with_user(self.portal_user).display_name_for(["res.company"]) + self.assertEqual(result, [{"display_name": "res.company", "model": "res.company"}]) + # external user without access rights cannot access business name + result = self.env['ir.model'].with_user(self.public_user).display_name_for(["res.company"]) + self.assertEqual(result, [{"display_name": "res.company", "model": "res.company"}]) + # admin has all rights + result = self.env['ir.model'].display_name_for(["res.company"]) + self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}]) + # non existent model yields same result as a lack of access rights + result = self.env['ir.model'].display_name_for(["unexistent"]) + self.assertEqual(result, [{"display_name": "unexistent", "model": "unexistent"}]) + # non existent model comes after existent model + result = self.env['ir.model'].display_name_for(["res.company", "unexistent"]) + self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}, {"display_name": "unexistent", "model": "unexistent"}])