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"}])