Recruitment changes

This commit is contained in:
pranaysaidurga 2026-06-03 10:48:17 +05:30
parent 90211776a1
commit 604d556501
115 changed files with 7793 additions and 1844 deletions

View File

@ -1,2 +1,3 @@
from . import models from . import models
from . import wizards from . import wizards
from . import controllers

View File

@ -48,6 +48,7 @@
'report/report_action.xml', 'report/report_action.xml',
'report/it_tax_template.xml', 'report/it_tax_template.xml',
'views/it_tax_menu_and_wizard_view.xml', 'views/it_tax_menu_and_wizard_view.xml',
'views/employee_payslip_download_wizard_views.xml',
'wizards/hr_tds_calculation.xml', 'wizards/hr_tds_calculation.xml',
'wizards/children_education_costing.xml', 'wizards/children_education_costing.xml',
'wizards/employee_life_insurance.xml', 'wizards/employee_life_insurance.xml',

View File

@ -0,0 +1 @@
from . import main

View File

@ -0,0 +1,63 @@
import io
import zipfile
from odoo import _
from odoo.exceptions import AccessError, UserError
from odoo.http import Controller, content_disposition, request, route
class EmployeePayslipDownloadController(Controller):
@route('/employee_it_declaration/my_payslips/<int:wizard_id>', type='http', auth='user')
def download_my_payslips(self, wizard_id, **kwargs):
wizard = request.env['employee.payslip.download.wizard'].browse(wizard_id)
if not wizard.exists() or wizard.create_uid != request.env.user:
return request.not_found()
try:
payslips = wizard._get_payslips_for_download()
except (AccessError, UserError):
return request.not_found()
if wizard.download_type == 'single':
payslip = payslips[:1]
report, pdf_content = wizard._get_pdf_content(payslip)
headers = [
('Content-Type', 'application/pdf'),
('Content-Length', len(pdf_content)),
('Content-Disposition', content_disposition(wizard._get_pdf_filename(payslip, report))),
]
return request.make_response(pdf_content, headers=headers)
zip_buffer = io.BytesIO()
used_filenames = set()
with zipfile.ZipFile(zip_buffer, 'w', compression=zipfile.ZIP_DEFLATED) as payslip_zip:
for payslip in payslips:
report, pdf_content = wizard._get_pdf_content(payslip)
filename = self._deduplicate_filename(wizard._get_pdf_filename(payslip, report), used_filenames)
payslip_zip.writestr(filename, pdf_content)
zip_content = zip_buffer.getvalue()
headers = [
('Content-Type', 'application/zip'),
('Content-Length', len(zip_content)),
('Content-Disposition', content_disposition(wizard._get_zip_filename())),
]
return request.make_response(zip_content, headers=headers)
@staticmethod
def _deduplicate_filename(filename, used_filenames):
if filename not in used_filenames:
used_filenames.add(filename)
return filename
stem, extension = filename.rsplit('.', 1) if '.' in filename else (filename, '')
counter = 2
while True:
candidate = _('%(stem)s (%(counter)s)', stem=stem, counter=counter)
if extension:
candidate = '%s.%s' % (candidate, extension)
if candidate not in used_filenames:
used_filenames.add(candidate)
return candidate
counter += 1

View File

@ -1,7 +1,8 @@
from . import payroll_periods from . import payroll_periods
from . import investment_types from . import investment_types
from . import investment_costings
from . import emp_it_declaration from . import emp_it_declaration
from . import investment_costings
from . import slab_master from . import slab_master
from . import it_tax_statement from . import it_tax_statement
from . import it_tax_statement_wiz from . import it_tax_statement_wiz
from . import employee_payslip_download_wiz

View File

@ -1,4 +1,39 @@
from odoo import models, fields, api from urllib.parse import urlencode
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class ITDeclarationSubmittedLockMixin(models.AbstractModel):
_name = 'it.declaration.submitted.lock.mixin'
_description = 'IT Declaration Submitted Lock Mixin'
def _get_related_it_declarations(self):
if 'it_declaration_id' in self._fields:
return self.mapped('it_declaration_id')
if 'child_education_id' in self._fields:
return self.mapped('child_education_id.it_declaration_id')
if 'parent_id' in self._fields:
return self.mapped('parent_id.it_declaration_id')
return self.env['emp.it.declaration']
def _check_it_declaration_is_editable(self):
if any(declaration.state == 'submitted' for declaration in self._get_related_it_declarations()):
raise UserError(_('Submitted IT declarations are read-only. Return the declaration to draft before editing it.'))
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._check_it_declaration_is_editable()
return records
def write(self, vals):
self._check_it_declaration_is_editable()
return super().write(vals)
def unlink(self):
self._check_it_declaration_is_editable()
return super().unlink()
class EmpITDeclaration(models.Model): class EmpITDeclaration(models.Model):
@ -38,6 +73,12 @@ class EmpITDeclaration(models.Model):
('new', 'New Regime'), ('new', 'New Regime'),
('old', 'Old Regime') ('old', 'Old Regime')
], string="Tax Regime", required=True, default='new') ], string="Tax Regime", required=True, default='new')
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
], string="Status", default='draft', required=True, copy=False)
return_reason = fields.Text(string="Return Reason", copy=False)
is_payroll_manager = fields.Boolean(compute='_compute_is_payroll_manager')
total_investment = fields.Float(string='Total Investment') total_investment = fields.Float(string='Total Investment')
@ -51,6 +92,28 @@ class EmpITDeclaration(models.Model):
) )
house_rent_costing_id = fields.Many2one('investment.costings', compute="_compute_investment_costing") house_rent_costing_id = fields.Many2one('investment.costings', compute="_compute_investment_costing")
is_section_open = fields.Boolean() is_section_open = fields.Boolean()
def _compute_is_payroll_manager(self):
is_manager = self.env.user.has_group('hr_payroll.group_hr_payroll_manager')
for rec in self:
rec.is_payroll_manager = is_manager
def _check_submitted_write_allowed(self, vals):
protected_vals = set(vals) - {'state', 'return_reason'}
submitted_records = self.filtered(lambda rec: rec.state == 'submitted')
if submitted_records and protected_vals:
raise UserError(_('Submitted IT declarations are read-only. Return the declaration to draft before editing it.'))
if submitted_records and set(vals) & {'state', 'return_reason'} and not self.env.user.has_group('hr_payroll.group_hr_payroll_manager'):
raise UserError(_('Only a Payroll Manager can return a submitted IT declaration.'))
def write(self, vals):
self._check_submitted_write_allowed(vals)
return super().write(vals)
def unlink(self):
if any(rec.state == 'submitted' for rec in self):
raise UserError(_('Submitted IT declarations cannot be deleted.'))
return super().unlink()
@api.depends('costing_details_generated','investment_costing_ids') @api.depends('costing_details_generated','investment_costing_ids')
def _compute_investment_costing(self): def _compute_investment_costing(self):
for rec in self: for rec in self:
@ -346,3 +409,155 @@ class EmpITDeclaration(models.Model):
rec._ensure_investment_costing_records() rec._ensure_investment_costing_records()
rec._update_investment_amounts() rec._update_investment_amounts()
rec.costing_details_generated = True rec.costing_details_generated = True
def action_submit(self):
for rec in self:
rec._update_investment_amounts()
rec.write({
'state': 'submitted',
'return_reason': False,
})
def action_return_to_draft(self):
for rec in self:
if not self.env.user.has_group('hr_payroll.group_hr_payroll_manager'):
raise UserError(_('Only a Payroll Manager can return a submitted IT declaration.'))
if not rec.return_reason:
raise UserError(_('Please enter a return reason before returning the declaration to draft.'))
rec.state = 'draft'
def action_download_submission_pdf(self):
self.ensure_one()
if self.state != 'submitted':
raise UserError(_('You can download the IT declaration PDF only after it is submitted.'))
return self.env.ref('employee_it_declaration.action_report_it_tax_statement').report_action(self)
def _get_regime_label(self):
self.ensure_one()
return dict(self._fields['tax_regime'].selection).get(self.tax_regime, self.tax_regime)
def _get_binary_link(self, record, field_name, filename_field=False):
if not record or not record[field_name]:
return False
params = {
'model': record._name,
'id': record.id,
'field': field_name,
'download': 'true',
}
if filename_field:
params['filename_field'] = filename_field
return '/web/content?%s' % urlencode(params)
def _prepare_report_line(self, line):
name = line.investment_type_line_id.name if line.investment_type_line_id else ''
return {
'name': name,
'declaration_amount': line.declaration_amount,
'proof_amount': line.proof_amount,
'limit': line.limit,
'remarks': line.remarks,
'attachment_name': line.proof_name or _('Proof'),
'attachment_url': self._get_binary_link(line, 'proof', 'proof_name'),
}
def _get_report_sections(self):
self.ensure_one()
old_regime = self.tax_regime == 'old'
section_fields = [
(_('Past Employment'), 'past_employment_costings' if old_regime else 'past_employment_costings_new'),
(_('US 80C'), 'us80c_costings' if old_regime else 'us80c_costings_new'),
(_('US 80D'), {
'self_family': 'us80d_costings' if old_regime else 'us80d_costings_new',
'self_family_parent': 'us80d_costings_parents' if old_regime else 'us80d_costings_parents_new',
'self_family_senior_parent': 'us80d_costings_senior_parents' if old_regime else 'us80d_costings_senior_parents_new',
}[self.us80d_selection_type]),
(_('US 10'), 'us10_costings' if old_regime else 'us10_costings_new'),
(_('US 80G'), 'us80g_costings' if old_regime else 'us80g_costings_new'),
(_('Chapter VIA'), 'chapter_via_costings' if old_regime else 'chapter_via_costings_new'),
(_('US 17'), 'us17_costings' if old_regime else 'us17_costings_new'),
(_('Other Income / Loss'), 'other_il_costings' if old_regime else 'other_il_costings_new'),
(_('Other Declarations'), 'other_declaration_costings' if old_regime else 'other_declaration_costings_new'),
]
sections = []
for title, field_name in section_fields:
lines = self[field_name].filtered(lambda line: line.declaration_amount or line.proof_amount or line.remarks or line.proof)
if lines:
sections.append({
'title': title,
'lines': [self._prepare_report_line(line) for line in lines],
})
if old_regime and self.house_rent_costings:
sections.append({
'title': _('House Rent'),
'house_rent': True,
'lines': [{
'hra_exemption_type': dict(line._fields['hra_exemption_type'].selection).get(line.hra_exemption_type, ''),
'rent_amount': line.rent_amount,
'from_date': line.from_date,
'to_date': line.to_date,
'landlord_pan_status': dict(line._fields['landlord_pan_status'].selection).get(line.landlord_pan_status, ''),
'landlord_pan_no': line.landlord_pan_no,
'landlord_name_address': line.landlord_name_address,
'remarks': line.remarks,
'attachment_name': line.attachment_filename or _('Proof'),
'attachment_url': self._get_binary_link(line, 'attachment', 'attachment_filename'),
} for line in self.house_rent_costings],
})
return sections
def _get_report_extra_sections(self):
self.ensure_one()
extra_sections = []
us80c_lines = self.us80c_costings if self.tax_regime == 'old' else self.us80c_costings_new
other_il_lines = self.other_il_costings if self.tax_regime == 'old' else self.other_il_costings_new
children_records = self.env['children.education'].sudo().search([('it_declaration_id', '=', self.id), ('us80c_id', 'in', us80c_lines.ids)])
if children_records:
extra_sections.append({
'title': _('Children Education Details'),
'headers': [_('Child'), _('Name'), _('Class / Grade'), _('School / College'), _('Tuition Fee')],
'rows': [[child.child_id, child.name, child.chile_class, child.organization, child.tuition_fee] for record in children_records for child in record.children_ids],
})
insurance_records = self.env['us80c.insurance.line'].sudo().search([('it_declaration_id', '=', self.id), ('us80c_id', 'in', us80c_lines.ids)])
if insurance_records:
extra_sections.append({
'title': _('Life Insurance Details'),
'headers': [_('Company'), _('Insured For'), _('Insured Name'), _('Policy No'), _('Premium'), _('Payment Date'), _('Exempt Amount')],
'rows': [[line.name_of_insurance_company, line.insured_in_favour_of, line.name_of_insured, line.policy_number, line.premium_amount, line.payment_date, line.exempt_amount] for record in insurance_records for line in record.life_insurance_ids],
})
nsc_records = self.env['nsc.declaration.line'].sudo().search([('it_declaration_id', '=', self.id), ('us80c_id', 'in', us80c_lines.ids)])
if nsc_records:
extra_sections.append({
'title': _('NSC Declaration Details'),
'headers': [_('NSC Number'), _('Amount'), _('Payment Date')],
'rows': [[line.nsc_number, line.nsc_amount, line.nsc_payment_date] for record in nsc_records for line in record.nsc_entry_ids],
})
self_occupied_records = self.env['self.occupied.property'].sudo().search([('it_declaration_id', '=', self.id), ('other_il_id', 'in', other_il_lines.ids)])
if self_occupied_records:
extra_sections.append({
'title': _('Self Occupied Property Details'),
'headers': [_('Address'), _('Period From'), _('Period To'), _('Interest Paid'), _('Income / Loss'), _('Lender'), _('Lender PAN')],
'rows': [[record.address, record.period_from, record.period_to, record.interest_paid_to, record.income_loss, record.lender_name, record.lender_pan] for record in self_occupied_records],
})
letout_records = self.env['letout.house.property'].sudo().search([('it_declaration_id', '=', self.id), ('other_il_id', 'in', other_il_lines.ids)])
if letout_records:
extra_sections.append({
'title': _('Let-out House Property Details'),
'headers': [_('Address'), _('Rent'), _('Property Tax'), _('Water Tax'), _('Interest Paid'), _('Income / Loss'), _('Lender'), _('Lender PAN')],
'rows': [[record.address, record.rent_received, record.property_tax, record.water_tax, record.interest_paid_to, record.income_loss, record.lender_name, record.lender_pan] for record in letout_records],
})
nsc_interest_records = self.env['nsc.interest.line'].sudo().search([('it_declaration_id', '=', self.id), ('other_il_id', 'in', other_il_lines.ids)])
if nsc_interest_records:
extra_sections.append({
'title': _('NSC Interest Details'),
'headers': [_('NSC Number'), _('Amount'), _('Payment Date'), _('Interest Amount')],
'rows': [[line.nsc_number, line.nsc_amount, line.nsc_payment_date, line.nsc_interest_amount] for record in nsc_interest_records for line in record.nsc_entry_ids],
})
return [section for section in extra_sections if section['rows']]

View File

@ -0,0 +1,127 @@
import re
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
class EmployeePayslipDownloadWizard(models.TransientModel):
_name = 'employee.payslip.download.wizard'
_rec_name = 'employee_id'
_description = 'Employee Payslip Download Wizard'
employee_id = fields.Many2one(
'hr.employee',
required=True,
default=lambda self: self.env.user.employee_id.id,
readonly=True,
)
download_type = fields.Selection(
selection=[
('single', 'Single Payslip'),
('multi', 'Multiple Payslips'),
],
string='Download Option',
required=True,
default='single',
)
period_id = fields.Many2one(
'payroll.period',
string='Payroll Period',
required=True,
)
period_line = fields.Many2one(
'payroll.period.line',
string='Month',
domain="[('period_id', '=', period_id)]",
)
payslip_count = fields.Integer(
string='Available Payslips',
compute='_compute_payslip_count',
)
@api.onchange('download_type', 'period_id')
def _onchange_download_type_period_id(self):
for rec in self:
rec.period_line = False
@api.depends('download_type', 'period_id', 'period_line')
def _compute_payslip_count(self):
for rec in self:
if not rec.period_id or (rec.download_type == 'single' and not rec.period_line):
rec.payslip_count = 0
else:
rec.payslip_count = len(rec._get_available_payslips())
def _get_current_employee(self):
employee = self.env.user.employee_id
if not employee:
raise UserError(_('No employee is linked to your user. Please contact HR.'))
return employee
def _get_date_range(self):
self.ensure_one()
if self.download_type == 'single':
if not self.period_line:
raise UserError(_('Please select a month to download a single payslip.'))
return self.period_line.from_date, self.period_line.to_date
return self.period_id.from_date, self.period_id.to_date
def _get_available_payslips(self):
self.ensure_one()
employee = self._get_current_employee()
date_from, date_to = self._get_date_range()
if not date_from or not date_to:
return self.env['hr.payslip']
return self.env['hr.payslip'].sudo().search([
('employee_id', '=', employee.id),
('state', 'in', ['done', 'paid']),
('date_from', '>=', date_from),
('date_to', '<=', date_to),
('company_id', 'in', self.env.companies.ids),
], order='date_from asc, date_to asc, id asc')
def _get_payslips_for_download(self):
self.ensure_one()
payslips = self._get_available_payslips()
if not payslips:
raise UserError(_('No confirmed or paid payslip is available for the selected period.'))
if self.download_type == 'single' and len(payslips) > 1:
raise UserError(_('More than one payslip is available for this month. Please contact HR.'))
return payslips
def _get_pdf_content(self, payslip):
report = payslip.struct_id.report_id or self.env.ref('hr_payroll.action_report_payslip')
pdf_content, dummy = self.env['ir.actions.report'].sudo().with_context(
lang=payslip.employee_id.lang or self.env.lang,
)._render_qweb_pdf(report, payslip.id, data={'company_id': payslip.company_id})
return report, pdf_content
def _get_pdf_filename(self, payslip, report=False):
report = report or payslip.struct_id.report_id or self.env.ref('hr_payroll.action_report_payslip')
if report.print_report_name:
filename = safe_eval(report.print_report_name, {'object': payslip})
else:
filename = _('Payslip - %(employee)s - %(period)s', employee=payslip.employee_id.name, period=payslip.name)
return self._sanitize_filename(filename, 'payslip') + '.pdf'
def _get_zip_filename(self):
self.ensure_one()
employee = self._get_current_employee()
filename = _('Payslips - %(employee)s - %(period)s', employee=employee.name, period=self.period_id.name)
return self._sanitize_filename(filename, 'payslips') + '.zip'
@api.model
def _sanitize_filename(self, filename, fallback):
filename = re.sub(r'[\\/:*?"<>|]+', '-', str(filename or '')).strip(' .')
return filename or fallback
def action_download_payslips(self):
self.ensure_one()
self._get_payslips_for_download()
return {
'type': 'ir.actions.act_url',
'url': '/employee_it_declaration/my_payslips/%s' % self.id,
'target': 'self',
}

View File

@ -7,6 +7,7 @@ import re
class investmentCostings(models.Model): class investmentCostings(models.Model):
_name = 'investment.costings' _name = 'investment.costings'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_id' _rec_name = 'investment_type_id'
investment_type_id = fields.Many2one('it.investment.type') investment_type_id = fields.Many2one('it.investment.type')
@ -25,6 +26,7 @@ class investmentCostings(models.Model):
class pastEmpcostingType(models.Model): class pastEmpcostingType(models.Model):
_name = 'past_employment.costing.type' _name = 'past_employment.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
@ -104,6 +106,7 @@ class pastEmpcostingType(models.Model):
class us80cCostingType(models.Model): class us80cCostingType(models.Model):
_name = 'us80c.costing.type' _name = 'us80c.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -140,6 +143,7 @@ class us80cCostingType(models.Model):
} }
class us80dCostingType(models.Model): class us80dCostingType(models.Model):
_name = 'us80d.costing.type' _name = 'us80d.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -157,6 +161,7 @@ class us80dCostingType(models.Model):
class us10CostingType(models.Model): class us10CostingType(models.Model):
_name = 'us10.costing.type' _name = 'us10.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -173,6 +178,7 @@ class us10CostingType(models.Model):
class us80gCostingType(models.Model): class us80gCostingType(models.Model):
_name = 'us80g.costing.type' _name = 'us80g.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -189,6 +195,7 @@ class us80gCostingType(models.Model):
class chapterViaCostingType(models.Model): class chapterViaCostingType(models.Model):
_name = 'chapter.via.costing.type' _name = 'chapter.via.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -205,6 +212,7 @@ class chapterViaCostingType(models.Model):
class us17CostingType(models.Model): class us17CostingType(models.Model):
_name = 'us17.costing.type' _name = 'us17.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -219,6 +227,7 @@ class us17CostingType(models.Model):
class OtherILCostingType(models.Model): class OtherILCostingType(models.Model):
_name = 'other.il.costing.type' _name = 'other.il.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -256,6 +265,7 @@ class OtherILCostingType(models.Model):
class OtherDeclarationCostingType(models.Model): class OtherDeclarationCostingType(models.Model):
_name = 'other.declaration.costing.type' _name = 'other.declaration.costing.type'
_inherit = ['it.declaration.submitted.lock.mixin']
_rec_name = 'investment_type_line_id' _rec_name = 'investment_type_line_id'
costing_type = fields.Many2one('investment.costings') costing_type = fields.Many2one('investment.costings')
@ -271,6 +281,7 @@ class OtherDeclarationCostingType(models.Model):
class HouseRentDeclaration(models.Model): class HouseRentDeclaration(models.Model):
_name = 'house.rent.declaration' _name = 'house.rent.declaration'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'House Rent Declaration' _description = 'House Rent Declaration'

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<report <record id="action_report_it_tax_statement" model="ir.actions.report">
id="action_report_it_tax_statement" <field name="name">IT Declaration Submission</field>
model="emp.it.declaration" <field name="model">emp.it.declaration</field>
string="IT Tax Statement" <field name="report_type">qweb-pdf</field>
report_type="qweb-pdf" <field name="report_name">employee_it_declaration.report_it_tax_statement</field>
name="your_module_name.report_it_tax_statement" <field name="report_file">employee_it_declaration.report_it_tax_statement</field>
file="your_module_name.report_it_tax_statement" <field name="print_report_name">'IT Declaration - %s - %s' % (object.employee_id.name or '', object.period_id.name or '')</field>
print_report_name="'IT_Tax_Statement_%s' % (object.employee_id.name)" <field name="binding_model_id" eval="False"/>
/> </record>
</odoo> </odoo>

View File

@ -56,6 +56,7 @@ access_house_rent_declaration_user,access.house.rent.declaration.user,model_hous
access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0 access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0
access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,0,0,0 access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,0,0,0
access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0
access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1 access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1
access_it_tax_statement_wizard_manager,it.tax.statement.wizard,model_it_tax_statement_wizard,hr.group_hr_manager,1,1,1,1 access_it_tax_statement_wizard_manager,it.tax.statement.wizard,model_it_tax_statement_wizard,hr.group_hr_manager,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
56
57
58
59
60
61
62

View File

@ -7,9 +7,11 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<list> <list>
<field name="employee_id"/>
<field name="period_id"/> <field name="period_id"/>
<field name="total_investment"/> <field name="total_investment"/>
<field name="tax_regime"/> <field name="tax_regime"/>
<field name="state"/>
</list> </list>
</field> </field>
</record> </record>
@ -20,18 +22,40 @@
<field name="model">emp.it.declaration</field> <field name="model">emp.it.declaration</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="IT Declaration"> <form string="IT Declaration">
<header>
<button name="action_submit"
string="Submit"
type="object"
class="btn-primary"
invisible="state != 'draft' or not costing_details_generated"/>
<button name="action_download_submission_pdf"
string="Download PDF"
type="object"
class="btn-primary"
icon="fa-download"
invisible="state != 'submitted'"/>
<button name="action_return_to_draft"
string="Return to Draft"
type="object"
class="btn-secondary"
invisible="state != 'submitted'"
groups="hr_payroll.group_hr_payroll_manager"/>
<field name="state" widget="statusbar" statusbar_visible="draft,submitted"/>
</header>
<sheet> <sheet>
<div class="oe_title mb24"> <div class="oe_title mb24">
<div class="o_row"> <div class="o_row">
<field name="employee_id" widget="res_partner_many2one" placeholder="Employee Name..." readonly="costing_details_generated"/> <field name="employee_id" widget="res_partner_many2one" placeholder="Employee Name..." readonly="costing_details_generated or state == 'submitted'"/>
</div> </div>
</div> </div>
<group> <group>
<group> <group>
<field name="period_id" readonly="costing_details_generated"/> <field name="period_id" readonly="costing_details_generated or state == 'submitted'"/>
</group> </group>
<group> <group>
<field name="total_investment"/> <field name="total_investment" readonly="state == 'submitted'"/>
<field name="return_reason" placeholder="Reason for returning the declaration to draft..." readonly="state != 'submitted' or not is_payroll_manager"/>
<field name="is_payroll_manager" invisible="1"/>
<field name="costing_details_generated" invisible="1" force_save="1"/> <field name="costing_details_generated" invisible="1" force_save="1"/>
<field name="house_rent_costing_id"/> <field name="house_rent_costing_id"/>
<field name="show_past_employment" invisible="1"/> <field name="show_past_employment" invisible="1"/>
@ -49,9 +73,9 @@
<br/> <br/>
<br/> <br/>
<field name="tax_regime" nolabel="1" widget="radio" options="{'horizontal': true}"/> <field name="tax_regime" nolabel="1" widget="radio" options="{'horizontal': true}" readonly="state == 'submitted'"/>
<br/> <br/>
<button name="generate_declarations" type="object" class="btn-primary" string="Generate" confirm="Upon Confirming you won't be able to change the Period &amp; Employee" help="Generate Data to upload the declaration Costing" invisible="costing_details_generated"/> <button name="generate_declarations" type="object" class="btn-primary" string="Generate" confirm="Upon Confirming you won't be able to change the Period &amp; Employee" help="Generate Data to upload the declaration Costing" invisible="costing_details_generated or state == 'submitted'"/>
<field name="is_section_open" invisible="1"/> <!-- Store toggle state --> <field name="is_section_open" invisible="1"/> <!-- Store toggle state -->
<group invisible="not costing_details_generated"> <group invisible="not costing_details_generated">
@ -71,7 +95,7 @@
<page string="Total Investment Costing"> <page string="Total Investment Costing">
<group> <group>
<group> <group>
<field name="visible_investment_costing_ids" nolabel="1"> <field name="visible_investment_costing_ids" nolabel="1" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0" edit="0"> <list editable="bottom" create="0" delete="0" edit="0">
<field name="investment_type_id"/> <field name="investment_type_id"/>
<field name="amount"/> <field name="amount"/>
@ -102,7 +126,7 @@
<!-- </page>--> <!-- </page>-->
<page name="past_employment_costings" string="PAST EMPLOYMENT" invisible="not show_past_employment"> <page name="past_employment_costings" string="PAST EMPLOYMENT" invisible="not show_past_employment">
<field name="past_employment_costings" invisible="tax_regime != 'old'"> <field name="past_employment_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -112,7 +136,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="past_employment_costings_new" invisible="tax_regime != 'new'"> <field name="past_employment_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -126,7 +150,7 @@
<page name="us_80c_costings" string="US 80C" invisible="not show_us_80c"> <page name="us_80c_costings" string="US 80C" invisible="not show_us_80c">
<field name="us80c_costings" invisible="tax_regime != 'old'"> <field name="us80c_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -142,7 +166,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="us80c_costings_new" invisible="tax_regime != 'new'"> <field name="us80c_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -162,10 +186,10 @@
</page> </page>
<page name="us_80d_costings" string="US 80D" invisible="not show_us_80d"> <page name="us_80d_costings" string="US 80D" invisible="not show_us_80d">
<group> <group>
<field name="us80d_selection_type" widget="radio" options="{'horizontal': true}" required="tax_regime == 'old' and costing_details_generated"/> <field name="us80d_selection_type" widget="radio" options="{'horizontal': true}" required="tax_regime == 'old' and costing_details_generated" readonly="state == 'submitted'"/>
<field name="us80d_health_checkup"/> <field name="us80d_health_checkup" readonly="state == 'submitted'"/>
</group> </group>
<field name="us80d_costings" invisible="tax_regime != 'old' or us80d_selection_type != 'self_family'"> <field name="us80d_costings" invisible="tax_regime != 'old' or us80d_selection_type != 'self_family'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -175,7 +199,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="us80d_costings_new" invisible="tax_regime != 'new' or us80d_selection_type != 'self_family'"> <field name="us80d_costings_new" invisible="tax_regime != 'new' or us80d_selection_type != 'self_family'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -185,7 +209,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="us80d_costings_parents" invisible="tax_regime != 'old' or us80d_selection_type != 'self_family_parent'"> <field name="us80d_costings_parents" invisible="tax_regime != 'old' or us80d_selection_type != 'self_family_parent'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -196,7 +220,7 @@
</list> </list>
</field> </field>
<field name="us80d_costings_parents_new" invisible="tax_regime != 'new' or us80d_selection_type != 'self_family_parent'"> <field name="us80d_costings_parents_new" invisible="tax_regime != 'new' or us80d_selection_type != 'self_family_parent'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -207,7 +231,7 @@
</list> </list>
</field> </field>
<field name="us80d_costings_senior_parents" invisible="tax_regime != 'old' or us80d_selection_type != 'self_family_senior_parent'"> <field name="us80d_costings_senior_parents" invisible="tax_regime != 'old' or us80d_selection_type != 'self_family_senior_parent'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -218,7 +242,7 @@
</list> </list>
</field> </field>
<field name="us80d_costings_senior_parents_new" invisible="tax_regime != 'new' or us80d_selection_type != 'self_family_senior_parent'"> <field name="us80d_costings_senior_parents_new" invisible="tax_regime != 'new' or us80d_selection_type != 'self_family_senior_parent'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -231,7 +255,7 @@
</field> </field>
</page> </page>
<page name="us_10_costing" string="US 10" invisible="not show_us_10"> <page name="us_10_costing" string="US 10" invisible="not show_us_10">
<field name="us10_costings" invisible="tax_regime != 'old'"> <field name="us10_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -241,7 +265,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="us10_costings_new" invisible="tax_regime != 'new'"> <field name="us10_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -253,7 +277,7 @@
</field> </field>
</page> </page>
<page name="us_80g_costing" string="US 80G" invisible="not show_us_80g"> <page name="us_80g_costing" string="US 80G" invisible="not show_us_80g">
<field name="us80g_costings" invisible="tax_regime != 'old'"> <field name="us80g_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -263,7 +287,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="us80g_costings_new" invisible="tax_regime != 'new'"> <field name="us80g_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -276,7 +300,7 @@
</page> </page>
<page name="chapter_via_costings" string="CHAPTER VIA" invisible="not show_chapter_via"> <page name="chapter_via_costings" string="CHAPTER VIA" invisible="not show_chapter_via">
<field name="chapter_via_costings" invisible="tax_regime != 'old'"> <field name="chapter_via_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -286,7 +310,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="chapter_via_costings_new" invisible="tax_regime != 'new'"> <field name="chapter_via_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -299,7 +323,7 @@
</page> </page>
<page name="us_17_costings" string="US 17" invisible="not show_us_17"> <page name="us_17_costings" string="US 17" invisible="not show_us_17">
<field name="us17_costings" invisible="tax_regime != 'old'"> <field name="us17_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -309,7 +333,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="us17_costings_new" invisible="tax_regime != 'new'"> <field name="us17_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -322,7 +346,7 @@
</page> </page>
<page name="house_rent_costings" string="HOUSE RENT" invisible="not show_house_rent"> <page name="house_rent_costings" string="HOUSE RENT" invisible="not show_house_rent">
<!-- <field name="house_rent_costing_line_ids"/>--> <!-- <field name="house_rent_costing_line_ids"/>-->
<field name="house_rent_costings" context="{ <field name="house_rent_costings" readonly="state == 'submitted'" context="{
'default_costing_type': house_rent_costing_id 'default_costing_type': house_rent_costing_id
}"> }">
<list string="House Rent Declarations"> <list string="House Rent Declarations">
@ -373,7 +397,7 @@
</field> </field>
</page> </page>
<page name="other_i_or_l_costings" string="OTHER INCOME/LOSS" invisible="not show_other_i_or_l"> <page name="other_i_or_l_costings" string="OTHER INCOME/LOSS" invisible="not show_other_i_or_l">
<field name="other_il_costings" invisible="tax_regime != 'old'"> <field name="other_il_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -390,7 +414,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="other_il_costings_new" invisible="tax_regime != 'new'"> <field name="other_il_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -409,7 +433,7 @@
</field> </field>
</page> </page>
<page name="other_declaration_costings" string="Other Declarations" invisible="not show_other_declaration"> <page name="other_declaration_costings" string="Other Declarations" invisible="not show_other_declaration">
<field name="other_declaration_costings" invisible="tax_regime != 'old'"> <field name="other_declaration_costings" invisible="tax_regime != 'old'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -419,7 +443,7 @@
<field name="limit" readonly="1" force_save="1"/> <field name="limit" readonly="1" force_save="1"/>
</list> </list>
</field> </field>
<field name="other_declaration_costings_new" invisible="tax_regime != 'new'"> <field name="other_declaration_costings_new" invisible="tax_regime != 'new'" readonly="state == 'submitted'">
<list editable="bottom" create="0" delete="0"> <list editable="bottom" create="0" delete="0">
<field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/> <field name="investment_type_line_id" width="600px" readonly="1" force_save="1" options="{'no_open': True, 'no_quick_create': True}"/>
<field name="declaration_amount" width="130px"/> <field name="declaration_amount" width="130px"/>
@ -443,10 +467,13 @@
<field name="res_model">emp.it.declaration</field> <field name="res_model">emp.it.declaration</field>
<field name="view_mode">list,form</field> <field name="view_mode">list,form</field>
</record> </record>
<menuitem id="menu_hr_payroll_emp_root" name="Payroll" sequence="190" web_icon="hr_payroll,static/description/icon.png" groups="base.group_user"/>
<menuitem id="menu_it_declarations" name="IT Declarations" <menuitem id="menu_it_declarations" name="IT Declarations"
parent="hr_payroll.menu_hr_payroll_root" parent="hr_payroll.menu_hr_payroll_root"
action="action_emp_it_declaration" sequence="99"/> action="action_emp_it_declaration" sequence="99"/>
<menuitem id="menu_it_declarations_emp" name="IT Declarations"
parent="menu_hr_payroll_emp_root"
action="action_emp_it_declaration" sequence="1"/>
</data> </data>
</odoo> </odoo>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_employee_payslip_download_wizard_form" model="ir.ui.view">
<field name="name">employee.payslip.download.wizard.form</field>
<field name="model">employee.payslip.download.wizard</field>
<field name="arch" type="xml">
<form string="Download Payslips">
<header>
<button name="action_download_payslips"
string="Download Payslip"
type="object"
class="oe_stat_button"
icon="fa-download"
invisible="download_type != 'single'"/>
<button name="action_download_payslips"
string="Download ZIP"
type="object"
class="oe_stat_button"
icon="fa-file-archive-o"
invisible="download_type != 'multi'"/>
</header>
<sheet>
<group>
<field name="employee_id" options="{'no_edit': True, 'no_create': True}"/>
<field name="download_type" widget="radio" options="{'horizontal': true}"/>
</group>
<group>
<group>
<field name="period_id" options="{'no_edit': True, 'no_create': True, 'no_open': True}"/>
</group>
<group>
<field name="period_line"
force_save="1"
domain="[('period_id', '=', period_id)]"
required="download_type == 'single'"
invisible="download_type != 'single'"/>
</group>
</group>
<group>
<field name="payslip_count" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_employee_payslip_download_wizard" model="ir.actions.act_window">
<field name="name">Download Payslips</field>
<field name="res_model">employee.payslip.download.wizard</field>
<field name="path">download-payslips</field>
<field name="view_mode">form</field>
<field name="target">current</field>
</record>
<menuitem id="menu_employee_payslip_download_wizard"
name="Salary Payslip"
parent="employee_it_declaration.menu_hr_payroll_emp_root"
action="action_employee_payslip_download_wizard"
sequence="2"
groups="base.group_user"/>
</odoo>

