From e489072aaec738e1c26984a9a20d373f148fc8a0 Mon Sep 17 00:00:00 2001 From: Pranay Date: Thu, 3 Apr 2025 11:48:51 +0530 Subject: [PATCH] recruitment Changes and fixes --- .../hr_recruitment_extended/__manifest__.py | 1 + .../hr_recruitment_extended/data/sequence.xml | 9 ++ .../models/hr_applicant.py | 4 + .../models/hr_job_recruitment.py | 40 ++++-- .../models/hr_recruitment.py | 130 ++++++++++++++++-- .../models/res_partner.py | 2 +- .../hr_recruitment_extended/models/stages.py | 2 +- .../security/ir.model.access.csv | 6 + .../views/hr_applicant_views.xml | 61 +++++++- .../views/hr_job_recruitment.xml | 74 +++++++--- .../views/hr_recruitment.xml | 102 +++++++++++++- .../wizards/__init__.py | 3 +- .../wizards/applicant_refuse_reason.py | 55 ++++++++ .../wizards/applicant_refuse_reason.xml | 13 ++ .../controllers/main.py | 2 + 15 files changed, 456 insertions(+), 48 deletions(-) create mode 100644 addons_extensions/hr_recruitment_extended/wizards/applicant_refuse_reason.py create mode 100644 addons_extensions/hr_recruitment_extended/wizards/applicant_refuse_reason.xml diff --git a/addons_extensions/hr_recruitment_extended/__manifest__.py b/addons_extensions/hr_recruitment_extended/__manifest__.py index d2fd3e4dd..d7b3cbb61 100644 --- a/addons_extensions/hr_recruitment_extended/__manifest__.py +++ b/addons_extensions/hr_recruitment_extended/__manifest__.py @@ -42,6 +42,7 @@ 'views/requisitions.xml', 'views/skills.xml', 'wizards/post_onboarding_attachment_wizard.xml', + 'wizards/applicant_refuse_reason.xml', # 'views/resume_pearser.xml', ], 'assets': { diff --git a/addons_extensions/hr_recruitment_extended/data/sequence.xml b/addons_extensions/hr_recruitment_extended/data/sequence.xml index 2a9f55591..a3118c9a1 100644 --- a/addons_extensions/hr_recruitment_extended/data/sequence.xml +++ b/addons_extensions/hr_recruitment_extended/data/sequence.xml @@ -8,5 +8,14 @@ 5 1 + + + HR Job Candidate Sequence + hr.job.candidate.sequence + C + 5 + 1 + + diff --git a/addons_extensions/hr_recruitment_extended/models/hr_applicant.py b/addons_extensions/hr_recruitment_extended/models/hr_applicant.py index ddb441afe..3331209ff 100644 --- a/addons_extensions/hr_recruitment_extended/models/hr_applicant.py +++ b/addons_extensions/hr_recruitment_extended/models/hr_applicant.py @@ -16,6 +16,9 @@ class HRApplicant(models.Model): candidate_image = fields.Image(related='candidate_id.candidate_image', readonly=False, compute_sudo=True) submitted_to_client = fields.Boolean(string="Submitted_to_client", default=False, readonly=True, tracking=True) client_submission_date = fields.Datetime(string="Submission Date") + submitted_stage = fields.Many2one('hr.recruitment.stage') + refused_stage = fields.Many2one('hr.recruitment.stage') + refused_comments = fields.Text() @api.model def _read_group_recruitment_stage_ids(self, stages, domain): @@ -130,6 +133,7 @@ class HRApplicant(models.Model): ) rec.submitted_to_client = True rec.client_submission_date = fields.Datetime.now() + rec.submitted_stage = rec.recruitment_stage_id.id def submit_for_approval(self): for rec in self: diff --git a/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py b/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py index 82be22788..232199e08 100644 --- a/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py +++ b/addons_extensions/hr_recruitment_extended/models/hr_job_recruitment.py @@ -18,17 +18,20 @@ class HRJobRecruitment(models.Model): ] def _get_first_stage(self): + """This function is used to fetch the starting stage""" self.ensure_one() return self.env['hr.recruitment.stage'].search([ ('job_recruitment_ids', '=', self.id)], order='sequence asc', limit=1) def _compute_application_count(self): + """this function is used to compute the application count""" read_group_result = self.env['hr.applicant']._read_group([('hr_job_recruitment', 'in', self.ids)], ['hr_job_recruitment'], ['__count']) result = {job.id: count for job, count in read_group_result} for job in self: job.application_count = result.get(job.id, 0) def _compute_all_application_count(self): + "this function is used to compute all the applicants count including inactive applicants" read_group_result = self.env['hr.applicant'].with_context(active_test=False)._read_group([ ('hr_job_recruitment', 'in', self.ids), '|', @@ -41,6 +44,7 @@ class HRJobRecruitment(models.Model): job.all_application_count = result.get(job.id, 0) def _compute_applicant_hired(self): + """this function is used to compute the hired applicants count""" hired_stages = self.env['hr.recruitment.stage'].search([('hired_stage', '=', True)]) hired_data = self.env['hr.applicant']._read_group([ ('hr_job_recruitment', 'in', self.ids), @@ -51,6 +55,7 @@ class HRJobRecruitment(models.Model): job.applicant_hired = job_hires.get(job.id, 0) def _compute_new_application_count(self): + """sthis function is used to fetch the count of applicants those who are in starting stage""" self.env.cr.execute( """ WITH job_stage AS ( @@ -78,19 +83,20 @@ class HRJobRecruitment(models.Model): for job in self: job.new_application_count = new_applicant_count.get(job.id, 0) - - # # display_name = fields.Char(string='Name', compute='_compute_display_name', store=True) application_count = fields.Integer(compute='_compute_application_count', string="Application Count") all_application_count = fields.Integer(compute='_compute_all_application_count', string="All Application Count") new_application_count = fields.Integer( compute='_compute_new_application_count', string="New Application", help="Number of applications that are new in the flow (typically at first step of the flow)") applicant_hired = fields.Integer(compute='_compute_applicant_hired', string="Applicants Hired") + def _get_default_favorite_user_ids(self): + """this function is used to set the default users i.e current user""" return [(6, 0, [self.env.uid])] @api.model def _default_address_id(self): + """this function is used to set the sefault company id """ last_used_address = self.env['hr.job.recruitment'].search([('company_id', 'in', self.env.companies.ids)], order='id desc', limit=1) if last_used_address: @@ -115,9 +121,11 @@ class HRJobRecruitment(models.Model): no_of_eligible_submissions = fields.Integer(string='Eligible Submissions', copy=False, help='Number of Submissions you expected to send.', default=1) + submission_status = fields.Selection([('zero','Zero Submissions'),('partial','Partial Submissions'),('filled','Filled Submissions')],default='zero', compute="_compute_submission_status", store=True) @api.onchange("no_of_recruitment") def onchange_no_of_recruitments(self): + """this function is used to set the no_of_eligible submissions""" for rec in self: if rec.no_of_eligible_submissions <= 1: rec.no_of_eligible_submissions = rec.no_of_recruitment @@ -186,7 +194,7 @@ class HRJobRecruitment(models.Model): store=True) no_of_submissions = fields.Integer( compute='_compute_no_of_submissions', - string='Hired', copy=False, + string='Submitted', copy=False, store=True, help='Number of Application submissions for this job position during recruitment phase.', ) no_of_refused_submissions = fields.Integer( @@ -213,6 +221,18 @@ class HRJobRecruitment(models.Model): if rec.job_category and rec.job_category.default_user: rec.user_id = rec.job_category.default_user.id + @api.depends('no_of_submissions','no_of_eligible_submissions') + def _compute_submission_status(self): + for rec in self: + if rec.no_of_submissions == 0: + rec.submission_status = 'zero' + elif rec.no_of_submissions > 0 and rec.no_of_submissions < rec.no_of_eligible_submissions: + rec.submission_status = 'partial' + elif rec.no_of_submissions > 0 and rec.no_of_submissions >= rec.no_of_eligible_submissions: + rec.submission_status = 'filled' + else: + rec.submission_status = 'zero' + @api.depends('application_ids.submitted_to_client') def _compute_no_of_submissions(self): @@ -318,6 +338,10 @@ class HRJobRecruitment(models.Model): result.append((record.id, name)) return result + @api.depends('job_id', 'recruitment_sequence') + def _compute_display_name(self): + for rec in self: + rec.display_name = False if not rec.recruitment_sequence else f"{rec.recruitment_sequence} ({rec.job_id.name})" def buttion_view_applicants(self): if self.skill_ids: @@ -334,11 +358,11 @@ class HRJobRecruitment(models.Model): action['context'] = dict(self._context) return action - def hr_job_recruitment_end_date_update(self): + def hr_job_recruitment_end_date_update(self): tomorrow_date = fields.Date.today() + timedelta(days=1) jobs_ending_tomorrow = self.sudo().search([('target_to', '=', tomorrow_date)]) - + jobs_unpublish_needed = self.sudo().search([('target_to','!=',False),('target_to', '<', fields.Date.today()),('website_published','=',True)]) for job in jobs_ending_tomorrow: # Fetch recruiters (assuming job has a field recruiter_id or similar) recruiter = job.sudo().user_id # Replacne with the appropriate field name @@ -348,13 +372,13 @@ class HRJobRecruitment(models.Model): 'hr_recruitment_extended.template_recruitment_deadline_alert') # Replace with your email template XML ID if template: template.sudo().send_mail(recruiter.id, force_send=True) + for job in jobs_unpublish_needed: + job.sudo().write({'website_published': False}) return True @api.model_create_multi def create(self, vals_list): - - for vals in vals_list: vals["favorite_user_ids"] = vals.get("favorite_user_ids", []) if vals.get('recruitment_sequence', '/') == '/': @@ -377,8 +401,6 @@ class HRJobRecruitment(models.Model): return jobs - def buttion_view_applicants(self): - pass def action_open_attachments(self): return { diff --git a/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py b/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py index 2b17e6964..88cc1f166 100644 --- a/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py +++ b/addons_extensions/hr_recruitment_extended/models/hr_recruitment.py @@ -1,4 +1,4 @@ -from odoo import models, fields, api, _ +from odoo import models, fields, api, _, tools from odoo.exceptions import ValidationError, UserError from datetime import date from datetime import timedelta @@ -14,7 +14,12 @@ import datetime class HrCandidate(models.Model): _inherit = "hr.candidate" + _sql_constraints = [ + ('unique_candidate_sequence', 'UNIQUE(candidate_sequence)', 'Candidate sequence must be unique!'), + ] #personal Details + candidate_sequence = fields.Char(string='Candidate Sequence', readonly=False, default='/', copy=False) + first_name = fields.Char(string='First Name',required=False, help="This is the person's first name, given at birth or during a naming ceremony. It’s the name people use to address you.") middle_name = fields.Char(string='Middle Name', help="This is an extra name that comes between the first name and last name. Not everyone has a middle name") last_name = fields.Char(string='Last Name',required=False, help="This is the family name, shared with other family members. It’s usually the last name.") @@ -23,6 +28,62 @@ class HrCandidate(models.Model): employee_code = fields.Char(related="employee_id.employee_id") resume = fields.Binary() + applications_stages_stat = fields.Many2many('application.stage.status',string="Applications History", compute="_compute_applications_stages_stat") + # availability_status = fields.Selection([('available','Available'),('not_available','Not Available'),('hired','Hired'),('abscond','Abscond')]) + + @api.depends('applicant_ids') + def _compute_applications_stages_stat(self): + for rec in self: + if rec.applicant_ids: + stage_status_records = self.env['application.stage.status'].with_context(active_test=False).search([ + ('applicant_id', 'in', rec.applicant_ids.ids) + ]) + rec.applications_stages_stat = [(6, 0, stage_status_records.ids)] + else: + rec.applications_stages_stat = [(5,)] + + @api.depends('partner_name', 'candidate_sequence') + def _compute_display_name(self): + for rec in self: + rec.display_name = rec.partner_name if not rec.candidate_sequence else f"{rec.partner_name} ({rec.candidate_sequence})" + + @api.constrains('email_from', 'partner_phone', 'alternate_phone') + def _candidate_unique_constraints(self): + for rec in self: + # Check for unique email + if rec.email_from: + existing_email = self.sudo().search([('id', '!=', rec.id), ('email_from', '=', rec.email_from)], + limit=1) + if existing_email: + raise ValidationError(_("A candidate with the email '%s' already exists, sourced by %s %s.") % ( + existing_email.email_from, existing_email.user_id.name, existing_email.candidate_sequence if existing_email.candidate_sequence else '')) + + # Check for unique phone number (partner_phone or alternate_phone) + if rec.partner_phone: + existing_phone = self.sudo().search( + [('id', '!=', rec.id), '|', ('partner_phone', '=', rec.partner_phone), + ('alternate_phone', '=', rec.partner_phone)], limit=1) + if existing_phone: + raise ValidationError(_("A candidate with the phone number '%s' already exists, sourced by %s %s.") % ( + existing_phone.partner_phone, existing_phone.user_id.name, existing_phone.candidate_sequence if existing_phone.candidate_sequence else '')) + + if rec.alternate_phone: + existing_al_phone = self.sudo().search( + [('id', '!=', rec.id), '|', ('partner_phone', '=', rec.alternate_phone), + ('alternate_phone', '=', rec.alternate_phone)], limit=1) + if existing_al_phone: + raise ValidationError( + _("A candidate with the alternate phone number '%s' already exists, sourced by %s %s.") % ( + existing_al_phone.alternate_phone, existing_al_phone.user_id.name, existing_al_phone.candidate_sequence if existing_al_phone.candidate_sequence else '')) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('candidate_sequence', '/') == '/': + vals['candidate_sequence'] = self.env['ir.sequence'].next_by_code( + 'hr.job.candidate.sequence') or '/' + return super(HrCandidate, self).create(vals_list) + def create_employee_from_candidate(self): @@ -134,12 +195,12 @@ class HRApplicant(models.Model): exp_type = fields.Selection([('fresher','Fresher'),('experienced','Experienced')], default='fresher', required=True) - total_exp = fields.Integer(string="Total Experience") - relevant_exp = fields.Integer(string="Relevant Experience") - total_exp_type = fields.Selection([('month',"Month's"),('year',"Year's")]) - relevant_exp_type = fields.Selection([('month',"Month's"),('year',"Year's")]) + total_exp = fields.Float(string="Total Experience") + relevant_exp = fields.Float(string="Relevant Experience") + total_exp_type = fields.Selection([('month',"Month's"),('year',"Year's")], default='year') + relevant_exp_type = fields.Selection([('month',"Month's"),('year',"Year's")], default='year') notice_period = fields.Integer(string="Notice Period") - notice_period_type = fields.Selection([('day',"Day's"),('month',"Month's"),('year',"Year's")], string='Type') + notice_period_type = fields.Selection([('day',"Day's"),('month',"Month's"),('year',"Year's")], string='Type', default='day') current_ctc = fields.Float(string="Current CTC", aggregator="avg", help="Applicant Current Salary", tracking=True, groups="hr_recruitment.group_hr_recruitment_user") salary_expected = fields.Float("Expected CTC", aggregator="avg", help="Salary Expected by Applicant", tracking=True, groups="hr_recruitment.group_hr_recruitment_user") @@ -148,7 +209,7 @@ class HRApplicant(models.Model): holding_offer = fields.Char(string="Holding Offer") applicant_comments = fields.Text(string='Applicant Comments') recruiter_comments = fields.Text(string='Recruiter Comments') - + medium_id = fields.Many2one(string='Mode') doj = fields.Date(tracking=True) gender = fields.Selection([ ('male', 'Male'), @@ -422,4 +483,57 @@ class RecruitmentCategory(models.Model): _rec_name = "category_name" category_name = fields.Char(string="Category Name") - default_user = fields.Many2one('res.users') \ No newline at end of file + default_user = fields.Many2one('res.users') + + +class ApplicationsStageStatus(models.Model): + _name = 'application.stage.status' + _rec_name = 'applicant_id' + _auto = False + + applicant_id = fields.Many2one('hr.applicant') + job_request = fields.Many2one('hr.job.recruitment', related='applicant_id.hr_job_recruitment') + stage_id = fields.Many2one('hr.recruitment.stage', related="applicant_id.recruitment_stage_id") + application_status = fields.Selection([ + ('ongoing', 'Ongoing'), + ('hired', 'Hired'), + ('refused', 'Refused'), + ('archived', 'Archived'), + ], related='applicant_id.application_status') + stage_color = fields.Char(related='applicant_id.stage_color', widget='color') + stage_color_int = fields.Integer("Stage Color Int", compute="_compute_stage_color") + + @api.depends("application_status") + def _compute_stage_color(self): + for record in self: + if record.application_status == 'hired': + record.stage_color_int = 10 + elif record.application_status == 'ongoing': + record.stage_color_int = 3 + elif record.application_status == 'refused': + record.stage_color_int = 1 + elif record.application_status == 'archived': + record.stage_color_int = 9 + else: + record.stage_color_int = 0 + + + @api.depends('applicant_id', 'stage_id', 'application_status', 'job_request', 'stage_color_int') + def _compute_display_name(self): + for rec in self: + rec.display_name = rec.applicant_id.display_name if not rec.stage_id else f"({rec.job_request.recruitment_sequence}){rec.applicant_id.display_name} {rec.stage_color_int} - ({rec.stage_id.name}, {rec.application_status})" + + def init(self): + """ Create the SQL view for application.stage.status """ + tools.drop_view_if_exists(self.env.cr, self._table) + self.env.cr.execute(""" + CREATE OR REPLACE VIEW %s AS ( + SELECT + a.id AS id, -- Ensuring a unique primary key + a.id AS applicant_id + FROM + hr_applicant a + WHERE + a.active = 't' or a.active = 'f' + ); + """ % (self._table)) \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_extended/models/res_partner.py b/addons_extensions/hr_recruitment_extended/models/res_partner.py index c428a99df..c3e24c064 100644 --- a/addons_extensions/hr_recruitment_extended/models/res_partner.py +++ b/addons_extensions/hr_recruitment_extended/models/res_partner.py @@ -4,4 +4,4 @@ from odoo import models, fields, api, _ class ResPartner(models.Model): _inherit = 'res.partner' - contact_type = fields.Selection([('internal','Internal'),('external','External')], required=True, default='internal') \ No newline at end of file + contact_type = fields.Selection([('internal','Internal'),('external','External')], required=True, default='external') \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_extended/models/stages.py b/addons_extensions/hr_recruitment_extended/models/stages.py index e9ed5f7ef..0086a3123 100644 --- a/addons_extensions/hr_recruitment_extended/models/stages.py +++ b/addons_extensions/hr_recruitment_extended/models/stages.py @@ -109,6 +109,6 @@ class Job(models.Model): def unlink(self): # Remove stage from all related jobs when stage is deleted for job in self: - for stage in stage.recruitment_stage_ids: + for stage in job.recruitment_stage_ids: stage.write({'job_recruitment_ids': [(3, job.id)]}) return super(Job, self).unlink() diff --git a/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv b/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv index 2886c3c93..4905d5b5a 100644 --- a/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv +++ b/addons_extensions/hr_recruitment_extended/security/ir.model.access.csv @@ -20,3 +20,9 @@ access_recruitment_attachments_user,access.recruitment.attachments.user,model_re access_post_onboarding_attachment_wizard,access.post.onboarding.attachment.wizard,model_post_onboarding_attachment_wizard,base.group_user,1,1,1,1 access_employee_recruitment_attachments,employee.recruitment.attachments,model_employee_recruitment_attachments,base.group_user,1,1,1,1 + +hr_recruitment.access_hr_applicant_interviewer,hr.applicant.interviewer,hr_recruitment.model_hr_applicant,hr_recruitment.group_hr_recruitment_interviewer,1,1,1,0 +hr_recruitment.access_hr_recruitment_stage_user,hr.recruitment.stage.user,hr_recruitment.model_hr_recruitment_stage,hr_recruitment.group_hr_recruitment_user,1,1,1,0 + + +access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml b/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml index 90227e3c6..3b26f5187 100644 --- a/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml +++ b/addons_extensions/hr_recruitment_extended/views/hr_applicant_views.xml @@ -1,6 +1,18 @@ - + + hr.applicant.view.list + + hr.applicant + + + 1 + + + + + + hr.applicant.view.form hr.applicant @@ -106,6 +118,16 @@ + + + + + + + + + + @@ -128,7 +150,6 @@ - @@ -172,10 +193,36 @@ invisible="application_status != 'refused'"/> - - - - +
+ + +
+
+
+ + + +
+
+ +
+ + +
+ + +
+ + +
+ +
+
+
+
+