19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

View file

@ -3,3 +3,6 @@
from . import applicant_refuse_reason
from . import applicant_send_mail
from . import job_add_applicants
from . import mail_activity_schedule
from . import talent_pool_add_applicants

View file

@ -1,52 +1,176 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from itertools import product
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.fields import Domain
class ApplicantGetRefuseReason(models.TransientModel):
_name = 'applicant.get.refuse.reason'
_inherit = ['mail.composer.mixin']
_description = 'Get Refuse Reason'
refuse_reason_id = fields.Many2one('hr.applicant.refuse.reason', 'Refuse Reason', required=True)
def _default_refuse_reason_id(self):
return self.env['hr.applicant.refuse.reason'].search([], limit=1)
refuse_reason_id = fields.Many2one('hr.applicant.refuse.reason', 'Refuse Reason', required=True, default=_default_refuse_reason_id)
applicant_ids = fields.Many2many('hr.applicant')
send_mail = fields.Boolean("Send Email", compute='_compute_send_mail', store=True, readonly=False)
send_mail = fields.Boolean("Send Email", compute='_compute_send_mail', precompute=True, store=True, readonly=False)
template_id = fields.Many2one('mail.template', string='Email Template',
compute='_compute_send_mail', store=True, readonly=False,
compute='_compute_template_id', precompute=True, store=True, readonly=False,
domain="[('model', '=', 'hr.applicant')]")
applicant_without_email = fields.Text(compute='_compute_applicant_without_email',
string='Applicant(s) not having email')
duplicates = fields.Boolean(string='Refuse Duplicate Applications')
duplicates_count = fields.Integer('Duplicates Count', compute='_compute_duplicate_applicant_ids_domain')
duplicate_applicant_ids = fields.Many2many(
'hr.applicant',
relation='applicant_get_refuse_reason_duplicate_applicants_rel',
string='Duplicate Applications',
compute="_compute_duplicate_applicant_ids",
store=True, readonly=False,
)
duplicate_applicant_ids_domain = fields.Binary(compute="_compute_duplicate_applicant_ids_domain")
attachment_ids = fields.Many2many(
'ir.attachment', string='Attachments',
compute="_compute_from_template_id", readonly=False, store=True, bypass_search_access=True,
)
scheduled_date = fields.Char(
'Scheduled Date',
compute='_compute_from_template_id', readonly=False, store=True,
help="send emails after that date. This date is considered as being in UTC timezone."
)
@api.depends('refuse_reason_id')
@api.depends('refuse_reason_id', 'applicant_without_email')
def _compute_send_mail(self):
for wizard in self:
template = wizard.refuse_reason_id.template_id
wizard.send_mail = bool(template)
wizard.template_id = template
wizard.send_mail = template and not wizard.applicant_without_email
@api.depends('applicant_ids', 'send_mail')
@api.depends('applicant_ids')
def _compute_applicant_without_email(self):
for wizard in self:
applicants = wizard.applicant_ids.filtered(lambda x: not x.email_from and not x.partner_id.email)
if applicants and wizard.send_mail:
if applicants:
wizard.applicant_without_email = "%s\n%s" % (
_("The email will not be sent to the following applicant(s) as they don't have email address."),
"\n".join([i.partner_name or i.name for i in applicants])
_("You can't select Send email option.\nThe email will not be sent to the following applicant(s) as they don't have an email address:"),
", ".join([i.partner_name or i.display_name or '' for i in applicants])
)
else:
wizard.applicant_without_email = False
@api.depends('applicant_ids')
def _compute_duplicate_applicant_ids_domain(self):
for wizard in self:
domain = (
self.applicant_ids._get_similar_applicants_domain()
& Domain('id', 'not in', self.applicant_ids.ids)
& Domain('application_status', 'not in', ['hired', 'refused', 'archived'])
)
wizard.duplicate_applicant_ids_domain = domain
wizard.duplicates_count = self.env['hr.applicant'].search_count(wizard.duplicate_applicant_ids_domain)
@api.depends('duplicates', 'duplicate_applicant_ids_domain')
def _compute_duplicate_applicant_ids(self):
if self.duplicates:
self.duplicate_applicant_ids = self.env['hr.applicant'].search(self.duplicate_applicant_ids_domain)
else:
self.duplicate_applicant_ids = self.env['hr.applicant']
# Overrides of mail.composer.mixin
@api.depends('refuse_reason_id') # fake trigger otherwise not computed in new mode
def _compute_render_model(self):
self.render_model = 'hr.applicant'
@api.depends('refuse_reason_id')
def _compute_template_id(self):
for wizard in self:
if wizard.refuse_reason_id:
wizard.template_id = wizard.refuse_reason_id.template_id
else:
wizard.template_id = False
@api.depends('template_id')
def _compute_from_template_id(self):
# wizard_field_name: template_field_name
fields_to_copy_name_mapping = {
'body': 'body_html',
'attachment_ids': 'attachment_ids',
'scheduled_date': 'scheduled_date',
'subject': 'subject',
}
for wizard in self:
for wizard_field_name, template_field_name in fields_to_copy_name_mapping.items():
if wizard.template_id:
wizard[wizard_field_name] = wizard.template_id[template_field_name]
else:
wizard[wizard_field_name] = False
def action_refuse_reason_apply(self):
if self.send_mail:
if not self.template_id:
raise UserError(_("Email template must be selected to send a mail"))
if not self.applicant_ids.filtered(lambda x: x.email_from or x.partner_id.email):
raise UserError(_("Email of the applicant is not set, email won't be sent."))
self.applicant_ids.write({'refuse_reason_id': self.refuse_reason_id.id, 'active': False})
if not self.env.user.email:
raise UserError(_("Unable to post message, please configure the sender's email address."))
if any(not (applicant.email_from or applicant.partner_id.email) for applicant in self.applicant_ids):
raise UserError(_("At least one applicant doesn't have a email; you can't use send email option."))
refused_applications = self.applicant_ids
if self.duplicates_count and self.duplicates:
refused_applications |= self.duplicate_applicant_ids
original_applicant_by_duplicate_applicant = self._get_related_original_applicants()
message_by_duplicate_applicant = {}
for duplicate_applicant in self.duplicate_applicant_ids:
url = original_applicant_by_duplicate_applicant[duplicate_applicant]._get_html_link()
message_by_duplicate_applicant[duplicate_applicant.id] = _(
"Refused automatically because this application has been identified as a duplicate of %(link)s",
link=url)
self.duplicate_applicant_ids._message_log_batch(bodies={
duplicate.id: message_by_duplicate_applicant[duplicate.id]
for duplicate in self.duplicate_applicant_ids
}
)
refused_applications.write({'refuse_reason_id': self.refuse_reason_id.id, 'active': False, 'refuse_date': datetime.now()})
if self.send_mail:
applicants = self.applicant_ids.filtered(lambda x: x.email_from or x.partner_id.email)
applicants.with_context(active_test=True).message_post_with_template(self.template_id.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'
})
self._prepare_send_refusal_mails()
return {'type': 'ir.actions.act_window_close'}
def _get_related_original_applicants(self):
duplication_fields = ['id', 'email_normalized', 'partner_phone_sanitized', 'linkedin_profile']
original_applicant_by_field_value = {field: {} for field in duplication_fields}
related_original_applicants = dict()
for original_applicant, field in product(self.applicant_ids, duplication_fields):
value = original_applicant[field]
if value:
original_applicant_by_field_value[field][value] = original_applicant
for duplicate_applicant in self.duplicate_applicant_ids:
for field in duplication_fields:
value = duplicate_applicant[field]
if original_applicant_by_field_value[field].get(value):
related_original_applicants[duplicate_applicant] = original_applicant_by_field_value[field][value]
break
return related_original_applicants
def _prepare_send_refusal_mails(self):
for applicant in self.applicant_ids:
mail_values = self._prepare_mail_values(applicant)
applicant.message_post(**mail_values)
def _prepare_mail_values(self, applicant):
""" Create mail specific for recipient """
lang = self._render_lang(applicant.ids)[applicant.id]
subject = self._render_field('subject', applicant.ids, set_lang=lang)[applicant.id]
body = self._render_field('body', applicant.ids, set_lang=lang)[applicant.id]
email_from = self.template_id.email_from if self.template_id and self.template_id.email_from else self.env.user.email_formatted
return {
'body': body,
'email_from': email_from,
'subject': subject,
'author_id': self.env.user.partner_id.id,
'scheduled_date': self.scheduled_date,
'attachment_ids': [(4, att.id) for att in self.attachment_ids],
'partner_ids': applicant.partner_id.ids
}