View File

@ -87,7 +87,7 @@
<field name="name">Generate Tax Statement</field> <field name="name">Generate Tax Statement</field>
<field name="res_model">it.tax.statement.wizard</field> <field name="res_model">it.tax.statement.wizard</field>
<field name="path">tax-statement</field> <field name="path">tax-statement</field>
<field name="view_mode">list,form</field> <field name="view_mode">form</field>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face"> <p class="o_view_nocontent_smiling_face">
Create a new employment type Create a new employment type
@ -96,8 +96,9 @@
</record> </record>
<menuitem id="menu_it_tax_statement_root" name="IT Tax Statement" <menuitem id="menu_it_tax_statement_root" name="IT Tax Statement"
parent="hr_payroll.menu_hr_payroll_root" parent="employee_it_declaration.menu_hr_payroll_emp_root"
action="action_it_tax_statement_wizard" sequence="99"/> groups="base.group_user"
action="action_it_tax_statement_wizard" sequence="3"/>
<record id="it_statement_paper_format" model="report.paperformat"> <record id="it_statement_paper_format" model="report.paperformat">
<field name="name">A4 - statement</field> <field name="name">A4 - statement</field>

View File

@ -1,28 +1,158 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<template id="report_it_tax_statement"> <template id="report_it_tax_statement">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-call="web.external_layout"> <t t-foreach="docs" t-as="doc">
<div class="page"> <t t-call="web.external_layout">
<h2>IT Tax Statement</h2> <div class="page">
<p><strong>Employee:</strong> <t t-esc="doc.employee_id.name"/></p> <style>
<p><strong>Period:</strong> <t t-esc="doc.period_id.name"/></p> .it-title { text-align: center; margin-bottom: 18px; }
<p><strong>Tax Regime:</strong> <t t-esc="dict(doc._fields['tax_regime'].selection).get(doc.tax_regime)"/></p> .it-title h2 { margin: 0; font-size: 21px; font-weight: 700; }
<table class="table table-sm"> .it-title p { margin: 4px 0 0; color: #555; }
<thead> .it-section { margin-top: 18px; }
<tr><th>Section</th><th>Amount</th></tr> .it-section h4 { font-size: 14px; font-weight: 700; border-bottom: 1px solid #999; padding-bottom: 4px; margin-bottom: 8px; }
</thead> .it-table { width: 100%; border-collapse: collapse; font-size: 11px; }
<tbody> .it-table th { background: #f2f2f2; font-weight: 700; }
<t t-foreach="doc.investment_costing_ids" t-as="line"> .it-table th, .it-table td { border: 1px solid #ddd; padding: 5px; vertical-align: top; }
<tr> .it-right { text-align: right; }
<td><t t-esc="line.investment_type_id.name"/></td> .it-muted { color: #777; }
<td><t t-esc="line.amount"/></td> </style>
</tr>
</t> <div class="it-title">
</tbody> <h2>IT Declaration Submission</h2>
</table> <p>
<p><strong>Total:</strong> <t t-esc="sum(doc.investment_costing_ids.mapped('amount'))"/></p> <span t-esc="doc.employee_id.company_id.name"/>
</div> </p>
</t> </div>
</t>
</template> <table class="it-table">
<tbody>
<tr>
<th>Employee</th>
<td><span t-esc="doc.employee_id.name"/></td>
<th>Payroll Period</th>
<td><span t-esc="doc.period_id.name"/></td>
</tr>
<tr>
<th>Tax Regime</th>
<td><span t-esc="doc._get_regime_label()"/></td>
<th>Status</th>
<td><span t-esc="dict(doc._fields['state'].selection).get(doc.state)"/></td>
</tr>
<tr>
<th>Submitted On</th>
<td><span t-esc="doc.write_date"/></td>
<th>Total Investment</th>
<td class="it-right"><span t-esc="'{:,.2f}'.format(doc.total_investment or 0.0)"/></td>
</tr>
<tr t-if="doc.return_reason">
<th>Return Reason</th>
<td colspan="3"><span t-esc="doc.return_reason"/></td>
</tr>
</tbody>
</table>
<t t-set="sections" t-value="doc._get_report_sections()"/>
<t t-if="not sections">
<div class="it-section">
<p class="it-muted">No declaration lines are available for the selected regime.</p>
</div>
</t>
<t t-foreach="sections" t-as="section">
<div class="it-section">
<h4><span t-esc="section.get('title')"/></h4>
<t t-if="section.get('house_rent')">
<table class="it-table">
<thead>
<tr>
<th>Type</th>
<th>Rent Amount</th>
<th>From</th>
<th>To</th>
<th>Landlord PAN</th>
<th>Remarks</th>
<th>Proof</th>
</tr>
</thead>
<tbody>
<tr t-foreach="section.get('lines')" t-as="line">
<td><span t-esc="line.get('hra_exemption_type')"/></td>
<td class="it-right"><span t-esc="'{:,.2f}'.format(line.get('rent_amount') or 0.0)"/></td>
<td><span t-esc="line.get('from_date')"/></td>
<td><span t-esc="line.get('to_date')"/></td>
<td>
<span t-esc="line.get('landlord_pan_status')"/>
<br t-if="line.get('landlord_pan_no')"/>
<span t-if="line.get('landlord_pan_no')" t-esc="line.get('landlord_pan_no')"/>
</td>
<td><span t-esc="line.get('remarks')"/></td>
<td>
<a t-if="line.get('attachment_url')" t-att-href="line.get('attachment_url')">
<span t-esc="line.get('attachment_name')"/>
</a>
<span t-if="not line.get('attachment_url')" class="it-muted">No proof</span>
</td>
</tr>
</tbody>
</table>
</t>
<t t-else="">
<table class="it-table">
<thead>
<tr>
<th>Description</th>
<th>Declaration Amount</th>
<th>Proof Amount</th>
<th>Limit</th>
<th>Remarks</th>
<th>Proof</th>
</tr>
</thead>
<tbody>
<tr t-foreach="section.get('lines')" t-as="line">
<td><span t-esc="line.get('name')"/></td>
<td class="it-right"><span t-esc="'{:,.2f}'.format(line.get('declaration_amount') or 0.0)"/></td>
<td class="it-right"><span t-esc="'{:,.2f}'.format(line.get('proof_amount') or 0.0)"/></td>
<td class="it-right"><span t-esc="'{:,.2f}'.format(line.get('limit') or 0.0)"/></td>
<td><span t-esc="line.get('remarks')"/></td>
<td>
<a t-if="line.get('attachment_url')" t-att-href="line.get('attachment_url')">
<span t-esc="line.get('attachment_name')"/>
</a>
<span t-if="not line.get('attachment_url')" class="it-muted">No proof</span>
</td>
</tr>
</tbody>
</table>
</t>
</div>
</t>
<t t-foreach="doc._get_report_extra_sections()" t-as="extra_section">
<div class="it-section">
<h4><span t-esc="extra_section.get('title')"/></h4>
<table class="it-table">
<thead>
<tr>
<th t-foreach="extra_section.get('headers')" t-as="header">
<span t-esc="header"/>
</th>
</tr>
</thead>
<tbody>
<tr t-foreach="extra_section.get('rows')" t-as="row">
<td t-foreach="row" t-as="cell">
<span t-esc="cell"/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</div>
</t>
</t>
</t>
</template>
</odoo> </odoo>

View File

@ -2,6 +2,7 @@ from odoo import models, fields, api
class ChildrenEducation(models.Model): class ChildrenEducation(models.Model):
_name = "children.education" _name = "children.education"
_inherit = ['it.declaration.submitted.lock.mixin']
_description = "Children Education" _description = "Children Education"
_rec_name = 'it_declaration_id' _rec_name = 'it_declaration_id'
@ -38,6 +39,7 @@ class ChildrenEducation(models.Model):
class ChildrenEducationCosting(models.Model): class ChildrenEducationCosting(models.Model):
_name = 'children.education.costing' _name = 'children.education.costing'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = "Children Education Costing" _description = "Children Education Costing"
child_id = fields.Char('Child ID') child_id = fields.Char('Child ID')

View File

@ -3,6 +3,7 @@ from odoo import models, fields, api, _
class US80CInsuranceLine(models.Model): class US80CInsuranceLine(models.Model):
_name = 'us80c.insurance.line' _name = 'us80c.insurance.line'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'US80C Insurance Line' _description = 'US80C Insurance Line'
it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration") it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration")
@ -30,6 +31,7 @@ class US80CInsuranceLine(models.Model):
class EmployeeLifeInsurance(models.Model): class EmployeeLifeInsurance(models.Model):
_name = 'employee.life.insurance' _name = 'employee.life.insurance'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'Employee Life Insurance' _description = 'Employee Life Insurance'
parent_id = fields.Many2one('us80c.insurance.line', string="Parent Line") # Link to parent parent_id = fields.Many2one('us80c.insurance.line', string="Parent Line") # Link to parent

View File

@ -3,6 +3,7 @@ import math
class LetoutHouseProperty(models.Model): class LetoutHouseProperty(models.Model):
_name = 'letout.house.property' _name = 'letout.house.property'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'Letout House Property Details' _description = 'Letout House Property Details'
it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration") it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration")

View File

@ -3,6 +3,7 @@ from odoo import models, fields, api
class NSCDeclarationLine(models.Model): class NSCDeclarationLine(models.Model):
_name = 'nsc.declaration.line' _name = 'nsc.declaration.line'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'NSC Declaration Line' _description = 'NSC Declaration Line'
it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration", required=True) it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration", required=True)
@ -19,6 +20,7 @@ class NSCDeclarationLine(models.Model):
class NSCEntry(models.Model): class NSCEntry(models.Model):
_name = 'nsc.entry' _name = 'nsc.entry'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'NSC Entry' _description = 'NSC Entry'
parent_id = fields.Many2one('nsc.declaration.line', string="NSC Declaration") parent_id = fields.Many2one('nsc.declaration.line', string="NSC Declaration")

View File

@ -3,6 +3,7 @@ from odoo import models, fields, api
class NSCInterestLine(models.Model): class NSCInterestLine(models.Model):
_name = 'nsc.interest.line' _name = 'nsc.interest.line'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'NSC Interest Line' _description = 'NSC Interest Line'
@ -23,6 +24,7 @@ class NSCInterestLine(models.Model):
class NSCEntry(models.Model): class NSCEntry(models.Model):
_name = 'nsc.interest.entry' _name = 'nsc.interest.entry'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'NSC Entry' _description = 'NSC Entry'
parent_id = fields.Many2one('nsc.interest.line', string="NSC Interest") parent_id = fields.Many2one('nsc.interest.line', string="NSC Interest")

View File

@ -3,6 +3,7 @@ from odoo import models, fields, api
class SelfOccupiedProperty(models.Model): class SelfOccupiedProperty(models.Model):
_name = 'self.occupied.property' _name = 'self.occupied.property'
_inherit = ['it.declaration.submitted.lock.mixin']
_description = 'Self Occupied House Property Details' _description = 'Self Occupied House Property Details'
it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration") it_declaration_id = fields.Many2one('emp.it.declaration', string="IT Declaration")

View File

@ -18,7 +18,7 @@ class website_hr_recruitment_applications_extended(website_hr_recruitment_applic
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists(): if not applicant.exists():
return request.not_found() return request.not_found()
if applicant and applicant.send_post_onboarding_form: if applicant:
if applicant.post_onboarding_form_status == 'done': if applicant.post_onboarding_form_status == 'done':
return request.render("hr_recruitment_extended.thank_you_template", { return request.render("hr_recruitment_extended.thank_you_template", {
'applicant': applicant 'applicant': applicant
@ -38,7 +38,7 @@ class website_hr_recruitment_applications_extended(website_hr_recruitment_applic
return f"Error: Applicant with ID {applicant_id} not found" return f"Error: Applicant with ID {applicant_id} not found"
# Business logic check # Business logic check
if not applicant.send_post_onboarding_form or applicant.post_onboarding_form_status != 'done': if applicant.post_onboarding_form_status != 'done':
return f"Error: Applicant {applicant_id} does not meet the criteria for download" return f"Error: Applicant {applicant_id} does not meet the criteria for download"
# Get the template # Get the template

View File

@ -1,4 +1,4 @@
from odoo import fields, api, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
@ -31,8 +31,7 @@ class HREmployee(models.Model):
'recruitment_stage_id': self.env.ref('employee_jod.hired_stage8').id, 'recruitment_stage_id': self.env.ref('employee_jod.hired_stage8').id,
}) })
rec.applicant_id = application.id rec.applicant_id = application.id
rec.sudo().applicant_id.send_post_onboarding_form = True return rec.sudo().applicant_id.send_jod_form_to_employee()
return rec.sudo().applicant_id.send_post_onboarding_form_to_candidate()
class PostOnboardingAttachmentWizard(models.TransientModel): class PostOnboardingAttachmentWizard(models.TransientModel):
@ -44,11 +43,12 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
def _onchange_template_id(self): def _onchange_template_id(self):
""" Update the email body and recipients based on the selected template. """ """ Update the email body and recipients based on the selected template. """
if self.template_id: if self.template_id:
record_id = self.env.context.get('active_id') record_id = self.env.context.get('applicant_id')
model = self.env.context.get('active_model') model = self.env.context.get('active_model')
if model == 'applicant.request.forms':
if model == 'hr.applicant':
applicant = self.env['hr.applicant'].browse(record_id) applicant = self.env['hr.applicant'].browse(record_id)
elif model == 'hr.applicant':
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id'))
else: else:
if model == 'hr.employee': if model == 'hr.employee':
applicant = self.env['hr.employee'].browse(record_id).applicant_id applicant = self.env['hr.employee'].browse(record_id).applicant_id
@ -74,14 +74,30 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
for rec in self: for rec in self:
self.ensure_one() self.ensure_one()
context = self.env.context context = self.env.context
active_id = context.get('active_id') active_id = context.get('applicant_id')
model = context.get('active_model') model = context.get('active_model')
request_token = False
request_upload_url = False
if model == 'hr.applicant': if model == 'applicant.request.forms':
applicant = self.env['hr.applicant'].browse(active_id) applicant = self.env['hr.applicant'].browse(active_id)
elif model == 'hr.applicant':
applicant = self.env['hr.applicant'].browse(context.get('active_id'))
elif model == 'hr.employee':
applicant = self.env['hr.employee'].browse(active_id).applicant_id
else: else:
if model == 'hr.employee': applicant = self.env['hr.applicant'].browse(active_id)
applicant = self.env['hr.employee'].browse(active_id).applicant_id
if rec.is_pre_onboarding_attachment_request and not rec.request_form_id:
raise UserError("A document request form is required before sending this email.")
if rec.request_form_id:
request_token = rec.request_form_id._issue_new_access_token()
base_url = self.get_base_url()
request_upload_url = (
f"{base_url}/FTPROTECH/DocRequests/"
f"{applicant.id}/{rec.request_form_id.id}?token={request_token}"
)
applicant.recruitment_attachments = [(4, attachment.id) for attachment in rec.req_attachment_ids] applicant.recruitment_attachments = [(4, attachment.id) for attachment in rec.req_attachment_ids]
@ -93,22 +109,29 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
lambda a: a.attachment_type == 'previous_employer').mapped('name') lambda a: a.attachment_type == 'previous_employer').mapped('name')
other_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'others').mapped('name') other_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'others').mapped('name')
# Prepare context for the template
email_context = { email_context = {
'applicant_request_form_id': rec.request_form_id.id,
'applicant_request_form_token': request_token,
'applicant_request_form_url': request_upload_url,
'personal_docs': personal_docs, 'personal_docs': personal_docs,
'education_docs': education_docs, 'education_docs': education_docs,
'previous_employer_docs': previous_employer_docs, 'previous_employer_docs': previous_employer_docs,
'other_docs': other_docs, 'other_docs': other_docs,
} }
rendered_subject = template.with_context(**email_context)._render_field(
'subject', [applicant.id]
)[applicant.id]
rendered_body_html = template.with_context(**email_context)._render_field(
'body_html', [applicant.id], compute_lang=True
)[applicant.id]
email_values = { email_values = {
'email_from': rec.email_from, 'email_from': rec.email_from,
'email_to': rec.email_to, 'email_to': rec.email_to,
'email_cc': rec.email_cc, 'email_cc': rec.email_cc,
'subject': rec.email_subject, 'subject': rendered_subject or rec.email_subject,
'body_html': rendered_body_html,
'attachment_ids': [(6, 0, rec.attachment_ids.ids)], 'attachment_ids': [(6, 0, rec.attachment_ids.ids)],
} }
# Use 'with_context' to override the email template fields dynamically
if rec.send_mail: if rec.send_mail:
template.sudo().with_context(default_body_html=rec.email_body, template.sudo().with_context(default_body_html=rec.email_body,
**email_context).send_mail(applicant.id, email_values=email_values, **email_context).send_mail(applicant.id, email_values=email_values,
@ -116,7 +139,7 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
base_url = self.get_base_url() base_url = self.get_base_url()
if rec.is_pre_onboarding_attachment_request: if rec.is_pre_onboarding_attachment_request:
applicant.doc_requests_form_status = 'email_sent_to_candidate' rec.request_form_id.status = 'email_sent_to_candidate'
else: else:
applicant.post_onboarding_form_status = 'email_sent_to_candidate' applicant.post_onboarding_form_status = 'email_sent_to_candidate'
applicant.joining_form_link = '%s/FTPROTECH/JoiningForm/%s'%(base_url,applicant.id) applicant.joining_form_link = '%s/FTPROTECH/JoiningForm/%s'%(base_url,applicant.id)

View File

@ -35,7 +35,7 @@
<field name="model">hr.applicant</field> <field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment_extended.hr_applicant_view_form_inherit"/> <field name="inherit_id" ref="hr_recruitment_extended.hr_applicant_view_form_inherit"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='post_onboarding_form_status']" position="after"> <xpath expr="//field[@name='candidate_id']" position="after">
<field name="joining_form_link" force_save="1" readonly="1"/> <field name="joining_form_link" force_save="1" readonly="1"/>
</xpath> </xpath>
</field> </field>

View File

@ -29,7 +29,8 @@
'views/res_config_settings.xml', 'views/res_config_settings.xml',
'views/hr_employee.xml', 'views/hr_employee.xml',
'views/bank_details.xml', 'views/bank_details.xml',
'wizards/work_location_wizard.xml' 'wizards/work_location_wizard.xml',
'wizards/html_preview_wizard.xml'
], ],
} }

View File

@ -10,3 +10,18 @@ class EmployerHistory(models.Model):
last_working_day = fields.Date(string='Last Working Day') last_working_day = fields.Date(string='Last Working Day')
ctc = fields.Char(string='CTC') ctc = fields.Char(string='CTC')
employee_id = fields.Many2one('hr.employee') employee_id = fields.Many2one('hr.employee')
summary = fields.Html(string='Summary')
def action_open_html_popup(self):
for rec in self:
return {
'type': 'ir.actions.act_window',
'name': 'Summary',
'res_model': 'html.preview.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_html_content': rec.summary,
'active_id': rec.id,
}
}

View File

@ -0,0 +1,16 @@
from odoo import models, fields
class HtmlPreviewWizard(models.TransientModel):
_name = 'html.preview.wizard'
_description = 'Edit HTML Popup'
html_content = fields.Html()
def action_save_html(self):
active_id = self.env.context.get('active_id')
record = self.env['employer.history'].browse(active_id)
record.summary = self.html_content
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_html_preview_wizard_form" model="ir.ui.view">
<field name="name">html.preview.wizard.form</field>
<field name="model">html.preview.wizard</field>
<field name="arch" type="xml">
<form string="Summary">
<sheet>
<field name="html_content" widget="html"/>
</sheet>
<footer>
<button name="action_save_html"
string="Save"
type="object"
class="btn-primary"/>
<button string="Cancel"
special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -1,6 +1,7 @@
import json import json
import mimetypes import mimetypes
import re import re
from calendar import monthrange
from datetime import datetime from datetime import datetime
from difflib import SequenceMatcher from difflib import SequenceMatcher
@ -232,6 +233,29 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
"degree": {"type": "string", "description": "Highest degree or main qualification"}, "degree": {"type": "string", "description": "Highest degree or main qualification"},
"skills": {"type": "list", "description": "All explicit technical and functional skills"}, "skills": {"type": "list", "description": "All explicit technical and functional skills"},
"summary": {"type": "string", "description": "Short professional summary from the resume"}, "summary": {"type": "string", "description": "Short professional summary from the resume"},
"education_history": {
"type": "list",
"description": (
"List of education entries if present in the resume. Each item must be an object with "
"education_type (10/inter/graduation/post_graduation/additional), specialization, "
"university, start_year, end_year, and marks_or_grade."
),
},
"employer_history": {
"type": "list",
"description": (
"List of previous employers if present in the resume. Each item must be an object with "
"company_name, designation, date_of_joining, last_working_day, ctc, and work_description. "
"work_description should capture the candidate's responsibilities, projects, and achievements for that employer."
),
},
"family_details": {
"type": "list",
"description": (
"List of family members only if explicitly present in the resume. Each item must be an object with "
"relation_type (father/mother/spouse/kid1/kid2), name, contact_no, dob, and location."
),
},
} }
def _get_jd_required_fields(self): def _get_jd_required_fields(self):
@ -255,7 +279,10 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
"Return precise contact details, experience, location, and skills. " "Return precise contact details, experience, location, and skills. "
"Do not guess missing values. " "Do not guess missing values. "
"Normalize skills into clean individual names. " "Normalize skills into clean individual names. "
"For experience values, return numeric years when clearly inferable." "For experience values, return numeric years when clearly inferable. "
"If the resume contains education details, previous employer details, or family details, return them as structured arrays of objects using the requested field names. "
"For each employer entry, extract the role-specific work description into work_description. "
"Only include entries that are explicitly present in the document. "
"Do not consider certifications, responsibilities, Non Technical Stuff as skills" "Do not consider certifications, responsibilities, Non Technical Stuff as skills"
) )
@ -283,12 +310,16 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
if self.update_existing_candidates: if self.update_existing_candidates:
candidate.write(self._prepare_sparse_update_vals(candidate, candidate_vals)) candidate.write(self._prepare_sparse_update_vals(candidate, candidate_vals))
self._sync_candidate_skills(candidate, parsed_data.get("skills") or []) self._sync_candidate_skills(candidate, parsed_data.get("skills") or [])
if self.target_model == "candidate":
self._sync_candidate_resume_histories(candidate, parsed_data)
message = _("Matched existing candidate: %s") % candidate.display_name message = _("Matched existing candidate: %s") % candidate.display_name
return candidate, "updated", message return candidate, "updated", message
self._ensure_resume_creation_allowed(parsed_data, line.file_name) self._ensure_resume_creation_allowed(parsed_data, line.file_name)
candidate = self.env["hr.candidate"].create(candidate_vals) candidate = self.env["hr.candidate"].create(candidate_vals)
self._sync_candidate_skills(candidate, parsed_data.get("skills") or []) self._sync_candidate_skills(candidate, parsed_data.get("skills") or [])
if self.target_model == "candidate":
self._sync_candidate_resume_histories(candidate, parsed_data)
message = _("Created candidate: %s") % candidate.display_name message = _("Created candidate: %s") % candidate.display_name
return candidate, "created", message return candidate, "created", message
@ -298,6 +329,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
("hr_job_recruitment", "=", self.job_recruitment_id.id), ("hr_job_recruitment", "=", self.job_recruitment_id.id),
], limit=1) ], limit=1)
if existing_applicant: if existing_applicant:
self._sync_applicant_resume_histories(existing_applicant, parsed_data)
return existing_applicant, "existing", _("Existing application reused for this job request.") return existing_applicant, "existing", _("Existing application reused for this job request.")
applicant_vals = { applicant_vals = {
@ -319,6 +351,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
applicant_vals["total_exp_type"] = "year" applicant_vals["total_exp_type"] = "year"
applicant_vals = {key: value for key, value in applicant_vals.items() if value not in (False, None, "")} applicant_vals = {key: value for key, value in applicant_vals.items() if value not in (False, None, "")}
applicant = self.env["hr.applicant"].create(applicant_vals) applicant = self.env["hr.applicant"].create(applicant_vals)
self._sync_applicant_resume_histories(applicant, parsed_data)
return applicant, "created", _("Created application for job request %s.") % self.job_recruitment_id.display_name return applicant, "created", _("Created application for job request %s.") % self.job_recruitment_id.display_name
def _find_existing_candidate(self, parsed_data): def _find_existing_candidate(self, parsed_data):
@ -368,6 +401,9 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
data["total_experience_years"] = self._guess_total_experience(extracted_text) data["total_experience_years"] = self._guess_total_experience(extracted_text)
data["skills"] = self._merge_resume_skills(data.get("skills") or [], extracted_text) data["skills"] = self._merge_resume_skills(data.get("skills") or [], extracted_text)
data["education_history"] = self._normalize_resume_list(data.get("education_history"))
data["employer_history"] = self._normalize_resume_list(data.get("employer_history"))
data["family_details"] = self._normalize_resume_list(data.get("family_details"))
return data return data
def _post_process_jd_data(self, parsed_data, extracted_text): def _post_process_jd_data(self, parsed_data, extracted_text):
@ -691,6 +727,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
"resume_name": line.file_name, "resume_name": line.file_name,
"resume_type": resume_mimetype, "resume_type": resume_mimetype,
"type_id": degree_id, "type_id": degree_id,
"candidate_sequence" : self.env['ir.sequence'].next_by_code('hr.job.candidate.sequence') or '/'
} }
return {key: value for key, value in vals.items() if value not in (False, None, "")} return {key: value for key, value in vals.items() if value not in (False, None, "")}
@ -726,6 +763,351 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
update_vals[field_name] = values[field_name] update_vals[field_name] = values[field_name]
return update_vals return update_vals
def _sync_candidate_resume_histories(self, candidate, parsed_data):
applicant = candidate.with_context(active_test=False).applicant_ids[:1]
history_vals = self._prepare_resume_history_write_vals(candidate, parsed_data)
candidate.write(history_vals)
if applicant:
self._sync_applicant_resume_histories(applicant, parsed_data)
def _sync_applicant_resume_histories(self, applicant, parsed_data):
history_vals = self._prepare_resume_history_write_vals(applicant, parsed_data)
if history_vals:
applicant.write(history_vals)
def _prepare_resume_history_write_vals(self, record, parsed_data):
vals = {}
education_commands = self._prepare_education_history_commands(parsed_data.get("education_history"))
employer_commands = self._prepare_employer_history_commands(parsed_data.get("employer_history"))
family_commands = self._prepare_family_details_commands(parsed_data.get("family_details"))
education_commands = self._filter_new_history_commands(
education_commands,
record.education_history,
self._education_signature_from_command,
self._education_signature_from_record,
)
employer_commands = self._filter_new_history_commands(
employer_commands,
record.employer_history,
self._employer_signature_from_command,
self._employer_signature_from_record,
)
family_commands = self._filter_new_history_commands(
family_commands,
record.family_details,
self._family_signature_from_command,
self._family_signature_from_record,
)
if education_commands:
vals["education_history"] = education_commands
if employer_commands:
vals["employer_history"] = employer_commands
if family_commands:
vals["family_details"] = family_commands
return vals
def _normalize_resume_list(self, value):
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
return [value]
return []
def _prepare_education_history_commands(self, education_history):
commands = []
for education in self._normalize_resume_list(education_history):
specialization = self._clean_text(
education.get("specialization")
or education.get("name")
or education.get("degree")
)
university = self._clean_text(
education.get("university")
or education.get("institution")
or education.get("college")
)
start_year = self._normalize_year(education.get("start_year"))
end_year = self._normalize_year(education.get("end_year"))
education_type = self._map_education_type(
education.get("education_type")
or education.get("degree")
or specialization
)
marks_or_grade = self._clean_text(
education.get("marks_or_grade")
or education.get("marks")
or education.get("grade")
or education.get("cgpa")
or education.get("percentage")
) or "N/A"
if not all([education_type, specialization, university, start_year, end_year]):
continue
commands.append((0, 0, {
"education_type": education_type,
"name": specialization,
"university": university,
"start_year": start_year,
"end_year": end_year,
"marks_or_grade": marks_or_grade,
}))
return commands
def _prepare_employer_history_commands(self, employer_history):
commands = []
for employer in self._normalize_resume_list(employer_history):
company_name = self._clean_text(employer.get("company_name") or employer.get("employer"))
designation = self._clean_text(employer.get("designation") or employer.get("role") or employer.get("job_title"))
date_of_joining = self._normalize_history_date(employer.get("date_of_joining") or employer.get("start_date"))
last_working_day = self._normalize_history_date(
employer.get("last_working_day") or employer.get("end_date"),
prefer_month_end=True,
)
ctc = self._clean_text(employer.get("ctc") or employer.get("salary"))
summary_html = self._build_employer_summary_html(
employer.get("work_description")
or employer.get("summary")
or employer.get("description")
or employer.get("responsibilities")
)
if not all([company_name, designation, date_of_joining]):
continue
commands.append((0, 0, {
"company_name": company_name,
"designation": designation,
"date_of_joining": date_of_joining,
"last_working_day": last_working_day,
"ctc": ctc,
"summary": summary_html,
}))
return commands
def _prepare_family_details_commands(self, family_details):
commands = []
used_relations = set()
for member in self._normalize_resume_list(family_details):
relation_type = self._map_family_relation(member.get("relation_type") or member.get("relation"), used_relations)
name = self._clean_text(member.get("name"))
contact_no = self._clean_text(member.get("contact_no") or member.get("contact") or member.get("phone"))
dob = self._normalize_history_date(member.get("dob"))
location = self._clean_text(member.get("location") or member.get("address"))
if not relation_type or not name:
continue
used_relations.add(relation_type)
commands.append((0, 0, {
"relation_type": relation_type,
"name": name,
"contact_no": contact_no,
"dob": dob,
"location": location,
}))
return commands
def _clean_text(self, value):
if value in (False, None):
return False
cleaned = re.sub(r"\s+", " ", str(value)).strip(" -,:;\n\t")
return cleaned or False
def _build_employer_summary_html(self, value):
if value in (False, None, "", []):
return False
if isinstance(value, list):
items = [self._clean_text(item) for item in value]
items = [item for item in items if item]
else:
raw_text = str(value).strip()
if not raw_text:
return False
normalized_text = raw_text.replace("\r", "\n")
bullet_like = re.split(r"(?:\n+|[•\u2022]|(?<=\.)\s+(?=[A-Z]))", normalized_text)
items = [self._clean_text(item) for item in bullet_like]
items = [item for item in items if item]
if len(items) <= 1:
items = [self._clean_text(part) for part in re.split(r"[;|]", normalized_text)]
items = [item for item in items if item]
if not items:
return False
if len(items) == 1:
return f"<div><p>{escape(items[0])}</p></div>"
bullet_items = "".join(f"<li>{escape(item)}</li>" for item in items)
return f"<div><p>Key responsibilities and contributions:</p><ul>{bullet_items}</ul></div>"
def _filter_new_history_commands(self, commands, existing_records, command_signature_getter, record_signature_getter):
if not commands:
return []
existing_signatures = {
signature for signature in (record_signature_getter(record) for record in existing_records) if signature
}
filtered_commands = []
for command in commands:
signature = command_signature_getter(command)
if not signature or signature in existing_signatures:
continue
existing_signatures.add(signature)
filtered_commands.append(command)
return filtered_commands
def _education_signature_from_command(self, command):
vals = command[2] if len(command) > 2 else {}
return (
vals.get("education_type"),
(vals.get("name") or "").strip().lower(),
(vals.get("university") or "").strip().lower(),
vals.get("start_year"),
vals.get("end_year"),
)
def _education_signature_from_record(self, record):
return (
record.education_type,
(record.name or "").strip().lower(),
(record.university or "").strip().lower(),
record.start_year,
record.end_year,
)
def _employer_signature_from_command(self, command):
vals = command[2] if len(command) > 2 else {}
return (
(vals.get("company_name") or "").strip().lower(),
(vals.get("designation") or "").strip().lower(),
vals.get("date_of_joining"),
vals.get("last_working_day"),
)
def _employer_signature_from_record(self, record):
return (
(record.company_name or "").strip().lower(),
(record.designation or "").strip().lower(),
record.date_of_joining,
record.last_working_day,
)
def _family_signature_from_command(self, command):
vals = command[2] if len(command) > 2 else {}
return (
vals.get("relation_type"),
(vals.get("name") or "").strip().lower(),
vals.get("dob"),
)
def _family_signature_from_record(self, record):
return (
record.relation_type,
(record.name or "").strip().lower(),
record.dob,
)
def _normalize_year(self, value):
if value in (False, None, ""):
return False
if isinstance(value, int):
return value if 1900 <= value <= 2100 else False
match = re.search(r"\b(19|20)\d{2}\b", str(value))
return int(match.group(0)) if match else False
def _map_education_type(self, value):
normalized = (self._clean_text(value) or "").lower()
if not normalized:
return False
mapping = {
"10": "10",
"10th": "10",
"ssc": "10",
"secondary": "10",
"inter": "inter",
"12": "inter",
"12th": "inter",
"intermediate": "inter",
"hsc": "inter",
"higher secondary": "inter",
"puc": "inter",
"graduation": "graduation",
"graduate": "graduation",
"bachelor": "graduation",
"bachelors": "graduation",
"post_graduation": "post_graduation",
"post graduation": "post_graduation",
"postgraduate": "post_graduation",
"post graduate": "post_graduation",
"master": "post_graduation",
"masters": "post_graduation",
"additional": "additional",
"additional qualification": "additional",
"diploma": "additional",
"certification": "additional",
}
if normalized in mapping:
return mapping[normalized]
if any(token in normalized for token in ["master", "mba", "m.tech", "mtech", "mca", "m.sc", "msc", "pgdm"]):
return "post_graduation"
if any(token in normalized for token in ["bachelor", "b.tech", "btech", "b.e", "be ", "bca", "b.sc", "bsc", "b.com", "bcom", "ba", "bba"]):
return "graduation"
if any(token in normalized for token in ["12", "inter", "intermediate", "hsc", "higher secondary", "puc"]):
return "inter"
if any(token in normalized for token in ["10", "10th", "ssc", "secondary"]):
return "10"
return "additional"
def _map_family_relation(self, value, used_relations=None):
normalized = (self._clean_text(value) or "").lower()
used_relations = used_relations or set()
if not normalized:
return False
if normalized in {"father", "dad"}:
return "father"
if normalized in {"mother", "mom"}:
return "mother"
if normalized in {"spouse", "wife", "husband"}:
return "spouse"
if normalized in {"kid1", "child1"}:
return "kid1"
if normalized in {"kid2", "child2"}:
return "kid2"
if normalized in {"son", "daughter", "child", "children", "kid"}:
if "kid1" not in used_relations:
return "kid1"
if "kid2" not in used_relations:
return "kid2"
return False
def _normalize_history_date(self, value, prefer_month_end=False):
cleaned = self._clean_text(value)
if not cleaned:
return False
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%Y/%m/%d"):
try:
return datetime.strptime(cleaned, fmt).date()
except ValueError:
continue
month_year_patterns = ("%Y-%m", "%m-%Y", "%m/%Y", "%b %Y", "%B %Y")
for fmt in month_year_patterns:
try:
parsed = datetime.strptime(cleaned, fmt)
last_day = monthrange(parsed.year, parsed.month)[1] if prefer_month_end else 1
return parsed.replace(day=last_day).date()
except ValueError:
continue
year = self._normalize_year(cleaned)
if year:
month = 12 if prefer_month_end else 1
day = 31 if prefer_month_end else 1
return datetime(year, month, day).date()
return False
def _sync_candidate_skills(self, candidate, skills): def _sync_candidate_skills(self, candidate, skills):
if not skills: if not skills:
return return

