From 63c4a9b951d374bec5796c525c99a3fbd4689e53 Mon Sep 17 00:00:00 2001 From: Kevin Baptiste Date: Fri, 15 Oct 2021 13:45:38 +0000 Subject: [PATCH] [IMP] hr_recruitment: add Interviewer access rights Create a new Interviewer access rights. This right is automatically given to users that are added as Interviewers on a Job Position or on an Application. An interviewer can only access applications they are interviewer of the job or the application itself, the chatter is disabled for them as it might contain some sensitive information (salary details, contract link, etc.) closes odoo/odoo#78622 Taskid: 2669730 Related: odoo/enterprise#21778 Signed-off-by: Yannick Tivisse (yti) --- addons/hr_recruitment/__manifest__.py | 2 +- addons/hr_recruitment/models/__init__.py | 2 + addons/hr_recruitment/models/hr_job.py | 14 ++ .../hr_recruitment/models/hr_recruitment.py | 40 +++++- addons/hr_recruitment/models/ir_ui_menu.py | 14 ++ addons/hr_recruitment/models/res_users.py | 39 ++++++ .../security/hr_recruitment_security.xml | 29 ++++ .../security/ir.model.access.csv | 5 + .../static/src/scss/hr_applicant.scss | 7 + addons/hr_recruitment/tests/__init__.py | 1 + .../tests/test_recruitment_interviewer.py | 130 ++++++++++++++++++ addons/hr_recruitment/views/hr_job_views.xml | 25 +++- .../views/hr_recruitment_views.xml | 27 +++- 13 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 addons/hr_recruitment/models/ir_ui_menu.py create mode 100644 addons/hr_recruitment/models/res_users.py create mode 100644 addons/hr_recruitment/static/src/scss/hr_applicant.scss create mode 100644 addons/hr_recruitment/tests/test_recruitment_interviewer.py diff --git a/addons/hr_recruitment/__manifest__.py b/addons/hr_recruitment/__manifest__.py index 69fb35ad2ee..d97e6d503ec 100644 --- a/addons/hr_recruitment/__manifest__.py +++ b/addons/hr_recruitment/__manifest__.py @@ -42,7 +42,7 @@ 'application': True, 'assets': { 'web.assets_backend': [ - 'hr_recruitment/static/src/scss/hr_job.scss', + 'hr_recruitment/static/src/scss/*.scss', 'hr_recruitment/static/src/js/recruitment.js', 'hr_recruitment/static/src/js/tours/hr_recruitment.js', ], diff --git a/addons/hr_recruitment/models/__init__.py b/addons/hr_recruitment/models/__init__.py index 32cdcd2933a..1f73d2e657d 100644 --- a/addons/hr_recruitment/models/__init__.py +++ b/addons/hr_recruitment/models/__init__.py @@ -7,3 +7,5 @@ from . import calendar from . import digest from . import utm_campaign from . import utm_source +from . import res_users +from . import ir_ui_menu diff --git a/addons/hr_recruitment/models/hr_job.py b/addons/hr_recruitment/models/hr_job.py index 2425b175bcc..6f7e0258eda 100644 --- a/addons/hr_recruitment/models/hr_job.py +++ b/addons/hr_recruitment/models/hr_job.py @@ -44,6 +44,13 @@ class Job(models.Model): color = fields.Integer("Color Index") is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite') favorite_user_ids = fields.Many2many('res.users', 'job_favorite_user_rel', 'job_id', 'user_id', default=_get_default_favorite_user_ids) + interviewer_ids = fields.Many2many('res.users', string='Interviewers', domain="[('share', '=', False), ('company_ids', 'in', company_id)]") + extended_interviewer_ids = fields.Many2many('res.users', 'hr_job_extended_interviewer_res_users', compute='_compute_extended_interviewer_ids', store=True) + + @api.depends('application_ids.interviewer_id') + def _compute_extended_interviewer_ids(self): + for job in self: + job.extended_interviewer_ids = job.application_ids.interviewer_id def _compute_is_favorite(self): for job in self: @@ -132,10 +139,17 @@ class Job(models.Model): 'job_id': job.id, } for job in jobs] self.env['hr.recruitment.source'].create(source_vals) + jobs.sudo().interviewer_ids._create_recruitment_interviewers() return jobs def write(self, vals): + old_interviewers = self.interviewer_ids res = super().write(vals) + if 'interviewer_ids' in vals: + interviewers_to_clean = old_interviewers - self.interviewer_ids + interviewers_to_clean._remove_recruitment_interviewers() + self.sudo().interviewer_ids._create_recruitment_interviewers() + # Since the alias is created upon record creation, the default values do not reflect the current values unless # specifically rewritten # List of fields to keep synched with the alias diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index e2e8dcae960..a8f23cb5960 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -6,7 +6,7 @@ from random import randint from odoo import api, fields, models, tools, SUPERUSER_ID from odoo.osv.query import Query from odoo.tools.translate import _ -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from dateutil.relativedelta import relativedelta @@ -137,10 +137,10 @@ class Applicant(models.Model): date_last_stage_update = fields.Datetime("Last Stage Update", index=True, default=fields.Datetime.now) priority = fields.Selection(AVAILABLE_PRIORITIES, "Appreciation", default='0') job_id = fields.Many2one('hr.job', "Applied Job", domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=True) - salary_proposed_extra = fields.Char("Proposed Salary Extra", help="Salary Proposed by the Organisation, extra advantages", tracking=True) - salary_expected_extra = fields.Char("Expected Salary Extra", help="Salary Expected by Applicant, extra advantages", tracking=True) - salary_proposed = fields.Float("Proposed Salary", group_operator="avg", help="Salary Proposed by the Organisation", tracking=True) - salary_expected = fields.Float("Expected Salary", group_operator="avg", help="Salary Expected by Applicant", tracking=True) + salary_proposed_extra = fields.Char("Proposed Salary Extra", help="Salary Proposed by the Organisation, extra advantages", tracking=True, groups="hr_recruitment.group_hr_recruitment_user") + salary_expected_extra = fields.Char("Expected Salary Extra", help="Salary Expected by Applicant, extra advantages", tracking=True, groups="hr_recruitment.group_hr_recruitment_user") + salary_proposed = fields.Float("Proposed Salary", group_operator="avg", help="Salary Proposed by the Organisation", tracking=True, groups="hr_recruitment.group_hr_recruitment_user") + salary_expected = fields.Float("Expected Salary", group_operator="avg", help="Salary Expected by Applicant", tracking=True, groups="hr_recruitment.group_hr_recruitment_user") availability = fields.Date("Availability", help="The date at which the applicant will be available to start working", tracking=True) partner_name = fields.Char("Applicant's Name") partner_phone = fields.Char("Phone", size=32, compute='_compute_partner_phone_email', @@ -177,6 +177,7 @@ class Applicant(models.Model): campaign_id = fields.Many2one(ondelete='set null') medium_id = fields.Many2one(ondelete='set null') source_id = fields.Many2one(ondelete='set null') + interviewer_id = fields.Many2one('res.users', string='Interviewer', domain="[('share', '=', False), ('company_ids', 'in', company_id)]") @api.depends('date_open', 'date_closed') def _compute_day(self): @@ -256,7 +257,6 @@ class Applicant(models.Model): else: applicant.meeting_display_text = _('Last Meeting') - def _get_attachment_number(self): read_group_res = self.env['ir.attachment'].read_group( [('res_model', '=', 'hr.applicant'), ('res_id', 'in', self.ids)], @@ -340,6 +340,10 @@ class Applicant(models.Model): if not applicant.stage_id.hired_stage: applicant.date_closed = False + def _check_interviewer_access(self): + if self.user_has_groups('hr_recruitment.group_hr_recruitment_interviewer'): + raise AccessError(_('You are not allowed to perform this action.')) + @api.model_create_multi def create(self, vals_list): for vals in vals_list: @@ -348,6 +352,7 @@ class Applicant(models.Model): if vals.get('email_from'): vals['email_from'] = vals['email_from'].strip() applicants = super().create(vals_list) + applicants.sudo().interviewer_id._create_recruitment_interviewers() # Record creation through calendar, creates the calendar event directly, it will also create the activity. if 'default_activity_date_deadline' in self.env.context: deadline = fields.Datetime.to_datetime(self.env.context.get('default_activity_date_deadline')) @@ -371,6 +376,7 @@ class Applicant(models.Model): vals['date_open'] = fields.Datetime.now() if vals.get('email_from'): vals['email_from'] = vals['email_from'].strip() + old_interviewers = self.interviewer_id # stage_id: track last stage before update if 'stage_id' in vals: vals['date_last_stage_update'] = fields.Datetime.now() @@ -381,6 +387,10 @@ class Applicant(models.Model): res = super(Applicant, self).write(vals) else: res = super(Applicant, self).write(vals) + if 'interviewer_id' in vals: + interviewers_to_clean = old_interviewers - self.interviewer_id + interviewers_to_clean._remove_recruitment_interviewers() + self.sudo().interviewer_id._create_recruitment_interviewers() return res def get_empty_list_help(self, help): @@ -406,6 +416,21 @@ class Applicant(models.Model): return nocontent_body % nocontent_values + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + if view_type == 'form' and self.user_has_groups('hr_recruitment.group_hr_recruitment_interviewer'): + view_id = self.env.ref('hr_recruitment.hr_applicant_view_form_interviewer').id + return super().fields_view_get(view_id, view_type, toolbar, submenu) + + def _notify_compute_recipients(self, message, msg_vals): + """ + Do not notify members of the Recruitment Interviewer group, as this + might leak some data they shouldn't have access to. + """ + recipients = super()._notify_compute_recipients(message, msg_vals) + interviewer_group = self.env.ref('hr_recruitment.group_hr_recruitment_interviewer').id + return [recipient for recipient in recipients if interviewer_group not in recipient['groups']] + def action_makeMeeting(self): """ This opens Meeting's calendar view to schedule meeting on current applicant @return: Dictionary value for created Meeting view @@ -546,7 +571,8 @@ class Applicant(models.Model): def create_employee_from_applicant(self): """ Create an hr.employee from the hr.applicants """ - employee = False + self._check_interviewer_access() + for applicant in self: contact_name = False if applicant.partner_id: diff --git a/addons/hr_recruitment/models/ir_ui_menu.py b/addons/hr_recruitment/models/ir_ui_menu.py new file mode 100644 index 00000000000..8a376826a5c --- /dev/null +++ b/addons/hr_recruitment/models/ir_ui_menu.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class IrUiMenu(models.Model): + _inherit = 'ir.ui.menu' + + def _load_menus_blacklist(self): + res = super()._load_menus_blacklist() + if self.env.user.has_group('hr_recruitment.group_hr_recruitment_interviewer'): + res.append(self.env.ref('hr_recruitment.menu_hr_job_position').id) + return res diff --git a/addons/hr_recruitment/models/res_users.py b/addons/hr_recruitment/models/res_users.py new file mode 100644 index 00000000000..9d7e83a17f5 --- /dev/null +++ b/addons/hr_recruitment/models/res_users.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class ResUsers(models.Model): + _inherit = 'res.users' + + def _create_recruitment_interviewers(self): + if not self: + return + interviewer_group = self.env.ref('hr_recruitment.group_hr_recruitment_interviewer') + recruitment_group = self.env.ref('hr_recruitment.group_hr_recruitment_user') + + interviewer_group.sudo().write({ + 'users': [ + (4, user.id) for user in self - recruitment_group.users + ] + }) + + def _remove_recruitment_interviewers(self): + if not self: + return + interviewer_group = self.env.ref('hr_recruitment.group_hr_recruitment_interviewer') + recruitment_group = self.env.ref('hr_recruitment.group_hr_recruitment_user') + + job_interviewers = self.env['hr.job'].read_group([('interviewer_ids', 'in', self.ids)], ['interviewer_ids'], ['interviewer_ids']) + user_ids = {j['interviewer_ids'][0] for j in job_interviewers} + + application_interviewers = self.env['hr.applicant'].read_group([('interviewer_id', 'in', self.ids)], ['interviewer_id'], ['interviewer_id']) + user_ids |= {a['interviewer_id'][0] for a in application_interviewers} + + # Remove users that are no longer interviewers on at least a job or an application + users_to_remove = set(self.ids) - (user_ids | set(recruitment_group.users.ids)) + interviewer_group.sudo().write({ + 'users': [ + (3, user_id) for user_id in users_to_remove + ] + }) diff --git a/addons/hr_recruitment/security/hr_recruitment_security.xml b/addons/hr_recruitment/security/hr_recruitment_security.xml index 9979890aefa..2adffe2a731 100644 --- a/addons/hr_recruitment/security/hr_recruitment_security.xml +++ b/addons/hr_recruitment/security/hr_recruitment_security.xml @@ -13,6 +13,12 @@ ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + Recruitment Interviewer + + + + Officer @@ -30,4 +36,27 @@ + + + Applicant Interviewer + + [ + '|', + ('job_id.interviewer_ids', 'in', user.id), + ('interviewer_id', '=', user.id), + ] + + + + + + + Interviewer: No Applicant Chatter + + [('model', '!=', 'hr.applicant')] + + + + + diff --git a/addons/hr_recruitment/security/ir.model.access.csv b/addons/hr_recruitment/security/ir.model.access.csv index 316301c53d1..91daed23899 100644 --- a/addons/hr_recruitment/security/ir.model.access.csv +++ b/addons/hr_recruitment/security/ir.model.access.csv @@ -1,8 +1,12 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_job_interviewer,hr.job.interviewer,hr.model_hr_job,group_hr_recruitment_interviewer,1,0,0,0 +access_hr_applicant_interviewer,hr.applicant.interviewer,model_hr_applicant,group_hr_recruitment_interviewer,1,1,0,0 access_hr_applicant_user,hr.applicant.user,model_hr_applicant,group_hr_recruitment_user,1,1,1,1 +access_hr_recruitment_stage_interviewer,hr.recruitment.stage.interviewer,model_hr_recruitment_stage,group_hr_recruitment_interviewer,1,0,0,0 access_hr_recruitment_stage_user,hr.recruitment.stage.user,model_hr_recruitment_stage,group_hr_recruitment_user,1,0,0,0 access_hr_recruitment_stage_manager,hr.recruitment.stage.manager,model_hr_recruitment_stage,group_hr_recruitment_manager,1,1,1,1 access_hr_recruitment_degree,hr.recruitment.degree,model_hr_recruitment_degree,group_hr_recruitment_user,1,1,1,1 +access_hr_recruitment_refuse_reason_interviewer,hr.applicant.refuse.reason.interviewer,model_hr_applicant_refuse_reason,group_hr_recruitment_interviewer,1,0,0,0 access_hr_recruitment_refuse_reason,hr.applicant.refuse.reason,model_hr_applicant_refuse_reason,group_hr_recruitment_user,1,1,1,1 access_res_partner_hr_user,res.partner.user,base.model_res_partner,group_hr_recruitment_user,1,1,1,1 access_calendar_event_hruser,calendar.event.hruser,calendar.model_calendar_event,group_hr_recruitment_user,1,1,1,1 @@ -12,3 +16,4 @@ access_hr_applicant_category,hr.applicant_category,model_hr_applicant_category,, access_hr_applicant_category_manager,hr.applicant_category,model_hr_applicant_category,group_hr_recruitment_user,1,1,1,1 access_calendar_event_type_hr_officer,calendar.event.type.officer,calendar.model_calendar_event_type,group_hr_recruitment_user,1,1,1,0 access_applicant_get_refuse_reason,access.applicant.get.refuse.reason,model_applicant_get_refuse_reason,hr_recruitment.group_hr_recruitment_user,1,1,1,0 +access_applicant_get_refuse_reason_interviewer,access.applicant.get.refuse.reason.interviewer,model_applicant_get_refuse_reason,hr_recruitment.group_hr_recruitment_interviewer,1,1,1,0 diff --git a/addons/hr_recruitment/static/src/scss/hr_applicant.scss b/addons/hr_recruitment/static/src/scss/hr_applicant.scss new file mode 100644 index 00000000000..fa2400084fe --- /dev/null +++ b/addons/hr_recruitment/static/src/scss/hr_applicant.scss @@ -0,0 +1,7 @@ +.o_applicant_interviewer_form { + .o_ChatterTopbar_buttonSendMessage, + .o_ChatterTopbar_buttonLogNote, + .o_MessageList { + display: none; + } +} diff --git a/addons/hr_recruitment/tests/__init__.py b/addons/hr_recruitment/tests/__init__.py index 1cf7cfd0304..66dcdd1094c 100644 --- a/addons/hr_recruitment/tests/__init__.py +++ b/addons/hr_recruitment/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_recruitment_process from . import test_recruitment from . import test_utm +from . import test_recruitment_interviewer diff --git a/addons/hr_recruitment/tests/test_recruitment_interviewer.py b/addons/hr_recruitment/tests/test_recruitment_interviewer.py new file mode 100644 index 00000000000..aa8aff9a32d --- /dev/null +++ b/addons/hr_recruitment/tests/test_recruitment_interviewer.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import AccessError +from odoo.tests.common import new_test_user + +from odoo.addons.mail.tests.common import MailCommon + +class TestRecruitmentInterviewer(MailCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.simple_user = new_test_user(cls.env, 'smp', + groups='base.group_user', name='Simple User', email='smp@example.com') + cls.interviewer_user = new_test_user(cls.env, 'itw', + groups='base.group_user,hr_recruitment.group_hr_recruitment_interviewer', + name='Recruitment Interviewer', email='itw@example.com') + cls.manager_user = new_test_user(cls.env, 'mng', + groups='base.group_user,hr_recruitment.group_hr_recruitment_manager', + name='Recruitment Manager', email='mng@example.com') + + cls.job = cls.env['hr.job'].create({ + 'name': 'super nice job', + 'user_id': cls.manager_user.id, + }) + + def test_interviewer_group(self): + """ + Test that adding a user as interviewer to a job / applicant adds + that user in the Interviewer group. Also checks that removing the + user will remove them when they are no longer required (e.g. no + longer interviewer of any job/applicant). + """ + interviewer_group = self.env.ref('hr_recruitment.group_hr_recruitment_interviewer') + + self.assertFalse(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should not be interviewer") + + self.job.interviewer_ids = self.simple_user.ids + self.assertTrue(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should be added as interviewer") + + self.job.write({'interviewer_ids': [(5, 0, 0)]}) + self.assertFalse(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should be removed from interviewer") + + applicant = self.env['hr.applicant'].create({ + 'name': 'toto', + 'partner_name': 'toto', + 'job_id': self.job.id, + 'interviewer_id': self.simple_user.id, + }) + self.assertTrue(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should be added as interviewer") + + applicant.interviewer_id = False + self.assertFalse(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should be removed from interviewer") + + self.job.interviewer_ids = self.simple_user.ids + applicant.interviewer_id = self.simple_user.id + self.assertTrue(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should be added as interviewer") + + applicant.interviewer_id = False + self.assertTrue(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should stay interviewer") + + self.job.write({'interviewer_ids': [(5, 0, 0)]}) + applicant.interviewer_id = self.simple_user.id + self.assertTrue(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should stay interviewer") + + applicant.interviewer_id = False + self.assertFalse(interviewer_group.id in self.simple_user.groups_id.ids, "Simple User should be removed from interviewer") + + # A Manager should not be added to the Interviewer group + self.assertFalse(interviewer_group.id in self.manager_user.groups_id.ids, "Manager User should not be interviewer") + applicant.interviewer_id = self.manager_user.id + self.assertFalse(interviewer_group.id in self.manager_user.groups_id.ids, "Manager User should not be added in Interviewer group") + + def test_interviewer_access_rights(self): + applicant = self.env['hr.applicant'].create({ + 'name': 'toto', + 'partner_name': 'toto', + 'job_id': self.job.id, + }) + with self.assertRaises(AccessError): + applicant.with_user(self.interviewer_user).read() + + applicant = self.env['hr.applicant'].create({ + 'name': 'toto', + 'partner_name': 'toto', + 'job_id': self.job.id, + 'interviewer_id': self.interviewer_user.id, + }) + applicant.with_user(self.interviewer_user).read() + + self.job.interviewer_ids = self.interviewer_user.ids + applicant = self.env['hr.applicant'].create({ + 'name': 'toto', + 'partner_name': 'toto', + 'job_id': self.job.id, + }) + applicant.with_user(self.interviewer_user).read() + + # An interviewer can change the interviewers + applicant.with_user(self.interviewer_user).interviewer_id = self.simple_user.id + self.assertEqual(self.simple_user, applicant.interviewer_id) + + with self.assertRaises(AccessError): + applicant.with_user(self.interviewer_user).create_employee_from_applicant() + + def test_interviewer_chatter(self): + self.manager_user.notification_type = 'email' + self.interviewer_user.notification_type = 'email' + applicant = self.env['hr.applicant'].create({ + 'name': 'toto', + 'partner_name': 'toto', + 'job_id': self.job.id, + 'interviewer_id': self.interviewer_user.id, + }) + + with self.mock_mail_gateway(): + message = applicant.message_post(body='A super secret message', message_type='comment', subtype_xmlid='mail.mt_comment') + + with self.assertRaises(AccessError): + message.with_user(self.interviewer_user).read() + + try: + self._find_mail_mail_wpartners(self.interviewer_user.partner_id, False) + except AssertionError: + pass + else: + raise AssertionError('No mail.mail should be sent to members of Interviewer group') + + self.assertSentEmail(self.env.user.partner_id, [self.manager_user.partner_id]) diff --git a/addons/hr_recruitment/views/hr_job_views.xml b/addons/hr_recruitment/views/hr_job_views.xml index dccf91f95e4..3c5857b10f1 100644 --- a/addons/hr_recruitment/views/hr_job_views.xml +++ b/addons/hr_recruitment/views/hr_job_views.xml @@ -42,7 +42,7 @@
- +
@@ -71,7 +71,7 @@
-
+
+
@@ -640,11 +655,11 @@ name="Recruitment" id="menu_hr_recruitment_root" web_icon="hr_recruitment,static/description/icon.png" - groups="hr_recruitment.group_hr_recruitment_user" + groups="hr_recruitment.group_hr_recruitment_user,hr_recruitment.group_hr_recruitment_interviewer" sequence="210"/> + groups="group_hr_recruitment_user" sequence="100"/> @@ -1069,7 +1084,7 @@ action = act

-