View file

@ -4,19 +4,54 @@
<field name="name">applicant.get.refuse.reason.form</field>
<field name="model">applicant.get.refuse.reason</field>
<field name="arch" type="xml">
<form string="Refuse Reason">
<form string="Refuse Reason" disable_autofocus="true">
<group col="1">
<field name="refuse_reason_id" widget="selection_badge" options="{'horizontal': true, 'no_create': True, 'no_open': True}"/>
<field name="send_mail" attrs="{'invisible': [('refuse_reason_id', '=', False)]}"/>
<field name="template_id" attrs="{'invisible': [('send_mail', '=', False)], 'required': [('send_mail', '=', True)]}" />
<field name="applicant_ids" invisible="1"/>
<field name="refuse_reason_id" string="Reason" widget="selection_badge" options="{'horizontal': true, 'no_create': True, 'no_open': True}"/>
<group invisible="not refuse_reason_id">
<field name="duplicates"
widget="boolean_toggle"
options="{'autosave': False}"
invisible="duplicates_count == 0"/>
<field name="duplicate_applicant_ids"
widget="applicant_line_many2many"
domain="duplicate_applicant_ids_domain"
invisible="not duplicates"
/>
<field name="send_mail" widget="boolean_toggle" options="{'autosave': False}"/>
</group>
<group col="1" invisible="not send_mail">
<group col="2">
<field name="applicant_ids"
widget="many2many_tags"
placeholder="Specify Refused Applicants..."
required="send_mail"
options="{'no_create': True}"/>
</group>
<group col="2">
<field name="lang" invisible="1"/>
<field name="render_model" invisible="1"/>
<field name="subject" required="send_mail" placeholder="Subject..."/>
</group>
<field name="can_edit_body" invisible="1"/>
<field name="body" nolabel="1" class="oe-bordered-editor" widget="html_mail" readonly="not can_edit_body" placeholder="Email Body..." force_save="1"/>
<field name="attachment_ids" widget="many2many_binary" invisible="not can_edit_body"/>
</group>
</group>
<div class="alert alert-danger" role="alert" attrs="{'invisible': [('applicant_without_email', '=', False)]}">
<div
class="alert alert-danger"
role="alert"
invisible="not applicant_without_email or not send_mail">
<field name="applicant_without_email" class="mr4"/>
</div>
<footer>
<button name="action_refuse_reason_apply" string="Refuse" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
<div class="d-flex" invisible="not send_mail">
<div invisible="not can_edit_body">
<field name="template_id" widget="mail_composer_template_selector"/>
</div>
<field name="scheduled_date" widget="text_scheduled_date"/>
</div>
</footer>
</form>
</field>
@ -24,7 +59,6 @@
<record id="applicant_get_refuse_reason_action" model="ir.actions.act_window">
<field name="name">Refuse Reason</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">applicant.get.refuse.reason</field>
<field name="view_mode">form</field>
<field name="view_id" ref="applicant_get_refuse_reason_view_form"/>