View File

@ -48,13 +48,17 @@
'wizards/applicant_refuse_reason.xml', 'wizards/applicant_refuse_reason.xml',
'wizards/ats_invite_mail_template_wizard.xml', 'wizards/ats_invite_mail_template_wizard.xml',
'wizards/client_submission_mail_template_wizard.xml', 'wizards/client_submission_mail_template_wizard.xml',
'wizards/applicant_stage_comment_wizard.xml',
# 'views/resume_pearser.xml', # 'views/resume_pearser.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
'hr_recruitment_extended/static/src/img/pdf_icon.png', 'hr_recruitment_extended/static/src/img/pdf_icon.png',
'hr_recruitment_extended/static/src/js/recruitment_match_panel.js', 'hr_recruitment_extended/static/src/js/recruitment_match_panel.js',
# 'hr_recruitment_extended/static/src/js/stage_comment_statusbar.js',
# 'hr_recruitment_extended/static/src/xml/stage_comment_statusbar.xml',
'hr_recruitment_extended/static/src/scss/recruitment_match_panel.scss', 'hr_recruitment_extended/static/src/scss/recruitment_match_panel.scss',
'hr_recruitment_extended/static/src/scss/hr_applicant_hold.scss',
], ],
'web.assets_frontend': [ 'web.assets_frontend': [
'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js', 'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js',

View File

@ -1,49 +1,58 @@
import warnings
from datetime import datetime from datetime import datetime
from dateutil.relativedelta import relativedelta
from operator import itemgetter
from werkzeug.urls import url_encode
from odoo import http, _ from odoo import http
from odoo.addons.website_hr_recruitment.controllers.main import WebsiteHrRecruitment
from odoo.osv.expression import AND
from odoo.http import request from odoo.http import request
from odoo.tools import email_normalize
from odoo.tools.misc import groupby
import base64
from odoo.exceptions import UserError
from PIL import Image
from io import BytesIO
import re
import json import json
class website_hr_recruitment_applications(http.Controller): class website_hr_recruitment_applications(http.Controller):
@http.route(['/hr_recruitment/second_application_form/<int:applicant_id>'], type='http', auth="public", website=True) def _get_request_form(self, applicant_id, applicant_request_id, token=None, form_type=None, require_token=False):
def second_application_form(self, applicant_id, **kwargs): applicant_request = request.env['applicant.request.forms'].sudo().browse(applicant_request_id)
if not applicant_request.exists():
return request.env['applicant.request.forms']
if applicant_request.applicant_id.id != applicant_id:
return request.env['applicant.request.forms']
if form_type and applicant_request.form_type != form_type:
return request.env['applicant.request.forms']
if require_token and not token:
return request.env['applicant.request.forms']
if token and applicant_request.access_token != token:
return request.env['applicant.request.forms']
return applicant_request
@staticmethod
def _is_submitted_for_token(applicant_request, token):
return bool(
applicant_request
and applicant_request.status == 'done'
and token
and applicant_request.submitted_token == token
)
@http.route(['/hr_recruitment/second_application_form/<int:applicant_id>/<int:applicant_request_id>'], type='http', auth="public", website=True)
def second_application_form(self, applicant_id, applicant_request_id, **kwargs):
"""Renders the website form for applicants to submit additional details.""" """Renders the website form for applicants to submit additional details."""
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant_request_id = request.env['applicant.request.forms'].sudo().browse(applicant_request_id)
if not applicant.exists(): if not applicant_request_id.exists():
return request.not_found() return request.not_found()
if applicant and applicant.send_second_application_form: if applicant_request_id :
if applicant.second_application_form_status == 'done': if applicant_request_id.status == 'done':
return request.render("hr_recruitment_extended.thank_you_template") return request.render("hr_recruitment_extended.thank_you_template")
else: else:
return request.render("hr_recruitment_extended.applicant_form_template", { return request.render("hr_recruitment_extended.applicant_form_template", {
'applicant': applicant 'applicant': applicant_request_id.applicant_id,
'applicant_request_id': applicant_request_id
}) })
else: else:
return request.not_found() return request.not_found()
@http.route(['/hr_recruitment/submit_second_application/<int:applicant_id>/submit'], type='http', auth="public", @http.route(['/hr_recruitment/submit_second_application/<int:applicant_id>/<int:applicant_request_id>/submit'], type='http', auth="public",
methods=['POST'], website=True, csrf=False) methods=['POST'], website=True, csrf=False)
def process_application_form(self, applicant_id, **kwargs): def process_application_form(self, applicant_id, applicant_request_id, **kwargs):
# Get the applicant # Get the applicant
candidate_image_base64 = kwargs.pop('candidate_image_base64') candidate_image_base64 = kwargs.pop('candidate_image_base64')
candidate_image = kwargs.pop('candidate_image') candidate_image = kwargs.pop('candidate_image')
@ -62,10 +71,11 @@ class website_hr_recruitment_applications(http.Controller):
kwargs['total_exp_type'] = 'year' kwargs['total_exp_type'] = 'year'
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists(): applicant_request_id = request.env['applicant.request.forms'].sudo().browse(applicant_request_id)
if not applicant_request_id.exists() or not applicant.exists():
return request.not_found() # Return 404 if applicant doesn't exist return request.not_found() # Return 404 if applicant doesn't exist
if applicant.second_application_form_status == 'done': if applicant_request_id.status == 'done':
return request.render("hr_recruitment_extended.thank_you_template") return request.render("hr_recruitment_extended.thank_you_template")
@ -78,65 +88,68 @@ class website_hr_recruitment_applications(http.Controller):
if template and applicant.user_id.email: if template and applicant.user_id.email:
template.sudo().send_mail(applicant.id, force_send=True) template.sudo().send_mail(applicant.id, force_send=True)
applicant.second_application_form_status = 'done' applicant_request_id.status = 'done'
# Redirect to a Thank You page # Redirect to a Thank You page
return request.render("hr_recruitment_extended.thank_you_template") return request.render("hr_recruitment_extended.thank_you_template")
@http.route(['/FTPROTECH/DocRequests/<int:applicant_id>'], type='http', auth="public", @http.route(['/FTPROTECH/DocRequests/<int:applicant_id>/<int:applicant_request_id>'], type='http', auth="public",
website=True) website=True)
def doc_request_form(self, applicant_id, **kwargs): def doc_request_form(self, applicant_id, applicant_request_id, **kwargs):
"""Renders the website form for applicants to submit additional details.""" request_token = kwargs.get('token')
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant_request = self._get_request_form(
if not applicant.exists(): applicant_id,
return request.not_found() applicant_request_id,
if applicant: token=request_token,
if applicant.doc_requests_form_status == 'done': form_type='documents_request',
return request.render("hr_recruitment_extended.thank_you_template") require_token=True,
else: )
return request.render("hr_recruitment_extended.doc_request_form_template", { if not applicant_request:
'applicant': applicant raise request.not_found()
}) if self._is_submitted_for_token(applicant_request, request_token):
else: return request.render("hr_recruitment_extended.thank_you_template")
return request.not_found()
@http.route(['/FTPROTECH/submit/<int:applicant_id>/docRequest'], type='http', auth="public", return request.render("hr_recruitment_extended.doc_request_form_template", {
'applicant': applicant_request.applicant_id,
'applicant_request_id': applicant_request,
'request_token': request_token,
})
@http.route(['/FTPROTECH/submit/<int:applicant_id>/<int:applicant_request_id>/docRequest'], type='http', auth="public",
methods=['POST'], website=True, csrf=False) methods=['POST'], website=True, csrf=False)
def process_applicant_doc_submission_form(self, applicant_id, **post): def process_applicant_doc_submission_form(self, applicant_id, applicant_request_id, **post):
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) request_token = post.get('request_token')
if not applicant.exists(): applicant_request = self._get_request_form(
return request.not_found() # Return 404 if applicant doesn't exist applicant_id,
applicant_request_id,
token=request_token,
form_type='documents_request',
require_token=True,
)
if not applicant_request:
raise request.not_found()
if applicant.doc_requests_form_status == 'done': applicant = applicant_request.applicant_id
if self._is_submitted_for_token(applicant_request, request_token):
return request.render("hr_recruitment_extended.thank_you_template") return request.render("hr_recruitment_extended.thank_you_template")
applicant_data = { applicant_data = {
'applicant_id': int(post.get('applicant_id', 0)), 'applicant_id': int(post.get('applicant_id', 0)),
'candidate_image': post.get('candidate_image_base64', ''), 'candidate_image': post.get('candidate_image_base64', ''),
'doc_requests_form_status': 'done'
} }
applicant_data = {k: v for k, v in applicant_data.items() if v != '' and v != 0} applicant_data = {k: v for k, v in applicant_data.items() if v != '' and v != 0}
# attachments
attachments_data_json = post.get('attachments_data_json', '[]') attachments_data_json = post.get('attachments_data_json', '[]')
attachments_data = json.loads(attachments_data_json) if attachments_data_json else [] attachments_data = json.loads(attachments_data_json) if attachments_data_json else []
if attachments_data:
applicant_data['joining_attachment_ids'] = [
(4, existing_id) for existing_id in
(applicant.joining_attachment_ids).ids
] + [
(0, 0, {
'name': attachment.get('file_name', ''),
'recruitment_attachment_id': attachment.get(
'attachment_rec_id', ''),
'file': attachment.get('file_content', '')
}) for attachment in attachments_data if
attachment.get('attachment_rec_id')
]
applicant.write(applicant_data) applicant.write(applicant_data)
applicant.replace_joining_attachments(attachments_data)
applicant_request.write({
'status': 'done',
'applicant_submitted_date': datetime.now(),
'submitted_token': request_token,
})
return request.render("hr_recruitment_extended.thank_you_template") return request.render("hr_recruitment_extended.thank_you_template")
@ -150,7 +163,7 @@ class website_hr_recruitment_applications(http.Controller):
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists(): if not applicant.exists():
return request.not_found() return request.not_found()
if applicant and applicant.send_post_onboarding_form: if applicant:
if applicant.post_onboarding_form_status == 'done': if applicant.post_onboarding_form_status == 'done':
return request.render("hr_recruitment_extended.thank_you_template") return request.render("hr_recruitment_extended.thank_you_template")
else: else:
@ -219,7 +232,6 @@ class website_hr_recruitment_applications(http.Controller):
applicant_data = {k: v for k, v in applicant_data.items() if v != '' and v != 0} applicant_data = {k: v for k, v in applicant_data.items() if v != '' and v != 0}
# Get family details data from JSON
family_data_json = post.get('family_data_json', '[]') family_data_json = post.get('family_data_json', '[]')
family_data = json.loads(family_data_json) if family_data_json else [] family_data = json.loads(family_data_json) if family_data_json else []
@ -231,9 +243,8 @@ class website_hr_recruitment_applications(http.Controller):
'contact_no': member.get('contact', ''), 'contact_no': member.get('contact', ''),
'dob': datetime.strptime(member.get('dob'), '%Y-%m-%d').date() if member.get('dob') else None, 'dob': datetime.strptime(member.get('dob'), '%Y-%m-%d').date() if member.get('dob') else None,
'location': member.get('location', ''), 'location': member.get('location', ''),
}) for member in family_data if member.get('name') and member.get('relation') # Optional filter to avoid empty members }) for member in family_data if member.get('name') and member.get('relation')
] ]
# education details
education_data_json = post.get('education_data_json', '[]') education_data_json = post.get('education_data_json', '[]')
education_data = json.loads(education_data_json) if education_data_json else [] education_data = json.loads(education_data_json) if education_data_json else []
if education_data: if education_data:
@ -262,28 +273,13 @@ class website_hr_recruitment_applications(http.Controller):
}) for company in employer_data }) for company in employer_data
] ]
#attachments
attachments_data_json = post.get('attachments_data_json', '[]') attachments_data_json = post.get('attachments_data_json', '[]')
attachments_data = json.loads(attachments_data_json) if attachments_data_json else [] attachments_data = json.loads(attachments_data_json) if attachments_data_json else []
if attachments_data:
applicant_data['joining_attachment_ids'] = [
(4, existing_id) for existing_id in
(applicant.joining_attachment_ids).ids
] + [
(0, 0, {
'name': attachment.get('file_name', ''),
'recruitment_attachment_id': attachment.get(
'attachment_rec_id', ''),
'file': attachment.get('file_content', '')
}) for attachment in attachments_data if
attachment.get('attachment_rec_id')
]
applicant.write(applicant_data) applicant.write(applicant_data)
applicant.replace_joining_attachments(attachments_data)
template = request.env.ref('hr_recruitment_extended.email_template_post_onboarding_form_user_submit', template = request.env.ref('hr_recruitment_extended.email_template_post_onboarding_form_user_submit',
raise_if_not_found=False) raise_if_not_found=False)
# Get HR managers with HR department
group = request.env.ref('hr.group_hr_manager') group = request.env.ref('hr.group_hr_manager')
users = request.env['res.users'].sudo().search([ users = request.env['res.users'].sudo().search([
('groups_id', 'in', group.ids), ('groups_id', 'in', group.ids),
@ -292,27 +288,20 @@ class website_hr_recruitment_applications(http.Controller):
('employee_id.department_id.name', '=', 'Human Resource') ('employee_id.department_id.name', '=', 'Human Resource')
]) ])
# Extract emails and join them into a comma-separated string
email_cc = ','.join([user.email for user in users]) email_cc = ','.join([user.email for user in users])
# Prepare email values
email_values = { email_values = {
'email_from': applicant.email_from, 'email_from': applicant.email_from,
'email_to': 'hr@ftprotech.com', 'email_to': 'hr@ftprotech.com',
'email_cc': email_cc 'email_cc': email_cc
} }
# Debug: Print the email_cc value to verify
print(f"Email CC value: {email_values['email_cc']}")
# Send email
template.sudo().send_mail( template.sudo().send_mail(
applicant.id, applicant.id,
email_values=email_values, email_values=email_values,
force_send=True force_send=True
) )
# Render thank you page
return request.render("hr_recruitment_extended.thank_you_template", { return request.render("hr_recruitment_extended.thank_you_template", {
'applicant': applicant 'applicant': applicant
}) })

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<data noupdate="1"> <data noupdate="1">
<!-- employee.recruitment.attachments-->
<record model="ir.attachment" id="employee_recruitment_attachments_preview"> <record model="ir.attachment" id="employee_recruitment_attachments_preview">
<field name="name">Attachment Preview</field> <field name="name">Attachment Preview</field>
<field name="type">binary</field> <field name="type">binary</field>

View File

@ -88,9 +88,10 @@
Request to employees to provide salary expectations, experience, and current offers. Request to employees to provide salary expectations, experience, and current offers.
</field> </field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<t t-set="request_form_id" t-value="ctx.get('applicant_request_form_id')"/>
<t t-set="applicant_name" t-value="object.candidate_id.partner_name or 'Applicant'"/> <t t-set="applicant_name" t-value="object.candidate_id.partner_name or 'Applicant'"/>
<t t-set="base_url" t-value="object.env['ir.config_parameter'].sudo().get_param('web.base.url')"/> <t t-set="base_url" t-value="object.env['ir.config_parameter'].sudo().get_param('web.base.url')"/>
<t t-set="form_url" t-value="base_url + '/hr_recruitment/second_application_form/%s' % object.id"/> <t t-set="form_url" t-value="base_url + '/hr_recruitment/second_application_form/%s/%s' % (object.id, request_form_id)"/>
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; padding: 20px; line-height: 1.6;"> <div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; padding: 20px; line-height: 1.6;">
<p>Dear <p>Dear
@ -143,7 +144,7 @@
<field name="name">Applicant Form Submission Notification</field> <field name="name">Applicant Form Submission Notification</field>
<field name="model_id" ref="hr_recruitment.model_hr_applicant"/> <field name="model_id" ref="hr_recruitment.model_hr_applicant"/>
<field name="email_from">{{ user.company_id.email or user.email_formatted }}</field> <field name="email_from">{{ user.company_id.email or user.email_formatted }}</field>
<field name="email_to">{{ object.user_id.email }}</field> <!-- Recruiter's Email --> <field name="email_to">{{ object.user_id.email }}</field>
<field name="subject">New Submission: Applicant Salary &amp; Experience Form</field> <field name="subject">New Submission: Applicant Salary &amp; Experience Form</field>
<field name="description"> <field name="description">
Notification sent to recruiter when an applicant submits the form. Notification sent to recruiter when an applicant submits the form.
@ -202,7 +203,7 @@
</field> </field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<t t-set="applicant_name" t-value="object.candidate_id.partner_name or 'Applicant'"/> <t t-set="applicant_name" t-value="object.candidate_id.partner_name or 'Applicant'"/>
<t t-set="request_form_id" t-value="ctx.get('applicant_request_form_id')"/>
<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; padding: 20px; line-height: 1.6;"> <div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; padding: 20px; line-height: 1.6;">
<p>Dear <p>Dear
<strong> <strong>
@ -218,7 +219,6 @@
<t t-if="ctx.get('personal_docs') or ctx.get('education_docs') or ctx.get('previous_employer_docs') or ctx.get('other_docs')"> <t t-if="ctx.get('personal_docs') or ctx.get('education_docs') or ctx.get('previous_employer_docs') or ctx.get('other_docs')">
<p>Please ensure to provide soft copies of the required documents:</p> <p>Please ensure to provide soft copies of the required documents:</p>
<!-- Personal Documents -->
<t t-if="ctx.get('personal_docs')"> <t t-if="ctx.get('personal_docs')">
<strong>Personal Documents:</strong> <strong>Personal Documents:</strong>
<ul> <ul>
@ -228,7 +228,6 @@
</ul> </ul>
</t> </t>
<!-- Education Documents -->
<t t-if="ctx.get('education_docs')"> <t t-if="ctx.get('education_docs')">
<strong>Education Documents:</strong> <strong>Education Documents:</strong>
<ul> <ul>
@ -238,7 +237,6 @@
</ul> </ul>
</t> </t>
<!-- Previous Employer Documents -->
<t t-if="ctx.get('previous_employer_docs')"> <t t-if="ctx.get('previous_employer_docs')">
<strong>Previous Employer Documents:</strong> <strong>Previous Employer Documents:</strong>
<ul> <ul>
@ -248,7 +246,6 @@
</ul> </ul>
</t> </t>
<!-- Additional Documents -->
<t t-if="ctx.get('other_docs')"> <t t-if="ctx.get('other_docs')">
<strong>Additional Documents:</strong> <strong>Additional Documents:</strong>
<ul> <ul>
@ -261,8 +258,8 @@
<p>Please upload your documents via the following link:</p> <p>Please upload your documents via the following link:</p>
<t t-set="base_url" t-value="object.env['ir.config_parameter'].sudo().get_param('web.base.url')"/> <t t-set="upload_url" t-value="ctx.get('applicant_request_form_url')"/>
<t t-set="upload_url" t-value="base_url + '/FTPROTECH/DocRequests/%s' % object.id"/> <t t-esc="upload_url"/>
<p style="text-align: center; margin-top: 20px;"> <p style="text-align: center; margin-top: 20px;">
<a t-att-href="upload_url" target="_blank" <a t-att-href="upload_url" target="_blank"
@ -635,29 +632,217 @@
</record> </record>
<record id="application_client_submission_email_template" model="mail.template"> <record id="application_client_submission_email_template" model="mail.template">
<field name="name">Applicant Client Submissions</field> <field name="name">Applicant Share Template</field>
<field name="model_id" ref="hr_recruitment.model_hr_applicant"/> <field name="model_id" ref="hr_recruitment.model_hr_applicant"/>
<field name="email_from">{{ user.email_formatted }}</field> <field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.hr_job_recruitment.requested_by.email }}</field> <field name="email_to">{{ object.hr_job_recruitment.requested_by.email }}</field>
<field name="subject">Applicant Submission</field> <field name="subject">{{ object.candidate_id.partner_name or object.partner_name or object.display_name }} -
{{ object.hr_job_recruitment.job_id.name or object.job_id.name }}
</field>
<field name="description"> <field name="description">
Submitting the Applicant Details to Client. Share applicant and job requisition details by email.
</field> </field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<p style="margin: 0px; padding: 0px; font-size: 13px;"> <t t-set="applicant_name"
Dear <t t-esc="ctx['client_name']">Sir/Madam</t>, t-value="object.candidate_id.partner_name or object.partner_name or object.display_name"/>
<br/> <t t-set="job_name">
<br/> <t t-if="object.hr_job_recruitment.job_id">
Submitting new applicant. <t t-set="job_name" t-value="object.hr_job_recruitment.job_id.display_name"/>
<br/> </t>
Kindly review the Applicant. <t t-elif="object.job_id">
<br/> <t t-set="job_name" t-value="object.job_id.name"/>
<br/> </t>
Regards, <t t-else="">
<br/> <t t-set="job_name" t-value="''"/>
<t t-out="user.name or 'Hiring Manager'">Hiring Manager</t> </t>
</p> </t>
<t t-set="recruitment_name"
t-value="object.hr_job_recruitment.recruitment_sequence or object.hr_job_recruitment.display_name or ''"/>
<t t-set="requested_by">
<t t-if="object.hr_job_recruitment.requested_by">
<t t-set="requested_by" t-value="object.hr_job_recruitment.requested_by.name"/>
</t>
<t t-else="">
<t t-set="requested_by" t-value="''"/>
</t>
</t>
<t t-set="locations">
<t t-if="object.hr_job_recruitment.locations">
<t t-set="locations_str"
t-value="', '.join(object.hr_job_recruitment.locations.mapped('name'))"/>
<t t-set="locations" t-value="locations_str"/>
</t>
<t t-else="">
<t t-set="locations" t-value="''"/>
</t>
</t>
<div style="margin: 0; padding: 0; font-size: 13px; line-height: 1.6;">
<p>Dear Sir/Madam,</p>
<p>Please find the applicant details below for your review.</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Applicant</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="applicant_name"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Email</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="object.email_from or ''"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Phone</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="object.partner_phone or ''"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Current Organization</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="object.current_organization or ''"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Total Experience</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="object.total_exp or ''"/>
<t t-esc="object.total_exp_type or ''"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Relevant Experience</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="object.relevant_exp or ''"/>
<t t-esc="object.relevant_exp_type or ''"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Job Requisition</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="recruitment_name"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Job Position</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="job_name"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Requested By</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="requested_by"/>
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<strong>Location</strong>
</td>
<td style="padding: 8px; border: 1px solid #d9d9d9;">
<t t-esc="locations"/>
</td>
</tr>
</table>
<p>The applicant resume is attached for reference. Please review and let us know your feedback.</p>
<p>Regards,
<br/>
<t t-out="user.name or 'Hiring Manager'">Hiring Manager</t>
</p>
</div>
</field>
</record>
<record id="job_recruitment_share_email_template" model="mail.template">
<field name="name">Job Recruitment Share Template</field>
<field name="model_id" ref="hr_recruitment_extended.model_hr_job_recruitment"/>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.requested_by.email or object.address_id.email or '' }}</field>
<field name="subject">{{ object.job_id.name or object.display_name }} - Job Description</field>
<field name="description">
Share job description and recruitment details by email.
</field>
<field name="body_html" type="html">
<t t-set="job_name" t-value="object.job_id.name or object.name or object.display_name or ''"/>
<t t-set="recruitment_name" t-value="object.recruitment_sequence or object.display_name or ''"/>
<t t-set="locations" t-value="', '.join(object.locations.mapped('name')) if object.locations else ''"/>
<t t-set="primary_skills" t-value="', '.join(object.skill_ids.mapped('name')) if object.skill_ids else ''"/>
<t t-set="secondary_skills" t-value="', '.join(object.secondary_skill_ids.mapped('name')) if object.secondary_skill_ids else ''"/>
<div style="margin: 0; padding: 0; font-size: 13px; line-height: 1.6;">
<p>Dear Sir/Madam,</p>
<p>Please find the job description and hiring details below for your review and sourcing support.</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Job Requisition</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="recruitment_name"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Job Position</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="job_name"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Recruitment Type</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="object.recruitment_type or ''"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Requested By</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="object.requested_by.name or ''"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Primary Recruiter</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="object.user_id.name or ''"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Open Positions</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="object.no_of_recruitment or 0"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Experience</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="object.experience.display_name or ''"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Budget</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="object.budget or ''"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Locations</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="locations"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Primary Skills</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="primary_skills"/></td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><strong>Secondary Skills</strong></td>
<td style="padding: 8px; border: 1px solid #d9d9d9;"><t t-esc="secondary_skills"/></td>
</tr>
</table>
<div>
<strong>Job Description</strong>
<div t-out="object.description or ''"/>
</div>
<p>Please review and share suitable profiles at the earliest convenience.</p>
<p>Regards,<br/><t t-out="user.name or 'Hiring Manager'">Hiring Manager</t></p>
</div>
</field> </field>
</record> </record>

View File

@ -1,14 +1,13 @@
# -*- coding: utf-8 -*-
from . import hr_recruitment from . import hr_recruitment
from . import hr_job_recruitment from . import hr_job_recruitment
from . import stages from . import stages
from . import applicant_request_forms
from . import hr_applicant_stage_comment
from . import hr_applicant from . import hr_applicant
from . import hr_job from . import hr_job
from . import res_partner from . import res_partner
from . import candidate_experience from . import candidate_experience
from . import hr_employee_education_employer_family from . import hr_employee_education_employer_family
# from . import resume_pearser
from . import recruitment_attachments from . import recruitment_attachments
from . import hr_recruitment_source from . import hr_recruitment_source
from . import requisitions from . import requisitions

View File

@ -0,0 +1,83 @@
import uuid
from odoo import fields, models
class ApplicantRequestForms(models.Model):
_name = 'applicant.request.forms'
_description = 'Applicant Request Forms'
applicant_id = fields.Many2one(
'hr.applicant',
string='Applicant',
required=True,
ondelete='cascade'
)
form_type = fields.Selection([
('experience_request_form', 'Experience Request Form'),
('documents_request', 'Documents Request'),
], string='Form Type', required=True)
status = fields.Selection([
('draft', 'Draft'),
('email_sent_to_candidate', 'Email Sent to Candidate'),
('done', 'Done'),
], string='Status', default='draft')
send_date = fields.Datetime(string='Sent Date')
applicant_submitted_date = fields.Datetime(string='Applicant Submitted Date')
access_token = fields.Char(string='Access Token', copy=False, readonly=True)
submitted_token = fields.Char(string='Submitted Token', copy=False, readonly=True)
def _generate_access_token(self):
self.ensure_one()
return uuid.uuid4().hex
def _issue_new_access_token(self):
self.ensure_one()
token = self._generate_access_token()
self.write({
'access_token': token,
'submitted_token': False,
'status': 'email_sent_to_candidate',
'send_date': fields.Datetime.now(),
'applicant_submitted_date': False,
})
return token
def action_send_form(self):
self.ensure_one()
applicant = self.applicant_id
if self.form_type == 'experience_request_form':
template = self.env.ref(
'hr_recruitment_extended.email_template_second_application_form',
raise_if_not_found=False
)
if template and applicant.email_from:
template.with_context(
applicant_request_form_id=self.id
).send_mail(applicant.id, force_send=True)
self.write({
'status': 'email_sent_to_candidate',
'send_date': fields.Datetime.now()
})
elif self.form_type == 'documents_request':
return {
'type': 'ir.actions.act_window',
'name': 'Select Attachments',
'res_model': 'post.onboarding.attachment.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_req_attachment_ids': [],
'default_is_pre_onboarding_attachment_request': True,
'default_request_form_id': self.id,
'applicant_id': self.applicant_id.id,
}
}

