From b2b26f1ff7e4dcf7d0c0ccc36b249045372329c7 Mon Sep 17 00:00:00 2001 From: RomainLibert Date: Tue, 2 Apr 2019 11:03:25 +0200 Subject: [PATCH 01/16] [REF] hr: split hr.py and hr_views.xml into model related files This is much better for code readability, and in our coding guidelines --- addons/hr/__manifest__.py | 5 + addons/hr/models/__init__.py | 9 +- addons/hr/models/hr_department.py | 84 ++ addons/hr/models/{hr.py => hr_employee.py} | 186 +--- addons/hr/models/hr_employee_category.py | 18 + addons/hr/models/hr_job.py | 71 ++ addons/hr/views/hr_department_views.xml | 139 +++ .../hr/views/hr_employee_category_views.xml | 38 + addons/hr/views/hr_employee_views.xml | 360 ++++++++ addons/hr/views/hr_job_views.xml | 121 +++ addons/hr/views/hr_plan_views.xml | 103 +++ addons/hr/views/hr_views.xml | 850 +----------------- addons/hr/views/res_users.xml | 11 + 13 files changed, 1015 insertions(+), 980 deletions(-) create mode 100644 addons/hr/models/hr_department.py rename addons/hr/models/{hr.py => hr_employee.py} (61%) create mode 100644 addons/hr/models/hr_employee_category.py create mode 100644 addons/hr/models/hr_job.py create mode 100644 addons/hr/views/hr_department_views.xml create mode 100644 addons/hr/views/hr_employee_category_views.xml create mode 100644 addons/hr/views/hr_employee_views.xml create mode 100644 addons/hr/views/hr_job_views.xml create mode 100644 addons/hr/views/hr_plan_views.xml diff --git a/addons/hr/__manifest__.py b/addons/hr/__manifest__.py index d3ed7d4b2fc..c38700c0404 100644 --- a/addons/hr/__manifest__.py +++ b/addons/hr/__manifest__.py @@ -26,6 +26,11 @@ 'security/ir.model.access.csv', 'wizard/hr_plan_wizard_views.xml', 'wizard/hr_departure_wizard_views.xml', + 'views/hr_job_views.xml', + 'views/hr_plan_views.xml', + 'views/hr_employee_category_views.xml', + 'views/hr_employee_views.xml', + 'views/hr_department_views.xml', 'views/hr_views.xml', 'views/hr_templates.xml', 'views/res_config_settings_views.xml', diff --git a/addons/hr/models/__init__.py b/addons/hr/models/__init__.py index fb22e57dcf7..dc940941bd1 100644 --- a/addons/hr/models/__init__.py +++ b/addons/hr/models/__init__.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import hr -from . import res_config_settings +from . import hr_employee +from . import hr_employee_category +from . import hr_department +from . import hr_job +from . import hr_plan from . import mail_alias from . import mail_channel +from . import res_config_settings from . import res_partner from . import res_users -from . import hr_plan diff --git a/addons/hr/models/hr_department.py b/addons/hr/models/hr_department.py new file mode 100644 index 00000000000..180b3372e46 --- /dev/null +++ b/addons/hr/models/hr_department.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class Department(models.Model): + _name = "hr.department" + _description = "HR Department" + _inherit = ['mail.thread'] + _order = "name" + _rec_name = 'complete_name' + + name = fields.Char('Department Name', required=True) + complete_name = fields.Char('Complete Name', compute='_compute_complete_name', store=True) + active = fields.Boolean('Active', default=True) + company_id = fields.Many2one('res.company', string='Company', index=True, default=lambda self: self.env.company) + parent_id = fields.Many2one('hr.department', string='Parent Department', index=True) + child_ids = fields.One2many('hr.department', 'parent_id', string='Child Departments') + manager_id = fields.Many2one('hr.employee', string='Manager', tracking=True) + member_ids = fields.One2many('hr.employee', 'department_id', string='Members', readonly=True) + jobs_ids = fields.One2many('hr.job', 'department_id', string='Jobs') + note = fields.Text('Note') + color = fields.Integer('Color Index') + + @api.multi + def name_get(self): + if not self.env.context.get('hierarchical_naming', True): + return [(record.id, record.name) for record in self] + return super(Department, self).name_get() + + @api.depends('name', 'parent_id.complete_name') + def _compute_complete_name(self): + for department in self: + if department.parent_id: + department.complete_name = '%s / %s' % (department.parent_id.complete_name, department.name) + else: + department.complete_name = department.name + + @api.constrains('parent_id') + def _check_parent_id(self): + if not self._check_recursion(): + raise ValidationError(_('You cannot create recursive departments.')) + + @api.model + def create(self, vals): + # TDE note: auto-subscription of manager done by hand, because currently + # the tracking allows to track+subscribe fields linked to a res.user record + # An update of the limited behavior should come, but not currently done. + department = super(Department, self.with_context(mail_create_nosubscribe=True)).create(vals) + manager = self.env['hr.employee'].browse(vals.get("manager_id")) + if manager.user_id: + department.message_subscribe(partner_ids=manager.user_id.partner_id.ids) + return department + + @api.multi + def write(self, vals): + """ If updating manager of a department, we need to update all the employees + of department hierarchy, and subscribe the new manager. + """ + # TDE note: auto-subscription of manager done by hand, because currently + # the tracking allows to track+subscribe fields linked to a res.user record + # An update of the limited behavior should come, but not currently done. + if 'manager_id' in vals: + manager_id = vals.get("manager_id") + if manager_id: + manager = self.env['hr.employee'].browse(manager_id) + # subscribe the manager user + if manager.user_id: + self.message_subscribe(partner_ids=manager.user_id.partner_id.ids) + # set the employees's parent to the new manager + self._update_employee_manager(manager_id) + return super(Department, self).write(vals) + + def _update_employee_manager(self, manager_id): + employees = self.env['hr.employee'] + for department in self: + employees = employees | self.env['hr.employee'].search([ + ('id', '!=', manager_id), + ('department_id', '=', department.id), + ('parent_id', '=', department.manager_id.id) + ]) + employees.write({'parent_id': manager_id}) diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr_employee.py similarity index 61% rename from addons/hr/models/hr.py rename to addons/hr/models/hr_employee.py index 971c5d9a561..8f9078b7292 100644 --- a/addons/hr/models/hr.py +++ b/addons/hr/models/hr_employee.py @@ -1,101 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 from random import choice from string import digits -import base64 -import logging from werkzeug import url_encode -from odoo import api, fields, models, SUPERUSER_ID -from odoo import tools, _ +from odoo import api, fields, models, tools, SUPERUSER_ID, _ from odoo.exceptions import ValidationError, AccessError from odoo.modules.module import get_module_resource -_logger = logging.getLogger(__name__) - -class EmployeeCategory(models.Model): - - _name = "hr.employee.category" - _description = "Employee Category" - - name = fields.Char(string="Employee Tag", required=True) - color = fields.Integer(string='Color Index') - employee_ids = fields.Many2many('hr.employee', 'employee_category_rel', 'category_id', 'emp_id', string='Employees') - - _sql_constraints = [ - ('name_uniq', 'unique (name)', "Tag name already exists !"), - ] - - -class Job(models.Model): - - _name = "hr.job" - _description = "Job Position" - _inherit = ['mail.thread'] - - name = fields.Char(string='Job Position', required=True, index=True, translate=True) - expected_employees = fields.Integer(compute='_compute_employees', string='Total Forecasted Employees', store=True, - help='Expected number of employees for this job position after new recruitment.') - no_of_employee = fields.Integer(compute='_compute_employees', string="Current Number of Employees", store=True, - help='Number of employees currently occupying this job position.') - no_of_recruitment = fields.Integer(string='Expected New Employees', copy=False, - help='Number of new employees you expect to recruit.', default=1) - no_of_hired_employee = fields.Integer(string='Hired Employees', copy=False, - help='Number of hired employees for this job position during recruitment phase.') - employee_ids = fields.One2many('hr.employee', 'job_id', string='Employees', groups='base.group_user') - description = fields.Text(string='Job Description') - requirements = fields.Text('Requirements') - department_id = fields.Many2one('hr.department', string='Department') - company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company) - state = fields.Selection([ - ('recruit', 'Recruitment in Progress'), - ('open', 'Not Recruiting') - ], string='Status', readonly=True, required=True, tracking=True, copy=False, default='recruit', help="Set whether the recruitment process is open or closed for this job position.") - - _sql_constraints = [ - ('name_company_uniq', 'unique(name, company_id, department_id)', 'The name of the job position must be unique per department in company!'), - ] - - @api.depends('no_of_recruitment', 'employee_ids.job_id', 'employee_ids.active') - def _compute_employees(self): - employee_data = self.env['hr.employee'].read_group([('job_id', 'in', self.ids)], ['job_id'], ['job_id']) - result = dict((data['job_id'][0], data['job_id_count']) for data in employee_data) - for job in self: - job.no_of_employee = result.get(job.id, 0) - job.expected_employees = result.get(job.id, 0) + job.no_of_recruitment - - @api.model - def create(self, values): - """ We don't want the current user to be follower of all created job """ - return super(Job, self.with_context(mail_create_nosubscribe=True)).create(values) - - @api.multi - @api.returns('self', lambda value: value.id) - def copy(self, default=None): - self.ensure_one() - default = dict(default or {}) - if 'name' not in default: - default['name'] = _("%s (copy)") % (self.name) - return super(Job, self).copy(default=default) - - @api.multi - def set_recruit(self): - for record in self: - no_of_recruitment = 1 if record.no_of_recruitment == 0 else record.no_of_recruitment - record.write({'state': 'recruit', 'no_of_recruitment': no_of_recruitment}) - return True - - @api.multi - def set_open(self): - return self.write({ - 'state': 'open', - 'no_of_recruitment': 0, - 'no_of_hired_employee': 0 - }) - - -class Employee(models.Model): +class HrEmployeePrivate(models.Model): _name = "hr.employee" _description = "Employee" _order = 'name' @@ -278,7 +194,7 @@ class Employee(models.Model): vals.update(self._sync_user(user)) vals['name'] = vals.get('name', user.name) tools.image_resize_images(vals) - employee = super(Employee, self).create(vals) + employee = super(HrEmployeePrivate, self).create(vals) url = '/web#%s' % url_encode({'action': 'hr.plan_wizard_action', 'active_id': employee.id, 'active_model': 'hr.employee'}) employee._message_log(body=_('Congratulations ! May I recommand you to setup an onboarding plan ?') % (url)) if employee.department_id: @@ -296,7 +212,7 @@ class Employee(models.Model): if vals.get('user_id'): vals.update(self._sync_user(self.env['res.users'].browse(vals['user_id']))) tools.image_resize_images(vals) - res = super(Employee, self).write(vals) + res = super(HrEmployeePrivate, self).write(vals) if vals.get('department_id') or vals.get('user_id'): department_id = vals['department_id'] if vals.get('department_id') else self[:1].department_id.id # When added to a department or changing user, subscribe to the channels auto-subscribed by department @@ -308,11 +224,11 @@ class Employee(models.Model): @api.multi def unlink(self): resources = self.mapped('resource_id') - super(Employee, self).unlink() + super(HrEmployeePrivate, self).unlink() return resources.unlink() def toggle_active(self): - res = super(Employee, self).toggle_active() + res = super(HrEmployeePrivate, self).toggle_active() self.filtered(lambda employee: employee.active).write({ 'departure_reason': False, 'departure_description': False, @@ -331,7 +247,8 @@ class Employee(models.Model): @api.multi def generate_random_barcode(self): - for i in self: i.barcode = "".join(choice(digits) for i in range(8)) + for employee in self: + employee.barcode = "".join(choice(digits) for i in range(8)) @api.depends('address_home_id.parent_id') def _compute_is_address_home_a_company(self): @@ -364,88 +281,9 @@ class Employee(models.Model): return self def _message_log(self, **kwargs): - return super(Employee, self._post_author())._message_log(**kwargs) + return super(HrEmployeePrivate, self._post_author())._message_log(**kwargs) @api.multi @api.returns('mail.message', lambda value: value.id) def message_post(self, *args, **kwargs): - return super(Employee, self._post_author()).message_post(*args, **kwargs) - - -class Department(models.Model): - _name = "hr.department" - _description = "HR Department" - _inherit = ['mail.thread'] - _order = "name" - _rec_name = 'complete_name' - - name = fields.Char('Department Name', required=True) - complete_name = fields.Char('Complete Name', compute='_compute_complete_name', store=True) - active = fields.Boolean('Active', default=True) - company_id = fields.Many2one('res.company', string='Company', index=True, default=lambda self: self.env.company) - parent_id = fields.Many2one('hr.department', string='Parent Department', index=True) - child_ids = fields.One2many('hr.department', 'parent_id', string='Child Departments') - manager_id = fields.Many2one('hr.employee', string='Manager', tracking=True) - member_ids = fields.One2many('hr.employee', 'department_id', string='Members', readonly=True) - jobs_ids = fields.One2many('hr.job', 'department_id', string='Jobs') - note = fields.Text('Note') - color = fields.Integer('Color Index') - - @api.multi - def name_get(self): - if not self.env.context.get('hierarchical_naming', True): - return [(record.id, record.name) for record in self] - return super(Department, self).name_get() - - @api.depends('name', 'parent_id.complete_name') - def _compute_complete_name(self): - for department in self: - if department.parent_id: - department.complete_name = '%s / %s' % (department.parent_id.complete_name, department.name) - else: - department.complete_name = department.name - - @api.constrains('parent_id') - def _check_parent_id(self): - if not self._check_recursion(): - raise ValidationError(_('You cannot create recursive departments.')) - - @api.model - def create(self, vals): - # TDE note: auto-subscription of manager done by hand, because currently - # the tracking allows to track+subscribe fields linked to a res.user record - # An update of the limited behavior should come, but not currently done. - department = super(Department, self.with_context(mail_create_nosubscribe=True)).create(vals) - manager = self.env['hr.employee'].browse(vals.get("manager_id")) - if manager.user_id: - department.message_subscribe(partner_ids=manager.user_id.partner_id.ids) - return department - - @api.multi - def write(self, vals): - """ If updating manager of a department, we need to update all the employees - of department hierarchy, and subscribe the new manager. - """ - # TDE note: auto-subscription of manager done by hand, because currently - # the tracking allows to track+subscribe fields linked to a res.user record - # An update of the limited behavior should come, but not currently done. - if 'manager_id' in vals: - manager_id = vals.get("manager_id") - if manager_id: - manager = self.env['hr.employee'].browse(manager_id) - # subscribe the manager user - if manager.user_id: - self.message_subscribe(partner_ids=manager.user_id.partner_id.ids) - # set the employees's parent to the new manager - self._update_employee_manager(manager_id) - return super(Department, self).write(vals) - - def _update_employee_manager(self, manager_id): - employees = self.env['hr.employee'] - for department in self: - employees = employees | self.env['hr.employee'].search([ - ('id', '!=', manager_id), - ('department_id', '=', department.id), - ('parent_id', '=', department.manager_id.id) - ]) - employees.write({'parent_id': manager_id}) + return super(HrEmployeePrivate, self._post_author()).message_post(*args, **kwargs) diff --git a/addons/hr/models/hr_employee_category.py b/addons/hr/models/hr_employee_category.py new file mode 100644 index 00000000000..a088b9af893 --- /dev/null +++ b/addons/hr/models/hr_employee_category.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class EmployeeCategory(models.Model): + + _name = "hr.employee.category" + _description = "Employee Category" + + name = fields.Char(string="Employee Tag", required=True) + color = fields.Integer(string='Color Index') + employee_ids = fields.Many2many('hr.employee', 'employee_category_rel', 'category_id', 'emp_id', string='Employees') + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists !"), + ] diff --git a/addons/hr/models/hr_job.py b/addons/hr/models/hr_job.py new file mode 100644 index 00000000000..d181a0b18f0 --- /dev/null +++ b/addons/hr/models/hr_job.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class Job(models.Model): + + _name = "hr.job" + _description = "Job Position" + _inherit = ['mail.thread'] + + name = fields.Char(string='Job Position', required=True, index=True, translate=True) + expected_employees = fields.Integer(compute='_compute_employees', string='Total Forecasted Employees', store=True, + help='Expected number of employees for this job position after new recruitment.') + no_of_employee = fields.Integer(compute='_compute_employees', string="Current Number of Employees", store=True, + help='Number of employees currently occupying this job position.') + no_of_recruitment = fields.Integer(string='Expected New Employees', copy=False, + help='Number of new employees you expect to recruit.', default=1) + no_of_hired_employee = fields.Integer(string='Hired Employees', copy=False, + help='Number of hired employees for this job position during recruitment phase.') + employee_ids = fields.One2many('hr.employee', 'job_id', string='Employees', groups='base.group_user') + description = fields.Text(string='Job Description') + requirements = fields.Text('Requirements') + department_id = fields.Many2one('hr.department', string='Department') + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company) + state = fields.Selection([ + ('recruit', 'Recruitment in Progress'), + ('open', 'Not Recruiting') + ], string='Status', readonly=True, required=True, tracking=True, copy=False, default='recruit', help="Set whether the recruitment process is open or closed for this job position.") + + _sql_constraints = [ + ('name_company_uniq', 'unique(name, company_id, department_id)', 'The name of the job position must be unique per department in company!'), + ] + + @api.depends('no_of_recruitment', 'employee_ids.job_id', 'employee_ids.active') + def _compute_employees(self): + employee_data = self.env['hr.employee'].read_group([('job_id', 'in', self.ids)], ['job_id'], ['job_id']) + result = dict((data['job_id'][0], data['job_id_count']) for data in employee_data) + for job in self: + job.no_of_employee = result.get(job.id, 0) + job.expected_employees = result.get(job.id, 0) + job.no_of_recruitment + + @api.model + def create(self, values): + """ We don't want the current user to be follower of all created job """ + return super(Job, self.with_context(mail_create_nosubscribe=True)).create(values) + + @api.multi + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + self.ensure_one() + default = dict(default or {}) + if 'name' not in default: + default['name'] = _("%s (copy)") % (self.name) + return super(Job, self).copy(default=default) + + @api.multi + def set_recruit(self): + for record in self: + no_of_recruitment = 1 if record.no_of_recruitment == 0 else record.no_of_recruitment + record.write({'state': 'recruit', 'no_of_recruitment': no_of_recruitment}) + return True + + @api.multi + def set_open(self): + return self.write({ + 'state': 'open', + 'no_of_recruitment': 0, + 'no_of_hired_employee': 0 + }) diff --git a/addons/hr/views/hr_department_views.xml b/addons/hr/views/hr_department_views.xml new file mode 100644 index 00000000000..a0ebffc6c27 --- /dev/null +++ b/addons/hr/views/hr_department_views.xml @@ -0,0 +1,139 @@ + + + + + hr.department.form + hr.department + +
+ +
+ +
+ + + + + + +
+
+ + +
+
+
+
+ + + hr.department.tree + hr.department + + + + + + + + + + + + hr.department.search + hr.department + + + + + + + + + + + + + hr.department.kanban + hr.department + + + + + + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + + Departments + hr.department + form + kanban,tree,form + + +

