From d77ce4c2a92c1da48150e4e84b714dd93f64847d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre?= Date: Tue, 12 Feb 2019 15:30:39 +0100 Subject: [PATCH] [IMP] hr_*: introduce the employee profile General Purpose =============== We want an 'Employee profile' gathering every data about an employee. The main form view is modified to become this employee profile. A user can also see his own profile through the Preferences menu. The new profile replaces the current Preferences view if the hr module is installed and the current user is linked to an employee. A user should be able to see and edit his own profile. *Problem*: Many fields on hr.employee are protected by groups="hr.group_hr_user". Therefore, a regular user cannot see or edit those fields. This protection must be bypassed to allow read/write access to the regular user's own data. A similar mechanism already exists for res.users (for Preferences) The better (least worst) solution found is to reuse this mechanism by adding related fields on res.users. Pros: - Don't change security access on hr.employee - Don't implement yet another custom security layer, risking to add new security breaches - A lot of fields are added by other modules on hr.employee. It would have required to integrate them with the custom security layer. - Fields added by other modules on the user's preferences view (normal view, not the profile) are automatically included in the employee's profile view. - Allow the hr.employee form view to be different than the user profile accessible through the Preferences menu. E.g. add custom buttons only relevant to the logged in user such as "Request a leave". Cons: - Each field from hr.employee that you want to appear on its profile must be added as a related field on res.users - Those related fields must be added to user's preferences view (duplicate views) - They also must be added to SELF_[READABLE | WRITABLE]_FIELDS Note: When the front-end loads the views it gets the list of available fields for the user (according to its access rights). Later, when the front-end wants to populate the view with data, it only asks to read those available fields. However, in this case, we want the user to be able to read/write its own data, even if they are protected by groups (groups are kept on the related fields on res.users). The front-end need to be made aware of those fields by sending all field definitions. hr_attendance ============= This commit integrate attendance in the new employee profile. It also adds a stat button to this employee profile showing the number of hours worked last month. Remove the boolean computed field 'manual_attendance'. This field is just a shortcut to add/remove the employee's user in the "Manual Attendance" group. The checkbox is confusing on the employee's form and this should be done through the normal group management screens. hr_presence =========== Display the presence status on the employee kanban template. The status is a colored chip which can be green (present), orange (to define) or red (absent). Currently, the presence status is only computed when accessing the report view. As this commits displays it on the employee kanban, it should be updated more frequently. The state should not be updated every time the kanban view is loaded since the computation is a bit heavy. Instead: add a cron to update status every 15 minutes. -> The status is accurate on the report view (status is still updated when loading the view) -> The status in accurate at 15 minutes on the kanban view [ADD] hr_attendance_presence ============================ Bridge module between hr_attendance and hr_presence. This commit integrates hr_presence module in the employee profile and adds the presence status on the employee kanban view. But hr_attendance adds at the same place a similar status icon for checkin/checkout. This bridge module makes the status from hr_presence invisible as hr_attendance should be the main presence control mechanism. Also, this commit adds the ability (through a new setting option) for hr_presence to take into account checkin/checkout to determine the presence status. l10n_be_hr_payroll ================== integration with employee profile --- addons/hr/__manifest__.py | 3 +- addons/hr/models/hr.py | 83 ++++++---- addons/hr/models/res_config_settings.py | 1 + addons/hr/models/res_partner.py | 14 ++ addons/hr/models/res_users.py | 145 ++++++++++++++++- addons/hr/static/src/js/chat.js | 71 ++++++++ addons/hr/static/src/xml/hr_templates.xml | 8 + addons/hr/tests/__init__.py | 1 + addons/hr/tests/test_self_user_access.py | 122 ++++++++++++++ addons/hr/views/hr_templates.xml | 1 + addons/hr/views/hr_views.xml | 131 ++++++++------- addons/hr/views/res_config_settings_views.xml | 14 ++ addons/hr/views/res_users.xml | 152 ++++++++++++++++++ addons/hr_attendance/models/__init__.py | 1 + addons/hr_attendance/models/hr_employee.py | 28 ++-- addons/hr_attendance/models/res_users.py | 25 +++ .../views/hr_attendance_view.xml | 2 +- .../hr_attendance/views/hr_employee_view.xml | 60 ++++++- addons/hr_attendance_presence/__init__.py | 1 + addons/hr_attendance_presence/__manifest__.py | 19 +++ .../hr_attendance_presence/models/__init__.py | 2 + .../models/hr_employee.py | 33 ++++ .../models/res_config_settings.py | 9 ++ .../views/hr_employee.xml | 21 +++ .../views/res_config_settings_views.xml | 17 ++ addons/hr_contract/models/__init__.py | 1 + addons/hr_contract/models/res_users.py | 27 ++++ .../hr_contract/views/hr_contract_views.xml | 19 ++- addons/hr_expense/models/hr_employee.py | 29 +++- addons/hr_expense/views/hr_expense_views.xml | 17 +- addons/hr_fleet/models/__init__.py | 1 + addons/hr_fleet/models/res_users.py | 26 +++ addons/hr_fleet/views/employee_views.xml | 37 ++++- addons/hr_holidays/models/hr.py | 54 ++++--- addons/hr_holidays/models/hr_leave.py | 4 +- addons/hr_holidays/models/hr_leave_type.py | 2 +- addons/hr_holidays/models/res_users.py | 30 +++- .../hr_holidays/report/hr_leave_reports.xml | 2 - addons/hr_holidays/views/hr_views.xml | 105 ++++++++++-- addons/hr_maintenance/__manifest__.py | 1 + addons/hr_maintenance/models/__init__.py | 1 + addons/hr_maintenance/models/res_users.py | 29 ++++ addons/hr_maintenance/views/hr_views.xml | 38 +++++ addons/hr_org_chart/__manifest__.py | 1 + .../static/src/js/hr_org_chart.js | 7 +- .../static/src/scss/hr_org_chart.scss | 4 +- addons/hr_org_chart/views/hr_views.xml | 17 +- addons/hr_payroll/views/hr_employee_views.xml | 2 +- addons/hr_presence/__manifest__.py | 2 +- addons/hr_presence/data/ir_cron.xml | 16 ++ addons/hr_presence/models/hr_employee.py | 28 +++- .../hr_presence/views/hr_employee_views.xml | 59 +++++++ .../views/res_config_settings_views.xml | 2 +- addons/im_livechat/views/res_users_views.xml | 2 +- addons/l10n_be_hr_payroll/models/__init__.py | 1 + addons/l10n_be_hr_payroll/models/res_users.py | 54 +++++++ .../views/l10n_be_hr_payroll_view.xml | 32 ++++ addons/mail_bot/models/res_users.py | 10 ++ addons/web/static/src/js/chrome/user_menu.js | 6 +- addons/web/static/src/xml/base.xml | 5 +- addons/website_forum/models/res_users.py | 1 + addons/website_hr/views/hr_employee_views.xml | 6 +- odoo/addons/base/models/ir_model.py | 2 +- odoo/addons/base/models/res_users.py | 3 +- odoo/addons/base/security/base_security.xml | 31 ---- odoo/addons/base/views/res_users_views.xml | 22 +-- 66 files changed, 1455 insertions(+), 245 deletions(-) create mode 100644 addons/hr/static/src/js/chat.js create mode 100644 addons/hr/static/src/xml/hr_templates.xml create mode 100644 addons/hr/tests/test_self_user_access.py create mode 100644 addons/hr/views/res_users.xml create mode 100644 addons/hr_attendance/models/res_users.py create mode 100644 addons/hr_attendance_presence/__init__.py create mode 100644 addons/hr_attendance_presence/__manifest__.py create mode 100644 addons/hr_attendance_presence/models/__init__.py create mode 100644 addons/hr_attendance_presence/models/hr_employee.py create mode 100644 addons/hr_attendance_presence/models/res_config_settings.py create mode 100644 addons/hr_attendance_presence/views/hr_employee.xml create mode 100644 addons/hr_attendance_presence/views/res_config_settings_views.xml create mode 100644 addons/hr_contract/models/res_users.py create mode 100644 addons/hr_fleet/models/res_users.py create mode 100644 addons/hr_maintenance/models/res_users.py create mode 100644 addons/hr_maintenance/views/hr_views.xml create mode 100644 addons/hr_presence/data/ir_cron.xml create mode 100644 addons/l10n_be_hr_payroll/models/res_users.py diff --git a/addons/hr/__manifest__.py b/addons/hr/__manifest__.py index 80c31263acd..edac09aa6f5 100644 --- a/addons/hr/__manifest__.py +++ b/addons/hr/__manifest__.py @@ -30,6 +30,7 @@ 'views/hr_templates.xml', 'views/res_config_settings_views.xml', 'views/mail_channel_views.xml', + 'views/res_users.xml', 'data/hr_data.xml', 'report/hr_employee_badge.xml' ], @@ -39,5 +40,5 @@ 'installable': True, 'application': True, 'auto_install': False, - 'qweb': [], + 'qweb': ['static/src/xml/hr_templates.xml'], } diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr.py index 665893c76a4..209235ff42b 100644 --- a/addons/hr/models/hr.py +++ b/addons/hr/models/hr.py @@ -6,7 +6,7 @@ import base64 import logging from werkzeug import url_encode -from odoo import api, fields, models +from odoo import api, fields, models, SUPERUSER_ID from odoo import tools, _ from odoo.exceptions import ValidationError, AccessError from odoo.modules.module import get_module_resource @@ -110,61 +110,63 @@ class Employee(models.Model): # resource and user # required on the resource, make sure required="True" set in the view - name = fields.Char(related='resource_id.name', store=True, oldname='name_related', readonly=False) + name = fields.Char(related='resource_id.name', store=True, oldname='name_related', readonly=False, tracking=True) user_id = fields.Many2one('res.users', 'User', related='resource_id.user_id', store=True, readonly=False) + user_partner_id = fields.Many2one(related='user_id.partner_id', related_sudo=False, string="User's partner") active = fields.Boolean('Active', related='resource_id.active', default=True, store=True, readonly=False) # private partner address_home_id = fields.Many2one( 'res.partner', 'Private Address', help='Enter here the private address of the employee, not the one linked to your company.', - groups="hr.group_hr_user") + groups="hr.group_hr_user", tracking=True) is_address_home_a_company = fields.Boolean( 'The employee adress has a company linked', compute='_compute_is_address_home_a_company', ) country_id = fields.Many2one( - 'res.country', 'Nationality (Country)', groups="hr.group_hr_user") + 'res.country', 'Nationality (Country)', groups="hr.group_hr_user", tracking=True) gender = fields.Selection([ ('male', 'Male'), ('female', 'Female'), ('other', 'Other') - ], groups="hr.group_hr_user", default="male") + ], groups="hr.group_hr_user", default="male", tracking=True) marital = fields.Selection([ ('single', 'Single'), ('married', 'Married'), ('cohabitant', 'Legal Cohabitant'), ('widower', 'Widower'), ('divorced', 'Divorced') - ], string='Marital Status', groups="hr.group_hr_user", default='single') - spouse_complete_name = fields.Char(string="Spouse Complete Name", groups="hr.group_hr_user") - spouse_birthdate = fields.Date(string="Spouse Birthdate", groups="hr.group_hr_user") - children = fields.Integer(string='Number of Children', groups="hr.group_hr_user") - place_of_birth = fields.Char('Place of Birth', groups="hr.group_hr_user") - country_of_birth = fields.Many2one('res.country', string="Country of Birth", groups="hr.group_hr_user") - birthday = fields.Date('Date of Birth', groups="hr.group_hr_user") - ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user") - sinid = fields.Char('SIN No', help='Social Insurance Number', groups="hr.group_hr_user") - identification_id = fields.Char(string='Identification No', groups="hr.group_hr_user") - passport_id = fields.Char('Passport No', groups="hr.group_hr_user") + ], string='Marital Status', groups="hr.group_hr_user", default='single', tracking=True) + spouse_complete_name = fields.Char(string="Spouse Complete Name", groups="hr.group_hr_user", tracking=True) + spouse_birthdate = fields.Date(string="Spouse Birthdate", groups="hr.group_hr_user", tracking=True) + children = fields.Integer(string='Number of Children', groups="hr.group_hr_user", tracking=True) + place_of_birth = fields.Char('Place of Birth', groups="hr.group_hr_user", tracking=True) + country_of_birth = fields.Many2one('res.country', string="Country of Birth", groups="hr.group_hr_user", tracking=True) + birthday = fields.Date('Date of Birth', groups="hr.group_hr_user", tracking=True) + ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user", tracking=True) + sinid = fields.Char('SIN No', help='Social Insurance Number', groups="hr.group_hr_user", tracking=True) + identification_id = fields.Char(string='Identification No', groups="hr.group_hr_user", tracking=True) + passport_id = fields.Char('Passport No', groups="hr.group_hr_user", tracking=True) bank_account_id = fields.Many2one( 'res.partner.bank', 'Bank Account Number', domain="[('partner_id', '=', address_home_id)]", groups="hr.group_hr_user", + tracking=True, help='Employee bank salary account') - permit_no = fields.Char('Work Permit No', groups="hr.group_hr_user") - visa_no = fields.Char('Visa No', groups="hr.group_hr_user") - visa_expire = fields.Date('Visa Expire Date', groups="hr.group_hr_user") - additional_note = fields.Text(string='Additional Note', groups="hr.group_hr_user") + permit_no = fields.Char('Work Permit No', groups="hr.group_hr_user", tracking=True) + visa_no = fields.Char('Visa No', groups="hr.group_hr_user", tracking=True) + visa_expire = fields.Date('Visa Expire Date', groups="hr.group_hr_user", tracking=True) + additional_note = fields.Text(string='Additional Note', groups="hr.group_hr_user", tracking=True) certificate = fields.Selection([ ('bachelor', 'Bachelor'), ('master', 'Master'), ('other', 'Other'), - ], 'Certificate Level', default='master', groups="hr.group_hr_user") - study_field = fields.Char("Field of Study", placeholder='Computer Science', groups="hr.group_hr_user") - study_school = fields.Char("School", groups="hr.group_hr_user") - emergency_contact = fields.Char("Emergency Contact", groups="hr.group_hr_user") - emergency_phone = fields.Char("Emergency Phone", groups="hr.group_hr_user") - km_home_work = fields.Integer(string="Km home-work", groups="hr.group_hr_user") - google_drive_link = fields.Char(string="Employee Documents", groups="hr.group_hr_user") + ], 'Certificate Level', default='master', groups="hr.group_hr_user", tracking=True) + study_field = fields.Char("Field of Study", placeholder='Computer Science', groups="hr.group_hr_user", tracking=True) + study_school = fields.Char("School", groups="hr.group_hr_user", tracking=True) + emergency_contact = fields.Char("Emergency Contact", groups="hr.group_hr_user", tracking=True) + 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 @@ -186,6 +188,7 @@ class Employee(models.Model): '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 @@ -210,7 +213,10 @@ class Employee(models.Model): ], string="Departure Reason") departure_description = fields.Text(string="Additional Information") - _sql_constraints = [('barcode_uniq', 'unique (barcode)', "The Badge ID must be unique, this one is already assigned to another employee.")] + _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.constrains('pin') def _verify_pin(self): @@ -341,6 +347,27 @@ class Employee(models.Model): 'template': '/hr/static/xls/hr_employee.xls' }] + def _post_author(self): + """ + When a user updates his own employee's data, all operations are performed + by super user. However, tracking messages should not be posted as OdooBot + but as the actual user. + This method is used in the overrides of `_message_log` and `message_post` + to post messages as the correct user. + """ + real_user = self.env.context.get('binary_field_real_user') + if self.env.user.id == SUPERUSER_ID and real_user: + self = self.sudo(real_user) + return self + + def _message_log(self, body='', subject=False, message_type='notification', **kwargs): + return super(Employee, self._post_author()).message_post(body=body, subject=subject, message_type=message_type, **kwargs) + + @api.multi + @api.returns('mail.message', lambda value: value.id) + def message_post(self, **kwargs): + return super(Employee, self._post_author()).message_post(**kwargs) + class Department(models.Model): _name = "hr.department" diff --git a/addons/hr/models/res_config_settings.py b/addons/hr/models/res_config_settings.py index ea96590434c..2b7f75b855a 100644 --- a/addons/hr/models/res_config_settings.py +++ b/addons/hr/models/res_config_settings.py @@ -14,3 +14,4 @@ class ResConfigSettings(models.TransientModel): hr_presence_control_login = fields.Boolean(string="According to the system login (User status on chat)", config_parameter='hr.hr_presence_control_login') hr_presence_control_email = fields.Boolean(string="According to the amount of sent emails", config_parameter='hr.hr_presence_control_email') hr_presence_control_ip = fields.Boolean(string="According to the IP address", config_parameter='hr.hr_presence_control_ip') + hr_employee_self_edit = fields.Boolean(string="Employee Edition", config_parameter='hr.hr_employee_self_edit') diff --git a/addons/hr/models/res_partner.py b/addons/hr/models/res_partner.py index 6b77f3f3371..9258c1ac5da 100644 --- a/addons/hr/models/res_partner.py +++ b/addons/hr/models/res_partner.py @@ -22,3 +22,17 @@ class Partner(models.Model): return suggestions except AccessError: return suggestions + + @api.multi + def name_get(self): + """ Override to allow an employee to see its private address in his profile. + This avoids to relax access rules on `res.parter` and to add an `ir.rule`. + (advantage in both security and performance). + Use a try/except instead of systematically checking to minimize the impact on performance. + """ + try: + return super(Partner, self).name_get() + except AccessError as e: + if len(self) == 1 and self in self.env.user.employee_ids.mapped('address_home_id'): + return super(Partner, self.sudo()).name_get() + raise e diff --git a/addons/hr/models/res_users.py b/addons/hr/models/res_users.py index be6b5148eba..8398b543776 100644 --- a/addons/hr/models/res_users.py +++ b/addons/hr/models/res_users.py @@ -1,21 +1,160 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, models, fields +from odoo import api, models, fields, _ +from odoo.exceptions import AccessError class User(models.Model): _inherit = ['res.users'] - employee_ids = fields.One2many('hr.employee', 'user_id', string='Related employees') + # note: a user can only be linked to one employee per company (see sql constraint in ´hr.employee´) + employee_ids = fields.One2many('hr.employee', 'user_id', string='Related employee') + employee_id = fields.Many2one('hr.employee', string="Company employee", compute='_compute_company_employee', store=True) + + job_title = fields.Char(related='employee_id.job_title', readonly=False) + work_phone = fields.Char(related='employee_id.work_phone', readonly=False) + mobile_phone = fields.Char(related='employee_id.mobile_phone', readonly=False) + phone = fields.Char(related='employee_id.phone', readonly=False, related_sudo=False) + work_email = fields.Char(related='employee_id.work_email', readonly=False, related_sudo=False) + category_ids = fields.Many2many(related='employee_id.category_ids', string="Employee Tags", readonly=False, related_sudo=False) + department_id = fields.Many2one(related='employee_id.department_id', readonly=False, related_sudo=False) + address_id = fields.Many2one(related='employee_id.address_id', readonly=False, related_sudo=False) + work_location = fields.Char(related='employee_id.work_location', readonly=False, related_sudo=False) + parent_id = fields.Many2one(related='employee_id.parent_id', related_sudo=False) + coach_id = fields.Many2one(related='employee_id.coach_id', readonly=False, related_sudo=False) + address_home_id = fields.Many2one(related='employee_id.address_home_id', readonly=False, related_sudo=False) + is_address_home_a_company = fields.Boolean(related='employee_id.is_address_home_a_company', readonly=False, related_sudo=False) + km_home_work = fields.Integer(related='employee_id.km_home_work', readonly=False, related_sudo=False) + # res.users already have a field bank_account_id and country_id from the res.partner inheritance: don't redefine them + employee_bank_account_id = fields.Many2one(related='employee_id.bank_account_id', string="Employee's Bank Account Number", related_sudo=False, readonly=False) + employee_country_id = fields.Many2one(related='employee_id.country_id', string="Employee's Country", readonly=False, related_sudo=False) + identification_id = fields.Char(related='employee_id.identification_id', readonly=False, related_sudo=False) + passport_id = fields.Char(related='employee_id.passport_id', readonly=False, related_sudo=False) + gender = fields.Selection(related='employee_id.gender', readonly=False, related_sudo=False) + birthday = fields.Date(related='employee_id.birthday', readonly=False, related_sudo=False) + place_of_birth = fields.Char(related='employee_id.place_of_birth', readonly=False, related_sudo=False) + country_of_birth = fields.Many2one(related='employee_id.country_of_birth', readonly=False, related_sudo=False) + marital = fields.Selection(related='employee_id.marital', readonly=False, related_sudo=False) + spouse_complete_name = fields.Char(related='employee_id.spouse_complete_name', readonly=False, related_sudo=False) + spouse_birthdate = fields.Date(related='employee_id.spouse_birthdate', readonly=False, related_sudo=False) + children = fields.Integer(related='employee_id.children', readonly=False, related_sudo=False) + emergency_contact = fields.Char(related='employee_id.emergency_contact', readonly=False, related_sudo=False) + emergency_phone = fields.Char(related='employee_id.emergency_phone', readonly=False, related_sudo=False) + visa_no = fields.Char(related='employee_id.visa_no', readonly=False, related_sudo=False) + permit_no = fields.Char(related='employee_id.permit_no', readonly=False, related_sudo=False) + visa_expire = fields.Date(related='employee_id.visa_expire', readonly=False, related_sudo=False) + google_drive_link = fields.Char(related='employee_id.google_drive_link', readonly=False, related_sudo=False) + additional_note = fields.Text(related='employee_id.additional_note', readonly=False, related_sudo=False) + barcode = fields.Char(related='employee_id.barcode', readonly=False, related_sudo=False) + pin = fields.Char(related='employee_id.pin', readonly=False, related_sudo=False) + + def __init__(self, pool, cr): + """ Override of __init__ to add access rights. + Access rights are disabled by default, but allowed + on some specific fields defined in self.SELF_{READ/WRITE}ABLE_FIELDS. + """ + hr_readable_fields = [ + 'active', + 'child_ids', + 'employee_id', + 'employee_ids', + 'parent_id', + ] + + hr_writable_fields = [ + 'additional_note', + 'address_home_id', + 'address_id', + 'barcode', + 'birthday', + 'category_ids', + 'children', + 'coach_id', + 'country_of_birth', + 'department_id', + 'display_name', + 'emergency_contact', + 'emergency_phone', + 'employee_bank_account_id', + 'employee_country_id', + 'gender', + 'google_drive_link', + 'identification_id', + 'is_address_home_a_company', + 'job_title', + 'km_home_work', + 'marital', + 'mobile_phone', + 'notes', + 'parent_id', + 'passport_id', + 'permit_no', + 'phone', + 'pin', + 'place_of_birth', + 'spouse_birthdate', + 'spouse_complete_name', + 'visa_expire', + 'visa_no', + 'work_email', + 'work_location', + 'work_phone' + ] + + init_res = super(User, self).__init__(pool, cr) + # duplicate list to avoid modifying the original reference + type(self).SELF_READABLE_FIELDS = type(self).SELF_READABLE_FIELDS + hr_readable_fields + hr_writable_fields + type(self).SELF_WRITEABLE_FIELDS = type(self).SELF_WRITEABLE_FIELDS + hr_writable_fields + return init_res + + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + # When the front-end loads the views it gets the list of available fields + # for the user (according to its access rights). Later, when the front-end wants to + # populate the view with data, it only asks to read those available fields. + # However, in this case, we want the user to be able to read/write its own data, + # even if they are protected by groups. + # We make the front-end aware of those fields by sending all field definitions. + if not self.env.user.share: + self = self.sudo() + return super(User, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) @api.multi def write(self, vals): - """ Synchronize user and its related employee """ + """ + Synchronize user and its related employee + and check access rights if employees are not allowed to update + their own data (otherwise sudo is applied for self data). + """ + hr_fields = { + field + for field_name, field in self._fields.items() + if field.related_field and field.related_field.model_name == 'hr.employee' and field_name in vals + } + can_edit_self = self.env['ir.config_parameter'].sudo().get_param('hr.hr_employee_self_edit') or self.env.user.has_group('hr.group_hr_user') + if hr_fields and not can_edit_self: + # Raise meaningful error message + raise AccessError(_("You are only allowed to update your preferences. Please contact a HR officer to update other informations.")) + result = super(User, self).write(vals) + employee_values = {} for fname in [f for f in ['name', 'email', 'image', 'tz'] if f in vals]: employee_values[fname] = vals[fname] if employee_values: + if 'email' in employee_values: + employee_values['work_email'] = employee_values.pop('email') self.env['hr.employee'].sudo().search([('user_id', 'in', self.ids)]).write(employee_values) return result + + @api.model + def action_get(self): + if self.env.user.employee_id: + return self.sudo().env.ref('hr.res_users_action_my').read()[0] + return super(User, self).action_get() + + @api.depends('company_id', 'employee_ids') + def _compute_company_employee(self): + for user in self: + user.employee_id = self.env['hr.employee'].search([('id', 'in', user.employee_ids.ids), ('company_id', '=', user.company_id.id)], limit=1) diff --git a/addons/hr/static/src/js/chat.js b/addons/hr/static/src/js/chat.js new file mode 100644 index 00000000000..2f39851de67 --- /dev/null +++ b/addons/hr/static/src/js/chat.js @@ -0,0 +1,71 @@ +odoo.define('hr.employee_chat', function (require) { +'use strict'; + + var FormController = require('web.FormController'); + var FormView = require('web.FormView'); + var FormRenderer = require('web.FormRenderer'); + var viewRegistry = require('web.view_registry'); + + var EmployeeFormRenderer = FormRenderer.extend({ + + /** + * @override + */ + _render: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + var $chat_button = self.$el.find('.o_employee_chat_btn'); + if (self.state.context.uid === self.state.data.user_id.res_id) { // Hide the button for yourself + $chat_button.hide(); + } + else { + $chat_button.off('click').on('click', self._onOpenChat.bind(self)); + } + }); + }, + + destroy: function () { + this.$el.find('.o_employee_chat_btn').off('click'); + return this._super(); + }, + + _onOpenChat: function(ev) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + this.trigger_up('open_chat', { + partner_id: this.state.data.user_partner_id.res_id + }); + return true; + }, + }); + + var EmployeeFormController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + open_chat: '_onOpenChat' + }), + + _onOpenChat: function(ev) { + var self = this; + var dmChat = this.call('mail_service', 'getDMChatFromPartnerID', ev.data.partner_id); + if (dmChat) { + dmChat.detach(); + } else { + var def = this.call('mail_service', 'createChannel', ev.data.partner_id, 'dm_chat').then(function (dmChatId) { + dmChat = self.call('mail_service', 'getChannel', dmChatId); + dmChat.detach(); + }); + $.when(def); + } + }, + }); + + var EmployeeFormView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: EmployeeFormController, + Renderer: EmployeeFormRenderer + }), + }); + + viewRegistry.add('hr_employee_form', EmployeeFormView); + return EmployeeFormView; +}); diff --git a/addons/hr/static/src/xml/hr_templates.xml b/addons/hr/static/src/xml/hr_templates.xml new file mode 100644 index 00000000000..c805e5921f0 --- /dev/null +++ b/addons/hr/static/src/xml/hr_templates.xml @@ -0,0 +1,8 @@ + + diff --git a/addons/hr/tests/__init__.py b/addons/hr/tests/__init__.py index 9e189d217c7..27d9c2fbb81 100644 --- a/addons/hr/tests/__init__.py +++ b/addons/hr/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_hr_employee from . import test_channel from . import test_hr_flow +from . import test_self_user_access diff --git a/addons/hr/tests/test_self_user_access.py b/addons/hr/tests/test_self_user_access.py new file mode 100644 index 00000000000..360cf4f5a1e --- /dev/null +++ b/addons/hr/tests/test_self_user_access.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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): + + def setUp(self): + super(TestSelfAccessRights, self).setUp() + self.richard = new_test_user(self.env, login='ric', groups='base.group_user', name='Simple employee', email='ric@example.com') + self.richard_emp = self.env['hr.employee'].create({ + 'name': 'Richard', + 'user_id': self.richard.id, + 'address_home_id': self.env['res.partner'].create({'name': 'Richard', 'phone': '21454', 'type': 'private'}).id, + }) + self.hubert = new_test_user(self.env, login='hub', groups='base.group_user', name='Simple employee', email='hub@example.com') + self.hubert_emp = self.env['hr.employee'].create({ + 'name': 'Hubert', + 'user_id': self.hubert.id, + 'address_home_id': self.env['res.partner'].create({'name': 'Hubert', 'type': 'private'}).id, + }) + + self.protected_fields_emp = OrderedDict([(k, v) for k, v in self.env['hr.employee']._fields.items() if v.groups == 'hr.group_hr_user']) + self.self_protected_fields_user = OrderedDict([ + (k, v) + for k, v in self.env['res.users']._fields.items() + if v.groups == 'hr.group_hr_user' and k in self.env['res.users'].SELF_READABLE_FIELDS + ]) + + # Read hr.employee # + def testReadSelfEmployee(self): + for f in self.protected_fields_emp: + with self.assertRaises(AccessError): + self.richard_emp.sudo(self.richard)[f] + + def testReadOtherEmployee(self): + for f in self.protected_fields_emp: + with self.assertRaises(AccessError): + self.hubert_emp.sudo(self.richard)[f] + + # Write hr.employee # + def testWriteSelfEmployee(self): + for f in self.protected_fields_emp: + with self.assertRaises(AccessError): + self.richard_emp.sudo(self.richard).write({f: 'dummy'}) + + def testWriteOtherEmployee(self): + for f in self.protected_fields_emp: + with self.assertRaises(AccessError): + self.hubert_emp.sudo(self.richard).write({f: 'dummy'}) + + # Read res.users # + def testReadSelfUserEmployee(self): + for f in self.self_protected_fields_user: + 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] + + # Write res.users # + def testWriteSelfUserEmployeeSettingFalse(self): + for f, v in self.self_protected_fields_user.items(): + with self.assertRaises(AccessError): + self.richard.sudo(self.richard).write({f: 'dummy'}) + + def testWriteSelfUserEmployee(self): + self.env['ir.config_parameter'].set_param('hr.hr_employee_self_edit', True) + for f, v in self.self_protected_fields_user.items(): + val = None + if v.type == 'char' or v.type == 'text': + val = 'dummy' + if val is not None: + self.richard.sudo(self.richard).write({f: val}) + + def testWriteSelfUserPreferencesEmployee(self): + # self should always be able to update non hr.employee fields if + # they are in SELF_READABLE_FIELDS + self.env['ir.config_parameter'].set_param('hr.hr_employee_self_edit', False) + # should not raise + vals = [ + {'tz': "Australia/ACT"}, + {'email': "new@example.com"}, + {'signature': "