View File

@ -1,11 +1,8 @@
from email.policy import default
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from datetime import datetime from datetime import datetime
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
import warnings
from odoo.tools.mimetypes import guess_mimetype, fix_filename_extension from odoo.tools.mimetypes import guess_mimetype, fix_filename_extension
@ -34,12 +31,29 @@ class HRApplicant(models.Model):
) )
candidate_image = fields.Image(related='candidate_id.candidate_image', readonly=False, compute_sudo=True) 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) submitted_to_client = fields.Boolean(string="Submitted to Client", default=False, tracking=True)
client_submission_date = fields.Datetime(string="Submission Date") client_submission_date = fields.Datetime(string="Submission Date", tracking=True)
submitted_stage = fields.Many2one('hr.recruitment.stage') submitted_stage = fields.Many2one('hr.recruitment.stage', string="Submitted Stage", tracking=True)
refused_stage = fields.Many2one('hr.recruitment.stage', string="Reject Stage") refused_stage = fields.Many2one('hr.recruitment.stage', string="Reject Stage")
refused_comments = fields.Text(string='Reject Comments') refused_comments = fields.Text(string='Reject Comments')
is_on_hold = fields.Boolean(string="Is On Hold", default=False) is_on_hold = fields.Boolean(string="Is On Hold", default=False)
hold_state = fields.Selection(
[('hold', 'On Hold')],
string="Hold Status",
compute='_compute_hold_state',
)
stage_comment_ids = fields.One2many(
'hr.applicant.stage.comment',
'applicant_id',
string='Stage Comments',
)
stage_comment_count = fields.Integer(compute='_compute_stage_comment_count')
stage_comment_tooltips = fields.Json(compute='_compute_stage_comment_tooltips')
@api.depends('is_on_hold')
def _compute_hold_state(self):
for applicant in self:
applicant.hold_state = 'hold' if applicant.is_on_hold else False
def hold_unhold_button(self): def hold_unhold_button(self):
for rec in self: for rec in self:
@ -47,12 +61,66 @@ class HRApplicant(models.Model):
rec.is_on_hold = False rec.is_on_hold = False
else: else:
rec.is_on_hold = True rec.is_on_hold = True
return {'type': 'ir.actions.client', 'tag': 'reload'}
def action_toggle_chatter_visibility(self): def action_toggle_chatter_visibility(self):
for record in self: for record in self:
record.hide_chatter_suggestion = not record.hide_chatter_suggestion record.hide_chatter_suggestion = not record.hide_chatter_suggestion
return {'type': 'ir.actions.client', 'tag': 'reload'} return {'type': 'ir.actions.client', 'tag': 'reload'}
@api.depends('stage_comment_ids')
def _compute_stage_comment_count(self):
for applicant in self:
applicant.stage_comment_count = len(applicant.stage_comment_ids)
@api.depends('stage_comment_ids.comment', 'stage_comment_ids.stage_id', 'stage_comment_ids.user_id', 'stage_comment_ids.comment_date')
def _compute_stage_comment_tooltips(self):
for applicant in self:
grouped_comments = {}
for comment in applicant.stage_comment_ids.sorted(lambda item: item.comment_date or fields.Datetime.now()):
if not comment.stage_id:
continue
line = '%s: %s' % (comment.user_id.name, comment.comment)
grouped_comments.setdefault(comment.stage_id.id, []).append(line)
applicant.stage_comment_tooltips = {
stage_id: '\n'.join(comments[-4:])
for stage_id, comments in grouped_comments.items()
}
def action_open_stage_comment_wizard(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Stage Comments'),
'res_model': 'applicant.stage.comment.wizard',
'view_mode': 'form',
'view_id': self.env.ref('hr_recruitment_extended.view_applicant_stage_comment_wizard_form').id,
'target': 'new',
'context': {
'active_id': self.id,
'default_applicant_id': self.id,
'default_stage_id': self.recruitment_stage_id.id,
},
}
@api.onchange('submitted_to_client')
def _onchange_submitted_to_client(self):
for applicant in self:
if applicant.submitted_to_client:
applicant.client_submission_date = applicant.client_submission_date or fields.Datetime.now()
applicant.submitted_stage = applicant.submitted_stage or applicant.recruitment_stage_id
else:
applicant.client_submission_date = False
applicant.submitted_stage = False
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('submitted_to_client'):
vals.setdefault('client_submission_date', fields.Datetime.now())
vals.setdefault('submitted_stage', vals.get('recruitment_stage_id'))
return super().create(vals_list)
@api.depends('hr_job_recruitment.skill_ids', 'hr_job_recruitment.secondary_skill_ids', 'candidate_id.skill_ids') @api.depends('hr_job_recruitment.skill_ids', 'hr_job_recruitment.secondary_skill_ids', 'candidate_id.skill_ids')
def _compute_skill_match_percentages(self): def _compute_skill_match_percentages(self):
for applicant in self: for applicant in self:
@ -82,13 +150,38 @@ class HRApplicant(models.Model):
search_domain = [] search_domain = []
if job_recruitment_id: if job_recruitment_id:
search_domain = [('job_recruitment_ids', '=', job_recruitment_id)] + search_domain search_domain = [('job_recruitment_ids', '=', job_recruitment_id)] + search_domain
# if stages:
# search_domain = [('id', 'in', stages.ids)] + search_domain
stage_ids = stages.sudo()._search(search_domain, order=stages._order) stage_ids = stages.sudo()._search(search_domain, order=stages._order)
return stages.browse(stage_ids) return stages.browse(stage_ids)
def write(self, vals): def write(self, vals):
if vals.get('submitted_to_client') and 'submitted_stage' not in vals and len(self) > 1:
for applicant in self:
applicant_vals = dict(vals)
applicant_vals.setdefault('client_submission_date', fields.Datetime.now())
applicant_vals['submitted_stage'] = vals.get('recruitment_stage_id') or applicant.recruitment_stage_id.id or False
applicant.write(applicant_vals)
return True
if vals.get('submitted_to_client'):
vals.setdefault('client_submission_date', fields.Datetime.now())
if 'submitted_stage' not in vals:
submitted_stage = vals.get('recruitment_stage_id') or self[:1].recruitment_stage_id.id
if submitted_stage:
vals['submitted_stage'] = submitted_stage
elif vals.get('submitted_to_client') is False:
vals.setdefault('client_submission_date', False)
vals.setdefault('submitted_stage', False)
if 'recruitment_stage_id' in vals:
blocked_records = self.filtered(
lambda applicant: applicant.is_on_hold and applicant.recruitment_stage_id.id != vals.get('recruitment_stage_id')
)
if blocked_records:
raise ValidationError(_("You cannot change the stage of an applicant while it is on hold. Please unhold it first."))
if 'stage_id' in vals:
blocked_records = self.filtered(
lambda applicant: applicant.is_on_hold and applicant.stage_id.id != vals.get('stage_id')
)
if blocked_records:
raise ValidationError(_("You cannot change the stage of an applicant while it is on hold. Please unhold it first."))
# user_id change: update date_open # user_id change: update date_open
res = super().write(vals) res = super().write(vals)
if vals.get('user_id'): if vals.get('user_id'):
@ -150,16 +243,15 @@ class HRApplicant(models.Model):
copy=False, index=True, copy=False, index=True,
group_expand='_read_group_recruitment_stage_ids') group_expand='_read_group_recruitment_stage_ids')
stage_color = fields.Char(related="recruitment_stage_id.stage_color") stage_color = fields.Char(related="recruitment_stage_id.stage_color")
request_form_ids = fields.One2many(
send_second_application_form = fields.Boolean(related='recruitment_stage_id.second_application_form') 'applicant.request.forms',
second_application_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft') 'applicant_id',
send_post_onboarding_form = fields.Boolean(related='recruitment_stage_id.post_onboarding_form') string='Request Forms'
)
post_onboarding_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft') post_onboarding_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft')
doc_requests_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft')
legend_blocked = fields.Char(related='recruitment_stage_id.legend_blocked', string='Kanban Blocked') legend_blocked = fields.Char(related='recruitment_stage_id.legend_blocked', string='Kanban Blocked')
legend_done = fields.Char(related='recruitment_stage_id.legend_done', string='Kanban Valid') legend_done = fields.Char(related='recruitment_stage_id.legend_done', string='Kanban Valid')
legend_normal = fields.Char(related='recruitment_stage_id.legend_normal', string='Kanban Ongoing') legend_normal = fields.Char(related='recruitment_stage_id.legend_normal', string='Kanban Ongoing')
# holding_offer = fields.HTML()
employee_code = fields.Char(related="employee_id.employee_id") employee_code = fields.Char(related="employee_id.employee_id")
recruitment_attachments = fields.Many2many( recruitment_attachments = fields.Many2many(
@ -193,37 +285,50 @@ class HRApplicant(models.Model):
def preview_resume(self): def preview_resume(self):
pass pass
# for record in self:
# if record.resume:
# attachment = self.env.ref("hr_recruitment_extended.employee_recruitment_attachments_preview")
# attachment.datas = record.resume
# return {
# 'name': "File Preview",
# 'type': 'ir.actions.act_url',
# 'url': f'/web/content/{attachment.id}?download=false',
# 'target': 'current', # Opens in a new tab
# }
def submit_to_client(self): def replace_joining_attachments(self, attachments_data):
for rec in self: self.ensure_one()
submitted_count = len(self.sudo().search([('id','!=',rec.id),('submitted_to_client','=',True)]).ids)
if submitted_count >= rec.hr_job_recruitment.no_of_eligible_submissions: latest_attachments = {}
warnings.warn( for attachment in attachments_data or []:
"Max no of submissions for this JD has been reached", attachment_id = attachment.get('attachment_rec_id')
DeprecationWarning, file_content = attachment.get('file_content')
) if not attachment_id or not file_content:
return { continue
'type': 'ir.actions.act_window', latest_attachments[int(attachment_id)] = {
'name': 'Submission', 'name': attachment.get('file_name', ''),
'res_model': 'client.submission.mails.template.wizard', 'recruitment_attachment_id': int(attachment_id),
'view_mode': 'form', 'file': file_content,
'view_id': self.env.ref('hr_recruitment_extended.view_client_submission_mails_template_wizard_form').id,
'target': 'new',
'context': {'default_template_id': self.env.ref(
"hr_recruitment_extended.application_client_submission_email_template").id,
},
} }
if not latest_attachments:
return
existing_lines = self.joining_attachment_ids.filtered(
lambda line: line.recruitment_attachment_id.id in latest_attachments
)
if existing_lines:
existing_lines.unlink()
self.write({
'joining_attachment_ids': [(0, 0, values) for values in latest_attachments.values()]
})
def action_share_applicant(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Share Applicant'),
'res_model': 'client.submission.mails.template.wizard',
'view_mode': 'form',
'view_id': self.env.ref('hr_recruitment_extended.view_client_submission_mails_template_wizard_form').id,
'target': 'new',
'context': {
'default_template_id': self.env.ref(
'hr_recruitment_extended.application_client_submission_email_template'
).id,
},
}
def submit_for_approval(self): def submit_for_approval(self):
for rec in self: for rec in self:
@ -231,7 +336,6 @@ class HRApplicant(models.Model):
if not manager_id: if not manager_id:
raise ValidationError(_("Recruitment Manager is not selected please go into the Configuration->Settings and add the Manager")) raise ValidationError(_("Recruitment Manager is not selected please go into the Configuration->Settings and add the Manager"))
mail_template = self.env.ref('hr_recruitment_extended.email_template_candidate_approval') mail_template = self.env.ref('hr_recruitment_extended.email_template_candidate_approval')
# menu_id = self.env.ref('hr_recruitment.menu_crm_case_categ0_act_job').id
manager_id = self.env['res.users'].sudo().browse(int(manager_id)) manager_id = self.env['res.users'].sudo().browse(int(manager_id))
render_ctx = dict(recruitment_manager=manager_id) render_ctx = dict(recruitment_manager=manager_id)
mail_template.with_context(render_ctx).send_mail( mail_template.with_context(render_ctx).send_mail(
@ -247,7 +351,6 @@ class HRApplicant(models.Model):
raise ValidationError( raise ValidationError(
_("Recruitment Manager is not selected please go into the Configuration->Settings and add the Manager")) _("Recruitment Manager is not selected please go into the Configuration->Settings and add the Manager"))
mail_template = self.env.ref('hr_recruitment_extended.email_template_stage_approved') mail_template = self.env.ref('hr_recruitment_extended.email_template_stage_approved')
# menu_id = self.env.ref('hr_recruitment.menu_crm_case_categ0_act_job').id
manager_id = self.env['res.users'].sudo().browse(int(manager_id)) manager_id = self.env['res.users'].sudo().browse(int(manager_id))
render_ctx = dict(recruitment_manager=manager_id) render_ctx = dict(recruitment_manager=manager_id)
mail_template.with_context(render_ctx).send_mail( mail_template.with_context(render_ctx).send_mail(
@ -280,7 +383,7 @@ class HRApplicant(models.Model):
template.send_mail(applicant.id, force_send=True) template.send_mail(applicant.id, force_send=True)
applicant.second_application_form_status = 'email_sent_to_candidate' applicant.second_application_form_status = 'email_sent_to_candidate'
def send_post_onboarding_form_to_candidate(self): def send_jod_form_to_employee(self):
for rec in self: for rec in self:
if not rec.employee_id: if not rec.employee_id:
raise ValidationError(_('You must first create the employee before before Sending the Post Onboarding Form')) raise ValidationError(_('You must first create the employee before before Sending the Post Onboarding Form'))

View File

@ -0,0 +1,21 @@
from odoo import api, fields, models
class HrApplicantStageComment(models.Model):
_name = 'hr.applicant.stage.comment'
_description = 'Applicant Stage Comment'
_order = 'create_date desc, id desc'
applicant_id = fields.Many2one('hr.applicant', required=True, ondelete='cascade', index=True)
stage_id = fields.Many2one('hr.recruitment.stage', required=True, index=True)
comment = fields.Text(required=True)
user_id = fields.Many2one('res.users', default=lambda self: self.env.user, required=True)
comment_date = fields.Datetime(default=fields.Datetime.now, required=True)
@api.depends('applicant_id', 'stage_id')
def _compute_display_name(self):
for comment in self:
comment.display_name = '%s - %s' % (
comment.applicant_id.display_name or '',
comment.stage_id.display_name or '',
)

View File

@ -13,6 +13,8 @@ class HRJobRecruitment(models.Model):
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_inherits = {'hr.job': 'job_id'} _inherits = {'hr.job': 'job_id'}
_rec_name = 'recruitment_sequence' _rec_name = 'recruitment_sequence'
_order = 'id desc'
active = fields.Boolean(default=True) active = fields.Boolean(default=True)
hide_chatter_suggestion = fields.Boolean(string="Hide Chatter Suggestions", default=False, tracking=True) hide_chatter_suggestion = fields.Boolean(string="Hide Chatter Suggestions", default=False, tracking=True)
@ -148,7 +150,6 @@ class HRJobRecruitment(models.Model):
target_from = fields.Date(string="This is the date in which we starting the recruitment process", target_from = fields.Date(string="This is the date in which we starting the recruitment process",
default=fields.Date.today, tracking=True) default=fields.Date.today, tracking=True)
target_to = fields.Date(string='This is the target end date', tracking=True) target_to = fields.Date(string='This is the target end date', tracking=True)
# hiring_history = fields.One2many('recruitment.status.history', 'job_id', string='History')
is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite',store=True, tracking=True) is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite',store=True, tracking=True)
department_id = fields.Many2one('hr.department', string='Department', check_company=True, tracking=True) department_id = fields.Many2one('hr.department', string='Department', check_company=True, tracking=True)
description = fields.Html(string='Job Description', sanitize_attributes=False) description = fields.Html(string='Job Description', sanitize_attributes=False)
@ -159,7 +160,6 @@ class HRJobRecruitment(models.Model):
help='Number of employees currently occupying this job position.') help='Number of employees currently occupying this job position.')
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, tracking=True, exportable=False) company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, tracking=True, exportable=False)
contract_type_id = fields.Many2one('hr.contract.type', string='Employment Type', tracking=True) contract_type_id = fields.Many2one('hr.contract.type', string='Employment Type', tracking=True)
# active = fields.Boolean(default=True)
user_id = fields.Many2one('res.users', "Recruiter", user_id = fields.Many2one('res.users', "Recruiter",
domain="[('share', '=', False), ('company_ids', 'in', company_id)]", domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
default=lambda self: self.env.user, default=lambda self: self.env.user,
@ -392,6 +392,22 @@ class HRJobRecruitment(models.Model):
}, },
} }
def action_share_job_recruitment(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Share Job Description'),
'res_model': 'client.submission.mails.template.wizard',
'view_mode': 'form',
'view_id': self.env.ref('hr_recruitment_extended.view_client_submission_mails_template_wizard_form').id,
'target': 'new',
'context': {
'default_template_id': self.env.ref(
'hr_recruitment_extended.job_recruitment_share_email_template'
).id,
},
}
@api.onchange('requested_by') @api.onchange('requested_by')
def _onchange_requested_by(self): def _onchange_requested_by(self):
for rec in self: for rec in self:

View File

@ -161,71 +161,6 @@ class HrCandidate(models.Model):
employee.write({ employee.write({
'image_1920': self.candidate_image}) 'image_1920': self.candidate_image})
return action return action
#
# doj = fields.Date(tracking=True)
# gender = fields.Selection([
# ('male', 'Male'),
# ('female', 'Female'),
# ('other', 'Other')
# ], tracking=True)
# birthday = fields.Date(tracking=True)
#
# blood_group = fields.Selection([
# ('A+', 'A+'),
# ('A-', 'A-'),
# ('B+', 'B+'),
# ('B-', 'B-'),
# ('O+', 'O+'),
# ('O-', 'O-'),
# ('AB+', 'AB+'),
# ('AB-', 'AB-'),
# ], string="Blood Group")
#
# private_street = fields.Char(string="Private Street", groups="hr.group_hr_user")
# private_street2 = fields.Char(string="Private Street2", groups="hr.group_hr_user")
# private_city = fields.Char(string="Private City", groups="hr.group_hr_user")
# private_state_id = fields.Many2one(
# "res.country.state", string="Private State",
# domain="[('country_id', '=?', private_country_id)]",
# groups="hr.group_hr_user")
# private_zip = fields.Char(string="Private Zip", groups="hr.group_hr_user")
# private_country_id = fields.Many2one("res.country", string="Private Country", groups="hr.group_hr_user")
#
# permanent_street = fields.Char(string="permanent Street", groups="hr.group_hr_user")
# permanent_street2 = fields.Char(string="permanent Street2", groups="hr.group_hr_user")
# permanent_city = fields.Char(string="permanent City", groups="hr.group_hr_user")
# permanent_state_id = fields.Many2one(
# "res.country.state", string="permanent State",
# domain="[('country_id', '=?', private_country_id)]",
# groups="hr.group_hr_user")
# permanent_zip = fields.Char(string="permanent Zip", groups="hr.group_hr_user")
# permanent_country_id = fields.Many2one("res.country", string="permanent Country", groups="hr.group_hr_user")
#
# marital = fields.Selection(
# selection='_get_marital_status_selection',
# string='Marital Status',
# groups="hr.group_hr_user",
# default='single',
# required=True,
# tracking=True)
#
# marriage_anniversary_date = fields.Date(string='Anniversary Date' ,tracking=True)
#
# #bank Details:
#
# full_name_as_in_bank = fields.Char(string='Full Name (as per bank)' ,tracking=True)
# bank_name = fields.Char(string='Bank Name' ,tracking=True)
# bank_branch = fields.Char(string='Bank Branch' ,tracking=True)
# bank_account_no = fields.Char(string='Bank Account N0' ,tracking=True)
# bank_ifsc_code = fields.Char(string='Bank IFSC Code' ,tracking=True)
#
#
# #passport details:
# passport_no = fields.Char(string="Passport No",tracking=True)
# passport_start_date = fields.Date(string="Start Date",tracking=True)
# passport_end_date = fields.Date(string="End Date",tracking=True)
# passport_issued_location = fields.Char(string="Start Date",tracking=True)
#
# #authotentication Details # #authotentication Details
# pan_no = fields.Char(string='PAN No',tracking=True) # pan_no = fields.Char(string='PAN No',tracking=True)
# identification_id = fields.Char(string='Aadhar No',tracking=True) # identification_id = fields.Char(string='Aadhar No',tracking=True)
@ -505,29 +440,6 @@ class Location(models.Model):
if record.zip_code and not record.zip_code.isdigit(): # Check if zip_code exists and is not digit if record.zip_code and not record.zip_code.isdigit(): # Check if zip_code exists and is not digit
raise ValidationError("Zip Code should contain only numeric characters. Please enter a valid zip code.") raise ValidationError("Zip Code should contain only numeric characters. Please enter a valid zip code.")
#
# class RecruitmentHistory(models.Model):
# _name='recruitment.status.history'
#
# date_from = fields.Date(string='Date From')
# date_end = fields.Date(string='Date End')
# target = fields.Integer(string='Target')
# job_id = fields.Many2one('hr.job', string='Job Position') # Ensure this field exists
# hired = fields.Many2many('hr.applicant')
#
# @api.depends('date_from', 'date_end', 'job_id')
# def _total_hired_users(self):
# for rec in self:
# if rec.date_from:
# # Use `date_end` or today's date if `date_end` is not provided
# date_end = rec.date_end or date.today()
#
# # Search for applicants matching the conditions
# hired_applicants = self.env['hr.applicant'].search([
# ('date_closed', '>=', rec.date_from),
# ('date_closed', '<=', date_end),
# ('job_id', '=', rec.job_id.id)
# ])
# rec.hired = hired_applicants # rec.hired = hired_applicants
# else: # else:
# rec.hired = False # rec.hired = False

View File

@ -10,8 +10,8 @@ class RecruitmentStage(models.Model):
job_recruitment_ids = fields.Many2many( job_recruitment_ids = fields.Many2many(
'hr.job.recruitment', string='Job Specific', 'hr.job.recruitment', string='Job Specific',
help='Specific jobs that use this stage. Other jobs will not use this stage.') help='Specific jobs that use this stage. Other jobs will not use this stage.')
second_application_form = fields.Boolean(default=False) # second_application_form = fields.Boolean(default=False)
post_onboarding_form = fields.Boolean(default=False) # post_onboarding_form = fields.Boolean(default=False)
require_approval = fields.Boolean(default=False) require_approval = fields.Boolean(default=False)
stage_color = fields.Char('Stage Color', default='#FFFFFF', help="Choose a color for the recruitment stage", widget='color') stage_color = fields.Char('Stage Color', default='#FFFFFF', help="Choose a color for the recruitment stage", widget='color')

View File

@ -26,9 +26,12 @@ hr_recruitment.access_hr_recruitment_stage_user,hr.recruitment.stage.user,hr_rec
access_hr_recruitment_stage_hr,hr.recruitment.stage.hr,hr_recruitment.model_hr_recruitment_stage,hr.group_hr_manager,1,0,0,0 access_hr_recruitment_stage_hr,hr.recruitment.stage.hr,hr_recruitment.model_hr_recruitment_stage,hr.group_hr_manager,1,0,0,0
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1 access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
access_hr_applicant_stage_comment_user,hr.applicant.stage.comment.user,model_hr_applicant_stage_comment,base.group_user,1,1,1,0
access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1 access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1 access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
access_applicant_stage_comment_wizard,applicant.stage.comment.wizard.user,model_applicant_stage_comment_wizard,base.group_user,1,1,1,1
access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0 access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
access_hr_application_group_hr,hr.applicant.hr.access,hr_recruitment.model_hr_applicant,hr.group_hr_manager,1,1,0,0 access_hr_application_group_hr,hr.applicant.hr.access,hr_recruitment.model_hr_applicant,hr.group_hr_manager,1,1,0,0
access_applicant_request_forms_hr_user,access.applicant.request.forms.hr.user,model_applicant_request_forms,hr.group_hr_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
26
27
28
29
30
31
32
33
34
35
36
37

View File