+ Create a new department +

+ Odoo's department structure is used to manage all documents + related to employees by departments: expenses, timesheets, + leaves, recruitments, etc. +

+
+
+
+
diff --git a/addons/hr/views/hr_employee_category_views.xml b/addons/hr/views/hr_employee_category_views.xml new file mode 100644 index 00000000000..56dea45d32d --- /dev/null +++ b/addons/hr/views/hr_employee_category_views.xml @@ -0,0 +1,38 @@ + + + + + + hr.employee.category.form + hr.employee.category + +
+ + + + + +
+
+
+ + + hr.employee.category.list + hr.employee.category + + + + + + + + + + Employee Tags + hr.employee.category + form + tree,form + + +
+
diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml new file mode 100644 index 00000000000..b4a9b6bfcc1 --- /dev/null +++ b/addons/hr/views/hr_employee_views.xml @@ -0,0 +1,360 @@ + + + + + + hr.employee.search + hr.employee + + + + + + + + + + + + + + + + + + + + + + + + + + + hr.employee.form + hr.employee + +
+ + +
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + +
+
+
+ + + + + +
+ Use here the home address of the employee. + This private address is used in the expense report reimbursement document. + It should be different from the work address. +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + + + +
+
+ + + + + + + + + + +
+ +
+
+ + + hr.plan.activity.type.view.tree + hr.plan.activity.type + + + + + + + + + + + hr.plan.activity.type.view.form + hr.plan.activity.type + +
+ + + + + + + + +
+
+
+ + + Planning + hr.plan + form + tree,form + + + + + Planning Types + hr.plan.activity.type + form + tree,form + + +
+
diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 81609ef89a5..da423b9adb7 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -2,7 +2,6 @@ - + + + + + - - hr.plan.view.search - hr.plan - - - - - - + - - hr.plan.view.tree - hr.plan - - - - - - + - - hr.plan.view.form - hr.plan - -
- -
- -
-
-
- - - - - - - - - - -
-
-
-
+ - - hr.plan.activity.type.view.tree - hr.plan.activity.type - - - - - - - - - - - hr.plan.activity.type.view.form - hr.plan.activity.type - -
- - - - - - - - -
-
-
- - - Planning - hr.plan - form - tree,form - - - - - Planning Types - hr.plan.activity.type - form - tree,form - - - - - - - - - - - hr.employee.form - hr.employee - -
- - -
-
- -
- -
- -
-
- - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - -
-
-
- - - - - -
- Use here the home address of the employee. - This private address is used in the expense report reimbursement document. - It should be different from the work address. -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
- - - - - - - - - - - - - - - - -
-
- - -
- -
-
- - - hr.department.tree - hr.department - child_ids - - - - - - - - - - - - hr.department.search - hr.department - - - - - - - - - - - - - hr.department.kanban - hr.department - - - - - - - - -
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- - - Departments - hr.department - form - kanban,tree,form - - -

