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

-