@ -38,21 +38,6 @@
<value eval=" {'noupdate': True} "/> <value eval=" {'noupdate': True} "/>
</function> </function>
<!-- <record id="hr_job_recruitment_rule" model="ir.rule">-->
<!-- <field name="name">Applicant Interviewer</field>-->
<!-- <field name="model_id" ref="model_hr_applicant"/>-->
<!-- <field name="domain_force">[-->
<!-- '|',-->
<!-- ('job_id.interviewer_ids', 'in', user.id),-->
<!-- ('interviewer_ids', 'in', user.id),-->
<!-- ]</field>-->
<!-- <field name="perm_create" eval="False"/>-->
<!-- <field name="perm_unlink" eval="False"/>-->
<!-- <field name="groups" eval="[(4, ref('hr_recruitment.group_hr_recruitment_interviewer'))]"/>-->
<!-- </record>-->
<record id="hr_recruitment.hr_applicant_interviewer_rule" model="ir.rule"> <record id="hr_recruitment.hr_applicant_interviewer_rule" model="ir.rule">
<field name="name">Applicant Interviewer</field> <field name="name">Applicant Interviewer</field>
<field name="domain_force">[ <field name="domain_force">[

View File

@ -26,16 +26,14 @@ publicWidget.registry.hrRecruitmentDocs = publicWidget.Widget.extend({
addUploadedFileRow(attachmentId, file, base64String) { addUploadedFileRow(attachmentId, file, base64String) {
const tableBody = this.$(`#preview_body_${attachmentId}`); const tableBody = this.$(`#preview_body_${attachmentId}`);
if (!this.uploadedFiles[attachmentId]) { this.uploadedFiles[attachmentId] = [];
this.uploadedFiles[attachmentId] = []; tableBody.empty();
}
// Generate a unique file ID using attachmentId and a timestamp
const fileId = `${attachmentId}-${Date.now()}`; const fileId = `${attachmentId}-${Date.now()}`;
const fileRecord = { const fileRecord = {
attachment_rec_id : attachmentId, attachment_rec_id: parseInt(attachmentId, 10),
id: fileId, // Unique file ID id: fileId,
name: file.name, name: file.name,
base64: base64String, base64: base64String,
type: file.type, type: file.type,
@ -43,36 +41,33 @@ publicWidget.registry.hrRecruitmentDocs = publicWidget.Widget.extend({
this.uploadedFiles[attachmentId].push(fileRecord); this.uploadedFiles[attachmentId].push(fileRecord);
const fileIndex = this.uploadedFiles[attachmentId].length - 1;
const previewImageId = `preview_image_${fileId}`;
const fileNameInputId = `file_name_input_${fileId}`; const fileNameInputId = `file_name_input_${fileId}`;
const previewWrapperId = `preview_wrapper_${fileId}`;
let previewContent = ''; let previewContent = '';
let previewClickHandler = ''; let previewClickHandler = '';
// Check if the file is an image or PDF and set preview content accordingly
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
previewContent = `<div class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;"> previewContent = `<div id="${previewWrapperId}" class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;">
<img src="data:image/png;base64,${base64String}" style="max-width: 100%; max-height: 100%; object-fit: contain; cursor: pointer;" /> <img src="data:image/png;base64,${base64String}" style="max-width: 100%; max-height: 100%; object-fit: contain; cursor: pointer;" />
</div>`; </div>`;
previewClickHandler = () => { previewClickHandler = () => {
this.$('#modal_attachment_photo_preview').attr('src', `data:image/png;base64,${base64String}`); this.$('#modal_attachment_photo_preview').attr('src', `data:image/png;base64,${base64String}`);
this.$('#modal_attachment_photo_preview').show(); this.$('#modal_attachment_photo_preview').show();
this.$('#modal_attachment_pdf_preview').hide(); // Hide PDF preview this.$('#modal_attachment_pdf_preview').hide();
this.$('#attachmentPreviewModal').modal('show'); this.$('#attachmentPreviewModal').modal('show');
}; };
} else if (file.type === 'application/pdf') { } else if (file.type === 'application/pdf') {
previewContent = `<div class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;"> previewContent = `<div id="${previewWrapperId}" class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;">
<iframe src="data:application/pdf;base64,${base64String}" style="width: 100%; height: 100%; border: none; cursor: pointer;"></iframe> <iframe src="data:application/pdf;base64,${base64String}" style="width: 100%; height: 100%; border: none; cursor: pointer;"></iframe>
</div>`; </div>`;
previewClickHandler = () => { previewClickHandler = () => {
this.$('#modal_attachment_pdf_preview').attr('src', `data:application/pdf;base64,${base64String}`); this.$('#modal_attachment_pdf_preview').attr('src', `data:application/pdf;base64,${base64String}`);
this.$('#modal_attachment_pdf_preview').show(); this.$('#modal_attachment_pdf_preview').show();
this.$('#modal_attachment_photo_preview').hide(); // Hide image preview this.$('#modal_attachment_photo_preview').hide();
this.$('#attachmentPreviewModal').modal('show'); this.$('#attachmentPreviewModal').modal('show');
}; };
} }
// Append new row to the table with a preview and buttons
tableBody.append(` tableBody.append(`
<tr data-attachment-id="${attachmentId}" data-file-id="${fileId}"> <tr data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<td> <td>
@ -96,10 +91,7 @@ publicWidget.registry.hrRecruitmentDocs = publicWidget.Widget.extend({
this.$(`#preview_table_container_${attachmentId}`).removeClass('d-none'); this.$(`#preview_table_container_${attachmentId}`).removeClass('d-none');
// Attach click handler for preview (image or PDF)
this.$(`#preview_wrapper_${fileId}`).on('click', previewClickHandler); this.$(`#preview_wrapper_${fileId}`).on('click', previewClickHandler);
// Attach click handler for the preview button (to trigger modal)
this.$(`.preview-btn[data-attachment-id="${attachmentId}"][data-file-id="${fileId}"]`).on('click', previewClickHandler); this.$(`.preview-btn[data-attachment-id="${attachmentId}"][data-file-id="${fileId}"]`).on('click', previewClickHandler);
}, },
@ -108,17 +100,14 @@ publicWidget.registry.hrRecruitmentDocs = publicWidget.Widget.extend({
const attachmentId = $(button).data('attachment-id'); const attachmentId = $(button).data('attachment-id');
const fileId = $(button).data('file-id'); const fileId = $(button).data('file-id');
// Find the index of the file to delete based on unique file ID
const fileIndex = this.uploadedFiles[attachmentId].findIndex(f => f.id === fileId); const fileIndex = this.uploadedFiles[attachmentId].findIndex(f => f.id === fileId);
if (fileIndex !== -1) { if (fileIndex !== -1) {
this.uploadedFiles[attachmentId].splice(fileIndex, 1); // Remove from array this.uploadedFiles[attachmentId].splice(fileIndex, 1);
} }
// Remove the row from DOM
this.$(`tr[data-file-id="${fileId}"]`).remove(); this.$(`tr[data-file-id="${fileId}"]`).remove();
// Hide table if no files left
if (this.uploadedFiles[attachmentId].length === 0) { if (this.uploadedFiles[attachmentId].length === 0) {
this.$(`#preview_table_container_${attachmentId}`).addClass('d-none'); this.$(`#preview_table_container_${attachmentId}`).addClass('d-none');
} }
@ -130,15 +119,15 @@ publicWidget.registry.hrRecruitmentDocs = publicWidget.Widget.extend({
const attachmentId = $(input).data('attachment-id'); const attachmentId = $(input).data('attachment-id');
if (input.files.length > 0) { if (input.files.length > 0) {
Array.from(input.files).forEach((file) => { const file = input.files[input.files.length - 1];
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const base64String = e.target.result.split(',')[1]; const base64String = e.target.result.split(',')[1];
this.addUploadedFileRow(attachmentId, file, base64String); this.addUploadedFileRow(attachmentId, file, base64String);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
});
} }
input.value = '';
}, },
handleUploadNewFile(ev) { handleUploadNewFile(ev) {

View File

@ -0,0 +1,27 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import {
statusBarDurationField,
StatusBarDurationField,
} from "@mail/views/fields/statusbar_duration/statusbar_duration_field";
export class RecruitmentStatusBarDurationField extends StatusBarDurationField {
getItemTooltip(item) {
const tooltip = super.getItemTooltip(item);
const comments = this.props.record.data.stage_comment_tooltips || {};
const comment = comments[item.value];
return comment ? `${tooltip}\n\n${comment}` : tooltip;
}
}
export const recruitmentStatusBarDurationField = {
...statusBarDurationField,
component: RecruitmentStatusBarDurationField,
fieldDependencies: [
...statusBarDurationField.fieldDependencies,
{ name: "stage_comment_tooltips", type: "JSON" },
],
};
registry.category("fields").add("recruitment_statusbar_duration", recruitmentStatusBarDurationField);

View File

@ -0,0 +1,12 @@
.o_kanban_record .o_hr_applicant_kanban_card_hold {
background: linear-gradient(180deg, #f5f5f5 0%, #ececec 100%);
border-radius: 12px;
box-shadow: inset 0 0 0 1px rgba(108, 117, 125, 0.18);
filter: grayscale(0.2);
opacity: 0.92;
}
.o_kanban_record .o_hr_applicant_kanban_card_hold .badge,
.o_kanban_record .o_hr_applicant_kanban_card_hold .o_tag {
filter: saturate(0.7);
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_recruitment_extended.RecruitmentStatusBarDurationField.Dropdown" t-inherit="mail.StatusBarDurationField.Dropdown" t-inherit-mode="extension">
<xpath expr="//DropdownItem" position="attributes">
<attribute name="attrs">{ title: getItemTooltip(item) }</attribute>
</xpath>
</t>
<t t-name="hr_recruitment_extended.RecruitmentStatusBarDurationField" t-inherit="mail.StatusBarDurationField" t-inherit-mode="extension">
<xpath expr="//button[@role='radio']" position="attributes">
<attribute name="t-att-title">getItemTooltip(item)</attribute>
</xpath>
</t>
</templates>

View File

@ -10,7 +10,6 @@
<field name="experience_code" placeholder = "E1" required="1" width="30%"/> <field name="experience_code" placeholder = "E1" required="1" width="30%"/>
<field name="experience_from" required="1" placeholder="0" /> <field name="experience_from" required="1" placeholder="0" />
<field name="experience_to" required="1" placeholder="2" /> <field name="experience_to" required="1" placeholder="2" />
<!-- <field name="active"/>-->
</list> </list>
</field> </field>
</record> </record>

View File

@ -5,11 +5,21 @@
<field name="inherit_id" ref="hr_recruitment.crm_case_tree_view_job"/> <field name="inherit_id" ref="hr_recruitment.crm_case_tree_view_job"/>
<field name="model">hr.applicant</field> <field name="model">hr.applicant</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="decoration-muted">hold_state == 'hold'</attribute>
</xpath>
<xpath expr="//field[@name='message_needaction']" position="after">
<field name="is_on_hold" column_invisible="1"/>
</xpath>
<xpath expr="//field[@name='stage_id']" position="attributes"> <xpath expr="//field[@name='stage_id']" position="attributes">
<attribute name="column_invisible">1</attribute> <attribute name="column_invisible">1</attribute>
</xpath> </xpath>
<xpath expr="//field[@name='stage_id']" position="after"> <xpath expr="//field[@name='stage_id']" position="after">
<field name="recruitment_stage_id"/> <field name="recruitment_stage_id"/>
<field name="hold_state" widget="badge" decoration-muted="hold_state == 'hold'" optional="show" nolabel="1"/>
<field name="submitted_to_client" optional="show"/>
<field name="client_submission_date" optional="show"/>
<field name="submitted_stage" optional="hide"/>
<field name="primary_skill_match_percentage" optional="hide"/> <field name="primary_skill_match_percentage" optional="hide"/>
<field name="secondary_skill_match_percentage" optional="hide"/> <field name="secondary_skill_match_percentage" optional="hide"/>
<field name="overall_skill_match_percentage" optional="hide"/> <field name="overall_skill_match_percentage" optional="hide"/>
@ -23,6 +33,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//button[@name='archive_applicant']" position="attributes"> <xpath expr="//button[@name='archive_applicant']" position="attributes">
<attribute name="string">Reject</attribute> <attribute name="string">Reject</attribute>
<attribute name="invisible">not active or is_on_hold</attribute>
</xpath> </xpath>
<xpath expr="//button[@name='archive_applicant']" position="before"> <xpath expr="//button[@name='archive_applicant']" position="before">
<field name="is_on_hold" invisible="1" force_save="1"/> <field name="is_on_hold" invisible="1" force_save="1"/>
@ -33,21 +44,34 @@
groups="hr_recruitment.group_hr_recruitment_user" groups="hr_recruitment.group_hr_recruitment_user"
invisible="application_status in ['refused'] or not is_on_hold"/> invisible="application_status in ['refused'] or not is_on_hold"/>
<button string="Submit" name="submit_for_approval" type="object" class="oe_stat_button" <button string="Submit" name="submit_for_approval" type="object" class="oe_stat_button"
invisible="not approval_required or application_submitted" groups="base.group_user"/> invisible="is_on_hold or not approval_required or application_submitted" groups="base.group_user"/>
<button string="Approve" name="approve_applicant" type="object" class="oe_stat_button" <button string="Approve" name="approve_applicant" type="object" class="oe_stat_button"
invisible="not approval_required or not application_submitted" invisible="is_on_hold or not approval_required or not application_submitted"
groups="hr_recruitment.group_hr_recruitment_user"/> groups="hr_recruitment.group_hr_recruitment_user"/>
<button name="submit_to_client" string="Send to Client" type="object" class="btn-primary" <button name="action_share_applicant" string="Share" type="object" class="btn-primary"
groups="hr_recruitment.group_hr_recruitment_user" groups="hr_recruitment.group_hr_recruitment_user"
invisible="submitted_to_client or application_status in ['refused']"/> invisible="not id"/>
</xpath> </xpath>
<xpath expr="//button[@name='create_employee_from_applicant']" position="attributes">
<attribute name="invisible">employee_id or not active or not date_closed or is_on_hold</attribute>
</xpath>
<xpath expr="//button[@name='action_create_meeting']" position="attributes">
<attribute name="invisible">not id or is_on_hold</attribute>
</xpath>
<xpath expr="//button[@name='toggle_active']" position="attributes">
<attribute name="invisible">active or is_on_hold</attribute>
</xpath>
<xpath expr="//sheet" position="attributes">
<attribute name="readonly">is_on_hold</attribute>
</xpath>
<xpath expr="//field[@name='kanban_state']" position="after"> <xpath expr="//field[@name='kanban_state']" position="after">
<div class="o_employee_avatar m-0 p-0"> <div class="o_employee_avatar m-0 p-0">
<field name="candidate_image" widget="image" class="oe_avatar m-0" <field name="candidate_image" widget="image" class="oe_avatar m-0"
options="{&quot;zoom&quot;: true, &quot;preview_image&quot;:&quot;candidate_image&quot;}"/> options="{&quot;zoom&quot;: true, &quot;preview_image&quot;:&quot;candidate_image&quot;}"/>
</div> </div>
<widget name="web_ribbon" title="ON HOLD" bg_color="text-bg-secondary" invisible="not is_on_hold"/>
<widget name="web_ribbon" title="Awaiting Approval" bg_color="text-bg-warning" invisible="not approval_required or not application_submitted"/> <widget name="web_ribbon" title="Awaiting Approval" bg_color="text-bg-warning" invisible="not approval_required or not application_submitted"/>
</xpath> </xpath>
@ -60,14 +84,18 @@
</xpath> </xpath>
<xpath expr="//field[@name='job_id']" position="after"> <xpath expr="//field[@name='job_id']" position="after">
<field name="employee_id" invisible="1"/> <field name="employee_id" invisible="1"/>
<field name="send_second_application_form"/> </xpath>
<field name="second_application_form_status" readonly="not send_second_application_form"/> <xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_stage_comment_wizard" type="object" class="oe_stat_button" icon="fa-comments">
<field name="send_post_onboarding_form"/> <field name="stage_comment_count" widget="statinfo" string="Stage Notes"/>
<field name="post_onboarding_form_status" readonly="not send_post_onboarding_form"/> </button>
<field name="doc_requests_form_status" readonly="1"/>
</xpath> </xpath>
<xpath expr="//page[@name='application_details']/group[1]" position="after"> <xpath expr="//page[@name='application_details']/group[1]" position="after">
<group string="Client Submission">
<field name="submitted_to_client"/>
<field name="client_submission_date" readonly="not submitted_to_client"/>
<field name="submitted_stage" readonly="not submitted_to_client"/>
</group>
<group string="Skill Matching"> <group string="Skill Matching">
<field name="primary_skill_match_percentage" readonly="1"/> <field name="primary_skill_match_percentage" readonly="1"/>
<field name="secondary_skill_match_percentage" readonly="1"/> <field name="secondary_skill_match_percentage" readonly="1"/>
@ -79,25 +107,14 @@
<attribute name="invisible">1</attribute> <attribute name="invisible">1</attribute>
</xpath> </xpath>
<xpath expr="//field[@name='stage_id']" position="before"> <xpath expr="//field[@name='stage_id']" position="before">
<field name="stage_comment_tooltips" invisible="1"/>
<field name="recruitment_stage_id" widget="statusbar_duration" <field name="recruitment_stage_id" widget="statusbar_duration"
options="{'clickable': '1', 'fold_field': 'fold'}" invisible="not active and not employee_id" options="{'clickable': '1', 'fold_field': 'fold'}" invisible="not active and not employee_id"
readonly="approval_required or is_on_hold" force_save="1"/> readonly="approval_required or is_on_hold" force_save="1"/>
</xpath> </xpath>
<!-- <xpath expr="//form" position="after">-->
<!-- </xpath>-->
<xpath expr="//header" position="inside"> <xpath expr="//header" position="inside">
<button string="Send Second Application Form" name="send_second_application_form_to_candidate"
type="object" groups="hr.group_hr_user"
invisible="not send_second_application_form or second_application_form_status in ['email_sent_to_candidate','done']"/>
<button string="Send Post Onboarding Form" name="send_post_onboarding_form_to_candidate" type="object"
groups="hr.group_hr_user"
invisible="not employee_id or not send_post_onboarding_form or post_onboarding_form_status in ['email_sent_to_candidate','done']"/>
<button string="Request Documents" name="send_pre_onboarding_doc_request_form_to_candidate" type="object"
groups="hr.group_hr_user"
invisible="doc_requests_form_status in ['email_sent_to_candidate'] or send_post_onboarding_form"/>
</xpath> </xpath>
<xpath expr="//notebook" position="inside"> <xpath expr="//page[@name='application_details']" position="after">
<page name="Attachments" id="attachment_ids_page"> <page name="Attachments" id="attachment_ids_page">
<field name="recruitment_attachments" widget="many2many_tags"/> <field name="recruitment_attachments" widget="many2many_tags"/>
@ -107,7 +124,7 @@
name="action_validate_attachments" name="action_validate_attachments"
type="object" type="object"
class="btn btn-success" class="btn btn-success"
invisible="attachments_validation_status == 'pending'" invisible="attachments_validation_status == 'pending' or not employee_id"
style="width: 100%;"> style="width: 100%;">
<div> <div>
Click here to save &amp; Update Employee Data (attachments) Click here to save &amp; Update Employee Data (attachments)
@ -121,7 +138,7 @@
name="action_validate_attachments" name="action_validate_attachments"
type="object" type="object"
class="btn btn-danger" class="btn btn-danger"
invisible="attachments_validation_status == 'validated'" invisible="attachments_validation_status == 'validated' or not employee_id"
style="width: 100%;"> style="width: 100%;">
<div> <div>
Click here to save, Validate and Update into Employee Data (attachments) Click here to save, Validate and Update into Employee Data (attachments)
@ -161,6 +178,27 @@
</group> </group>
</sheet> </sheet>
</xpath> </xpath>
<xpath expr="//notebook/page[@name='additional_info']" position="after">
<page string="Request Forms" name="request_forms">
<div class="oe_title" style="display:inline-flex; align-items:center; gap:4px;">
<label for="post_onboarding_form_status" string="JOD Status:"/>
<field name="post_onboarding_form_status" class="oe_inline"/>
</div>
<field name="request_form_ids">
<list editable="bottom">
<field name="form_type"/>
<button name="action_send_form"
string="Send / Resend"
type="object"
class="btn-primary"/>
<field name="status" readonly="1" force_save="1"/>
<field name="send_date" readonly="1" force_save="1" optional="hide"/>
<field name="applicant_submitted_date" readonly="1" force_save="1" optional="hide"/>
</list>
</field>
</page>
</xpath>
<xpath expr="//chatter" position="attributes"> <xpath expr="//chatter" position="attributes">
<attribute name="invisible">hide_chatter_suggestion</attribute> <attribute name="invisible">hide_chatter_suggestion</attribute>
</xpath> </xpath>
@ -181,10 +219,13 @@
<xpath expr="//search/field[@name='job_id']" position="after"> <xpath expr="//search/field[@name='job_id']" position="after">
<field name="hr_job_recruitment"/> <field name="hr_job_recruitment"/>
<field name="recruitment_stage_id" domain="[]"/> <field name="recruitment_stage_id" domain="[]"/>
<field name="submitted_stage" domain="[]"/>
<field name="approval_required"/> <field name="approval_required"/>
</xpath> </xpath>
<xpath expr="//filter[@name='my_applications']" position="after"> <xpath expr="//filter[@name='my_applications']" position="after">
<filter name="to_approve" string="To Approve" domain="[('approval_required', '=', True),('application_submitted', '=', True)]"/> <filter name="to_approve" string="To Approve" domain="[('approval_required', '=', True),('application_submitted', '=', True)]"/>
<filter name="submitted_to_client" string="Submitted to Client" domain="[('submitted_to_client', '=', True)]"/>
<filter name="not_submitted_to_client" string="Not Submitted to Client" domain="[('submitted_to_client', '=', False)]"/>
</xpath> </xpath>
<xpath expr="//search/group" position="inside"> <xpath expr="//search/group" position="inside">
<filter string="Job Recruitment" name="job_recruitment" domain="[]" <filter string="Job Recruitment" name="job_recruitment" domain="[]"
@ -192,6 +233,10 @@
<filter string="Job Recruitment Stage" name="job_recruitment_stage" domain="[]" <filter string="Job Recruitment Stage" name="job_recruitment_stage" domain="[]"
context="{'group_by': 'recruitment_stage_id'}"/> context="{'group_by': 'recruitment_stage_id'}"/>
<filter string="Client Submission" name="client_submission_group" domain="[]"
context="{'group_by': 'submitted_to_client'}"/>
<filter string="Submitted Stage" name="submitted_stage_group" domain="[]"
context="{'group_by': 'submitted_stage'}"/>
</xpath> </xpath>
<xpath expr="//search/group/filter[@name='stage']" position="attributes"> <xpath expr="//search/group/filter[@name='stage']" position="attributes">
<attribute name="invisible">1</attribute> <attribute name="invisible">1</attribute>
@ -207,6 +252,43 @@
<xpath expr="//kanban" position="attributes"> <xpath expr="//kanban" position="attributes">
<attribute name="default_group_by">recruitment_stage_id</attribute> <attribute name="default_group_by">recruitment_stage_id</attribute>
</xpath> </xpath>
<xpath expr="//field[@name='application_status']" position="after">
<field name="is_on_hold" invisible="1"/>
</xpath>
<xpath expr="//t[@t-name='menu']/a[@name='action_create_meeting']" position="attributes">
<attribute name="t-if">!record.is_on_hold.raw_value</attribute>
</xpath>
<xpath expr="//t[@t-name='menu']/a[@name='archive_applicant']" position="attributes">
<attribute name="t-if">!record.is_on_hold.raw_value</attribute>
</xpath>
<xpath expr="//t[@t-name='menu']/a[@name='archive_applicant']" position="before">
<a t-if="!record.is_on_hold.raw_value and record.application_status.raw_value != 'refused'"
role="menuitem" name="hold_unhold_button" type="object" class="dropdown-item">Hold</a>
<a t-if="record.is_on_hold.raw_value and record.application_status.raw_value != 'refused'"
role="menuitem" name="hold_unhold_button" type="object" class="dropdown-item">Un Hold</a>
</xpath>
<xpath expr="//t[@t-name='menu']/a[@type='archive']" position="attributes">
<attribute name="t-if">record.active.raw_value and !record.is_on_hold.raw_value</attribute>
</xpath>
<xpath expr="//t[@t-name='menu']/a[@type='unarchive']" position="attributes">
<attribute name="t-if">!record.active.raw_value and !record.is_on_hold.raw_value</attribute>
</xpath>
<xpath expr="//t[@t-name='card']/widget[@name='web_ribbon'][1]" position="before">
<widget name="web_ribbon" title="HOLD" bg_color="text-bg-secondary" invisible="not is_on_hold"/>
</xpath>
<xpath expr="//t[@t-name='card']/field[@name='partner_name']" position="replace">
<div t-attf-class="#{record.is_on_hold.raw_value ? 'o_hr_applicant_kanban_card_hold' : ''}">
<field t-if="record.partner_name.raw_value" class="fw-bold fs-5" name="partner_name"/>
</div>
</xpath>
<xpath expr="//t[@t-name='card']/field[@name='job_id']" position="before">
<div class="mb-2" invisible="not is_on_hold">
<span class="badge text-bg-secondary">On Hold</span>
</div>
</xpath>
<xpath expr="//field[@name='kanban_state']" position="attributes">
<attribute name="invisible">is_on_hold</attribute>
</xpath>
</field> </field>
</record> </record>
<record id="hr_kanban_view_applicant_inherit" model="ir.ui.view"> <record id="hr_kanban_view_applicant_inherit" model="ir.ui.view">
@ -225,19 +307,22 @@
<field name="user_id"/> <field name="user_id"/>
<field name="active"/> <field name="active"/>
<field name="application_status"/> <field name="application_status"/>
<field name="submitted_to_client"/>
<field name="is_on_hold" invisible="1"/> <field name="is_on_hold" invisible="1"/>
<field name="company_id" <field name="company_id"
invisible="1"/> <!-- We need to keep this field as it is used in the domain of user_id in the model --> invisible="1"/> <!-- We need to keep this field as it is used in the domain of user_id in the model -->
<progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger"}'/> <progressbar field="kanban_state" colors='{"done": "success", "blocked": "danger"}'/>
<templates> <templates>
<t t-name="menu"> <t t-name="menu">
<a role="menuitem" name="action_create_meeting" type="object" class="dropdown-item">Schedule <a t-if="!record.is_on_hold.raw_value" role="menuitem" name="action_create_meeting" type="object" class="dropdown-item">Schedule
Interview Interview
</a> </a>
<a role="menuitem" name="archive_applicant" type="object" class="dropdown-item">Reject</a> <a t-if="!record.is_on_hold.raw_value and record.application_status.raw_value != 'refused'" role="menuitem" name="hold_unhold_button" type="object" class="dropdown-item">Hold</a>
<a t-if="record.active.raw_value" role="menuitem" type="archive" class="dropdown-item">Archive <a t-if="record.is_on_hold.raw_value and record.application_status.raw_value != 'refused'" role="menuitem" name="hold_unhold_button" type="object" class="dropdown-item">Un Hold</a>
<a t-if="!record.is_on_hold.raw_value" role="menuitem" name="archive_applicant" type="object" class="dropdown-item">Reject</a>
<a t-if="record.active.raw_value and !record.is_on_hold.raw_value" role="menuitem" type="archive" class="dropdown-item">Archive
</a> </a>
<a t-if="!record.active.raw_value" role="menuitem" type="unarchive" class="dropdown-item"> <a t-if="!record.active.raw_value and !record.is_on_hold.raw_value" role="menuitem" type="unarchive" class="dropdown-item">
Unarchive Unarchive
</a> </a>
<t t-if="widget.deletable"> <t t-if="widget.deletable">
@ -245,54 +330,57 @@
</t> </t>
</t> </t>
<t t-name="card"> <t t-name="card">
<widget name="web_ribbon" title="HOLD" bg_color="text-bg-success" invisible="not is_on_hold"/> <div t-attf-class="o_hr_applicant_kanban_card #{record.is_on_hold.raw_value ? 'o_hr_applicant_kanban_card_hold' : ''}">
<widget name="web_ribbon" title="Hired" bg_color="text-bg-success" invisible="not date_closed"/> <widget name="web_ribbon" title="HOLD" bg_color="text-bg-secondary" invisible="not is_on_hold"/>
<widget name="web_ribbon" title="Refused" bg_color="text-bg-danger" <widget name="web_ribbon" title="Hired" bg_color="text-bg-success" invisible="not date_closed"/>
invisible="application_status != 'refused'"/> <widget name="web_ribbon" title="Refused" bg_color="text-bg-danger"
<widget name="web_ribbon" title="Archived" bg_color="text-bg-secondary" invisible="application_status != 'refused'"/>
invisible="application_status != 'archived'"/> <widget name="web_ribbon" title="Archived" bg_color="text-bg-secondary"
<div class="d-flex align-items-baseline gap-1 ms-2"> invisible="application_status != 'archived'"/>
<field t-if="record.partner_name.raw_value" class="fw-bold fs-5" name="partner_name"/> <div class="d-flex align-items-baseline gap-1 ms-2">
<field name="job_id" invisible="context.get('search_default_job_id', False)"/> <field t-if="record.partner_name.raw_value" class="fw-bold fs-5" name="partner_name"/>
</div> <field name="job_id" invisible="context.get('search_default_job_id', False)"/>
<div class="row g-0 mt-0 mt-sm-3 ms-2">
<div class="col-7">
<!-- Categories and applicant properties -->
<field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field name="applicant_properties" widget="properties"/>
</div> </div>
<div class="col-5"> <div class="ms-2 mb-2" invisible="not is_on_hold">
<!-- Refused information, visible when status is 'refused' --> <span class="badge text-bg-secondary">On Hold</span>
<div invisible="application_status != 'refused'"> </div>
<sheet> <div class="ms-2 mb-2" invisible="not submitted_to_client">
<!-- Refused Stage --> <span class="badge text-bg-info">Submitted to Client</span>
<div class="form-group"> </div>
<label for="refuse_reason_id"><strong>Refuse Details</strong></label> <div class="row g-0 mt-0 mt-sm-3 ms-2">
<field name="refuse_reason_id" readonly="1" force_save="1" <div class="col-7">
invisible="application_status != 'refused'"/> <field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
</div> <field name="applicant_properties" widget="properties"/>
</div>
<!-- Refuse Date --> <div class="col-5">
<div class="form-group"> <div invisible="application_status != 'refused'">
<field name="refuse_date" string="Refused On" readonly="1" force_save="1" <sheet>
invisible="application_status != 'refused'" widget="date"/> <div class="form-group">
</div> <label for="refuse_reason_id"><strong>Refuse Details</strong></label>
</sheet> <field name="refuse_reason_id" readonly="1" force_save="1"
invisible="application_status != 'refused'"/>
</div>
<div class="form-group">
<field name="refuse_date" string="Refused On" readonly="1" force_save="1"
invisible="application_status != 'refused'" widget="date"/>
</div>
</sheet>
</div>
</div> </div>
</div> </div>
<footer>
<field name="priority" widget="priority"/>
<field class="ms-1 align-items-center" name="activity_ids" widget="kanban_activity"/>
<div class="d-flex ms-auto align-items-center">
<a name="action_open_attachments" type="object">
<i class='fa fa-paperclip' role="img" aria-label="Documents"/>
<field name="attachment_number"/>
</a>
<field name="kanban_state" class="mx-1" widget="state_selection" invisible="is_on_hold"/>
<field name="user_id" widget="many2one_avatar_user"/>
</div>
</footer>
</div> </div>
<footer>
<field name="priority" widget="priority"/>
<field class="ms-1 align-items-center" name="activity_ids" widget="kanban_activity"/>
<div class="d-flex ms-auto align-items-center">
<a name="action_open_attachments" type="object">
<i class='fa fa-paperclip' role="img" aria-label="Documents"/>
<field name="attachment_number"/>
</a>
<field name="kanban_state" class="mx-1" widget="state_selection"/>
<field name="user_id" widget="many2one_avatar_user"/>
</div>
</footer>
</t> </t>
</templates> </templates>
</kanban> </kanban>
@ -428,6 +516,17 @@
<field name="orientation">Portrait</field> <field name="orientation">Portrait</field>
</record> </record>
<record id="action_send_jod_to_emp" model="ir.actions.server">
<field name="name">Send JOD to EMPLOYEE</field>
<field name="model_id" ref="hr_recruitment.model_hr_applicant"/>
<field name="groups_id" eval="[(4, ref('hr_recruitment.group_hr_recruitment_manager'))]"/>
<field name="binding_model_id" ref="hr_recruitment.model_hr_applicant"/>
<field name="binding_view_types">form</field>
<field name="state">code</field>
<field name="code">action = records.send_jod_form_to_employee()</field>
</record>
<record id="action_download_joining_form" model="ir.actions.report"> <record id="action_download_joining_form" model="ir.actions.report">
<field name="name">Download Joining Form</field> <field name="name">Download Joining Form</field>
<field name="model">hr.applicant</field> <field name="model">hr.applicant</field>

View File

@ -14,10 +14,10 @@
name="action_validate_personal_details" name="action_validate_personal_details"
type="object" type="object"
class="btn btn-success" class="btn btn-success"
invisible="personal_details_status == 'pending' or not employee_id"> invisible="not employee_id">
<div> <div>
Click here to save &amp; Update Employee Data Click here to save &amp; Update Employee Data
<field name="personal_details_status" <field name="contact_details_status"
widget="badge" widget="badge"
options="{'pending': 'danger', 'validated': 'success'}"/> options="{'pending': 'danger', 'validated': 'success'}"/>
@ -29,7 +29,7 @@
name="action_validate_personal_details" name="action_validate_personal_details"
type="object" type="object"
class="btn btn-danger" class="btn btn-danger"
invisible="personal_details_status == 'validated' or not employee_id"> invisible="not employee_id">
<div> <div>
Click here to save, Validate and Update into Employee Data Click here to save, Validate and Update into Employee Data
<field name="personal_details_status" <field name="personal_details_status"
@ -110,13 +110,12 @@
</div> </div>
</group> </group>
</group> </group>
<group string="Bank Details">
<group string="Bank Details" invisible="not employee_id">
<button string="Validate/update" <button string="Validate/update"
name="action_validate_bank_details" name="action_validate_bank_details"
type="object" type="object"
class="btn btn-success" class="btn btn-success"
invisible="bank_details_status == 'pending'"> invisible="bank_details_status == 'pending' or not employee_id">
<div> <div>
Click here to save &amp; Update Employee Data Click here to save &amp; Update Employee Data
<field name="bank_details_status" <field name="bank_details_status"
@ -131,7 +130,7 @@
name="action_validate_bank_details" name="action_validate_bank_details"
type="object" type="object"
class="btn btn-danger" class="btn btn-danger"
invisible="bank_details_status == 'validated'"> invisible="bank_details_status == 'validated' or not employee_id">
<div> <div>
Click here to save, Validate and Update into Employee Data Click here to save, Validate and Update into Employee Data
<field name="bank_details_status" <field name="bank_details_status"
@ -149,13 +148,12 @@
<field name="bank_ifsc_code"/> <field name="bank_ifsc_code"/>
</group> </group>
</group> </group>
<group string="Passport Details">
<group string="Passport Details" invisible="not employee_id">
<button string="Validate/update" <button string="Validate/update"
name="action_validate_passport_details" name="action_validate_passport_details"
type="object" type="object"
class="btn btn-success" class="btn btn-success"
invisible="passport_details_status == 'pending'"> invisible="passport_details_status == 'pending' or not employee_id">
<div> <div>
Click here to save &amp; Update Employee Data Click here to save &amp; Update Employee Data
<field name="passport_details_status" <field name="passport_details_status"
@ -170,7 +168,7 @@
name="action_validate_passport_details" name="action_validate_passport_details"
type="object" type="object"
class="btn btn-danger" class="btn btn-danger"
invisible="passport_details_status == 'validated'"> invisible="passport_details_status == 'validated' or not employee_id">
<div> <div>
Click here to save, Validate and Update into Employee Data Click here to save, Validate and Update into Employee Data
<field name="passport_details_status" <field name="passport_details_status"
@ -187,13 +185,12 @@
<field name="passport_issued_location" string="Issued Location"/> <field name="passport_issued_location" string="Issued Location"/>
</group> </group>
</group> </group>
<group string="Authentication Details">
<group string="Authentication Details" invisible="not employee_id">
<button string="Validate/update" <button string="Validate/update"
name="action_validate_authentication_details" name="action_validate_authentication_details"
type="object" type="object"
class="btn btn-success" class="btn btn-success"
invisible="authentication_details_status == 'pending'"> invisible="authentication_details_status == 'pending' or not employee_id">
<div> <div>
Click here to save &amp; Update Employee Data Click here to save &amp; Update Employee Data
<field name="authentication_details_status" <field name="authentication_details_status"
@ -208,7 +205,7 @@
name="action_validate_authentication_details" name="action_validate_authentication_details"
type="object" type="object"
class="btn btn-danger" class="btn btn-danger"
invisible="authentication_details_status == 'validated'"> invisible="authentication_details_status == 'validated' or not employee_id">
<div> <div>
Click here to save, Validate and Update into Employee Data Click here to save, Validate and Update into Employee Data
<field name="authentication_details_status" <field name="authentication_details_status"
@ -226,13 +223,12 @@
</group> </group>
</group> </group>
</group> </group>
<group>
<group invisible="not employee_id">
<button string="Validate/update" <button string="Validate/update"
name="action_validate_family_education_employer_details" name="action_validate_family_education_employer_details"
type="object" type="object"
class="btn btn-success" class="btn btn-success"
invisible="family_education_employer_details_status == 'pending'"> invisible="family_education_employer_details_status == 'pending' or not employee_id">
<div> <div>
Click here to save &amp; Update Employee Data (Education, Employer, Family Details) Click here to save &amp; Update Employee Data (Education, Employer, Family Details)
<field name="family_education_employer_details_status" <field name="family_education_employer_details_status"
@ -247,7 +243,7 @@
name="action_validate_family_education_employer_details" name="action_validate_family_education_employer_details"
type="object" type="object"
class="btn btn-danger" class="btn btn-danger"
invisible="family_education_employer_details_status == 'validated'"> invisible="family_education_employer_details_status == 'validated' or not employee_id">
<div> <div>
Click here to save, Validate and Update into Employee Data (Education, Employer, Family Details) Click here to save, Validate and Update into Employee Data (Education, Employer, Family Details)
<field name="family_education_employer_details_status" <field name="family_education_employer_details_status"
@ -262,6 +258,9 @@
<list string="Employer Details"> <list string="Employer Details">
<field name="company_name"/> <field name="company_name"/>
<field name="designation"/> <field name="designation"/>
<button string="Summary" name="action_open_html_popup" type="object"
class="oe_highlight" icon="fa-eye"/>
<field name="date_of_joining"/> <field name="date_of_joining"/>
<field name="last_working_day"/> <field name="last_working_day"/>
<field name="ctc"/> <field name="ctc"/>
@ -286,7 +285,7 @@
</field> </field>
</group> </group>
<group string="Education History" colspan="2"> <group string="Education Education" colspan="2">
<field mode="list" nolabel="1" name="education_history" class="mt-2"> <field mode="list" nolabel="1" name="education_history" class="mt-2">
<list string="Education Details"> <list string="Education Details">
<field name="education_type"/> <field name="education_type"/>
@ -350,6 +349,8 @@
<list string="Employer Details"> <list string="Employer Details">
<field name="company_name"/> <field name="company_name"/>
<field name="designation"/> <field name="designation"/>
<button string="Summary" name="action_open_html_popup" type="object" class="oe_highlight" icon="fa-eye"/>
<field name="date_of_joining"/> <field name="date_of_joining"/>
<field name="last_working_day"/> <field name="last_working_day"/>
<field name="ctc"/> <field name="ctc"/>
@ -386,7 +387,6 @@
<field name="start_year"/> <field name="start_year"/>
<field name="end_year"/> <field name="end_year"/>
<field name="marks_or_grade"/> <field name="marks_or_grade"/>
<!-- <field name="attachments"/>-->
<field name="employee_id" column_invisible="1"/> <field name="employee_id" column_invisible="1"/>
</list> </list>
<form string="Education Details"> <form string="Education Details">

View File

@ -43,23 +43,11 @@
<field name="name">hr.job.recruitment.form</field> <field name="name">hr.job.recruitment.form</field>
<field name="model">hr.job.recruitment</field> <field name="model">hr.job.recruitment</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- <form string="job">-->
<!-- <sheet>-->
<!-- <group>-->
<!-- <div class="oe_title">-->
<!-- <field name="recruitment_sequence" readonly="1" force_save="1"/>-->
<!-- <field name="job_id"/>-->
<!-- </div>-->
<!-- </group>-->
<!-- </sheet>-->
<!-- </form>-->
<!-- Add the recruitment_sequence field into the form -->
<form string="Job" js_class="recruitment_form_view"> <form string="Job" js_class="recruitment_form_view">
<header> <header>
<button name="send_mail_to_recruiters" type="object" string="Send Recruiters Notification" class="oe_highlight" groups="hr_recruitment.group_hr_recruitment_user"/> <button name="send_mail_to_recruiters" type="object" string="Send Recruiters Notification" class="oe_highlight" groups="hr_recruitment.group_hr_recruitment_user"/>
<button name="action_share_job_recruitment" type="object" string="Share JD" class="btn-primary"
groups="hr_recruitment.group_hr_recruitment_user" invisible="not id"/>
<field name="recruitment_status" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}"/> <field name="recruitment_status" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}"/>
</header> <!-- inherited in other module --> </header> <!-- inherited in other module -->
<field name="active" invisible="1"/> <field name="active" invisible="1"/>
@ -446,11 +434,11 @@
<record id="action_hr_job_recruitment_awaiting_published" model="ir.actions.act_window"> <record id="action_hr_job_recruitment_awaiting_published" model="ir.actions.act_window">
<field name="name">UnPublished Recruitments</field> <field name="name">JD</field>
<field name="res_model">hr.job.recruitment</field> <field name="res_model">hr.job.recruitment</field>
<field name="view_mode">kanban,list,form,search</field> <field name="view_mode">kanban,list,form,search</field>
<field name="search_view_id" ref="view_job_recruitment_filter"/> <field name="search_view_id" ref="view_job_recruitment_filter"/>
<field name="context">{"search_default_open_status":1,"search_default_my_assignments":1,"search_default_unpublished_records":1,'no_of_eligible_submissions': 0}</field> <field name="context">{"search_default_open_status":1,"search_default_my_assignments":1}</field>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face"> <p class="o_view_nocontent_smiling_face">
Ready to recruit more efficiently? Ready to recruit more efficiently?
@ -461,21 +449,21 @@
</field> </field>
</record> </record>
<record id="action_hr_job_recruitment_published" model="ir.actions.act_window"> <!-- <record id="action_hr_job_recruitment_published" model="ir.actions.act_window">-->
<field name="name">Published Recruitments</field> <!-- <field name="name">Published</field>-->
<field name="res_model">hr.job.recruitment</field> <!-- <field name="res_model">hr.job.recruitment</field>-->
<field name="view_mode">kanban,list,form,search</field> <!-- <field name="view_mode">kanban,list,form,search</field>-->
<field name="search_view_id" ref="view_job_recruitment_filter"/> <!-- <field name="search_view_id" ref="view_job_recruitment_filter"/>-->
<field name="context">{"search_default_open_status":1,"search_default_my_assignments":1,"search_default_published_records":1,'no_of_eligible_submissions': 0}</field> <!-- <field name="context">{"search_default_open_status":1,"search_default_my_assignments":1,"search_default_published_records":1,'no_of_eligible_submissions': 0}</field>-->
<field name="help" type="html"> <!-- <field name="help" type="html">-->
<p class="o_view_nocontent_smiling_face"> <!-- <p class="o_view_nocontent_smiling_face">-->
Ready to recruit more efficiently? <!-- Ready to recruit more efficiently?-->
</p> <!-- </p>-->
<p> <!-- <p>-->
Let's create a job position Recruitment Requests. <!-- Let's create a job position Recruitment Requests.-->
</p> <!-- </p>-->
</field> <!-- </field>-->
</record> <!-- </record>-->
@ -489,23 +477,24 @@
<menuitem name="JD" <menuitem name="JD"
id="menu_hr_job_descriptions" id="menu_hr_job_descriptions"
parent="hr_recruitment.menu_hr_recruitment_root" parent="hr_recruitment.menu_hr_recruitment_root"
action="action_hr_job_recruitment_awaiting_published"
sequence="1" sequence="1"
groups="base.group_user"/> groups="base.group_user"/>
<menuitem <!-- <menuitem-->
name="Awaiting Publication" <!-- name="Awaiting Publication"-->
id="menu_hr_job_recruitment_awaiting_publication" <!-- id="menu_hr_job_recruitment_awaiting_publication"-->
parent="menu_hr_job_descriptions" <!-- parent="menu_hr_job_descriptions"-->
action="action_hr_job_recruitment_awaiting_published" <!-- action="action_hr_job_recruitment_awaiting_published"-->
sequence="1" <!-- sequence="1"-->
groups="base.group_user"/> <!-- groups="base.group_user"/>-->
<menuitem <!-- <menuitem-->
name="Published" <!-- name="Published"-->
id="menu_hr_job_recruitment_published" <!-- id="menu_hr_job_recruitment_published"-->
parent="menu_hr_job_descriptions" <!-- parent="menu_hr_job_descriptions"-->
action="action_hr_job_recruitment_published" <!-- action="action_hr_job_recruitment_published"-->
sequence="2" <!-- sequence="2"-->
groups="base.group_user"/> <!-- groups="base.group_user"/>-->
<menuitem <menuitem
name="Job Positions" name="Job Positions"
@ -514,15 +503,5 @@
action="hr_recruitment.action_hr_job" action="hr_recruitment.action_hr_job"
sequence="0" sequence="0"
groups="hr_recruitment.group_hr_recruitment_user"/> groups="hr_recruitment.group_hr_recruitment_user"/>
<!-- <menuitem-->
<!-- name="Candidates"-->
<!-- parent="hr_recruitment.menu_crm_case_categ0_act_job"-->
<!-- id="menu_hr_candidate_interviewer"-->
<!-- action="hr_recruitment.action_hr_candidate"-->
<!-- sequence="29"-->
<!-- groups="base.group_user"/>-->
</data> </data>
</odoo> </odoo>

View File

@ -81,7 +81,6 @@
<attribute name="class" add=""/> <attribute name="class" add=""/>
</xpath> </xpath>
<xpath expr="//kanban" position="attributes"> <xpath expr="//kanban" position="attributes">
<!-- action="%(action_hr_job_recruitment_applications)d" type="action"-->
<attribute name="action">%(hr_recruitment_extended.action_hr_job_recruitment_requests)d</attribute> <attribute name="action">%(hr_recruitment_extended.action_hr_job_recruitment_requests)d</attribute>
</xpath> </xpath>
<xpath expr="//field[@name='no_of_recruitment']" position="attributes"> <xpath expr="//field[@name='no_of_recruitment']" position="attributes">
@ -124,41 +123,11 @@
<xpath expr="//notebook" position="inside"> <xpath expr="//notebook" position="inside">
<page string="All Recruitments" name="hr_job_recruitments_page"> <page string="All Recruitments" name="hr_job_recruitments_page">
<field name="hr_job_recruitments"/> <field name="hr_job_recruitments"/>
<!-- <field name="hr_job_recruitments">-->
<!-- <list editable="bottom">-->
<!-- <field name="recruitment_sequence"/>-->
<!-- <field name="date_from"/>-->
<!-- <field name="date_end"/>-->
<!-- <field name="target"/>-->
<!-- <field name="application_count"/>-->
<!-- <field name="applicant_hired"/>-->
<!-- </list>-->
<!-- </field>-->
</page> </page>
</xpath> </xpath>
</field> </field>
</record> </record>
<!-- <record model="ir.ui.view" id="hr_job_form_extended">-->
<!-- <field name="name">hr.job.form.extended</field>-->
<!-- <field name="model">hr.job</field>-->
<!-- <field name="inherit_id" ref="hr_recruitment_skills.hr_job_form_inherit_hr_recruitment_skills"/>-->
<!-- <field name="arch" type="xml">-->
<!-- <xpath expr="//div[hasclass('oe_button_box')]" position="inside">-->
<!-- <button name="buttion_view_applicants" type="object" class="oe_stat_button" string="Candidates" widget="statinfo" icon="fa-th-large"/>-->
<!-- </xpath>-->
<!-- <xpath expr="//field[@name='skill_ids']" position="after">-->
<!-- <field name="secondary_skill_ids" widget="many2many_tags" options="{'color_field': 'color'}"-->
<!-- context="{'search_default_group_skill_type_id': 1}"/>-->
<!-- </xpath>-->
<!-- <xpath expr="//group[@name='recruitment2']" position="inside">-->
<!-- <field name="locations" widget="many2many_tags"/>-->
<!-- <field name="recruitment_stage_ids" widget="many2many_tags"/>-->
<!-- </xpath>-->
<!-- </field>-->
<!-- </record>-->
<record model="ir.ui.view" id="hr_recruitment_hr_applicant_view_form_extend"> <record model="ir.ui.view" id="hr_recruitment_hr_applicant_view_form_extend">
<field name="name">hr.applicant.view.form.extended</field> <field name="name">hr.applicant.view.form.extended</field>
<field name="model">hr.applicant</field> <field name="model">hr.applicant</field>
@ -191,12 +160,9 @@
<field name="submitted_to_client" force_save="1" readonly="1" invisible="not submitted_to_client"/>
<field name="client_submission_date" force_save="1" readonly="1" invisible="not submitted_to_client"/>
<field name="submitted_stage" force_save="1" readonly="1" invisible="not submitted_to_client"/>
</xpath> </xpath>
<xpath expr="//group[@name='recruitment_contract']/label[@for='salary_expected']" position="before"> <xpath expr="//group[@name='recruitment_contract']/label[@for='salary_expected']" position="before">
<field name="current_ctc"/> <field name="current_ctc" invisible="1"/>
</xpath> </xpath>
<xpath expr="//page[@name='application_details']" position="inside"> <xpath expr="//page[@name='application_details']" position="inside">
<group> <group>

View File

@ -3,6 +3,7 @@
<template id="applicant_form_template" name="Second Application Form"> <template id="applicant_form_template" name="Second Application Form">
<t t-call="website.layout"> <t t-call="website.layout">
<t t-set="applicant" t-value="applicant"/> <t t-set="applicant" t-value="applicant"/>
<t t-set="applicant_request_id" t-value="applicant_request_id"/>
<section class="container mt-5"> <section class="container mt-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -13,7 +14,7 @@
</h2> </h2>
<form id="hr_recruitment_second_form_applicant" <form id="hr_recruitment_second_form_applicant"
t-att-action="'/hr_recruitment/submit_second_application/%s/submit'%(applicant.id)" t-att-action="'/hr_recruitment/submit_second_application/%s/%s/submit'%(applicant.id,applicant_request_id.id)"
method="post"> method="post">
<input type="hidden" name="applicant_id" t-att-value="applicant.id"/> <input type="hidden" name="applicant_id" t-att-value="applicant.id"/>
<input type="hidden" name="candidate_image_base64"/> <input type="hidden" name="candidate_image_base64"/>
@ -1863,24 +1864,21 @@
<template id="doc_request_form_template" name="FTPROTECH Doc Request Form"> <template id="doc_request_form_template" name="FTPROTECH Doc Request Form">
<t t-call="website.layout"> <t t-call="website.layout">
<t t-set="applicant_request_id" t-value="applicant_request_id"/>
<section class="container mt-5"> <section class="container mt-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-10"> <div class="col-md-10">
<div class="card shadow-lg p-4"> <div class="card shadow-lg p-4">
<form id="doc_request_form" <form id="doc_request_form"
t-att-action="'/FTPROTECH/submit/%s/docRequest'%(applicant.id)" method="post" t-att-action="'/FTPROTECH/submit/%s/%s/docRequest'%(applicant.id,applicant_request_id.id)" method="post"
enctype="multipart/form-data"> enctype="multipart/form-data">
<div> <div>
<!-- Upload or Capture Photo -->
<input type="hidden" name="applicant_id" t-att-value="applicant.id"/> <input type="hidden" name="applicant_id" t-att-value="applicant.id"/>
<input type="hidden" name="request_token" t-att-value="request_token"/>
<input type="hidden" name="candidate_image_base64"/> <input type="hidden" name="candidate_image_base64"/>
<input type="hidden" name="attachments_data_json" id="attachments_data_json"/> <input type="hidden" name="attachments_data_json" id="attachments_data_json"/>
<!-- Applicant Photo -->
<!-- Profile Picture Upload (Similar to res.users) -->
<div class="mb-3 text-center"> <div class="mb-3 text-center">
<!-- Image Preview with Label Click -->
<label for="candidate_image" style="cursor: pointer;"> <label for="candidate_image" style="cursor: pointer;">
<img id="photo_preview" <img id="photo_preview"
t-att-src="'data:image/png;base64,' + (applicant.candidate_image.decode() if applicant.candidate_image else '')" t-att-src="'data:image/png;base64,' + (applicant.candidate_image.decode() if applicant.candidate_image else '')"
@ -1888,11 +1886,9 @@
style="display: flex; align-items: center; justify-content: center; width: 150px; height: 150px; object-fit: cover; border: 2px solid #ddd; overflow: hidden;"/> style="display: flex; align-items: center; justify-content: center; width: 150px; height: 150px; object-fit: cover; border: 2px solid #ddd; overflow: hidden;"/>
</label> </label>
<!-- Hidden File Input -->
<input type="file" class="d-none" name="candidate_image" id="candidate_image" <input type="file" class="d-none" name="candidate_image" id="candidate_image"
accept="image/*"/> accept="image/*"/>
<!-- Action Buttons -->
<div class="mt-2"> <div class="mt-2">
<button type="button" class="btn btn-sm btn-primary" <button type="button" class="btn btn-sm btn-primary"
onclick="document.getElementById('candidate_image').click();"> onclick="document.getElementById('candidate_image').click();">
@ -1908,7 +1904,6 @@
</div> </div>
</div> </div>
<!-- Image Preview Modal -->
<div class="modal fade" id="photoPreviewModal" tabindex="-1" <div class="modal fade" id="photoPreviewModal" tabindex="-1"
aria-labelledby="photoPreviewModalLabel" aria-hidden="true"> aria-labelledby="photoPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@ -1956,13 +1951,11 @@
<input type="file" <input type="file"
class="form-control d-none attachment-input" class="form-control d-none attachment-input"
t-att-data-attachment-id="attachment.id" t-att-data-attachment-id="attachment.id"
multiple="multiple"
accept="image/*,application/pdf"/> accept="image/*,application/pdf"/>
</label> </label>
</div> </div>
</div> </div>
<!-- Table to display uploaded files -->
<div t-att-id="'preview_table_container_%s' % attachment.id" <div t-att-id="'preview_table_container_%s' % attachment.id"
class="uploaded-files-preview d-none"> class="uploaded-files-preview d-none">
<table class="table table-bordered table-striped"> <table class="table table-bordered table-striped">
@ -1981,7 +1974,6 @@
<p>No attachments required.</p> <p>No attachments required.</p>
</div> </div>
<!-- Image Preview Modal -->
<div class="modal fade" id="attachmentPreviewModal" tabindex="-1" <div class="modal fade" id="attachmentPreviewModal" tabindex="-1"
aria-labelledby="attachmentPreviewModalLabel" aria-hidden="true"> aria-labelledby="attachmentPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
@ -1993,12 +1985,10 @@
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<div class="modal-body text-center"> <div class="modal-body text-center">
<!-- Image Preview -->
<img id="modal_attachment_photo_preview" src="" <img id="modal_attachment_photo_preview" src=""
class="img-fluid rounded shadow" class="img-fluid rounded shadow"
style="max-width: 100%; display: none;"/> style="max-width: 100%; display: none;"/>
<!-- PDF Preview -->
<iframe id="modal_attachment_pdf_preview" src="" <iframe id="modal_attachment_pdf_preview" src=""
width="100%" height="500px" width="100%" height="500px"
style="border: none; display: none;"></iframe> style="border: none; display: none;"></iframe>

View File

@ -6,7 +6,6 @@
<field name="model">recruitment.requisition</field> <field name="model">recruitment.requisition</field>
<field name="inherit_id" ref="requisitions.view_requisition_form"/> <field name="inherit_id" ref="requisitions.view_requisition_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- <field name="job_id" invisible="job_id == False" readonly="1" force_save="1"/>-->
<xpath expr="//field[@name='job_id']" position="attributes"> <xpath expr="//field[@name='job_id']" position="attributes">
<attribute name="invisible">0</attribute> <attribute name="invisible">0</attribute>
<attribute name="readonly">state not in ['draft']</attribute> <attribute name="readonly">state not in ['draft']</attribute>

View File

@ -27,9 +27,7 @@
<field name="inherit_id" ref="base.view_partner_form"/> <field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after"> <xpath expr="//field[@name='category_id']" position="after">
<!-- <group>-->
<field name="contact_type"/> <field name="contact_type"/>
<!-- </group>-->
</xpath> </xpath>
</field> </field>

View File

@ -11,8 +11,6 @@
</xpath> </xpath>
<xpath expr="//field[@name='job_ids']" position="after"> <xpath expr="//field[@name='job_ids']" position="after">
<field name="job_recruitment_ids" string="Job Recruitment Ids" widget="many2many_tags"/> <field name="job_recruitment_ids" string="Job Recruitment Ids" widget="many2many_tags"/>
<field name="second_application_form"/>
<field name="post_onboarding_form"/>
<field name="require_approval" string="Approval Required"/> <field name="require_approval" string="Approval Required"/>
<field name="stage_color" widget="color" string="Select Stage Color"/> <field name="stage_color" widget="color" string="Select Stage Color"/>
</xpath> </xpath>

View File

@ -2,3 +2,4 @@ from . import post_onboarding_attachment_wizard
from . import applicant_refuse_reason from . import applicant_refuse_reason
from . import ats_invite_mail_template_wizard from . import ats_invite_mail_template_wizard
from . import client_submission_mail_template_wizard from . import client_submission_mail_template_wizard
from . import applicant_stage_comment_wizard

View File

@ -0,0 +1,178 @@
import base64
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ApplicantOfferMailWizard(models.TransientModel):
_name = 'applicant.offer.mail.wizard'
_description = 'Applicant Offer Mail Wizard'
applicant_id = fields.Many2one('hr.applicant', string='Applicant', required=True, readonly=True)
offer_letter_id = fields.Many2one('offer.letter', string='Offer Letter', readonly=True)
template_id = fields.Many2one('mail.template', string='Email Template', required=True, readonly=True)
position = fields.Char(string='Position', required=True)
salary = fields.Float(string='Annual CTC', required=True)
joining_date = fields.Date(string='Joining Date', required=True)
pay_struct_id = fields.Many2one('hr.payroll.structure', string='Salary Structure', required=True)
manager_id = fields.Many2one('hr.employee', string='Manager')
contract_type = fields.Selection([
('permanent', 'Permanent'),
('contract', 'Fixed Term Contract'),
('intern', 'Internship'),
], string='Contract Type', default='permanent', required=True)
probation_period = fields.Integer(string='Probation Period (months)', default=3, required=True)
email_from = fields.Char('Email From', required=True)
email_to = fields.Char('Email To', required=True)
email_cc = fields.Text('Email CC')
email_subject = fields.Char('Subject', required=True)
email_body = fields.Html(
'Body',
render_engine='qweb',
render_options={'post_process': True},
prefetch=True,
translate=True,
sanitize='email_outgoing',
required=True,
)
attachment_ids = fields.Many2many('ir.attachment', string='Attachments')
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
applicant = self._get_applicant()
template = self.env.ref(
'hr_recruitment_extended.applicant_offer_email_template',
raise_if_not_found=False,
)
offer_letter = self._create_offer_letter(applicant)
defaults.update({
'applicant_id': applicant.id,
'offer_letter_id': offer_letter.id,
'position': offer_letter.position,
'salary': offer_letter.salary,
'joining_date': offer_letter.joining_date,
'pay_struct_id': offer_letter.pay_struct_id.id,
'manager_id': offer_letter.manager_id.id,
'contract_type': offer_letter.contract_type,
'probation_period': offer_letter.probation_period,
})
if template:
defaults['template_id'] = template.id
defaults.update(self._prepare_mail_defaults(template, offer_letter))
return defaults
def _get_applicant(self):
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id'))
if not applicant.exists():
raise UserError(_("The applicant does not exist or is not accessible."))
return applicant
def _get_default_pay_structure(self, applicant):
company = applicant.company_id or self.env.company
return self.env['hr.payroll.structure'].search([
'|',
('company_id', '=', company.id),
('company_id', '=', False),
], limit=1)
def _get_default_manager(self, applicant):
return applicant.user_id.employee_id or self.env.user.employee_id
def _create_offer_letter(self, applicant):
pay_structure = self._get_default_pay_structure(applicant)
if not pay_structure:
raise UserError(_("Please configure at least one salary structure before sending an offer."))
offer_letter = self.env['offer.letter'].create({
'candidate_id': applicant.id,
'position': applicant.job_id.name or applicant.hr_job_recruitment.job_id.name or applicant.partner_name or applicant.display_name,
'salary': applicant.salary_expected or applicant.current_ctc or 0.0,
'joining_date': fields.Date.today(),
'pay_struct_id': pay_structure.id,
'manager_id': self._get_default_manager(applicant).id,
})
offer_letter.get_paydetailed_lines()
return offer_letter
def _update_offer_letter(self):
self.ensure_one()
vals = {
'candidate_id': self.applicant_id.id,
'position': self.position,
'salary': self.salary,
'joining_date': self.joining_date,
'pay_struct_id': self.pay_struct_id.id,
'manager_id': self.manager_id.id,
'contract_type': self.contract_type,
'probation_period': self.probation_period,
}
self.offer_letter_id.write(vals)
self.offer_letter_id.get_paydetailed_lines()
return self.offer_letter_id
def _generate_offer_attachment(self, offer_letter):
report = self.env.ref('offer_letters.hr_offer_letters_employee_print')
pdf_content, _ = report.sudo()._render_qweb_pdf(report, offer_letter.id)
attachment_name = _('Offer Letter - %s.pdf') % (offer_letter.candidate_id.partner_name or offer_letter.name)
existing_attachment = self.attachment_ids.filtered(
lambda attachment: attachment.name == attachment_name and attachment.mimetype == 'application/pdf'
)[:1]
attachment_vals = {
'name': attachment_name,
'datas': base64.b64encode(pdf_content),
'mimetype': 'application/pdf',
'res_model': self._name,
'res_id': self.id or 0,
'type': 'binary',
}
if existing_attachment:
existing_attachment.write(attachment_vals)
return existing_attachment
return self.env['ir.attachment'].create(attachment_vals)
def _prepare_mail_defaults(self, template, offer_letter):
render_results = template._generate_template(
[offer_letter.id],
['subject', 'body_html', 'email_from', 'email_to', 'email_cc', 'reply_to'],
find_or_create_partners=False,
)
generated_values = render_results.get(offer_letter.id, {})
attachment = self._generate_offer_attachment(offer_letter)
attachment_ids = [attachment.id] if attachment else []
return {
'email_from': generated_values.get('email_from') or offer_letter.candidate_id.user_id.email or self.env.user.email,
'email_to': generated_values.get('email_to') or offer_letter.candidate_id.email_from or '',
'email_cc': generated_values.get('email_cc', ''),
'email_subject': generated_values.get('subject', ''),
'email_body': generated_values.get('body_html', ''),
'attachment_ids': [(6, 0, attachment_ids)],
}
def action_send_offer(self):
self.ensure_one()
offer_letter = self._update_offer_letter()
offer_attachment = self._generate_offer_attachment(offer_letter)
attachment_ids = self.attachment_ids.ids
if offer_attachment.id not in attachment_ids:
attachment_ids.append(offer_attachment.id)
mail = self.env['mail.mail'].create({
'email_from': self.email_from,
'email_to': self.email_to,
'email_cc': self.email_cc,
'subject': self.email_subject,
'body_html': self.email_body,
'attachment_ids': [(6, 0, attachment_ids)],
'auto_delete': False,
'model': 'offer.letter',
'res_id': offer_letter.id,
})
mail.send()
offer_letter.action_send_offer()
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="view_applicant_offer_mail_wizard_form" model="ir.ui.view">
<field name="name">applicant.offer.mail.wizard.form</field>
<field name="model">applicant.offer.mail.wizard</field>
<field name="arch" type="xml">
<form string="Send Offer">
<group>
<field name="applicant_id" readonly="1"/>
<field name="template_id" options="{'no_create': True}" readonly="1"/>
</group>
<group string="Offer Details" col="2">
<field name="position"/>
<field name="salary"/>
<field name="joining_date"/>
<field name="pay_struct_id" options="{'no_create': True}"/>
<field name="manager_id"/>
<field name="contract_type"/>
<field name="probation_period"/>
</group>
<group string="Email Details">
<field name="email_from" placeholder="Sender email"/>
<field name="email_to" placeholder="Recipient email"/>
<field name="email_cc" placeholder="Comma-separated CC recipients"/>
<field name="email_subject" options="{'dynamic_placeholder': true}" placeholder="Email subject"/>
</group>
<notebook>
<page string="Body">
<field name="email_body" widget="html_mail" class="oe-bordered-editor"
options="{'codeview': true, 'dynamic_placeholder': true}"/>
</page>
<page string="Attachments">
<field name="attachment_ids" widget="many2many_binary"/>
</page>
</notebook>
<footer>
<button name="action_send_offer" type="object" string="Send Offer" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,45 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ApplicantStageCommentWizard(models.TransientModel):
_name = 'applicant.stage.comment.wizard'
_description = 'Applicant Stage Comment Wizard'
applicant_id = fields.Many2one('hr.applicant', required=True, readonly=True)
stage_id = fields.Many2one('hr.recruitment.stage', required=True)
comment = fields.Text(string='Comment')
stage_comment_ids = fields.Many2many(
'hr.applicant.stage.comment',
compute='_compute_stage_comment_ids',
string='Stage Comments',
)
@api.model
def default_get(self, fields_list):
values = super().default_get(fields_list)
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id'))
if applicant:
values.setdefault('applicant_id', applicant.id)
values.setdefault('stage_id', applicant.recruitment_stage_id.id)
return values
@api.depends('applicant_id', 'stage_id')
def _compute_stage_comment_ids(self):
Comment = self.env['hr.applicant.stage.comment']
for wizard in self:
domain = [('applicant_id', '=', wizard.applicant_id.id)]
# if wizard.stage_id:
# domain.append(('stage_id', '=', wizard.stage_id.id))
wizard.stage_comment_ids = Comment.search(domain)
def action_add_comment(self):
self.ensure_one()
if not self.comment:
raise UserError(_('Please enter a comment before saving.'))
self.env['hr.applicant.stage.comment'].create({
'applicant_id': self.applicant_id.id,
'stage_id': self.stage_id.id,
'comment': self.comment,
})
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_applicant_stage_comment_wizard_form" model="ir.ui.view">
<field name="name">applicant.stage.comment.wizard.form</field>
<field name="model">applicant.stage.comment.wizard</field>
<field name="arch" type="xml">
<form string="Stage Comment">
<group>
<field name="applicant_id"/>
<field name="stage_id"/>
<field name="comment" placeholder="Write a comment for this stage..."/>
</group>
<group string="Existing Comments">
<field name="stage_comment_ids" nolabel="1" readonly="1">
<list create="0" edit="0" delete="0">
<field name="comment_date"/>
<field name="stage_id"/>
<field name="user_id"/>
<field name="comment"/>
</list>
</field>
</group>
<footer>
<button name="action_add_comment" type="object" string="Save Comment" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -1,4 +1,4 @@
from odoo import models, fields, api from odoo import api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
@ -10,6 +10,7 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
'recruitment.attachments', 'recruitment.attachments',
string='Attachments to Request' string='Attachments to Request'
) )
request_form_id = fields.Many2one('applicant.request.forms')
attachment_ids = fields.Many2many('ir.attachment') attachment_ids = fields.Many2many('ir.attachment')
is_pre_onboarding_attachment_request = fields.Boolean(default=False) is_pre_onboarding_attachment_request = fields.Boolean(default=False)
template_id = fields.Many2one('mail.template', string='Email Template',compute='_compute_template_id') template_id = fields.Many2one('mail.template', string='Email Template',compute='_compute_template_id')
@ -36,9 +37,9 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
@api.onchange('template_id') @api.onchange('template_id')
def _onchange_template_id(self): def _onchange_template_id(self):
""" Update the email body and recipients based on the selected template. """ """Update the email body and recipients based on the selected template."""
if self.template_id: if self.template_id:
record_id = self.env.context.get('active_id') record_id = self.env.context.get('applicant_id')
applicant = self.env['hr.applicant'].browse(record_id) applicant = self.env['hr.applicant'].browse(record_id)
if record_id: if record_id:
@ -47,7 +48,6 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
if not record.exists(): if not record.exists():
raise UserError("The record does not exist or is not accessible.") raise UserError("The record does not exist or is not accessible.")
# Fetch email template
email_template = self.env['mail.template'].sudo().browse(self.template_id.id) email_template = self.env['mail.template'].sudo().browse(self.template_id.id)
if not email_template: if not email_template:
@ -55,7 +55,7 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
self.email_from = self.env.company.email self.email_from = self.env.company.email
self.email_to = applicant.email_from self.email_to = applicant.email_from
self.email_body = email_template.body_html # Assign the rendered email bodyc self.email_body = email_template.body_html
self.email_subject = email_template.subject self.email_subject = email_template.subject
@api.model @api.model
@ -72,8 +72,21 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
for rec in self: for rec in self:
self.ensure_one() self.ensure_one()
context = self.env.context context = self.env.context
active_id = context.get('active_id') active_id = context.get('applicant_id')
applicant = self.env['hr.applicant'].browse(active_id) applicant = self.env['hr.applicant'].browse(active_id)
request_token = False
request_upload_url = False
if rec.is_pre_onboarding_attachment_request and not rec.request_form_id:
raise UserError("A document request form is required before sending this email.")
if rec.request_form_id:
request_token = rec.request_form_id._issue_new_access_token()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
request_upload_url = (
f"{base_url}/FTPROTECH/DocRequests/"
f"{applicant.id}/{rec.request_form_id.id}?token={request_token}"
)
applicant.recruitment_attachments = [(4, attachment.id) for attachment in rec.req_attachment_ids] applicant.recruitment_attachments = [(4, attachment.id) for attachment in rec.req_attachment_ids]
@ -85,31 +98,36 @@ class PostOnboardingAttachmentWizard(models.TransientModel):
lambda a: a.attachment_type == 'previous_employer').mapped('name') lambda a: a.attachment_type == 'previous_employer').mapped('name')
other_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'others').mapped('name') other_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'others').mapped('name')
print(request_upload_url)
# Prepare context for the template
email_context = { email_context = {
'applicant_request_form_id': rec.request_form_id.id,
'applicant_request_form_token': request_token,
'applicant_request_form_url': request_upload_url,
'personal_docs': personal_docs, 'personal_docs': personal_docs,
'education_docs': education_docs, 'education_docs': education_docs,
'previous_employer_docs': previous_employer_docs, 'previous_employer_docs': previous_employer_docs,
'other_docs': other_docs, 'other_docs': other_docs,
} }
rendered_subject = template.with_context(**email_context)._render_field(
'subject', [applicant.id]
)[applicant.id]
rendered_body_html = template.with_context(**email_context)._render_field(
'body_html', [applicant.id], compute_lang=True
)[applicant.id]
email_values = { email_values = {
'email_from': rec.email_from, 'email_from': rec.email_from,
'email_to': rec.email_to, 'email_to': rec.email_to,
'email_cc': rec.email_cc, 'email_cc': rec.email_cc,
'subject': rec.email_subject, 'subject': rendered_subject or rec.email_subject,
'body_html': rendered_body_html,
'attachment_ids': [(6, 0, rec.attachment_ids.ids)], 'attachment_ids': [(6, 0, rec.attachment_ids.ids)],
} }
# Use 'with_context' to override the email template fields dynamically
template.sudo().with_context(default_body_html=rec.email_body, template.sudo().with_context(default_body_html=rec.email_body,
**email_context).send_mail(applicant.id, email_values=email_values, **email_context).send_mail(applicant.id, email_values=email_values,
force_send=True) force_send=True)
if rec.is_pre_onboarding_attachment_request: if not rec.is_pre_onboarding_attachment_request:
applicant.doc_requests_form_status = 'email_sent_to_candidate'
else:
applicant.post_onboarding_form_status = 'email_sent_to_candidate' applicant.post_onboarding_form_status = 'email_sent_to_candidate'
return {'type': 'ir.actions.act_window_close'} return {'type': 'ir.actions.act_window_close'}

View File

@ -15,7 +15,7 @@
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/data.xml', 'data/data.xml',
'views/masters.xml', 'views/masters.xml',
# 'views/groups.xml', 'views/groups.xml',
'views/login.xml', 'views/login.xml',
'views/menu_access_control_views.xml', 'views/menu_access_control_views.xml',
], ],

View File

@ -13,7 +13,7 @@ class CustomMasterLogin(Home):
response = super(CustomMasterLogin, self).web_login(*args, **kw) response = super(CustomMasterLogin, self).web_login(*args, **kw)
if response.is_qweb: if response.is_qweb:
response.qcontext['masters'] = request.env['master.control'].sudo().search([]) response.qcontext['masters'] = request.env['master.control'].sudo().search([], order='sequence asc, id asc')
request.env['ir.ui.menu'].sudo().clear_caches() request.env['ir.ui.menu'].sudo().clear_caches()
request.env['ir.ui.menu'].sudo()._visible_menu_ids() request.env['ir.ui.menu'].sudo()._visible_menu_ids()

View File

@ -152,3 +152,22 @@ class IrUiMenu(models.Model):
parent = parent.parent_id parent = parent.parent_id
return visible return visible
def unlink(self):
# Clean up references before deleting menus
menu_ids = self.ids
# Delete master control menu lines
self.env['master.control.menu.line'].sudo().search([
('menu_id', 'in', menu_ids)
]).unlink()
# Delete menu access lines
self.env['menu.access.line'].sudo().search([
('menu_id', 'in', menu_ids)
]).unlink()
# Clear caches
self.clear_caches()
return super().unlink()

View File

@ -4,7 +4,7 @@
<record id="base.action_res_groups" model="ir.actions.act_window"> <record id="base.action_res_groups" model="ir.actions.act_window">
<field name="name">Roles</field> <field name="name">Roles</field>
<field name="res_model">res.groups</field> <field name="res_model">res.groups</field>
<!-- <field name="domain">[('is_visible_for_master', '=', True)]</field>--> <field name="domain"></field>
<!-- <field name="context">{'search_default_filter_no_share': 1}</field>--> <!-- <field name="context">{'search_default_filter_no_share': 1}</field>-->
<!-- <field name="help">A group is a set of functional areas that will be assigned to the user in order to give--> <!-- <field name="help">A group is a set of functional areas that will be assigned to the user in order to give-->
<!-- them access and rights to specific applications and tasks in the system. You can create custom groups or--> <!-- them access and rights to specific applications and tasks in the system. You can create custom groups or-->
@ -13,7 +13,7 @@
<!-- </field>--> <!-- </field>-->
</record> </record>
<menuitem action="base.action_res_groups" name="Roles" id="base.menu_action_res_groups" parent="base.menu_users" groups="base.group_user" sequence="3"/> <!-- <menuitem action="base.action_res_groups" name="Roles" id="base.menu_action_res_groups" parent="base.menu_users" groups="base.group_user" sequence="3"/>-->
</data> </data>
</odoo> </odoo>

View File

@ -27,9 +27,13 @@ export class ModuleSelector extends Component {
const masters = await this.orm.searchRead( const masters = await this.orm.searchRead(
"master.control", "master.control",
[["user_ids", "in", [user.userId]]], [["user_ids", "in", [user.userId]]],
["name", "code"] ["name", "code", "sequence", "id"],
{
order: "sequence ASC, id ASC",
offset: 0,
limit: false
}
); );
this.state.masters = masters; this.state.masters = masters;
}); });
} }

View File

@ -1 +1,3 @@
from . import models from . import models
from . import wizards
from . import controllers

View File

@ -8,12 +8,18 @@
""", """,
'author': 'Raman Marikanti', 'author': 'Raman Marikanti',
'category': 'Human Resources', 'category': 'Human Resources',
'depends': ['base', 'hr_recruitment','hr_payroll','hr_ftp'], 'depends': ['base', 'hr_recruitment', 'hr_payroll', 'hr_recruitment_extended'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/mail_template.xml',
'views/offer_letter_views.xml', 'views/offer_letter_views.xml',
'views/hr_applicant_offer_views.xml',
'views/offer_response_templates.xml',
# 'views/templates.xml', # 'views/templates.xml',
'views/menu_views.xml', 'views/menu_views.xml',
'wizards/offer_release_request_wizard.xml',
'wizards/applicant_offer_mail_wizard.xml',
'wizards/offer_letter_reject_wizard.xml',
'report/offer_letter_report.xml', 'report/offer_letter_report.xml',
'report/offer_letter_template.xml', 'report/offer_letter_template.xml',
], ],