View file

@ -5,11 +5,12 @@ from odoo import api, fields, models, _
class ApplicantSendMail(models.TransientModel):
_name = 'applicant.send.mail'
_inherit = 'mail.composer.mixin'
_inherit = ['mail.composer.mixin']
_description = 'Send mails to applicants'
applicant_ids = fields.Many2many('hr.applicant', string='Applications', required=True)
applicant_ids = fields.Many2many('hr.applicant', string='Applications', required=True, context={'active_test': False})
author_id = fields.Many2one('res.partner', 'Author', required=True, default=lambda self: self.env.user.partner_id.id)
attachment_ids = fields.Many2many('ir.attachment', string='Attachments', readonly=False, store=True, bypass_search_access=True)
@api.depends('subject')
def _compute_render_model(self):
@ -25,7 +26,7 @@ class ApplicantSendMail(models.TransientModel):
'tag': 'display_notification',
'params': {
'type': 'danger',
'message': _("The following applicants are missing an email address: %s.", ', '.join(without_emails.mapped(lambda a: a.partner_name or a.name))),
'message': _("The following applicants are missing an email address: %s.", ', '.join(without_emails.mapped(lambda a: a.partner_name or a.display_name))),
}
}
@ -40,18 +41,22 @@ class ApplicantSendMail(models.TransientModel):
if not applicant.partner_id:
applicant.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,
})
attachment_ids = []
for attachment_id in self.attachment_ids:
new_attachment = attachment_id.copy({'res_model': 'hr.applicant', 'res_id': applicant.id})
attachment_ids.append(new_attachment.id)
applicant.message_post(
subject=subjects[applicant.id],
author_id=self.author_id.id,
body=bodies[applicant.id],
message_type='comment',
email_from=self.author_id.email,
email_layout_xmlid='mail.mail_notification_light',
message_type='comment',
partner_ids=applicant.partner_id.ids,
subject=subjects[applicant.id],
attachment_ids=attachment_ids
)

