[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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="web.ModelSelector" owl="1">
|
||||
<div class="o_sp_input_dropdown" t-ref="autocomplete_container">
|
||||
<input t-if="env.isSmall"
|
||||
type="text"
|
||||
class="o_input"
|
||||
readonly=""
|
||||
t-att-value="props.value"
|
||||
/>
|
||||
<AutoComplete t-else=""
|
||||
value="props.value || ''"
|
||||
sources="sources"
|
||||
placeholder="placeholder"
|
||||
autoSelect="props.autoSelect"
|
||||
onSelect.bind="onSelect"
|
||||
/>
|
||||
<a role="button" class="o_dropdown_button" draggable="false" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"}])
|
||||
Reference in New Issue
Block a user