View File

@ -0,0 +1 @@
from . import main

View File

@ -0,0 +1,77 @@
from odoo import http
from odoo.http import request
class OfferLetterResponseController(http.Controller):
def _get_offer_letter(self, offer_id, token):
offer_letter = request.env['offer.letter'].sudo().browse(offer_id)
if not offer_letter.exists():
raise request.not_found()
if not token or offer_letter.response_token != token:
raise request.not_found()
if not offer_letter._is_latest_offer():
raise request.not_found()
return offer_letter
@http.route('/offer_letters/respond/<int:offer_id>/accept', type='http', auth='public')
def accept_offer(self, offer_id, token=None, **kwargs):
offer_letter = self._get_offer_letter(offer_id, token)
if offer_letter.state == 'requested':
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Not Released',
'message': 'This offer is not yet available for response.',
})
if offer_letter.state == 'accepted':
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Already Accepted',
'message': 'Your acceptance has already been recorded.',
})
if offer_letter.state == 'rejected':
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Already Rejected',
'message': 'This offer has already been rejected.',
})
offer_letter.action_accept_offer()
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Accepted',
'message': 'Thank you. Your offer acceptance has been recorded successfully.',
})
@http.route('/offer_letters/respond/<int:offer_id>/reject', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
def reject_offer(self, offer_id, token=None, **post):
offer_letter = self._get_offer_letter(offer_id, token)
if offer_letter.state == 'requested':
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Not Released',
'message': 'This offer is not yet available for response.',
})
if request.httprequest.method == 'POST':
reason = post.get('rejection_reason', '').strip()
if not reason:
return request.render('offer_letters.offer_reject_reason_page', {
'offer_letter': offer_letter,
'token': token,
'error': 'Please enter a rejection reason.',
})
offer_letter.action_reject_offer(reason)
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Rejected',
'message': 'Your rejection response has been recorded successfully.',
})
if offer_letter.state == 'accepted':
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Already Accepted',
'message': 'This offer has already been accepted.',
})
if offer_letter.state == 'rejected':
return request.render('offer_letters.offer_response_message', {
'title': 'Offer Already Rejected',
'message': 'This offer has already been rejected.',
})
return request.render('offer_letters.offer_reject_reason_page', {
'offer_letter': offer_letter,
'token': token,
'error': False,
})

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="applicant_offer_email_template" model="mail.template">
<field name="name">Applicant Offer Email Template</field>
<field name="model_id" ref="offer_letters.model_offer_letter"/>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.candidate_id.email_from or '' }}</field>
<field name="subject">Offer Letter - {{ object.position or object.candidate_id.job_id.name or '' }}</field>
<field name="description">
Send applicant offer mail with offer letter attachment.
</field>
<field name="body_html" type="html">
<div style="margin: 0; padding: 0; font-size: 13px; line-height: 1.7;">
<p>Dear <t t-esc="object.candidate_id.partner_name or ''"/>,</p>
<p>
With reference to the interview and subsequent discussions you had with us, we are pleased to select
you for the position of "<t t-esc="object.position or ''"/>" in our organization with the following
terms and conditions.
</p>
<p>
You will be paid a compensation of
<strong>Rs <t t-esc="'{:,.0f}'.format(object.salary or 0.0)"/> LPA</strong>
(<t t-esc="env.company.currency_id.amount_to_text(object.salary or 0.0)"/>) on a cost-to-company basis.
</p>
<p>
We would like you to join us at the earliest but not later than
<strong><t t-esc="object.joining_date or ''"/></strong>.
</p>
<p>
Enclosed is the Offer Letter cum Appointment with the break-up of salary for your reference.
The signed copy of the same will be issued to you on the date of joining.
</p>
<p>Your salary structure may be revised as per statutory compliances, keeping the CTC intact.</p>
<p>
You will be required to sign a Code of Conduct, Non-Disclosure Agreement, and Anti-Corruption Policy
at the time of your joining. The details are mentioned in the enclosed letter.
</p>
<p>You would need to travel as per business requirement.</p>
<p>
Kindly acknowledge this email as a token of acceptance of this offer within 2 days of the email dated,
failing which the offer stands withdrawn. This offer is valid subject to the current employer's
reference check.
</p>
<p style="margin-top: 20px;">
<a t-att-href="ctx.get('offer_accept_url')" target="_blank"
style="background-color: #198754; color: #ffffff; padding: 10px 18px; text-decoration: none; border-radius: 4px; display: inline-block; margin-right: 10px;">
Accept Offer
</a>
<a t-att-href="ctx.get('offer_reject_url')" target="_blank"
style="background-color: #dc3545; color: #ffffff; padding: 10px 18px; text-decoration: none; border-radius: 4px; display: inline-block;">
Reject Offer
</a>
</p>
<p>
Regards,<br/>
HR Team<br/>
9703546916
</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -1 +1,3 @@
from . import offer_letter from . import offer_letter
from . import hr_applicant
from . import hr_candidate

