[IMP] hr*: change employee field widgets to show private/public employee

In HR modules we have both hr.employee and hr.employee.public models and the
issue is that if a certain user doesn't have access to read the hr.employee
record, it will throw errors/avatar pictures won't appear. For this reason,
https://github.com/odoo/odoo/pull/116828 introduced a change in many2many
employee fields so that they always show hr.employee.public records. However,
we can improve this behavior by checking if the user has access to the record.
If they have access, the private record is shown (making it easier to navigate,
edit employee information) and if they don't have access, we show the public
record. In order to allow forcing a relation, it was decided to convert all
employee fields to accept a relation parameter in the options. If this option
is set, we use that as the relation.

This commit also changes the HR views using these widgets, removing the
relation option from fields that would benefit from this dynamic check for the
most suitable relation.

task-3524305

closes odoo/odoo#136786

Related: odoo/enterprise#48008
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Leonardo Pavan Rocha
2023-10-12 16:11:59 +00:00
committed by Yannick Tivisse
parent 1c9eccce00
commit 43122b3efc
8 changed files with 217 additions and 39 deletions
@@ -0,0 +1,33 @@
/** @odoo-module **/
import { onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
/**
* Mixin that handles public/private access of employee records in many2X fields
* @param { Class } fieldClass
* @returns Class
*/
export function EmployeeFieldRelationMixin(fieldClass) {
return class extends fieldClass {
static props = {
...fieldClass.props,
relation: { type: String, optional: true },
};
setup() {
super.setup();
this.user = useService("user");
onWillStart(async () => {
this.isHrUser = await this.user.hasGroup("hr.group_hr_user");
});
}
get relation() {
if (this.props.relation) {
return this.props.relation;
}
return this.isHrUser ? "hr.employee" : "hr.employee.public";
}
};
}
@@ -8,12 +8,11 @@ import {
kanbanMany2ManyTagsAvatarUserField,
listMany2ManyTagsAvatarUserField,
} from "@mail/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field";
import { EmployeeFieldRelationMixin } from "@hr/views/fields/employee_field_relation_mixin";
export class Many2ManyTagsAvatarEmployeeField extends Many2ManyTagsAvatarUserField {
get relation() {
return 'hr.employee.public';
}
}
export class Many2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
Many2ManyTagsAvatarUserField
) {}
export const many2ManyTagsAvatarEmployeeField = {
...many2ManyTagsAvatarUserField,
@@ -25,16 +24,15 @@ export const many2ManyTagsAvatarEmployeeField = {
extractProps: (fieldInfo, dynamicInfo) => ({
...many2ManyTagsAvatarUserField.extractProps(fieldInfo, dynamicInfo),
canQuickCreate: false,
relation: fieldInfo.options?.relation,
}),
};
registry.category("fields").add("many2many_avatar_employee", many2ManyTagsAvatarEmployeeField);
export class KanbanMany2ManyTagsAvatarEmployeeField extends KanbanMany2ManyTagsAvatarUserField {
get relation() {
return 'hr.employee.public';
}
}
export class KanbanMany2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
KanbanMany2ManyTagsAvatarUserField
) {}
export const kanbanMany2ManyTagsAvatarEmployeeField = {
...kanbanMany2ManyTagsAvatarUserField,
@@ -43,6 +41,10 @@ export const kanbanMany2ManyTagsAvatarEmployeeField = {
...kanbanMany2ManyTagsAvatarUserField.additionalClasses,
"o_field_many2many_avatar_user",
],
extractProps: (fieldInfo, dynamicInfo) => ({
...kanbanMany2ManyTagsAvatarUserField.extractProps(fieldInfo, dynamicInfo),
relation: fieldInfo.options?.relation,
}),
};
registry
@@ -7,8 +7,11 @@ import {
many2OneAvatarUserField,
kanbanMany2OneAvatarUserField,
} from "@mail/views/web/fields/many2one_avatar_user_field/many2one_avatar_user_field";
import { EmployeeFieldRelationMixin } from "@hr/views/fields/employee_field_relation_mixin";
export class Many2OneAvatarEmployeeField extends Many2OneAvatarUserField { }
export class Many2OneAvatarEmployeeField extends EmployeeFieldRelationMixin(
Many2OneAvatarUserField
) {}
export const many2OneAvatarEmployeeField = {
...many2OneAvatarUserField,
@@ -26,7 +29,9 @@ export const many2OneAvatarEmployeeField = {
registry.category("fields").add("many2one_avatar_employee", many2OneAvatarEmployeeField);
export class KanbanMany2OneAvatarEmployeeField extends KanbanMany2OneAvatarUserField { }
export class KanbanMany2OneAvatarEmployeeField extends EmployeeFieldRelationMixin(
KanbanMany2OneAvatarUserField
) {}
export const kanbanMany2OneAvatarEmployeeField = {
...kanbanMany2OneAvatarUserField,
@@ -34,7 +39,7 @@ export const kanbanMany2OneAvatarEmployeeField = {
extractProps: (fieldInfo, dynamicInfo) => ({
...kanbanMany2OneAvatarUserField.extractProps(fieldInfo, dynamicInfo),
relation: fieldInfo.options?.relation,
})
}),
};
registry
@@ -6,6 +6,10 @@ import { start } from "@mail/../tests/helpers/test_utils";
import { dom } from "@web/../tests/legacy/helpers/test_utils";
import { contains } from "@web/../tests/utils";
import { registry } from "@web/core/registry";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
const serviceRegistry = registry.category("services");
QUnit.module("M2XAvatarEmployee");
@@ -125,6 +129,94 @@ QUnit.test("many2one_avatar_employee widget in kanban view", async function (ass
);
});
QUnit.test("many2one_avatar_employee with hr group widget in kanban view", async function (assert) {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const employeeId = pyEnv["hr.employee.public"].create({
user_id: userId,
user_partner_id: partnerId,
});
pyEnv["m2x.avatar.employee"].create({
employee_id: employeeId,
employee_ids: [employeeId],
});
serviceRegistry.add(
"user",
makeFakeUserService(() => true),
{ force: true }
);
const views = {
"m2x.avatar.employee,false,kanban": `<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="employee_id" widget="many2one_avatar_employee"/>
</div>
</t>
</templates>
</kanban>`,
};
const { openView } = await start({ serverData: { views } });
await openView({
res_model: "m2x.avatar.employee",
views: [[false, "kanban"]],
});
assert.strictEqual(document.querySelector(".o_kanban_record").innerText.trim(), "");
await contains(".o_m2o_avatar");
assert.strictEqual(
document.querySelector(".o_m2o_avatar > img").getAttribute("data-src"),
`/web/image/hr.employee/${employeeId}/avatar_128`
);
});
QUnit.test("many2one_avatar_employee with relation set in options", async function (assert) {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const employeeId = pyEnv["hr.employee.public"].create({
user_id: userId,
user_partner_id: partnerId,
});
pyEnv["m2x.avatar.employee"].create({
employee_id: employeeId,
employee_ids: [employeeId],
});
serviceRegistry.add(
"user",
makeFakeUserService(() => true),
{ force: true }
);
const views = {
"m2x.avatar.employee,false,kanban": `<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="employee_id" widget="many2one_avatar_employee" options="{'relation': 'hr.employee.public'}"/>
</div>
</t>
</templates>
</kanban>`,
};
const { openView } = await start({ serverData: { views } });
await openView({
res_model: "m2x.avatar.employee",
views: [[false, "kanban"]],
});
assert.strictEqual(document.querySelector(".o_kanban_record").innerText.trim(), "");
await contains(".o_m2o_avatar");
assert.strictEqual(
document.querySelector(".o_m2o_avatar > img").getAttribute("data-src"),
`/web/image/hr.employee.public/${employeeId}/avatar_128`
);
});
QUnit.test(
"many2one_avatar_employee: click on an employee not associated with a user",
async function (assert) {
@@ -220,6 +312,70 @@ QUnit.test("many2many_avatar_employee widget in form view", async function (asse
assert.verifySteps([`web_read m2x.avatar.employee ${avatarId_1}`]);
});
QUnit.test("many2many_avatar_employee with hr group widget in form view", async function (assert) {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([{}, {}]);
const [userId_1, userId_2] = pyEnv["res.users"].create([
{ partner_id: partnerId_1 },
{ partner_id: partnerId_2 },
]);
const [employeeId_1, employeeId_2] = pyEnv["hr.employee.public"].create([
{ user_id: userId_1, user_partner_id: partnerId_1 },
{ user_id: userId_2, user_partner_id: partnerId_2 },
]);
const avatarId_1 = pyEnv["m2x.avatar.employee"].create({
employee_ids: [employeeId_1, employeeId_2],
});
const views = {
"m2x.avatar.employee,false,form":
'<form><field name="employee_ids" widget="many2many_avatar_employee"/></form>',
};
// if the user doesn't have access to hr.group_hr_user, the employee field should show the public employee
serviceRegistry.add(
"user",
makeFakeUserService(() => true),
{ force: true }
);
const { openView } = await start({
mockRPC(route, args) {
if (args.method === "web_read") {
assert.step(`web_read ${args.model} ${args.args[0]}`);
}
if (args.method === "read") {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
});
await openView({
res_model: "m2x.avatar.employee",
res_id: avatarId_1,
views: [[false, "form"]],
});
assert.containsN(
document.body,
".o_field_many2many_avatar_employee .o_tag",
2,
"should have 2 records"
);
assert.strictEqual(
document
.querySelector(".o_field_many2many_avatar_employee .o_tag img")
.getAttribute("data-src"),
`/web/image/hr.employee/${employeeId_1}/avatar_128`
);
await dom.click(
document.querySelector(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar")
);
await dom.click(
document.querySelectorAll(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar")[1]
);
assert.verifySteps([`web_read m2x.avatar.employee ${avatarId_1}`]);
});
QUnit.test("many2many_avatar_employee widget in list view", async function (assert) {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
@@ -194,7 +194,7 @@
<field name="active" invisible="1"/>
<!-- employee_id = fields.Many2one('hr.employee', string='Employee', tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") -->
<field name="company_id" invisible="1"/>
<field name="employee_id" widget="many2one_avatar_employee" options="{'relation': 'hr.employee.public'}"/>
<field name="employee_id" widget="many2one_avatar_employee"/>
<field name="date_start" string="Contract Start Date"/>
<field name="date_end" string="Contract End Date"/>
<field name="company_country_id" invisible="1"/>
@@ -174,14 +174,12 @@
<field name="employee_id" groups="hr_holidays.group_hr_holidays_user"
invisible="holiday_type != 'employee' or not employee_id or state == 'confirm'"
readonly="state in ['refuse', 'validate']"
widget="many2one_avatar_employee"
options="{'relation': 'hr.employee.public'}"/>
widget="many2one_avatar_employee"/>
<field name="employee_ids" widget="many2many_avatar_employee"
groups="hr_holidays.group_hr_holidays_user"
invisible="holiday_type != 'employee' or state != 'confirm' and employee_id"
readonly="state in ['refuse', 'validate']"
required="holiday_type == 'employee' and state == 'confirm'"
options="{'relation': 'hr.employee.public'}"/>
required="holiday_type == 'employee' and state == 'confirm'"/>
</field>
<field name="employee_id" position="after">
<field name="category_id"
@@ -26,13 +26,8 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='owner_user_id']" position="replace">
<field name="employee_id" string="Created By"
widget="many2one_avatar_employee"
options="{'no_create_edit': True, 'no_open': True, 'relation': 'hr.employee.public'}"
groups="!hr.group_hr_user"/>
<field name="employee_id" string="Created By"
widget="many2one_avatar_employee"
options="{'no_create_edit': True, 'no_open': True, 'relation': 'hr.employee'}"
groups="hr.group_hr_user"/>
widget="many2one_avatar_employee"
options="{'no_create_edit': True, 'no_open': True}"/>
</xpath>
</field>
</record>
@@ -43,10 +43,7 @@
<field name="readonly_timesheet" column_invisible="True"/>
<field name="date" readonly="readonly_timesheet"/>
<field name="user_id" column_invisible="True"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" options="{'relation': 'hr.employee.public'}" groups="!hr.group_hr_user"
required="1"
readonly="readonly_timesheet"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" options="{'relation': 'hr.employee'}" groups="hr.group_hr_user"
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}"
required="1"
readonly="readonly_timesheet"/>
<field name="name" required="0" readonly="readonly_timesheet"/>
@@ -69,10 +66,7 @@
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="row">
<div class="col-6">
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" options="{'relation': 'hr.employee.public'}" groups="!hr.group_hr_user"
readonly="readonly_timesheet"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" options="{'relation': 'hr.employee'}" groups="hr.group_hr_user"
readonly="readonly_timesheet"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" readonly="readonly_timesheet"/>
<strong><span><t t-esc="record.employee_id.value"/></span></strong>
</div>
<div class="col-6 float-end text-end">
@@ -99,12 +93,7 @@
<field name="readonly_timesheet" invisible="1"/>
<field name="date" readonly="readonly_timesheet"/>
<field name="user_id" invisible="1" readonly="readonly_timesheet"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" options="{'relation': 'hr.employee.public'}" groups="!hr.group_hr_user"
required="1"
readonly="readonly_timesheet"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" options="{'relation': 'hr.employee'}" groups="hr.group_hr_user"
required="1"
readonly="readonly_timesheet"/>
<field name="employee_id" widget="many2one_avatar_employee" context="{'active_test': True}" required="1" readonly="readonly_timesheet"/>
<field name="name" required="0" readonly="readonly_timesheet"/>
<field name="unit_amount" string="Duration" widget="float_time" decoration-danger="unit_amount &gt; 24"
readonly="readonly_timesheet"/>