[IMP] hr: Introduce the public employee profile

Purpose
=======

1/ Robustness & security: right now it is not easy to understand and do something
   clean in term of security (hr people vs employees, private info vs public). A
   HR officer doesn't know if he can write something on the chatter. Currently, a
   note will be visible for all the employees who have access to the employee form
   view for example.
2/ In term of business, it makes sense to let a hr manages payroll stuff (contract,
   employees private information, ... and other employee see public information
   (résumé and work information)

Specification
=============

Introduce 2 new models:

- hr.employee.base (AbstractModel): This represents the basic skeleton
  model on which the shared fields and methods between the public and
  the private employees models.
- hr.employee.public (_auto=False): This is a sql view based on the
  employee values, readable for an internal user (i.e. an employee).

The model hr.employee is not readable anymore for an employee.

There are now 3 ways to access the employee data:
1/ From the hr.employee views. HR officer access rights are required
2/ From the public profile. The public data for an employee are accessible
   but can't be modified.
3/ From the 'My Profile' menu. A classic employee can access its own
   data from there, and can modify them.
This commit is contained in:
RomainLibert
2019-05-31 10:21:20 +02:00
committed by Yannick Tivisse
parent b2b26f1ff7
commit c9ca376146
10 changed files with 363 additions and 52 deletions
+1
View File
@@ -29,6 +29,7 @@
'views/hr_job_views.xml',
'views/hr_plan_views.xml',
'views/hr_employee_category_views.xml',
'views/hr_employee_public_views.xml',
'views/hr_employee_views.xml',
'views/hr_department_views.xml',
'views/hr_views.xml',
+2
View File
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_employee_base
from . import hr_employee
from . import hr_employee_category
from . import hr_employee_public
from . import hr_department
from . import hr_job
from . import hr_plan
+72 -18
View File
@@ -12,11 +12,17 @@ from odoo.modules.module import get_module_resource
class HrEmployeePrivate(models.Model):
"""
NB: Any field only available on the model hr.employee (i.e. not on the
hr.employee.public model) should have `groups="hr.group_hr_user"` on its
definition to avoid being prefetched when the user hasn't access to the
hr.employee model. Indeed, the prefetch loads the data for all the fields
that are available according to the group defined on them.
"""
_name = "hr.employee"
_description = "Employee"
_order = 'name'
_inherit = ['mail.thread', 'mail.activity.mixin', 'resource.mixin']
_inherit = ['hr.employee.base', 'mail.thread', 'mail.activity.mixin', 'resource.mixin']
_mail_post_access = 'read'
@api.model
@@ -83,7 +89,6 @@ class HrEmployeePrivate(models.Model):
emergency_phone = fields.Char("Emergency Phone", groups="hr.group_hr_user", tracking=True)
km_home_work = fields.Integer(string="Km home-work", groups="hr.group_hr_user", tracking=True)
google_drive_link = fields.Char(string="Employee Documents", groups="hr.group_hr_user", tracking=True)
job_title = fields.Char("Job Title")
# image: all image fields are base64 encoded and PIL-supported
image = fields.Binary(
@@ -99,17 +104,8 @@ class HrEmployeePrivate(models.Model):
help="Small-sized photo of the employee. It is automatically "
"resized as a 64x64px image, with aspect ratio preserved. "
"Use this field anywhere a small image is required.")
# work
address_id = fields.Many2one(
'res.partner', 'Work Address')
work_phone = fields.Char('Work Phone')
mobile_phone = fields.Char('Work Mobile')
phone = fields.Char(related='address_home_id.phone', related_sudo=False, string="Private Phone", groups="hr.group_hr_user")
work_email = fields.Char('Work Email')
work_location = fields.Char('Work Location')
# employee in company
job_id = fields.Many2one('hr.job', 'Job Position')
department_id = fields.Many2one('hr.department', 'Department')
parent_id = fields.Many2one('hr.employee', 'Manager')
child_ids = fields.One2many('hr.employee', 'parent_id', string='Direct subordinates')
coach_id = fields.Many2one('hr.employee', 'Coach')
@@ -118,22 +114,80 @@ class HrEmployeePrivate(models.Model):
'emp_id', 'category_id',
string='Tags')
# misc
notes = fields.Text('Notes')
color = fields.Integer('Color Index', default=0)
barcode = fields.Char(string="Badge ID", help="ID used for employee identification.", copy=False)
pin = fields.Char(string="PIN", help="PIN used to Check In/Out in Kiosk Mode (if enabled in Configuration).", copy=False)
notes = fields.Text('Notes', groups="hr.group_hr_user")
color = fields.Integer('Color Index', default=0, groups="hr.group_hr_user")
barcode = fields.Char(string="Badge ID", help="ID used for employee identification.", groups="hr.group_hr_user", copy=False)
pin = fields.Char(string="PIN", groups="hr.group_hr_user", copy=False,
help="PIN used to Check In/Out in Kiosk Mode (if enabled in Configuration).")
departure_reason = fields.Selection([
('fired', 'Fired'),
('resigned', 'Resigned'),
('retired', 'Retired')
], string="Departure Reason", copy=False, tracking=True)
departure_description = fields.Text(string="Additional Information", copy=False, tracking=True)
], string="Departure Reason", groups="hr.group_hr_user", copy=False, tracking=True)
departure_description = fields.Text(string="Additional Information", groups="hr.group_hr_user", copy=False, tracking=True)
message_main_attachment_id = fields.Many2one(groups="hr.group_hr_user")
_sql_constraints = [
('barcode_uniq', 'unique (barcode)', "The Badge ID must be unique, this one is already assigned to another employee."),
('user_uniq', 'unique (user_id, company_id)', "A user cannot be linked to multiple employees in the same company.")
]
@api.multi
def name_get(self):
if self.check_access_rights('read', raise_exception=False):
return super(HrEmployeePrivate, self).name_get()
return self.env['hr.employee.public'].browse(self.ids).name_get()
@api.multi
def read(self, fields, load='_classic_read'):
if self.check_access_rights('read', raise_exception=False):
return super(HrEmployeePrivate, self).read(fields, load=load)
private_fields = set(fields).difference(self.env['hr.employee.public']._fields.keys())
if private_fields:
raise AccessError(_('The fields "%s" you try to read is not available on the public employee profile.') % (','.join(private_fields)))
return self.env['hr.employee.public'].browse(self.ids).read(fields, load=load)
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
"""
We override the _search because it is the method that checks the access rights
This is correct to override the _search. That way we enforce the fact that calling
search on an hr.employee returns a hr.employee recordset, even if you don't have access
to this model, as the result of _search (the ids of the public employees) is to be
browsed on the hr.employee model. This can be trusted as the ids of the public
employees exactly match the ids of the related hr.employee.
"""
if self.check_access_rights('read', raise_exception=False):
return super(HrEmployeePrivate, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
return self.env['hr.employee.public']._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
@api.multi
def get_formview_id(self, access_uid=None):
""" Override this method in order to redirect many2one towards the right model depending on access_uid """
if access_uid:
self_sudo = self.sudo(access_uid)
else:
self_sudo = self
if self_sudo.check_access_rights('read', raise_exception=False):
return super(HrEmployeePrivate, self).get_formview_id(access_uid=access_uid)
# Hardcode the form view for public employee
return self.env.ref('hr.hr_employee_public_view_form').id
@api.multi
def get_formview_action(self, access_uid=None):
""" Override this method in order to redirect many2one towards the right model depending on access_uid """
res = super(HrEmployeePrivate, self).get_formview_action(access_uid=access_uid)
if access_uid:
self_sudo = self.sudo(access_uid)
else:
self_sudo = self
if not self_sudo.check_access_rights('read', raise_exception=False):
res['res_model'] = 'hr.employee.public'
return res
@api.constrains('pin')
def _verify_pin(self):
for employee in self:
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class HrEmployeeBase(models.AbstractModel):
_name = "hr.employee.base"
_description = "Basic Employee"
_order = 'name'
name = fields.Char()
active = fields.Boolean("Active")
department_id = fields.Many2one('hr.department', 'Department')
job_id = fields.Many2one('hr.job', 'Job Position')
job_title = fields.Char("Job Title")
company_id = fields.Many2one('res.company', 'Company')
address_id = fields.Many2one('res.partner', 'Work Address')
work_phone = fields.Char('Work Phone')
mobile_phone = fields.Char('Work Mobile')
work_email = fields.Char('Work Email')
work_location = fields.Char('Work Location')
user_id = fields.Many2one('res.users')
resource_id = fields.Many2one('resource.resource')
resource_calendar_id = fields.Many2one('resource.calendar')
+59
View File
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
class HrEmployeePublic(models.Model):
_name = "hr.employee.public"
_inherit = ["hr.employee.base"]
_description = 'Public Employee'
_order = 'name'
_auto = False
_log_access = True # Include magic fields
# Fields coming from hr.employee.base
create_date = fields.Datetime(readonly=True)
name = fields.Char(readonly=True)
active = fields.Boolean(readonly=True)
department_id = fields.Many2one(readonly=True)
job_id = fields.Many2one(readonly=True)
job_title = fields.Char(readonly=True)
company_id = fields.Many2one(readonly=True)
address_id = fields.Many2one(readonly=True)
mobile_phone = fields.Char(readonly=True)
work_phone = fields.Char(readonly=True)
work_email = fields.Char(readonly=True)
work_location = fields.Char(readonly=True)
user_id = fields.Many2one(readonly=True)
resource_id = fields.Many2one(readonly=True)
resource_calendar_id = fields.Many2one(readonly=True)
# hr.employee.public specific fields
child_ids = fields.One2many('hr.employee.public', 'parent_id', string='Direct subordinates', readonly=True)
image = fields.Binary("Photo", compute='_compute_image', readonly=True, compute_sudo=True)
image_medium = fields.Binary("Medium-sized photo", compute='_compute_image', readonly=True, compute_sudo=True)
image_small = fields.Binary("Small-sized photo", compute='_compute_image', readonly=True, compute_sudo=True)
parent_id = fields.Many2one('hr.employee.public', 'Manager', readonly=True)
coach_id = fields.Many2one('hr.employee.public', 'Coach', readonly=True)
def _compute_image(self):
for employee in self:
# We have to be in sudo to have access to the images
employee_id = self.sudo().env['hr.employee'].browse(employee.id)
employee.image = employee_id.image
employee.image_medium = employee_id.image_medium
employee.image_small = employee_id.image_small
@api.model
def _get_fields(self):
return ','.join('emp.%s' % name for name, field in self._fields.items() if field.store and field.type not in ['many2many', 'one2many'])
@api.model_cr
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute("""CREATE or REPLACE VIEW %s as (
SELECT
%s
FROM hr_employee emp
)""" % (self._table, self._get_fields()))
+7
View File
@@ -40,6 +40,13 @@
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="hr_employee_public_comp_rule" model="ir.rule">
<field name="name">Employee multi company rule</field>
<field name="model_id" ref="model_hr_employee_public"/>
<field name="global" eval="True"/>
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record id="hr_job_comp_rule" model="ir.rule">
<field name="name">Job multi company rule</field>
<field name="model_id" ref="model_hr_job"/>
+2 -1
View File
@@ -2,7 +2,8 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_employee_category_user,hr.employee.category.user,model_hr_employee_category,group_hr_user,1,1,1,1
access_hr_employee_category_emp,hr.employee.category.emp,model_hr_employee_category,base.group_user,1,0,0,0
access_hr_employee_user,hr.employee user,model_hr_employee,group_hr_user,1,1,1,1
access_hr_employee_system_user,hr.employee system user,model_hr_employee,base.group_user,1,0,0,0
access_hr_employee_system_user,hr.employee system user,model_hr_employee,base.group_user,0,0,0,0
access_hr_employee_public_user,hr.employee_public,model_hr_employee_public,base.group_user,1,0,0,0
access_hr_employee_resource_user,resource.resource.user,resource.model_resource_resource,group_hr_user,1,1,1,1
access_hr_department_user,hr.department.user,model_hr_department,group_hr_user,1,1,1,1
access_hr_department_employee,hr.department.employee,model_hr_department,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_employee_category_user hr.employee.category.user model_hr_employee_category group_hr_user 1 1 1 1
3 access_hr_employee_category_emp hr.employee.category.emp model_hr_employee_category base.group_user 1 0 0 0
4 access_hr_employee_user hr.employee user model_hr_employee group_hr_user 1 1 1 1
5 access_hr_employee_system_user hr.employee system user model_hr_employee base.group_user 1 0 0 0 0
6 access_hr_employee_public_user hr.employee_public model_hr_employee_public base.group_user 1 0 0 0
7 access_hr_employee_resource_user resource.resource.user resource.model_resource_resource group_hr_user 1 1 1 1
8 access_hr_department_user hr.department.user model_hr_department group_hr_user 1 1 1 1
9 access_hr_department_employee hr.department.employee model_hr_department base.group_user 1 0 0 0
+11 -14
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from odoo.addons.hr.tests.common import TestHrCommon
from odoo.tests import new_test_user
from odoo.exceptions import AccessError
from datetime import date
from collections import OrderedDict
class TestSelfAccessRights(TestHrCommon):
@@ -26,6 +26,8 @@ class TestSelfAccessRights(TestHrCommon):
})
self.protected_fields_emp = OrderedDict([(k, v) for k, v in self.env['hr.employee']._fields.items() if v.groups == 'hr.group_hr_user'])
# Compute fields and id field are always readable by everyone
self.read_protected_fields_emp = OrderedDict([(k, v) for k, v in self.env['hr.employee']._fields.items() if not v.compute and k != 'id'])
self.self_protected_fields_user = OrderedDict([
(k, v)
for k, v in self.env['res.users']._fields.items()
@@ -34,14 +36,12 @@ class TestSelfAccessRights(TestHrCommon):
# Read hr.employee #
def testReadSelfEmployee(self):
for f in self.protected_fields_emp:
with self.assertRaises(AccessError):
self.richard_emp.sudo(self.richard)[f]
with self.assertRaises(AccessError):
self.hubert_emp.sudo(self.richard).read(self.protected_fields_emp.keys())
def testReadOtherEmployee(self):
for f in self.protected_fields_emp:
with self.assertRaises(AccessError):
self.hubert_emp.sudo(self.richard)[f]
with self.assertRaises(AccessError):
self.hubert_emp.sudo(self.richard).read(self.protected_fields_emp.keys())
# Write hr.employee #
def testWriteSelfEmployee(self):
@@ -60,10 +60,8 @@ class TestSelfAccessRights(TestHrCommon):
self.richard.sudo(self.richard).read([f]) # should not raise
def testReadOtherUserEmployee(self):
for f in self.self_protected_fields_user:
with self.assertRaises(AccessError, msg="Field %s should not be readable by other usrs" % f):
self.hubert.sudo(self.richard)[f]
with self.assertRaises(AccessError):
self.hubert.sudo(self.richard).read(self.self_protected_fields_user)
# Write res.users #
def testWriteSelfUserEmployeeSettingFalse(self):
@@ -76,7 +74,7 @@ class TestSelfAccessRights(TestHrCommon):
for f, v in self.self_protected_fields_user.items():
val = None
if v.type == 'char' or v.type == 'text':
val = 'dummy'
val = '0000' if f == 'pin' else 'dummy'
if val is not None:
self.richard.sudo(self.richard).write({f: val})
@@ -116,7 +114,6 @@ class TestSelfAccessRights(TestHrCommon):
self.richard.sudo(self.richard).write({'phone': '2154545'})
def testWriteOtherUserEmployee(self):
for f in self.self_protected_fields_user:
with self.assertRaises(AccessError):
self.hubert.sudo(self.richard).write({f: 'dummy'})
@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="hr_employee_public_view_search" model="ir.ui.view">
<field name="name">hr.employee.search</field>
<field name="model">hr.employee.public</field>
<field name="arch" type="xml">
<search string="Employees">
<field name="name" string="Employees" filter_domain="['|',('work_email','ilike',self),('name','ilike',self)]"/>
</search>
</field>
</record>
<record id="hr_employee_public_view_form" model="ir.ui.view">
<field name="name">hr.employee.public.form</field>
<field name="model">hr.employee.public</field>
<field name="arch" type="xml">
<form string="Employee" create="0" write="0">
<sheet>
<field name="user_id" invisible="1"/>
<field name="active" invisible="1"/>
<div class="oe_button_box" name="button_box">
<!-- Used by other modules-->
</div>
<field name="image" widget='image' class="oe_avatar" options='{"preview_image":"image_medium"}'/>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" placeholder="Employee's Name" required="True"/>
</h1>
<h2>
<field name="job_title" placeholder="Job Title" />
</h2>
</div>
<group>
<group>
<field name="mobile_phone" widget="phone"/>
<field name="work_phone" widget="phone"/>
<field name="work_email" widget="email"/>
</group>
<group>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page name="public" string="Work Information">
<div id="o_work_employee_container"> <!-- These two div are used to position org_chart -->
<div id="o_work_employee_main">
<group string="Location">
<field name="address_id"
context="{'show_address': 1}"
options='{"always_reload": True, "highlight_first_line": True}'/>
<field name="work_location"/>
</group>
<group name="managers" string="Managers">
<field name="parent_id"/>
<field name="coach_id"/>
</group>
<group string="Schedule" groups="base.group_no_one">
<field name="resource_calendar_id"/>
</group>
</div>
</div>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="hr_employee_public_view_tree" model="ir.ui.view">
<field name="name">hr.employee.tree</field>
<field name="model">hr.employee.public</field>
<field name="arch" type="xml">
<tree string="Employees">
<field name="name"/>
<field name="work_phone"/>
<field name="work_email"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="department_id"/>
<field name="job_id"/>
<field name="parent_id"/>
<field name="coach_id" invisible="1"/>
</tree>
</field>
</record>
<record id="hr_employee_public_view_kanban" model="ir.ui.view">
<field name="name">hr.employee.kanban</field>
<field name="model">hr.employee.public</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<kanban class="o_hr_employee_kanban">
<field name="id"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click o_kanban_record_has_image_fill o_hr_kanban_record">
<!-- Medium employee's picture -->
<div class="o_kanban_image_fill_left d-none d-md-block" t-attf-style="background-image: url(#{kanban_image('hr.employee.public', 'image_medium', record.id.raw_value)})" role="img"/>
<!-- Employee's picture optimized for mobile -->
<div class="o_kanban_image d-md-none rounded-circle" t-attf-style="background-image: url(#{kanban_image('hr.employee.public', 'image_small', record.id.raw_value)})" role="img"/>
<div class="oe_kanban_details">
<div class="o_kanban_record_top">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
<span t-if="record.job_id.raw_value" class="o_kanban_record_subtitle"><field name="job_id"/></span>
</div>
</div>
<ul>
<li id="last_login"/>
<li t-if="record.work_location.raw_value"><field name="work_location"/></li>
</ul>
</div>
</div>
</t>
</templates>
<searchpanel>
<field name="company_id" groups="base.group_multi_company" icon="fa-building"/>
<field name="department_id" icon="fa-users"/>
</searchpanel>
</kanban>
</field>
</record>
<record id="hr_employee_public_action" model="ir.actions.act_window">
<field name="name">Employees</field>
<field name="res_model">hr.employee.public</field>
<field name="view_type">form</field>
<field name="view_mode">kanban,tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="view_id" eval="False"/>
<field name="search_view_id" ref="hr_employee_public_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new employee
</p><p>
With just a quick glance on the Odoo employee screen, you
can easily find all the information you need for each person;
contact data, job position, availability, etc.
</p>
</field>
</record>
</data>
</odoo>
+33 -19
View File
@@ -15,6 +15,27 @@
parent="menu_hr_root"
sequence="0"/>
<menuitem
id="menu_hr_employee_payroll"
name="Payroll"
parent="menu_hr_root"
groups="group_hr_user"
sequence="3"/>
<menuitem
id="menu_hr_employee_user"
name="Employees"
action="open_view_employee_list_my"
parent="menu_hr_employee_payroll"
sequence="1"/>
<menuitem
id="menu_hr_employee"
name="Company Address"
action="hr_employee_public_action"
parent="menu_hr_root"
sequence="4"/>
<menuitem
id="hr_menu_hr_reports"
name="Reporting"
@@ -22,25 +43,11 @@
sequence="95"/>
<menuitem
id="menu_open_view_employee_list_my"
action="open_view_employee_list_my"
parent="menu_hr_root"
sequence="3"/>
<menuitem
id="menu_hr_department_tree"
action="open_module_tree_department"
parent="menu_hr_root"
sequence="90"
groups="group_hr_user"/>
<!-- RLI TODO Rename this, it has nothing to do with timesheet -->
<menuitem
id="menu_hr_reporting_timesheet"
name="Reporting"
parent="menu_hr_root"
groups="group_hr_manager,group_hr_user"
sequence="99"/>
id="menu_hr_reporting_timesheet"
name="Reporting"
parent="menu_hr_root"
groups="group_hr_manager,group_hr_user"
sequence="99"/>
<menuitem
id="menu_human_resources_configuration"
@@ -64,6 +71,13 @@
groups="base.group_no_one"
sequence="1"/>
<menuitem
id="menu_hr_department_tree"
action="open_module_tree_department"
parent="menu_human_resources_configuration"
sequence="2"
groups="group_hr_user"/>
<menuitem
id="menu_config_plan"
name="Activity Planning"