View File

@ -0,0 +1,52 @@
from odoo import api, fields, models, _
class HRApplicant(models.Model):
_inherit = 'hr.applicant'
finalized_ctc = fields.Float(string='Finalized CTC', tracking=True)
offer_letter_ids = fields.One2many('offer.letter', 'candidate_id', string='Offer Letters')
current_offer_letter_id = fields.Many2one(
'offer.letter',
string='Current Offer Letter',
compute='_compute_current_offer_letter',
store=True,
)
offer_release_status = fields.Selection(
selection=[
('requested', 'Requested'),
('sent', 'Sent to Applicant'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('expired', 'Expired'),
],
string='Offer Status',
related='current_offer_letter_id.state',
readonly=True,
store=False,
)
@api.depends('offer_letter_ids', 'offer_letter_ids.create_date', 'offer_letter_ids.state')
def _compute_current_offer_letter(self):
for applicant in self:
offer_letters = applicant.offer_letter_ids.sorted(
key=lambda offer: (offer.create_date or fields.Datetime.from_string('1970-01-01 00:00:00'), offer.id)
)
applicant.current_offer_letter_id = offer_letters[-1] if offer_letters else False
def action_request_offer_release(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Request Offer Release'),
'res_model': 'offer.release.request.wizard',
'view_mode': 'form',
'view_id': self.env.ref('offer_letters.view_offer_release_request_wizard_form').id,
'target': 'new',
'context': {
'default_applicant_id': self.id,
},
}
def action_send_offer(self):
return self.action_request_offer_release()

View File

@ -0,0 +1,33 @@
from odoo import api, fields, models
class HRCandidate(models.Model):
_inherit = 'hr.candidate'
current_offer_letter_id = fields.Many2one(
'offer.letter',
string='Current Offer Letter',
compute='_compute_current_offer_letter',
store=False,
)
offer_release_status = fields.Selection(
selection=[
('requested', 'Requested'),
('sent', 'Sent to Applicant'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('expired', 'Expired'),
],
string='Offer Status',
related='current_offer_letter_id.state',
readonly=True,
store=False,
)
@api.depends('applicant_ids.current_offer_letter_id', 'applicant_ids.current_offer_letter_id.create_date')
def _compute_current_offer_letter(self):
for candidate in self:
offer_letters = candidate.applicant_ids.mapped('current_offer_letter_id').sorted(
key=lambda offer: (offer.create_date or fields.Datetime.from_string('1970-01-01 00:00:00'), offer.id)
)
candidate.current_offer_letter_id = offer_letters[-1] if offer_letters else False

View File

@ -27,6 +27,8 @@ class OfferLetter(models.Model):
) )
candidate_id = fields.Many2one( 'hr.applicant', string='Candidate', required=True, candidate_id = fields.Many2one( 'hr.applicant', string='Candidate', required=True,
) )
requested_by_id = fields.Many2one('res.users', string='Requested By', readonly=True, tracking=True)
request_date = fields.Datetime(string='Requested On', readonly=True, tracking=True)
employee_id = fields.Char( employee_id = fields.Char(
string='Employee ID', string='Employee ID',
@ -68,20 +70,20 @@ class OfferLetter(models.Model):
default=lambda self: self._default_terms() default=lambda self: self._default_terms()
) )
state = fields.Selection([ state = fields.Selection([
('draft', 'Draft'), ('requested', 'Requested'),
('sent', 'Sent'), ('sent', 'Sent'),
('accepted', 'Accepted'), ('accepted', 'Accepted'),
('rejected', 'Rejected'), ('rejected', 'Rejected'),
('expired', 'Expired')], ('expired', 'Expired')],
string='Status', string='Status',
default='draft', default='requested',
tracking=True tracking=True
) )
sent_date = fields.Datetime(string='Sent Date') sent_date = fields.Datetime(string='Sent Date')
response_date = fields.Datetime(string='Response Date') response_date = fields.Datetime(string='Response Date')
pay_struct_id = fields.Many2one('hr.payroll.structure', string="Salary Structure", required=True) pay_struct_id = fields.Many2one('hr.payroll.structure', string="Salary Structure", required=True)
manager_id = fields.Many2one('hr.employee', string='Manager') manager_id = fields.Many2one('hr.employee', string='Manager')
rejection_reason = fields.Char()
@api.model @api.model
def _default_terms(self): def _default_terms(self):
@ -95,13 +97,41 @@ class OfferLetter(models.Model):
def create(self, vals): def create(self, vals):
if vals.get('name', _('New')) == _('New'): if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('offer.letter') or _('New') vals['name'] = self.env['ir.sequence'].next_by_code('offer.letter') or _('New')
if vals.get('candidate_id') and 'salary' in vals:
self.env['hr.applicant'].browse(vals['candidate_id']).write({
'finalized_ctc': vals['salary'],
})
return super(OfferLetter, self).create(vals) return super(OfferLetter, self).create(vals)
def write(self, vals):
result = super(OfferLetter, self).write(vals)
if 'salary' in vals:
for record in self.filtered('candidate_id'):
record.candidate_id.finalized_ctc = record.salary
return result
def action_open_send_offer_wizard(self):
self.ensure_one()
if not self.env.user.has_group('hr.group_hr_manager'):
raise UserError(_("Only HR can release the offer letter to the applicant."))
return {
'type': 'ir.actions.act_window',
'name': _('Send Offer'),
'res_model': 'applicant.offer.mail.wizard',
'view_mode': 'form',
'view_id': self.env.ref('offer_letters.view_applicant_offer_mail_wizard_form').id,
'target': 'new',
'context': {
'active_model': 'offer.letter',
'active_id': self.id,
},
}
def action_send_offer(self): def action_send_offer(self):
self.ensure_one() self.ensure_one()
# template = self.env.ref('offer_letters.email_template_offer_letter') if not self.env.user.has_group('hr.group_hr_manager'):
raise UserError(_("Only HR can mark the offer as sent."))
self.write({'state': 'sent', 'sent_date': fields.Datetime.now()}) self.write({'state': 'sent', 'sent_date': fields.Datetime.now()})
# template.send_mail(self.id, force_send=True)
return True return True
def action_accept_offer(self): def action_accept_offer(self):

View File

@ -1,3 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_offer_letter_user,offer.letter.user,model_offer_letter,base.group_user,1,1,1,0 access_offer_letter_user,offer.letter.user,model_offer_letter,base.group_user,1,1,1,0
access_offer_letter_manager,offer.letter.manager,model_offer_letter,hr.group_hr_manager,1,1,1,1 access_offer_letter_manager,offer.letter.manager,model_offer_letter,hr.group_hr_manager,1,1,1,1
access_applicant_offer_mail_wizard,applicant.offer.mail.wizard.user,offer_letters.model_applicant_offer_mail_wizard,base.group_user,1,1,1,1
access_offer_release_request_wizard,offer.release.request.wizard.user,model_offer_release_request_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_offer_letter_user offer.letter.user model_offer_letter base.group_user 1 1 1 0
3 access_offer_letter_manager offer.letter.manager model_offer_letter hr.group_hr_manager 1 1 1 1
4 access_applicant_offer_mail_wizard applicant.offer.mail.wizard.user offer_letters.model_applicant_offer_mail_wizard base.group_user 1 1 1 1
5 access_offer_release_request_wizard offer.release.request.wizard.user model_offer_release_request_wizard base.group_user 1 1 1 1

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="hr_applicant_offer_letter_status" model="ir.ui.view">
<field name="name">hr.applicant.view.form.offer.letter</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment.hr_applicant_view_form"/>
<field name="arch" type="xml">
<xpath expr="//widget[@name='web_ribbon']" position="before">
<field name="offer_release_status" invisible="1"/>
<widget name="web_ribbon" title="Offer Requested" bg_color="text-bg-warning"
invisible="offer_release_status != 'requested'"/>
<widget name="web_ribbon" title="Offer Released" bg_color="text-bg-info"
invisible="offer_release_status != 'sent'"/>
<widget name="web_ribbon" title="Offer Accepted" bg_color="text-bg-success"
invisible="offer_release_status != 'accepted'"/>
<widget name="web_ribbon" title="Offer Rejected" bg_color="text-bg-danger"
invisible="offer_release_status != 'rejected'"/>
</xpath>
</field>
</record>
<record id="hr_applicant_view_form_offer_letters_inherit" model="ir.ui.view">
<field name="name">hr.applicant.view.form.offer.letters.inherit</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment_extended.hr_applicant_view_form_inherit"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_share_applicant']" position="before">
<button name="action_request_offer_release" string="Request Offer Release" type="object" class="btn-primary"
groups="hr_recruitment.group_hr_recruitment_user"
invisible="not id"/>
</xpath>
<xpath expr="//notebook/page[@name='request_forms']" position="after">
<page string="Offer Letters" name="offer_letters">
<group>
<field name="finalized_ctc"/>
<field name="current_offer_letter_id" readonly="1"/>
<field name="offer_release_status" widget="badge" readonly="1"/>
</group>
<field name="offer_letter_ids" nolabel="1">
<list>
<field name="name"/>
<field name="requested_by_id"/>
<field name="request_date"/>
<field name="salary"/>
<field name="state"/>
<field name="sent_date"/>
<field name="response_date"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
<record id="hr_applicant_view_kanban_offer_letters_inherit" model="ir.ui.view">
<field name="name">hr.applicant.view.kanban.offer.letters.inherit</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment.hr_kanban_view_applicant"/>
<field name="arch" type="xml">
<xpath expr="//kanban" position="inside">
<field name="offer_release_status"/>
</xpath>
<xpath expr="//t[@t-name='card']/widget[@name='web_ribbon']" position="after">
<widget name="web_ribbon" title="Offer Req" bg_color="text-bg-warning"
invisible="offer_release_status != 'requested'"/>
<widget name="web_ribbon" title="Offer Rel" bg_color="text-bg-info"
invisible="offer_release_status != 'sent'"/>
<widget name="web_ribbon" title="Offer Accept" bg_color="text-bg-success"
invisible="offer_release_status != 'accepted'"/>
<widget name="web_ribbon" title="Offer Rej" bg_color="text-bg-danger"
invisible="offer_release_status != 'rejected'"/>
</xpath>
</field>
</record>
<record id="hr_candidate_view_kanban_offer_letters_inherit" model="ir.ui.view">
<field name="name">hr.candidate.view.kanban.offer.letters.inherit</field>
<field name="model">hr.candidate</field>
<field name="inherit_id" ref="hr_recruitment_extended.hr_candidate_view_kanban_inherit"/>
<field name="arch" type="xml">
<xpath expr="//kanban" position="inside">
<field name="offer_release_status"/>
</xpath>
<xpath expr="//t[@t-name='card']" position="inside">
<div class="mb-2" t-if="record.offer_release_status.raw_value == 'requested'">
<span class="badge text-bg-warning">Offer Requested</span>
</div>
<div class="mb-2" t-if="record.offer_release_status.raw_value == 'sent'">
<span class="badge text-bg-info">Offer Released</span>
</div>
<div class="mb-2" t-if="record.offer_release_status.raw_value == 'accepted'">
<span class="badge text-bg-success">Offer Accepted</span>
</div>
<div class="mb-2" t-if="record.offer_release_status.raw_value == 'rejected'">
<span class="badge text-bg-danger">Offer Rejected</span>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<odoo> <odoo>
<menuitem id="menu_offer_letters_list" name="Offer Letters" parent="hr_ftp.menu_hr_recruitment_hr" action="action_offer_letters"/> <menuitem id="menu_offer_letters_list" sequence="10" name="Offer Letters" parent="hr_payroll.menu_hr_payroll_root" action="action_offer_letters"/>
</odoo> </odoo>

View File

@ -20,22 +20,26 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form> <form>
<header> <header>
<button name="action_send_offer" type="object" string="Send Offer" class="oe_highlight" invisble="state != 'draft'"/> <button name="action_open_send_offer_wizard" type="object" string="Send Offer" class="oe_highlight"
<button name="action_accept_offer" type="object" string="Accept Offer" invisble="state != 'sent'" class="oe_highlight"/> invisible="state != 'requested'" groups="hr.group_hr_manager"/>
<button name="action_reject_offer" type="object" string="Reject Offer" invisble="state != 'sent'" class="oe_danger"/> <button name="action_accept_offer" type="object" string="Accept Offer" invisible="state != 'sent'" class="oe_highlight"/>
<button name="get_paydetailed_lines" type="object" string="Get Data" invisble="state != 'sent'" class="oe_danger"/> <!-- <button name="action_open_reject_wizard" type="object" string="Reject Offer" invisible="state != 'sent'" class="oe_danger"/>-->
<button name="get_paydetailed_lines" type="object" string="Get Data" class="oe_danger"/>
<button name="generate_pdf_report" type="object" string="Generate PDF" class="oe_highlight"/> <button name="generate_pdf_report" type="object" string="Generate PDF" class="oe_highlight"/>
<field name="state" widget="statusbar" statusbar_visible="draft,sent,accepted,rejected,expired"/> <field name="state" widget="statusbar" statusbar_visible="requested,sent,accepted,rejected,expired"/>
</header> </header>
<sheet> <sheet>
<group> <group>
<group> <group>
<field name="name" readonly="state != 'draft'"/> <field name="name" readonly="state != 'requested'"/>
<field name="candidate_id"/> <field name="candidate_id"/>
<field name="requested_by_id" readonly="1"/>
<field name="request_date" readonly="1"/>
<field name="manager_id"/> <field name="manager_id"/>
<field name="position"/> <field name="position"/>
<field name="salary"/> <field name="salary"/>
<field name="mi"/> <field name="mi"/>
<field name="rejection_reason" readonly="state != 'rejected'"/>
</group> </group>
<group> <group>
<field name="currency_id"/> <field name="currency_id"/>
@ -43,6 +47,8 @@
<field name="contract_type"/> <field name="contract_type"/>
<field name="probation_period"/> <field name="probation_period"/>
<field name="pay_struct_id"/> <field name="pay_struct_id"/>
<field name="sent_date" readonly="1"/>
<field name="response_date" readonly="1"/>
</group> </group>
</group> </group>
<notebook> <notebook>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="offer_response_message">
<html>
<body style="font-family: Arial, sans-serif; background: #f5f7fa; margin: 0; padding: 40px;">
<div style="max-width: 640px; margin: 0 auto; background: #ffffff; border-radius: 10px; padding: 32px; box-shadow: 0 10px 30px rgba(0,0,0,0.08);">
<h2 style="margin-top: 0; color: #143d5d;">
<t t-esc="title"/>
</h2>
<p style="font-size: 16px; color: #333333; line-height: 1.6;">
<t t-esc="message"/>
</p>
</div>
</body>
</html>
</template>
<template id="offer_reject_reason_page">
<html>
<body style="font-family: Arial, sans-serif; background: #f5f7fa; margin: 0; padding: 40px;">
<div style="max-width: 720px; margin: 0 auto; background: #ffffff; border-radius: 10px; padding: 32px; box-shadow: 0 10px 30px rgba(0,0,0,0.08);">
<h2 style="margin-top: 0; color: #143d5d;">Reject Offer</h2>
<p style="font-size: 15px; color: #333333; line-height: 1.6;">
Please let us know the reason for rejecting the offer for
<strong><t t-esc="offer_letter.position"/></strong>.
</p>
<p t-if="error" style="color: #dc3545; font-size: 14px;">
<t t-esc="error"/>
</p>
<form t-att-action="'/offer_letters/respond/%s/reject?token=%s' % (offer_letter.id, token)" method="post">
<div style="margin-bottom: 16px;">
<label for="rejection_reason" style="display: block; font-weight: 600; margin-bottom: 8px; color: #143d5d;">Rejection Reason</label>
<textarea id="rejection_reason" name="rejection_reason" rows="6"
style="width: 100%; padding: 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px;"
placeholder="Please enter your reason here"></textarea>
</div>
<button type="submit"
style="background: #dc3545; color: #ffffff; border: none; padding: 12px 18px; border-radius: 6px; font-size: 14px; cursor: pointer;">
Submit Rejection
</button>
</form>
</div>
</body>
</html>
</template>
</odoo>

View File

@ -0,0 +1,3 @@
from . import applicant_offer_mail_wizard
from . import offer_release_request_wizard
from . import offer_letter_reject_wizard

View File