- Create a new department -

- Odoo's department structure is used to manage all documents - related to employees by departments: expenses, timesheets, - leaves, recruitments, etc. -

-
-
- - - res.users.preferences.form.resource - res.users - - - - 1 - - - - - + +
diff --git a/addons/hr/views/res_users.xml b/addons/hr/views/res_users.xml index 7a93c6745a7..675d495869b 100644 --- a/addons/hr/views/res_users.xml +++ b/addons/hr/views/res_users.xml @@ -18,6 +18,17 @@ + + res.users.preferences.form.resource + res.users + + + + 1 + + + + res.users.preferences.form.inherit res.users From c9ca3761464413327d2beb697553a3ccd7eef4d1 Mon Sep 17 00:00:00 2001 From: RomainLibert Date: Mon, 8 Apr 2019 11:41:13 +0200 Subject: [PATCH 02/16] [IMP] hr: Introduce the public employee profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- addons/hr/__manifest__.py | 1 + addons/hr/models/__init__.py | 2 + addons/hr/models/hr_employee.py | 90 ++++++++--- addons/hr/models/hr_employee_base.py | 25 +++ addons/hr/models/hr_employee_public.py | 59 ++++++++ addons/hr/security/hr_security.xml | 7 + addons/hr/security/ir.model.access.csv | 3 +- addons/hr/tests/test_self_user_access.py | 25 ++- addons/hr/views/hr_employee_public_views.xml | 151 +++++++++++++++++++ addons/hr/views/hr_views.xml | 52 ++++--- 10 files changed, 363 insertions(+), 52 deletions(-) create mode 100644 addons/hr/models/hr_employee_base.py create mode 100644 addons/hr/models/hr_employee_public.py create mode 100644 addons/hr/views/hr_employee_public_views.xml diff --git a/addons/hr/__manifest__.py b/addons/hr/__manifest__.py index c38700c0404..bcbd2fafdf9 100644 --- a/addons/hr/__manifest__.py +++ b/addons/hr/__manifest__.py @@ -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', diff --git a/addons/hr/models/__init__.py b/addons/hr/models/__init__.py index dc940941bd1..efe3c1d702c 100644 --- a/addons/hr/models/__init__.py +++ b/addons/hr/models/__init__.py @@ -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 diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index 8f9078b7292..1764b1e0550 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -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: diff --git a/addons/hr/models/hr_employee_base.py b/addons/hr/models/hr_employee_base.py new file mode 100644 index 00000000000..fec003ccfc6 --- /dev/null +++ b/addons/hr/models/hr_employee_base.py @@ -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') diff --git a/addons/hr/models/hr_employee_public.py b/addons/hr/models/hr_employee_public.py new file mode 100644 index 00000000000..82799661d0e --- /dev/null +++ b/addons/hr/models/hr_employee_public.py @@ -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())) diff --git a/addons/hr/security/hr_security.xml b/addons/hr/security/hr_security.xml index c735d05b1ce..bbafaf7fb38 100644 --- a/addons/hr/security/hr_security.xml +++ b/addons/hr/security/hr_security.xml @@ -40,6 +40,13 @@ ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + Employee multi company rule + + + ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + Job multi company rule diff --git a/addons/hr/security/ir.model.access.csv b/addons/hr/security/ir.model.access.csv index 345374b31fe..e28b4065ee3 100644 --- a/addons/hr/security/ir.model.access.csv +++ b/addons/hr/security/ir.model.access.csv @@ -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 diff --git a/addons/hr/tests/test_self_user_access.py b/addons/hr/tests/test_self_user_access.py index 360cf4f5a1e..ab732f9f9e6 100644 --- a/addons/hr/tests/test_self_user_access.py +++ b/addons/hr/tests/test_self_user_access.py @@ -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'}) diff --git a/addons/hr/views/hr_employee_public_views.xml b/addons/hr/views/hr_employee_public_views.xml new file mode 100644 index 00000000000..6b7430c21f0 --- /dev/null +++ b/addons/hr/views/hr_employee_public_views.xml @@ -0,0 +1,151 @@ + + + + + + hr.employee.search + hr.employee.public + + + + + + + + + hr.employee.public.form + hr.employee.public + +
+ + + +
+ +
+ +
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + hr.employee.tree + hr.employee.public + + + + + + + + + + + + + + + + hr.employee.kanban + hr.employee.public + 10 + + + + + +
+ +