I'm Richard!

"}, + {'notification_type': "email"}, + ] + for v in vals: + # should not raise + self.richard.sudo(self.richard).write(v) + + def testWriteOtherUserPreferencesEmployee(self): + # self should always be able to update non hr.employee fields if + # they are in SELF_READABLE_FIELDS + self.env['ir.config_parameter'].set_param('hr.hr_employee_self_edit', False) + vals = [ + {'tz': "Australia/ACT"}, + {'email': "new@example.com"}, + {'signature': "

I'm Richard!

"}, + {'notification_type': "email"}, + ] + for v in vals: + with self.assertRaises(AccessError): + self.hubert.sudo(self.richard).write(v) + + def testWriteSelfPhoneEmployee(self): + # phone is a related from res.partner (from base) but added in SELF_READABLE_FIELDS + self.env['ir.config_parameter'].set_param('hr.hr_employee_self_edit', False) + with self.assertRaises(AccessError): + 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_templates.xml b/addons/hr/views/hr_templates.xml index dd7d5acb9e0..465c98d39e0 100644 --- a/addons/hr/views/hr_templates.xml +++ b/addons/hr/views/hr_templates.xml @@ -4,6 +4,7 @@ diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 9bf5f22c7b8..2ddfbb34e18 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -141,63 +141,66 @@ hr.employee.form hr.employee -
+ + +
+
- +
-
- + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - +
+
+ + + + + + + + + + + + + + + + +
+
- - - - - - - + + - + + + + - - - - - - + + + + + + + + + + + + - - - - - - - - - @@ -260,7 +263,7 @@
-
+
@@ -270,7 +273,6 @@ - hr.employee.tree hr.employee @@ -595,6 +597,17 @@ + + Archive/Restore + + + code + + if records: + records.toggle_active() + + + Employees hr.employee diff --git a/addons/hr/views/res_config_settings_views.xml b/addons/hr/views/res_config_settings_views.xml index 1e4550f3cb9..97ff8116337 100644 --- a/addons/hr/views/res_config_settings_views.xml +++ b/addons/hr/views/res_config_settings_views.xml @@ -42,6 +42,20 @@
+

Employee Update Rights

+
+
+
+ +
+
+
+
+
diff --git a/addons/hr/views/res_users.xml b/addons/hr/views/res_users.xml new file mode 100644 index 00000000000..b2e64082bd3 --- /dev/null +++ b/addons/hr/views/res_users.xml @@ -0,0 +1,152 @@ + + + + + + + + + res.users.preferences.form.simplified.inherit + res.users + + primary + +