@ -0,0 +1,225 @@
import base64
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ApplicantOfferMailWizard(models.TransientModel):
_name = 'applicant.offer.mail.wizard'
_description = 'Applicant Offer Mail Wizard'
applicant_id = fields.Many2one('hr.applicant', string='Applicant', required=True, readonly=True)
offer_letter_id = fields.Many2one('offer.letter', string='Offer Letter', readonly=True)
template_id = fields.Many2one('mail.template', string='Email Template', required=True, readonly=True)
generated_attachment_id = fields.Many2one('ir.attachment', string='Generated Offer Attachment', readonly=True)
position = fields.Char(string='Position', required=True)
salary = fields.Float(string='Annual CTC', required=True)
joining_date = fields.Date(string='Joining Date', required=True)
pay_struct_id = fields.Many2one('hr.payroll.structure', string='Salary Structure', required=True)
manager_id = fields.Many2one('hr.employee', string='Manager')
contract_type = fields.Selection([
('permanent', 'Permanent'),
('contract', 'Fixed Term Contract'),
('intern', 'Internship'),
], string='Contract Type', default='permanent', required=True)
probation_period = fields.Integer(string='Probation Period (months)', default=3, required=True)
email_from = fields.Char('Email From', required=True)
email_to = fields.Char('Email To', required=True)
email_cc = fields.Text('Email CC')
email_subject = fields.Char('Subject', required=True)
email_body = fields.Html(
'Body',
render_engine='qweb',
render_options={'post_process': True},
prefetch=True,
translate=True,
sanitize='email_outgoing',
required=True,
)
attachment_ids = fields.Many2many('ir.attachment', string='Attachments')
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
offer_letter = self._get_offer_letter_from_context()
applicant = offer_letter.candidate_id if offer_letter else self._get_applicant()
template = self.env.ref('offer_letters.applicant_offer_email_template', raise_if_not_found=False)
offer_letter = offer_letter or self._create_offer_letter(applicant)
defaults.update({
'applicant_id': applicant.id,
'offer_letter_id': offer_letter.id,
'position': offer_letter.position,
'salary': offer_letter.salary,
'joining_date': offer_letter.joining_date,
'pay_struct_id': offer_letter.pay_struct_id.id,
'manager_id': offer_letter.manager_id.id,
'contract_type': offer_letter.contract_type,
'probation_period': offer_letter.probation_period,
})
if template:
defaults['template_id'] = template.id
defaults.update(self._prepare_mail_defaults(template, offer_letter))
return defaults
def _get_applicant(self):
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id'))
if not applicant.exists():
raise UserError(_("The applicant does not exist or is not accessible."))
return applicant
def _get_offer_letter_from_context(self):
if self.env.context.get('active_model') != 'offer.letter':
return self.env['offer.letter']
offer_letter = self.env['offer.letter'].browse(self.env.context.get('active_id'))
if not offer_letter.exists():
raise UserError(_("The offer letter does not exist or is not accessible."))
return offer_letter
def _get_default_pay_structure(self, applicant):
company = applicant.company_id or self.env.company
return self.env['hr.payroll.structure'].search([
'|',
('company_id', '=', company.id),
('company_id', '=', False),
], limit=1)
def _get_default_manager(self, applicant):
return applicant.user_id.employee_id or self.env.user.employee_id
def _create_offer_letter(self, applicant):
pay_structure = self._get_default_pay_structure(applicant)
if not pay_structure:
raise UserError(_("Please configure at least one salary structure before sending an offer."))
offer_letter = self.env['offer.letter'].create({
'candidate_id': applicant.id,
'position': applicant.job_id.name or applicant.hr_job_recruitment.job_id.name or applicant.partner_name or applicant.display_name,
'salary': applicant.finalized_ctc or applicant.salary_expected or applicant.current_ctc or 0.0,
'joining_date': fields.Date.today(),
'pay_struct_id': pay_structure.id,
'manager_id': self._get_default_manager(applicant).id,
})
offer_letter.get_paydetailed_lines()
return offer_letter
def _update_offer_letter(self):
self.ensure_one()
vals = {
'candidate_id': self.applicant_id.id,
'position': self.position,
'salary': self.salary,
'joining_date': self.joining_date,
'pay_struct_id': self.pay_struct_id.id,
'manager_id': self.manager_id.id,
'contract_type': self.contract_type,
'probation_period': self.probation_period,
}
self.offer_letter_id.write(vals)
self.offer_letter_id.get_paydetailed_lines()
return self.offer_letter_id
def _generate_offer_attachment(self, offer_letter):
report = self.env.ref('offer_letters.hr_offer_letters_employee_print')
from odoo import _ as translate
pdf_content, _ = report.sudo()._render_qweb_pdf(report, offer_letter.id)
attachment_name = translate('Offer Letter - %s.pdf') % (offer_letter.candidate_id.partner_name or offer_letter.name)
return self.env['ir.attachment'].create({
'name': attachment_name,
'datas': base64.b64encode(pdf_content),
'mimetype': 'application/pdf',
'res_model': self._name,
'res_id': self.id or 0,
'type': 'binary',
})
def _prepare_mail_defaults(self, template, offer_letter):
response_token = offer_letter._issue_response_token()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
accept_url = f"{base_url}/offer_letters/respond/{offer_letter.id}/accept?token={response_token}"
reject_url = f"{base_url}/offer_letters/respond/{offer_letter.id}/reject?token={response_token}"
render_results = template.with_context(
offer_accept_url=accept_url,
offer_reject_url=reject_url,
)._generate_template(
[offer_letter.id],
['subject', 'body_html', 'email_from', 'email_to', 'email_cc', 'reply_to'],
find_or_create_partners=False,
)
generated_values = render_results.get(offer_letter.id, {})
attachment = self._generate_offer_attachment(offer_letter)
other_attachments = self.env['ir.attachment']
previous_attachment = self.env['ir.attachment']
if self.ids:
previous_attachment = self.generated_attachment_id
other_attachments = self.attachment_ids - previous_attachment
attachment_ids = (other_attachments | attachment).ids if attachment else other_attachments.ids
return {
'email_from': generated_values.get('email_from') or offer_letter.candidate_id.user_id.email or self.env.user.email,
'email_to': generated_values.get('email_to') or offer_letter.candidate_id.email_from or '',
'email_cc': generated_values.get('email_cc', ''),
'email_subject': generated_values.get('subject', ''),
'email_body': generated_values.get('body_html', ''),
'generated_attachment_id': attachment.id if attachment else False,
'attachment_ids': [(6, 0, attachment_ids)],
}
def action_generate_offer(self):
self.ensure_one()
offer_letter = self._update_offer_letter()
previous_attachment = self.generated_attachment_id
self.write(self._prepare_mail_defaults(self.template_id, offer_letter))
if previous_attachment:
previous_attachment.unlink()
return {
'type': 'ir.actions.act_window',
'name': _('Send Offer'),
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref('offer_letters.view_applicant_offer_mail_wizard_form').id,
'target': 'new',
}
def action_preview_offer_attachment(self):
self.ensure_one()
if not self.generated_attachment_id:
raise UserError(_("Please generate the offer first to preview the latest PDF."))
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=false' % self.generated_attachment_id.id,
'target': 'new',
}
def action_send_offer(self):
self.ensure_one()
if not self.env.user.has_group('hr.group_hr_manager'):
raise UserError(_("Only HR can send the offer to the applicant."))
offer_letter = self._update_offer_letter()
previous_attachment = self.generated_attachment_id
refreshed_values = self._prepare_mail_defaults(self.template_id, offer_letter)
self.write(refreshed_values)
if previous_attachment:
previous_attachment.unlink()
attachment_ids = self.attachment_ids.ids
offer_attachment = self.generated_attachment_id
if offer_attachment.id not in attachment_ids:
attachment_ids.append(offer_attachment.id)
mail = self.env['mail.mail'].create({
'email_from': self.email_from,
'email_to': self.email_to,
'email_cc': self.email_cc,
'subject': self.email_subject,
'body_html': self.email_body,
'attachment_ids': [(6, 0, attachment_ids)],
'auto_delete': False,
'model': 'offer.letter',
'res_id': offer_letter.id,
})
mail.send()
offer_letter.action_send_offer()
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="view_applicant_offer_mail_wizard_form" model="ir.ui.view">
<field name="name">applicant.offer.mail.wizard.form</field>
<field name="model">applicant.offer.mail.wizard</field>
<field name="arch" type="xml">
<form string="Send Offer">
<group>
<field name="applicant_id" readonly="1"/>
<field name="template_id" options="{'no_create': True}" readonly="1"/>
<field name="generated_attachment_id" invisible="1"/>
</group>
<group string="Offer Details" col="2">
<field name="position"/>
<field name="salary"/>
<field name="joining_date"/>
<field name="pay_struct_id" options="{'no_create': True}"/>
<field name="manager_id"/>
<field name="contract_type"/>
<field name="probation_period"/>
</group>
<button name="action_generate_offer" type="object" string="Generate Offer" class="btn-secondary"/>
<group string="Email Details">
<field name="email_from" placeholder="Sender email"/>
<field name="email_to" placeholder="Recipient email"/>
<field name="email_cc" placeholder="Comma-separated CC recipients"/>
<field name="email_subject" options="{'dynamic_placeholder': true}" placeholder="Email subject"/>
</group>
<notebook>
<page string="Body">
<field name="email_body" widget="html_mail" class="oe-bordered-editor"
options="{'codeview': true, 'dynamic_placeholder': true}"/>
</page>
<page string="Attachments">
<field name="attachment_ids" widget="many2many_binary"/>
</page>
</notebook>
<footer>
<button name="action_preview_offer_attachment" type="object" string="Preview Offer Letter"
class="btn-secondary" invisible="not generated_attachment_id"/>
<button name="action_send_offer" type="object" string="Send Offer" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,14 @@
from odoo import _, fields, models
class OfferLetterRejectWizard(models.TransientModel):
_name = 'offer.letter.reject.wizard'
_description = 'Offer Letter Reject Wizard'
offer_letter_id = fields.Many2one('offer.letter', string='Offer Letter', required=True, readonly=True)
rejection_reason = fields.Text(string='Rejection Reason', required=True)
def action_confirm_reject(self):
self.ensure_one()
self.offer_letter_id.action_reject_offer(self.rejection_reason)
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_offer_letter_reject_wizard_form" model="ir.ui.view">
<field name="name">offer.letter.reject.wizard.form</field>
<field name="model">offer.letter.reject.wizard</field>
<field name="arch" type="xml">
<form string="Reject Offer">
<group>
<field name="offer_letter_id" readonly="1"/>
<field name="rejection_reason" placeholder="Enter rejection reason"/>
</group>
<footer>
<button name="action_confirm_reject" type="object" string="Reject Offer" class="btn-danger"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,118 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class OfferReleaseRequestWizard(models.TransientModel):
_name = 'offer.release.request.wizard'
_description = 'Offer Release Request Wizard'
applicant_id = fields.Many2one('hr.applicant', string='Applicant', required=True, readonly=True)
position = fields.Char(string='Position', readonly=True)
finalized_ctc = fields.Float(string='Finalized CTC', required=True)
email_from = fields.Char(string='Email From', required=True)
email_to = fields.Char(string='Mail To', required=True)
email_cc = fields.Text(string='Mail CC')
email_subject = fields.Char(string='Subject', required=True)
email_body = fields.Html(
string='Mail Body',
render_engine='qweb',
render_options={'post_process': True},
prefetch=True,
translate=True,
sanitize='email_outgoing',
required=True,
)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
applicant = self.env['hr.applicant'].browse(
self.env.context.get('default_applicant_id') or self.env.context.get('active_id')
)
if not applicant.exists():
raise UserError(_("The applicant does not exist or is not accessible."))
recruiter_name = applicant.user_id.name or self.env.user.name
candidate_name = applicant.partner_name or applicant.candidate_id.partner_name or applicant.display_name
position = applicant.job_id.name or applicant.hr_job_recruitment.job_id.name or applicant.display_name
finalized_ctc = applicant.finalized_ctc or applicant.salary_expected or applicant.current_ctc or 0.0
defaults.update({
'applicant_id': applicant.id,
'position': position,
'finalized_ctc': finalized_ctc,
'email_from': self.env.user.email or self.env.company.email or '',
'email_to': self._get_hr_email_to(),
'email_subject': _('Offer Release Request - %s') % candidate_name,
'email_body': (
f"<p>Dear HR Team,</p>"
f"<p>Please release the offer letter for <strong>{candidate_name}</strong>.</p>"
f"<p><strong>Position:</strong> {position}<br/>"
f"<strong>Finalized CTC:</strong> {finalized_ctc:.2f}<br/>"
f"<strong>Requested By:</strong> {recruiter_name}</p>"
f"<p>Please review the request and release the offer letter to the applicant.</p>"
),
})
return defaults
def _get_hr_email_to(self):
hr_id = self.env['ir.config_parameter'].sudo().get_param('requisitions.requisition_hr_id')
hr_manager_id = self.env['res.users'].sudo().browse(int(hr_id)) if hr_id else False
users = hr_manager_id
if not hr_id:
group = self.env.ref('hr.group_hr_manager')
users = self.env['res.users'].sudo().search([
('groups_id', 'in', group.ids),
('email', '!=', False),
])
emails = users.mapped('email')
return ','.join(emails) or self.env.company.email or ''
def _get_default_pay_structure(self, applicant):
company = applicant.company_id or self.env.company
return self.env['hr.payroll.structure'].search([
], limit=1)
def _get_default_manager(self, applicant):
return applicant.user_id.employee_id or self.env.user.employee_id
def action_submit_request(self):
self.ensure_one()
applicant = self.applicant_id
pay_structure = self._get_default_pay_structure(applicant)
if not pay_structure:
raise UserError(_("Please configure at least one salary structure before requesting an offer release."))
applicant.finalized_ctc = self.finalized_ctc
offer_letter = self.env['offer.letter'].create({
'candidate_id': applicant.id,
'position': self.position,
'salary': self.finalized_ctc,
'joining_date': fields.Date.today(),
'pay_struct_id': pay_structure.id,
'manager_id': self._get_default_manager(applicant).id,
'requested_by_id': self.env.user.id,
'request_date': fields.Datetime.now(),
'state': 'requested',
})
offer_letter.get_paydetailed_lines()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
offer_url = f"{base_url}/web#id={offer_letter.id}&model=offer.letter&view_type=form"
body_html = (
f"{self.email_body}"
f"<p><a href=\"{offer_url}\">Review Offer Letter Request</a></p>"
)
mail = self.env['mail.mail'].create({
'email_from': self.email_from,
'email_to': self.email_to,
'email_cc': self.email_cc,
'subject': self.email_subject,
'body_html': body_html,
'auto_delete': False,
'model': 'offer.letter',
'res_id': offer_letter.id,
})
mail.send()
return {'type': 'ir.actions.act_window_close'}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_offer_release_request_wizard_form" model="ir.ui.view">
<field name="name">offer.release.request.wizard.form</field>
<field name="model">offer.release.request.wizard</field>
<field name="arch" type="xml">
<form string="Request Offer Release">
<group>
<field name="applicant_id" readonly="1"/>
<field name="position" readonly="1"/>
<field name="finalized_ctc"/>
</group>
<group string="Email Details">
<field name="email_from" placeholder="Sender email"/>
<field name="email_to" placeholder="Recipient email(s)"/>
<field name="email_cc" placeholder="Comma-separated CC recipients"/>
<field name="email_subject" placeholder="Email subject"/>
</group>
<group string="Mail Body">
<field name="email_body" widget="html_mail" class="oe-bordered-editor"
options="{'codeview': true, 'dynamic_placeholder': true}"/>
</group>
<footer>
<button name="action_submit_request" type="object" string="Submit Request" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -1,277 +0,0 @@
# Task Document: Project by Module
## Requirement
Add a **Project by Module** submenu in the Odoo **Project** menu.
The menu should show **Project records grouped by Module**, so users can easily view projects module-wise.
## Understanding
Initially, the addon already had a `Module` field on `project.task`:
```python
model_id = fields.Many2one('project.module.source', string="Module")
```
But the requirement is to show **projects by module**, not tasks by module. Since `project.project` did not have a module field, a new field was added on the Project model.
## Implementation Summary
The implementation adds:
- A `module_id` field on `project.project`
- A Module field on the Project form
- A Project search group-by filter for Module
- A new Project menu item named **Project by Module**
- A new action that opens `project.project` grouped by `module_id`
- Manifest entry to load the new XML file
## Files Changed
### 1. `models/project.py`
Added a new Many2one field on Project:
```python
module_id = fields.Many2one('project.module.source', string="Module")
```
Location:
```python
sequence_name = fields.Char("Project Number", copy=False, readonly=True)
module_id = fields.Many2one('project.module.source', string="Module")
task_sequence_id = fields.Many2one(
'ir.sequence',
string="Task Sequence",
readonly=True,
copy=False,
help="Sequence for tasks of this project"
)
```
### 2. `view/project_by_module.xml`
Created a new XML file to handle form view, search view, action, and menu.
Full code:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="project_project_form_module_inherit" model="ir.ui.view">
<field name="name">project.project.form.module.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project_task_timesheet_extended.project_project_inherit_form_view2"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='project_lead']" position="after">
<field name="module_id" options="{'no_open': True}"/>
</xpath>
</field>
</record>
<record id="project_project_search_project_by_module" model="ir.ui.view">
<field name="name">project.project.search.project.by.module</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.view_project_project_filter"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<filter name="group_by_module" string="Module" context="{'group_by': 'module_id'}"/>
</xpath>
</field>
</record>
<record id="action_project_by_module" model="ir.actions.act_window">
<field name="name">Project by Module</field>
<field name="res_model">project.project</field>
<field name="view_mode">kanban,list,form,calendar,activity</field>
<field name="search_view_id" ref="project.view_project_project_filter"/>
<field name="context">{'search_default_group_by_module': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No projects found.
</p>
<p>
Create projects and set their Module to review projects module-wise.
</p>
</field>
</record>
<menuitem id="menu_project_by_module"
name="Project by Module"
parent="project.menu_main_pm"
action="action_project_by_module"
sequence="25"/>
</data>
</odoo>
```
### 3. `__manifest__.py`
Added the new XML file in the data list:
```python
'view/project_by_module.xml',
```
Location:
```python
'view/project_task.xml',
'view/project.xml',
'view/project_portfolio.xml',
'view/project_by_module.xml',
'view/timesheets.xml',
```
## Expected Result
After upgrading the module:
1. Open a Project record.
2. Set the **Module** field.
3. Go to **Project > Project by Module**.
4. Odoo opens Project records grouped module-wise.
Example:
```text
PMT
Project A
Project B
Finance
Project C
ATS
Project D
```
## Module Upgrade Command
Use the appropriate database name:
```bash
odoo-bin -d your_database -u project_task_timesheet_extended
```
## Validation Done
- XML syntax checked successfully.
- Python syntax checked successfully using `compile()`.
- `py_compile` could not write bytecode because Windows denied write access to `models/__pycache__`, but the Python syntax itself is valid.
## Additional Update: Portfolio on Project Card and Module on Task
After the initial Project by Module work, two display changes were added.
### 1. Portfolio visible on Project Kanban card
Project records already have `portfolio_id`. The project kanban card was extended to show the portfolio name under the project title/metadata area.
Code added in `view/project_by_module.xml`:
```xml
<record id="project_project_kanban_portfolio_inherit" model="ir.ui.view">
<field name="name">project.project.kanban.portfolio.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.view_project_kanban"/>
<field name="arch" type="xml">
<xpath expr="//kanban/templates" position="before">
<field name="portfolio_id"/>
</xpath>
<xpath expr="//span[@name='partner_name']" position="after">
<span name="portfolio_name" class="text-muted d-flex align-items-baseline" t-if="record.portfolio_id.value">
<span class="fa fa-folder-open me-2" aria-label="Portfolio" title="Portfolio"></span>
<field class="text-truncate" name="portfolio_id"/>
</span>
</xpath>
</field>
</record>
```
### 2. Task Module comes from Project Module
The task module field now comes from the task's selected project, so the task automatically shows which module it belongs to.
Code changed in `models/project_task.py`:
```python
model_id = fields.Many2one(
'project.module.source',
string="Module",
related='project_id.module_id',
store=True,
readonly=True,
)
```
The task form also displays the Module near the task title area and keeps the regular Module field read-only.
Code added in `view/project_by_module.xml`:
```xml
<record id="project_task_form_project_module_display" model="ir.ui.view">
<field name="name">project.task.form.project.module.display</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project_task_timesheet_extended.project_task_form_inherit"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('oe_title','pe-0')]" position="inside">
<div class="text-muted mt-1" invisible="not model_id">
<span>Module: </span>
<field name="model_id" readonly="1" nolabel="1" options="{'no_open': True}"/>
</div>
</xpath>
<xpath expr="//field[@name='model_id']" position="attributes">
<attribute name="readonly">1</attribute>
<attribute name="options">{'no_open': True}</attribute>
</xpath>
</field>
</record>
```
Expected behavior:
- Project kanban cards show their Portfolio when assigned.
- Task form shows the Module from the parent Project.
- Users only need to set Module on Project; related tasks display it automatically.
### 3. Task ID and Module visible on Task Kanban card
The task kanban card was extended to show the generated task sequence and the module directly under the task title.
Code added in `view/project_task.xml`:
```xml
<record id="project_task_kanban_task_id_module" model="ir.ui.view">
<field name="name">project.task.kanban.task.id.module</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='subtask_count']" position="after">
<field name="sequence_name"/>
<field name="model_id"/>
</xpath>
<xpath expr="//t[@t-name='card']//field[@name='name']" position="after">
<div class="text-muted mt-1 small" t-if="record.sequence_name.raw_value">
<span>Task ID: </span>
<field name="sequence_name"/>
</div>
<div class="text-muted small" t-if="record.model_id.raw_value">
<span>Module: </span>
<field name="model_id"/>
</div>
</xpath>
</field>
</record>
```
Expected behavior on task kanban cards:
```text
project task
Task ID: PROJ-012/TASK-001
Module: PMT
```

View File

@ -15,3 +15,8 @@ class ResConfigSettings(models.TransientModel):
domain=lambda self: [ domain=lambda self: [
('groups_id', 'in', ('groups_id', 'in',
self.env.ref('account.group_account_manager').id)]) self.env.ref('account.group_account_manager').id)])
requisition_manager = fields.Many2one('res.users',config_parameter='requisitions.requisition_manager', string='Requisition Managers',
domain=lambda self: [
('groups_id', 'in',
self.env.ref('hr_recruitment.group_hr_recruitment_manager').id)]
)

View File

@ -17,7 +17,11 @@
id="requisition_finance_access_control"> id="requisition_finance_access_control">
<field name="requisition_finance_manager" options="{'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/> <field name="requisition_finance_manager" options="{'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/>
</setting> </setting>
<setting string="Requisition Manager Access"
help="Select the Manager responsible for Requisitions."
id="requisition_manager_access_control">
<field name="requisition_manager" options="{'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/>
</setting>
</block> </block>
</xpath> </xpath>
</field> </field>

View File

@ -2,10 +2,10 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="universal_attachment_preview.PopupPreview"> <t t-name="universal_attachment_preview.PopupPreview">
<Dialog title="props.filename" size="'lg'"> <Dialog title="props.filename + ' (' + props.mimetype + ')'" size="'lg'">
<p class="text-muted mt-2"> <!-- <p class="text-muted mt-2">-->
<t t-esc="props.mimetype"/> <!-- <t t-esc="props.mimetype"/>-->
</p> <!-- </p>-->
<t t-if="props.mimetype.endsWith('pdf')"> <t t-if="props.mimetype.endsWith('pdf')">
<iframe t-att-src="props.url" <iframe t-att-src="props.url"

View File

@ -0,0 +1,202 @@
from . import models, wizards
def post_init_remove_duplicate_skills(env):
# =====================================================
# STEP 1 : Ensure every skill type has levels
# =====================================================
skill_types = env['hr.skill.type'].search([])
for skill_type in skill_types:
# -------------------------------------------------
# Create level if no levels exist
# -------------------------------------------------
if not skill_type.skill_level_ids:
env['hr.skill.level'].create({
'name': 'Beginner',
'level_progress': 10,
'default_level': True,
'skill_type_id': skill_type.id,
})
# -------------------------------------------------
# Ensure one default level exists
# -------------------------------------------------
elif not skill_type.skill_level_ids.filtered('default_level'):
skill_type.skill_level_ids[0].default_level = True
# =====================================================
# STEP 2 : Fix invalid candidate skill records
# =====================================================
candidate_skill_lines = env['hr.candidate.skill'].search([])
for line in candidate_skill_lines:
# -------------------------------------------------
# Skip invalid records
# -------------------------------------------------
if not line.skill_id:
continue
# -------------------------------------------------
# Fix wrong skill type
# -------------------------------------------------
correct_skill_type = line.skill_id.skill_type_id
# -------------------------------------------------
# Ensure skill type has levels
# -------------------------------------------------
levels = correct_skill_type.skill_level_ids
if not levels:
level = env['hr.skill.level'].create({
'name': 'Beginner',
'level_progress': 10,
'default_level': True,
'skill_type_id': correct_skill_type.id,
})
levels = level
# -------------------------------------------------
# Ensure default level exists
# -------------------------------------------------
default_level = levels.filtered(
lambda l: l.default_level
)[:1]
if not default_level:
default_level = levels[0]
default_level.default_level = True
# -------------------------------------------------
# Update all fields together
# -------------------------------------------------
line.write({
'skill_type_id': correct_skill_type.id,
'skill_level_id': default_level.id,
})
# =====================================================
# STEP 3 : Remove duplicate skills
# =====================================================
skills = env['hr.skill'].search([
('active', 'in', [True, False])
])
processed_skills = {}
for skill in skills:
normalized_name = skill.name.strip().lower()
# -------------------------------------------------
# Keep first occurrence
# -------------------------------------------------
if normalized_name not in processed_skills:
processed_skills[normalized_name] = skill
continue
original_skill = processed_skills[normalized_name]
duplicate_skill = skill
# -------------------------------------------------
# Ensure original skill type has levels
# -------------------------------------------------
levels = original_skill.skill_type_id.skill_level_ids
if not levels:
level = env['hr.skill.level'].create({
'name': 'Beginner',
'level_progress': 10,
'default_level': True,
'skill_type_id': original_skill.skill_type_id.id,
})
levels = level
# -------------------------------------------------
# Ensure default level exists
# -------------------------------------------------
default_level = levels.filtered(
lambda l: l.default_level
)[:1]
if not default_level:
default_level = levels[0]
default_level.default_level = True
# =================================================
# Update hr.candidate.skill lines
# =================================================
duplicate_lines = env['hr.candidate.skill'].search([
('skill_id', '=', duplicate_skill.id)
])
for line in duplicate_lines:
# ---------------------------------------------
# Update all fields together
# ---------------------------------------------
line.write({
'skill_id': original_skill.id,
'skill_type_id': original_skill.skill_type_id.id,
'skill_level_id': default_level.id,
})
# =================================================
# Update quick_skill_ids in hr.candidate
# =================================================
candidates = env['hr.candidate'].search([
('quick_skill_ids', 'in', duplicate_skill.id)
])
for candidate in candidates:
updated_skill_ids = []
for skill_id in candidate.quick_skill_ids.ids:
if skill_id == duplicate_skill.id:
updated_skill_ids.append(original_skill.id)
else:
updated_skill_ids.append(skill_id)
# Remove duplicate ids
updated_skill_ids = list(set(updated_skill_ids))
candidate.write({
'quick_skill_ids': [(6, 0, updated_skill_ids)]
})
# =================================================
# Archive duplicate skill
# =================================================
duplicate_skill.active = False

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Form Chages',
'category': 'Website/Website',
'sequence': 310,
'version': '1.1',
'summary': 'Changes of the form',
'description': "This module contains the changes in the job portal, candidates, appliant form",
'depends': ['hr_recruitment_extended', 'website_hr_recruitment','website_hr_recruitment_extended','survey','hr_recruitment'],
'data': [
'data/mail_template_data.xml',
'security/ir.model.access.csv',
'views/hr_applicant_form.xml',
'views/hr_candidate_form.xml',
'views/job_portal_changes.xml',
],
'assets': {
'web.assets_backend': {
'web_portal_form_custom/static/src/css/candidate_card.css'
},
},
'post_init_hook': 'post_init_remove_duplicate_skills',
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_applicant_interview_invite" model="mail.template">
<field name="name">Applicant: Interview</field>
<field name="model_id" ref="model_survey_user_input" />
<field name="subject">Participate to {{ object.survey_id.display_name }} interview</field>
<field name="email_to">{{ (object.partner_id.email_formatted or object.email) }}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear <t t-out="object.partner_id.name or 'applicant'">[applicant name]</t><br/><br/>
<t>
You've progressed through the recruitment process and we would like you to answer some questions.
</t>
<div style="margin: 16px 0px 16px 0px;">
<a t-att-href="(object.get_start_url())"
style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
<t>
Start the written interview
</t>
</a>
</div>
<t t-if="object.deadline">
Please answer the interview for <t t-out="format_date(object.deadline)">[deadline date]</t>.<br/><br/>
</t>
<t>
We wish you good luck! Thank you in advance for your participation.
</t>
</p>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
from . import application_candidate_changes
from . import survey_line

View File

@ -0,0 +1,217 @@
from odoo import api, fields, models,_
class ApplicantCandidate(models.Model):
_inherit = 'hr.candidate'
quick_skill_ids = fields.Many2many(
'hr.skill',
'hr_candidate_quick_skill_rel',
'candidate_id',
'skill_id',
compute='_compute_quick_skill_ids',
inverse='_inverse_quick_skill_ids',
store=True,
domain=[('active', '=', True)],
string="Skills",
)
priority = fields.Selection(
selection_add=[
('4', 'Outstanding'),
('5', 'Top Priority')
]
)
resume = fields.Binary(
string="Resume",
attachment=True
)
resume_name = fields.Char(
string="Resume Name"
)
resume_type = fields.Char(
string="Resume Type"
)
def action_open_resume_candidate(self):
self.ensure_one()
if not self.resume:
return
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/hr.candidate/{self.id}/resume/{self.resume_name}?download=false',
'target': 'new',
}
@api.onchange('resume_name')
def _onchange_resume_name(self):
for rec in self:
if rec.resume_name:
ext = rec.resume_name.split('.')[-1].lower()
if ext == 'pdf':
rec.resume_type = 'pdf'
elif ext in ['png', 'jpg', 'jpeg', 'gif']:
rec.resume_type = 'image'
else:
rec.resume_type = 'other'
@api.depends('candidate_skill_ids.skill_id')
def _compute_quick_skill_ids(self):
for rec in self:
rec.quick_skill_ids = rec.candidate_skill_ids.mapped('skill_id')
def _inverse_quick_skill_ids(self):
candidate_skill_model = self.env['hr.candidate.skill']
for rec in self:
desired_skills = rec.quick_skill_ids
existing_lines = rec.candidate_skill_ids.filtered('skill_id')
existing_skills = existing_lines.mapped('skill_id')
lines_to_remove = existing_lines.filtered(lambda line: line.skill_id not in desired_skills)
if lines_to_remove:
rec.candidate_skill_ids -= lines_to_remove
skills_to_add = desired_skills - existing_skills
for skill in skills_to_add:
skill_type = skill.skill_type_id
if not skill_type:
continue
default_level = rec._get_default_skill_level(skill_type)
if not default_level:
continue
candidate_skill_model.create({
'candidate_id': rec.id,
'skill_id': skill.id,
'skill_type_id': skill_type.id,
'skill_level_id': default_level.id,
})
rec._cleanup_duplicate_candidate_skill_lines()
def _cleanup_duplicate_candidate_skill_lines(self):
candidate_skill_model = self.env['hr.candidate.skill']
for rec in self:
seen_names = set()
duplicate_lines = candidate_skill_model
for line in rec.candidate_skill_ids.filtered(lambda l: l.skill_id and l.skill_id.name):
skill_name = line.skill_id.name.strip().lower()
if skill_name in seen_names:
duplicate_lines += line
continue
seen_names.add(skill_name)
if duplicate_lines:
rec.candidate_skill_ids -= duplicate_lines
def _get_default_skill_level(self, skill_type):
skill_type.ensure_one()
default_level = skill_type.skill_level_ids.filtered('default_level')[:1]
if default_level:
return default_level
if skill_type.skill_level_ids:
skill_type.skill_level_ids[0].default_level = True
return skill_type.skill_level_ids[0]
return self.env['hr.skill.level'].create({
'name': 'Beginner',
'level_progress': 10,
'default_level': True,
'skill_type_id': skill_type.id,
})
class CandidateApplicant(models.Model):
_inherit = 'hr.applicant'
quick_skill_ids = fields.Many2many(
related='candidate_id.quick_skill_ids',
readonly=False,
string="Skills"
)
priority = fields.Selection(
selection_add=[
('4', 'Outstanding'),
('5', 'Top Priority')
]
)
applicant_history_ids = fields.One2many(
related='candidate_id.applicant_ids',
string='Applications',
readonly=True,
)
survey_line_ids = fields.One2many(
'applicant.survey.line',
'applicant_id',
string="Online Tests"
)
certificate_ids = fields.One2many(
'applicant.certificate',
'applicant_id',
string='Certificates'
)
def action_open_survey_wizard(self):
return {
'name': _('Assign Online Test'),
'type': 'ir.actions.act_window',
'res_model': 'assign.survey.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_applicant_id': self.id,
}
}
def action_open_resume(self):
self.ensure_one()
if not self.resume:
return
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/hr.applicant/{self.id}/resume/{self.resume_name}?download=false',
'target': 'new',
}
def action_open_resume_applicant(self):
pass
class HRSkill(models.Model):
_inherit = 'hr.skill'
active = fields.Boolean(default=True)
class ApplicantCertificate(models.Model):
_name = 'applicant.certificate'
_description = 'Applicant Certificate'
applicant_id = fields.Many2one(
'hr.applicant',
string="Applicant",
ondelete='cascade'
)
name = fields.Char(
string="Certificate Name",
required=True
)
certificate_file = fields.Binary(
string="Certificate File"
)
certificate_filename = fields.Char(
string="Filename"
)

View File

@ -0,0 +1,188 @@
import werkzeug
from odoo import api, fields, models, _
from datetime import timedelta
class ApplicantSurveyLine(models.Model):
_name = 'applicant.survey.line'
_description = 'Applicant Survey Line'
_order = 'id desc'
applicant_id = fields.Many2one(
'hr.applicant',
string="Applicant",
ondelete='cascade'
)
survey_id = fields.Many2one(
'survey.survey',
string="Online Test",
required=True
)
survey_user_input_id = fields.Many2one(
'survey.user_input',
string="Survey Answer"
)
survey_start_url = fields.Char(
string="Survey URL"
)
survey_state = fields.Selection([
('new', 'New'),
('in_progress', 'In Progress'),
('done', 'Completed')], string='Status', default='new', compute="_compute_survey_state",
store=True, readonly=True)
survey_score = fields.Float(
string="Score",
compute="_compute_survey_score",
store=True
)
response_ids = fields.One2many('survey.user_input', 'applicant_survey_line', string="Responses")
@api.depends('survey_user_input_id.state')
def _compute_survey_state(self):
for rec in self:
rec.survey_state = rec.survey_user_input_id.state or False
@api.depends('response_ids.user_input_line_ids.answer_score','survey_user_input_id.scoring_percentage')
def _compute_survey_score(self):
for rec in self:
# if rec.response_ids:
# latest_response = rec.response_ids.sorted('id', reverse=True)[:1]
# rec.survey_score = sum(
# latest_response.user_input_line_ids.mapped('answer_score')
# ) if latest_response else 0.0
# else:
rec.survey_score = rec.survey_user_input_id.scoring_percentage if rec.survey_user_input_id else 0.0
def _sync_latest_response(self):
for line in self:
latest_response = line.response_ids.sorted(lambda response: (response.create_date, response.id))[-1:]
if not latest_response:
continue
line.write({
'survey_user_input_id': latest_response.id,
'survey_start_url': werkzeug.urls.url_join(
line.survey_id.get_base_url(),
latest_response.get_start_url(),
),
})
class AssignSurveyWizard(models.TransientModel):
_name = 'assign.survey.wizard'
_description = 'Assign Survey Wizard'
applicant_id = fields.Many2one(
'hr.applicant',
string="Applicant"
)
survey_id = fields.Many2one(
'survey.survey',
string="Survey",
required=True
)
def action_assign_survey(self):
self.ensure_one()
self.survey_id.check_validity()
template = self.env.ref('web_portal_form_custom.mail_template_applicant_interview_invite',
raise_if_not_found=False)
local_context = dict(
default_applicant_id=self.applicant_id.id,
default_partner_ids=self.applicant_id.partner_id.ids,
default_survey_id=self.survey_id.id,
default_use_template=bool(template),
default_template_id=template and template.id or False,
default_email_layout_xmlid='mail.mail_notification_light',
default_deadline=fields.Datetime.now() + timedelta(days=15)
)
return {
'type': 'ir.actions.act_window',
'name': _("Send an interview"),
'view_mode': 'form',
'res_model': 'survey.invite',
'target': 'new',
'context': local_context,
}
class SurveyUserInput(models.Model):
_inherit = "survey.user_input"
applicant_survey_line = fields.Many2one('applicant.survey.line', string="Applicant Survey")
applicant_id = fields.Many2one('hr.applicant', related='applicant_survey_line.applicant_id', store=True)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records.mapped('applicant_survey_line')._sync_latest_response()
return records
def write(self, vals):
previous_lines = self.mapped('applicant_survey_line')
result = super().write(vals)
if 'applicant_survey_line' in vals:
(previous_lines | self.mapped('applicant_survey_line'))._sync_latest_response()
return result
def _mark_done(self):
odoobot = self.env.ref('base.partner_root')
for user_input in self:
if user_input.applicant_id:
body = _('The applicant "%s" has finished the survey.', user_input.applicant_id.partner_name)
user_input.applicant_id.message_post(body=body, author_id=odoobot.id)
return super()._mark_done()
class SurveySurvey(models.Model):
_inherit = 'survey.survey'
def _create_answer(self, user=False, partner=False, email=False, test_entry=False, check_attempts=True, **additional_vals):
applicant_survey_line = False
invite_token = additional_vals.get('invite_token')
if invite_token:
previous_answer = self.env['survey.user_input'].search([
('survey_id', 'in', self.ids),
('invite_token', '=', invite_token),
('applicant_survey_line', '!=', False),
], order='id desc', limit=1)
applicant_survey_line = previous_answer.applicant_survey_line
if not applicant_survey_line:
search_domain = [('survey_id', 'in', self.ids)]
if partner:
search_domain.append(('partner_id', '=', partner.id))
elif email:
search_domain.append(('email', '=', email))
else:
search_domain = []
if search_domain:
previous_answer = self.env['survey.user_input'].search(search_domain, order='id desc', limit=1)
applicant_survey_line = previous_answer.applicant_survey_line
if applicant_survey_line and not additional_vals.get('applicant_survey_line'):
additional_vals['applicant_survey_line'] = applicant_survey_line.id
return super()._create_answer(
user=user,
partner=partner,
email=email,
test_entry=test_entry,
check_attempts=check_attempts,
**additional_vals,
)

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_assign_survey_wizard,assign.survey.wizard,model_assign_survey_wizard,,1,1,1,1
access_applicant_survey_line_user,access.applicant.survey.line.user,model_applicant_survey_line,base.group_user,1,1,1,1
access_applicant_certificate,applicant.certificate,model_applicant_certificate,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_assign_survey_wizard assign.survey.wizard model_assign_survey_wizard 1 1 1 1
3 access_applicant_survey_line_user access.applicant.survey.line.user model_applicant_survey_line base.group_user 1 1 1 1
4 access_applicant_certificate applicant.certificate model_applicant_certificate 1 1 1 1

View File

@ -0,0 +1,20 @@
.o_form_view .o_group.row.align-items-start {
margin-top: 0px !important;
margin-bottom: 0px !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
min-height: auto !important;
}
.o_form_view h1 {
margin-top: 0px !important;
margin-bottom: 0px !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
min-height: auto !important;
}
.o_form_view .o_inner_group {
margin-top: 0px !important;
padding-top: 0px !important;
}

Some files were not shown because too many files have changed in this diff Show More