View file

@ -5,17 +5,25 @@
<field name="arch" type="xml">
<form>
<field name="author_id" invisible="1"/>
<field name="lang" invisible="1"/>
<field name="render_model" invisible="1"/>
<field name="template_id" invisible="1"/>
<group>
<field name="subject" required="1"/>
<field name="applicant_ids" widget="many2many_tags" context="{'show_partner_name': 1}"/>
</group>
<field name="body" nolabel="1" class="oe-bordered-editor"
widget="html_mail"
placeholder="Write your message here..."
options="{'style-inline': true, 'codeview': true, 'dynamic_placeholder': true}"
force_save="1"/>
options="{'codeview': true, 'dynamic_placeholder': true}" force_save="1"/>
<group>
<field name="attachment_ids"
widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2"/>
<field name="template_id" string="Load template" options="{'no_create': True}"/>
</group>
<footer>
<button name="action_send" string="Send" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>

View file

@ -0,0 +1,58 @@
from odoo import fields, models
class JobAddApplicants(models.TransientModel):
_name = "job.add.applicants"
_description = "Add applicants to a job"
applicant_ids = fields.Many2many("hr.applicant", string="Applications", required=True)
job_ids = fields.Many2many("hr.job", string="Job Positions", required=True)
def _add_applicants_to_job(self):
applicant_data = self.with_context(no_copy_in_partner_name=True).applicant_ids.copy_data()
new_applicants_vals = []
stage_per_job = dict(self.env['hr.recruitment.stage']._read_group(
domain=[('job_ids', 'in', self.job_ids.ids + [False]), ('fold', '=', False)],
groupby=['job_ids'],
aggregates=['id:recordset'],
))
for applicant in applicant_data:
for job in self.job_ids:
job_stages = ((stage_per_job.get(job) or self.env['hr.recruitment.stage']) +
(stage_per_job.get(self.env['hr.job']) or self.env['hr.recruitment.stage']))
stage = min(job_stages, key=lambda job: job.sequence) if job_stages else self.env['hr.job']
new_applicants_vals.append({
**applicant,
'job_id': job.id,
'talent_pool_ids': False,
'stage_id': stage.id,
})
new_applicants = self.env["hr.applicant"].create(new_applicants_vals)
return new_applicants
def action_add_applicants_to_job(self):
new_applicants = self._add_applicants_to_job()
if len(new_applicants) == 1:
return {
"type": "ir.actions.act_window",
"res_model": "hr.applicant",
"view_mode": "form",
"target": "current",
"res_id": new_applicants.id,
}
else:
message = self.env._(
"Created %(amount)s new applications for: %(names)s",
amount=len(new_applicants),
names=", ".join({a.partner_name for a in new_applicants}),
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"type": "success",
"message": message,
"next": {"type": "ir.actions.act_window_close"},
},
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0"?>
<odoo>
<record id="job_add_applicants_view_form" model="ir.ui.view">
<field name="name">job.add.applicants.view.form</field>
<field name="model">job.add.applicants</field>
<field name="arch" type="xml">
<form string="Add Applicants to Pool">
<group>
<field name="job_ids" widget="many2many_tags" options="{'color_field': 'color'}" placeholder="Move to..."/>
</group>
<footer>
<button
name="action_add_applicants_to_job"
string="Create Applications"
type="object"
class="btn-primary"
data-hotkey="q"
/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailActivitySchedule(models.TransientModel):
_inherit = 'mail.activity.schedule'
def _compute_plan_department_filterable(self):
super()._compute_plan_department_filterable()
for wizard in self:
if not wizard.plan_department_filterable:
wizard.plan_department_filterable = wizard.res_model == 'hr.applicant'

