# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from random import randint from odoo import api, fields, models, tools, SUPERUSER_ID from odoo.tools.translate import _ from odoo.exceptions import UserError AVAILABLE_PRIORITIES = [ ('0', 'Normal'), ('1', 'Good'), ('2', 'Very Good'), ('3', 'Excellent') ] class RecruitmentSource(models.Model): _name = "hr.recruitment.source" _description = "Source of Applicants" _inherits = {"utm.source": "source_id"} source_id = fields.Many2one('utm.source', "Source", ondelete='cascade', required=True) email = fields.Char(related='alias_id.display_name', string="Email", readonly=True) job_id = fields.Many2one('hr.job', "Job", ondelete='cascade') alias_id = fields.Many2one('mail.alias', "Alias ID") def create_alias(self): campaign = self.env.ref('hr_recruitment.utm_campaign_job') medium = self.env.ref('utm.utm_medium_email') for source in self: vals = { 'alias_parent_thread_id': source.job_id.id, 'alias_model_id': self.env['ir.model']._get('hr.applicant').id, 'alias_parent_model_id': self.env['ir.model']._get('hr.job').id, 'alias_name': "%s+%s" % (source.job_id.alias_name or source.job_id.name, source.name), 'alias_defaults': { 'job_id': source.job_id.id, 'campaign_id': campaign.id, 'medium_id': medium.id, 'source_id': source.source_id.id, }, } source.alias_id = self.env['mail.alias'].create(vals) source.name = source.source_id.name class RecruitmentStage(models.Model): _name = "hr.recruitment.stage" _description = "Recruitment Stages" _order = 'sequence' name = fields.Char("Stage Name", required=True, translate=True) sequence = fields.Integer( "Sequence", default=10, help="Gives the sequence order when displaying a list of stages.") job_ids = fields.Many2many( 'hr.job', string='Job Specific', help='Specific jobs that uses this stage. Other jobs will not use this stage.') requirements = fields.Text("Requirements") template_id = fields.Many2one( 'mail.template', "Email Template", help="If set, a message is posted on the applicant using the template when the applicant is set to the stage.") fold = fields.Boolean( "Folded in Kanban", help="This stage is folded in the kanban view when there are no records in that stage to display.") legend_blocked = fields.Char( 'Red Kanban Label', default=lambda self: _('Blocked'), translate=True, required=True) legend_done = fields.Char( 'Green Kanban Label', default=lambda self: _('Ready for Next Stage'), translate=True, required=True) legend_normal = fields.Char( 'Grey Kanban Label', default=lambda self: _('In Progress'), translate=True, required=True) @api.model def default_get(self, fields): if self._context and self._context.get('default_job_id') and not self._context.get('hr_recruitment_stage_mono', False): context = dict(self._context) context.pop('default_job_id') self = self.with_context(context) return super(RecruitmentStage, self).default_get(fields) class RecruitmentDegree(models.Model): _name = "hr.recruitment.degree" _description = "Applicant Degree" _sql_constraints = [ ('name_uniq', 'unique (name)', 'The name of the Degree of Recruitment must be unique!') ] name = fields.Char("Degree Name", required=True, translate=True) sequence = fields.Integer("Sequence", default=1, help="Gives the sequence order when displaying a list of degrees.") class Applicant(models.Model): _name = "hr.applicant" _description = "Applicant" _order = "priority desc, id desc" _inherit = ['mail.thread.cc', 'mail.activity.mixin', 'utm.mixin'] name = fields.Char("Subject / Application Name", required=True, help="Email subject for applications sent via email") active = fields.Boolean("Active", default=True, help="If the active field is set to false, it will allow you to hide the case without removing it.") description = fields.Text("Description") email_from = fields.Char("Email", size=128, help="Applicant email", compute='_compute_partner_phone_email', inverse='_inverse_partner_email', store=True) probability = fields.Float("Probability") partner_id = fields.Many2one('res.partner', "Contact", copy=False) create_date = fields.Datetime("Creation Date", readonly=True, index=True) stage_id = fields.Many2one('hr.recruitment.stage', 'Stage', ondelete='restrict', tracking=True, compute='_compute_stage', store=True, readonly=False, domain="['|', ('job_ids', '=', False), ('job_ids', '=', job_id)]", copy=False, index=True, group_expand='_read_group_stage_ids') last_stage_id = fields.Many2one('hr.recruitment.stage', "Last Stage", help="Stage of the applicant before being in the current stage. Used for lost cases analysis.") categ_ids = fields.Many2many('hr.applicant.category', string="Tags") company_id = fields.Many2one('res.company', "Company", compute='_compute_company', store=True, readonly=False, tracking=True) user_id = fields.Many2one( 'res.users', "Recruiter", compute='_compute_user', tracking=True, store=True, readonly=False) date_closed = fields.Datetime("Closed", compute='_compute_date_closed', store=True, index=True) date_open = fields.Datetime("Assigned", readonly=True, index=True) 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) 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', inverse='_inverse_partner_phone', store=True) partner_mobile = fields.Char("Mobile", size=32, compute='_compute_partner_phone_email', inverse='_inverse_partner_mobile', store=True) type_id = fields.Many2one('hr.recruitment.degree', "Degree") department_id = fields.Many2one( 'hr.department', "Department", compute='_compute_department', store=True, readonly=False, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=True) day_open = fields.Float(compute='_compute_day', string="Days to Open", compute_sudo=True) day_close = fields.Float(compute='_compute_day', string="Days to Close", compute_sudo=True) delay_close = fields.Float(compute="_compute_day", string='Delay to Close', readonly=True, group_operator="avg", help="Number of days to close", store=True) color = fields.Integer("Color Index", default=0) emp_id = fields.Many2one('hr.employee', string="Employee", help="Employee linked to the applicant.", copy=False) user_email = fields.Char(related='user_id.email', string="User Email", readonly=True) attachment_number = fields.Integer(compute='_get_attachment_number', string="Number of Attachments") employee_name = fields.Char(related='emp_id.name', string="Employee Name", readonly=False, tracking=False) attachment_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', 'hr.applicant')], string='Attachments') kanban_state = fields.Selection([ ('normal', 'Grey'), ('done', 'Green'), ('blocked', 'Red')], string='Kanban State', copy=False, default='normal', required=True) legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked') legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid') legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing') application_count = fields.Integer(compute='_compute_application_count', help='Applications with the same email') meeting_count = fields.Integer(compute='_compute_meeting_count', help='Meeting Count') refuse_reason_id = fields.Many2one('hr.applicant.refuse.reason', string='Refuse Reason', tracking=True) @api.depends('date_open', 'date_closed') def _compute_day(self): for applicant in self: if applicant.date_open: date_create = applicant.create_date date_open = applicant.date_open applicant.day_open = (date_open - date_create).total_seconds() / (24.0 * 3600) else: applicant.day_open = False if applicant.date_closed: date_create = applicant.create_date date_closed = applicant.date_closed applicant.day_close = (date_closed - date_create).total_seconds() / (24.0 * 3600) applicant.delay_close = applicant.day_close - applicant.day_open else: applicant.day_close = False applicant.delay_close = False @api.depends('email_from') def _compute_application_count(self): application_data = self.env['hr.applicant'].with_context(active_test=False).read_group([ ('email_from', 'in', list(set(self.mapped('email_from'))))], ['email_from'], ['email_from']) application_data_mapped = dict((data['email_from'], data['email_from_count']) for data in application_data) applicants = self.filtered(lambda applicant: applicant.email_from) for applicant in applicants: applicant.application_count = application_data_mapped.get(applicant.email_from, 1) - 1 (self - applicants).application_count = False def _compute_meeting_count(self): if self.ids: meeting_data = self.env['calendar.event'].sudo().read_group( [('applicant_id', 'in', self.ids)], ['applicant_id'], ['applicant_id'] ) mapped_data = {m['applicant_id'][0]: m['applicant_id_count'] for m in meeting_data} else: mapped_data = dict() for applicant in self: applicant.meeting_count = mapped_data.get(applicant.id, 0) def _get_attachment_number(self): read_group_res = self.env['ir.attachment'].read_group( [('res_model', '=', 'hr.applicant'), ('res_id', 'in', self.ids)], ['res_id'], ['res_id']) attach_data = dict((res['res_id'], res['res_id_count']) for res in read_group_res) for record in self: record.attachment_number = attach_data.get(record.id, 0) @api.model def _read_group_stage_ids(self, stages, domain, order): # retrieve job_id from the context and write the domain: ids + contextual columns (job or default) job_id = self._context.get('default_job_id') search_domain = [('job_ids', '=', False)] if job_id: search_domain = ['|', ('job_ids', '=', job_id)] + search_domain if stages: search_domain = ['|', ('id', 'in', stages.ids)] + search_domain stage_ids = stages._search(search_domain, order=order, access_rights_uid=SUPERUSER_ID) return stages.browse(stage_ids) @api.depends('job_id', 'department_id') def _compute_company(self): for applicant in self: company_id = False if applicant.department_id: company_id = applicant.department_id.company_id.id if not company_id and applicant.job_id: company_id = applicant.job_id.company_id.id applicant.company_id = company_id or self.env.company.id @api.depends('job_id') def _compute_department(self): for applicant in self: applicant.department_id = applicant.job_id.department_id.id @api.depends('job_id') def _compute_stage(self): for applicant in self: if applicant.job_id: if not applicant.stage_id: stage_ids = self.env['hr.recruitment.stage'].search([ '|', ('job_ids', '=', False), ('job_ids', '=', applicant.job_id.id), ('fold', '=', False) ], order='sequence asc', limit=1).ids applicant.stage_id = stage_ids[0] if stage_ids else False else: applicant.stage_id = False @api.depends('job_id') def _compute_user(self): for applicant in self: applicant.user_id = applicant.job_id.user_id.id or self.env.uid @api.depends('partner_id') def _compute_partner_phone_email(self): for applicant in self: applicant.partner_phone = applicant.partner_id.phone applicant.partner_mobile = applicant.partner_id.mobile applicant.email_from = applicant.partner_id.email def _inverse_partner_email(self): for applicant in self.filtered(lambda a: a.partner_id and a.email_from and not a.partner_id.email): applicant.partner_id.email = applicant.email_from def _inverse_partner_phone(self): for applicant in self.filtered(lambda a: a.partner_id and a.partner_phone and not a.partner_id.phone): applicant.partner_id.phone = applicant.partner_phone def _inverse_partner_mobile(self): for applicant in self.filtered(lambda a: a.partner_id and a.partner_mobile and not a.partner_id.mobile): applicant.partner_id.mobile = applicant.partner_mobile @api.depends('stage_id') def _compute_date_closed(self): for applicant in self: if applicant.stage_id and applicant.stage_id.fold: applicant.date_closed = fields.datetime.now() else: applicant.date_closed = False @api.model def create(self, vals): if vals.get('department_id') and not self._context.get('default_department_id'): self = self.with_context(default_department_id=vals.get('department_id')) if vals.get('user_id'): vals['date_open'] = fields.Datetime.now() if vals.get('email_from'): vals['email_from'] = vals['email_from'].strip() return super(Applicant, self).create(vals) def write(self, vals): # user_id change: update date_open if vals.get('user_id'): vals['date_open'] = fields.Datetime.now() if vals.get('email_from'): vals['email_from'] = vals['email_from'].strip() # stage_id: track last stage before update if 'stage_id' in vals: vals['date_last_stage_update'] = fields.Datetime.now() if 'kanban_state' not in vals: vals['kanban_state'] = 'normal' for applicant in self: vals['last_stage_id'] = applicant.stage_id.id res = super(Applicant, self).write(vals) else: res = super(Applicant, self).write(vals) return res def get_empty_list_help(self, help): if 'active_id' in self.env.context and self.env.context.get('active_model') == 'hr.job': alias_id = self.env['hr.job'].browse(self.env.context['active_id']).alias_id else: alias_id = False nocontent_values = { 'help_title': _('No application yet'), 'para_1': _('Let people apply by email to save time.') , 'para_2': _('Attachments, like resumes, get indexed automatically.'), } nocontent_body = """
%(help_title)s
%(para_1)s
%(para_2)s
%(email_link)s
""" return nocontent_body % nocontent_values def action_makeMeeting(self): """ This opens Meeting's calendar view to schedule meeting on current applicant @return: Dictionary value for created Meeting view """ self.ensure_one() partners = self.partner_id | self.user_id.partner_id | self.department_id.manager_id.user_id.partner_id category = self.env.ref('hr_recruitment.categ_meet_interview') res = self.env['ir.actions.act_window']._for_xml_id('calendar.action_calendar_event') res['context'] = { 'default_applicant_id': self.id, 'default_partner_ids': partners.ids, 'default_user_id': self.env.uid, 'default_name': self.name, 'default_categ_ids': category and [category.id] or False, } return res def action_get_attachment_tree_view(self): action = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment') action['context'] = {'default_res_model': self._name, 'default_res_id': self.ids[0]} action['domain'] = str(['&', ('res_model', '=', self._name), ('res_id', 'in', self.ids)]) action['search_view_id'] = (self.env.ref('hr_recruitment.ir_attachment_view_search_inherit_hr_recruitment').id, ) return action def action_applications_email(self): return { 'type': 'ir.actions.act_window', 'name': _('Applications'), 'res_model': self._name, 'view_mode': 'kanban,tree,form,pivot,graph,calendar,activity', 'domain': [('email_from', 'in', self.mapped('email_from'))], 'context': { 'active_test': False }, } def _track_template(self, changes): res = super(Applicant, self)._track_template(changes) applicant = self[0] if 'stage_id' in changes and applicant.stage_id.template_id: res['stage_id'] = (applicant.stage_id.template_id, { 'auto_delete_message': True, 'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'), 'email_layout_xmlid': 'mail.mail_notification_light' }) return res def _creation_subtype(self): return self.env.ref('hr_recruitment.mt_applicant_new') def _track_subtype(self, init_values): record = self[0] if 'stage_id' in init_values and record.stage_id: return self.env.ref('hr_recruitment.mt_applicant_stage_changed') return super(Applicant, self)._track_subtype(init_values) def _notify_get_reply_to(self, default=None, records=None, company=None, doc_names=None): """ Override to set alias of applicants to their job definition if any. """ aliases = self.mapped('job_id')._notify_get_reply_to(default=default, records=None, company=company, doc_names=None) res = {app.id: aliases.get(app.job_id.id) for app in self} leftover = self.filtered(lambda rec: not rec.job_id) if leftover: res.update(super(Applicant, leftover)._notify_get_reply_to(default=default, records=None, company=company, doc_names=doc_names)) return res def _message_get_suggested_recipients(self): recipients = super(Applicant, self)._message_get_suggested_recipients() for applicant in self: if applicant.partner_id: applicant._message_add_suggested_recipient(recipients, partner=applicant.partner_id, reason=_('Contact')) elif applicant.email_from: email_from = applicant.email_from if applicant.partner_name: email_from = tools.formataddr((applicant.partner_name, email_from)) applicant._message_add_suggested_recipient(recipients, email=email_from, reason=_('Contact Email')) return recipients @api.model def message_new(self, msg, custom_values=None): """ Overrides mail_thread message_new that is called by the mailgateway through message_process. This override updates the document according to the email. """ # remove default author when going through the mail gateway. Indeed we # do not want to explicitly set user_id to False; however we do not # want the gateway user to be responsible if no other responsible is # found. self = self.with_context(default_user_id=False) val = msg.get('from').split('<')[0] defaults = { 'name': msg.get('subject') or _("No Subject"), 'partner_name': val, 'email_from': msg.get('from'), 'partner_id': msg.get('author_id', False), } if msg.get('priority'): defaults['priority'] = msg.get('priority') if custom_values: defaults.update(custom_values) return super(Applicant, self).message_new(msg, custom_values=defaults) def _message_post_after_hook(self, message, msg_vals): if self.email_from and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) if new_partner: if new_partner.create_date.date() == fields.Date.today(): new_partner.write({ 'type': 'private', 'phone': self.partner_phone, 'mobile': self.partner_mobile, }) self.search([ ('partner_id', '=', False), ('email_from', '=', new_partner.email), ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) return super(Applicant, self)._message_post_after_hook(message, msg_vals) def create_employee_from_applicant(self): """ Create an hr.employee from the hr.applicants """ employee = False for applicant in self: contact_name = False if applicant.partner_id: address_id = applicant.partner_id.address_get(['contact'])['contact'] contact_name = applicant.partner_id.display_name else: if not applicant.partner_name: raise UserError(_('You must define a Contact Name for this applicant.')) new_partner_id = self.env['res.partner'].create({ 'is_company': False, 'type': 'private', 'name': applicant.partner_name, 'email': applicant.email_from, 'phone': applicant.partner_phone, 'mobile': applicant.partner_mobile }) applicant.partner_id = new_partner_id address_id = new_partner_id.address_get(['contact'])['contact'] if applicant.partner_name or contact_name: employee_data = { 'default_name': applicant.partner_name or contact_name, 'default_job_id': applicant.job_id.id, 'default_job_title': applicant.job_id.name, 'address_home_id': address_id, 'default_department_id': applicant.department_id.id or False, 'default_address_id': applicant.company_id and applicant.company_id.partner_id and applicant.company_id.partner_id.id or False, 'default_work_email': applicant.department_id and applicant.department_id.company_id and applicant.department_id.company_id.email or False, 'default_work_phone': applicant.department_id.company_id.phone, 'form_view_initial_mode': 'edit', 'default_applicant_id': applicant.ids, } dict_act_window = self.env['ir.actions.act_window']._for_xml_id('hr.open_view_employee_list') dict_act_window['context'] = employee_data return dict_act_window def archive_applicant(self): return { 'type': 'ir.actions.act_window', 'name': _('Refuse Reason'), 'res_model': 'applicant.get.refuse.reason', 'view_mode': 'form', 'target': 'new', 'context': {'default_applicant_ids': self.ids, 'active_test': False}, 'views': [[False, 'form']] } def reset_applicant(self): """ Reinsert the applicant into the recruitment pipe in the first stage""" default_stage = dict() for job_id in self.mapped('job_id'): default_stage[job_id.id] = self.env['hr.recruitment.stage'].search( ['|', ('job_ids', '=', False), ('job_ids', '=', job_id.id), ('fold', '=', False) ], order='sequence asc', limit=1).id for applicant in self: applicant.write( {'stage_id': applicant.job_id.id and default_stage[applicant.job_id.id], 'refuse_reason_id': False}) def toggle_active(self): res = super(Applicant, self).toggle_active() applicant_active = self.filtered(lambda applicant: applicant.active) if applicant_active: applicant_active.reset_applicant() applicant_inactive = self.filtered(lambda applicant: not applicant.active) if applicant_inactive: return applicant_inactive.archive_applicant() return res class ApplicantCategory(models.Model): _name = "hr.applicant.category" _description = "Category of applicant" def _get_default_color(self): return randint(1, 11) name = fields.Char("Tag Name", required=True) color = fields.Integer(string='Color Index', default=_get_default_color) _sql_constraints = [ ('name_uniq', 'unique (name)', "Tag name already exists !"), ] class ApplicantRefuseReason(models.Model): _name = "hr.applicant.refuse.reason" _description = 'Refuse Reason of Applicant' name = fields.Char('Description', required=True, translate=True) active = fields.Boolean('Active', default=True)