View file

@ -0,0 +1,72 @@
from odoo import fields, models, Command
class TalentPoolAddApplicants(models.TransientModel):
_name = "talent.pool.add.applicants"
_description = "Add applicants to talent pool"
applicant_ids = fields.Many2many(
"hr.applicant",
string="Applicants",
required=True,
domain=[
"|",
("talent_pool_ids", "!=", False),
("is_applicant_in_pool", "=", False),
],
)
talent_pool_ids = fields.Many2many("hr.talent.pool", string="Talent Pool")
categ_ids = fields.Many2many(
"hr.applicant.category",
string="Tags",
)
def _add_applicants_to_pool(self):
talents = self.env["hr.applicant"]
for applicant in self.applicant_ids:
if applicant.talent_pool_ids:
applicant.write(
{
"talent_pool_ids": [
Command.link(talent_pool.id)
for talent_pool in self.talent_pool_ids
],
"categ_ids": [
Command.link(categ.id) for categ in self.categ_ids
],
}
)
talents += applicant
else:
talent = applicant.with_context(no_copy_in_partner_name=True).copy(
{
"job_id": False,
"talent_pool_ids": self.talent_pool_ids,
"categ_ids": applicant.categ_ids + self.categ_ids,
}
)
talent.write({"pool_applicant_id": talent.id})
applicant.write({"pool_applicant_id": talent.id})
talents += talent
return talents
def action_add_applicants_to_pool(self):
talents = self.sudo()._add_applicants_to_pool()
if len(talents) == 1:
return {
"type": "ir.actions.act_window",
"res_model": "hr.applicant",
"view_mode": "form",
"views": [
(
self.env.ref("hr_recruitment.hr_applicant_view_form").id,
"form",
)
],
"target": "current",
"res_id": talents.id,
}
else:
return {
"type": "ir.actions.client",
"tag": "soft_reload",
}

View file

@ -0,0 +1,40 @@
<?xml version="1.0"?>
<odoo>
<record id="talent_pool_add_applicants_view_form" model="ir.ui.view">
<field name="name">talent.pool.add.applicants.view.form</field>
<field name="model">talent.pool.add.applicants</field>
<field name="arch" type="xml">
<form string="Add Applicants to the Pool">
<group>
<field
name="applicant_ids"
widget="many2many_tags"
options="{'color_field': 'color'}"
invisible="context.get('default_applicant_ids')"
/>
<field
name="talent_pool_ids"
widget="many2many_tags"
options="{'color_field': 'color'}"
invisible="context.get('default_talent_pool_ids')"
/>
<field
name="categ_ids"
options="{'color_field': 'color'}"
widget="many2many_tags"
/>
</group>
<footer>
<button
name="action_add_applicants_to_pool"
string="Add to Pool"
type="object"
class="btn-primary"
data-hotkey="q"
/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>