The ION Career app integrates seamlessly with ERPNext’s recruitment workflow through hooks, event handlers, and API methods. This guide covers integration points and customization options.
ERPNext Workflow Integration
ION Career extends the standard ERPNext recruitment process by adding screening questions to Job Applicants.
Document Flow
Standard ERPNext Doctypes Used
Job Opening : Extended with custom_job_question_set field
Job Applicant : Extended with custom fields for questions and scoring
Job Applicant Source : Used for tracking application sources
Hooks Configuration
The app registers document event hooks in hooks.py:
# hooks.py
doc_events = {
"Job Applicant" : {
"after_insert" : "ion_career.handlers.process_job_questions"
}
}
The after_insert event ensures that screening questions are processed immediately after a Job Applicant is created.
Event Handlers
The main event handler processes screening questions and calculates applicant scores.
process_job_questions Handler
Location: ion_career/handlers.py
import json
import frappe
@frappe.whitelist ()
def process_job_questions ( doc , method ):
# Check if answers exist
if not doc.custom_job_question_answers:
return
# Parse JSON answers
answers = json.loads(doc.custom_job_question_answers)
# Get the job opening's question set
job_opening = doc.job_title
qset_name = frappe.db.get_value(
"Job Opening" ,
job_opening,
"custom_job_question_set"
)
if not qset_name:
return
qset = frappe.get_doc( "Job Question Set" , qset_name)
# Process each question
total_answered = 0
for q in qset.questions:
doc.append( "custom_question_answers" , {
"question" : q.question,
"fieldname" : q.fieldname,
"answer" : answers.get(q.fieldname),
"job_opening" : job_opening
})
if answers.get(q.fieldname) == "Yes" :
total_answered += 1
# Calculate score (0-10 scale)
score_multiplier = 10 / len (qset.questions)
doc.custom_score = score_multiplier * total_answered
doc.save( ignore_permissions = True )
How It Works
When a Job Applicant is created via the web form, it includes:
Standard applicant fields (name, email, phone, etc.)
custom_job_question_answers: JSON string of answers
The after_insert event triggers process_job_questions
Parses the JSON answers
Retrieves the associated question set
Creates child table entries in custom_question_answers
Calculates the applicant’s score based on “Yes” answers
Score is calculated on a 0-10 scale:
score = (number_of_yes_answers / total_questions) * 10
Custom Fields Reference
Job Opening Custom Fields
{
"fieldname" : "custom_job_question_set" ,
"fieldtype" : "Link" ,
"options" : "Job Question Set" ,
"label" : "Job Question Set" ,
"reqd" : 1 ,
"insert_after" : "designation"
}
Job Applicant Custom Fields
[
{
"fieldname" : "custom_questions" ,
"fieldtype" : "Tab Break" ,
"label" : "Questions"
},
{
"fieldname" : "custom_job_question_answers" ,
"fieldtype" : "Text" ,
"label" : "Job Question Answers" ,
"hidden" : 1
},
{
"fieldname" : "custom_question_answers" ,
"fieldtype" : "Table" ,
"label" : "Question Answers" ,
"options" : "Job Applicant Question Answer"
},
{
"fieldname" : "custom_questions_html" ,
"fieldtype" : "HTML" ,
"label" : "Questions HTML"
},
{
"fieldname" : "custom_score" ,
"fieldtype" : "Data" ,
"label" : "Score" ,
"read_only" : 1
}
]
These custom fields are automatically created during app installation via fixtures.
API Methods
The app exposes whitelisted API methods for use in client scripts and web forms.
get_job_questions
Fetches questions for a specific job opening.
Location: ion_career/api.py
import frappe
@frappe.whitelist ()
def get_job_questions ( job_opening ):
jo = frappe.get_doc( "Job Opening" , job_opening)
qset_name = jo.custom_job_question_set
if not qset_name:
return []
qset = frappe.get_doc( "Job Question Set" , qset_name)
return [{
"question" : q.question,
"fieldname" : q.fieldname,
"input_type" : q.input_type,
"required" : q.required
} for q in qset.questions]
Usage:
frappe . call ({
method: "ion_career.api.get_job_questions" ,
args: {
job_opening: "Software Developer - 2026"
},
callback : function ( r ) {
console . log ( r . message ); // Array of questions
}
});
Custom Workflows
You can extend ION Career with custom workflows by hooking into additional events.
Example: Email Notification on High Score
Add a custom handler to notify HR when an applicant scores above a threshold:
# custom_app/handlers.py
import frappe
from frappe import _
def notify_high_score_applicant ( doc , method ):
"""Send email notification for high-scoring applicants"""
if not doc.custom_score:
return
score = float (doc.custom_score)
if score >= 8.0 : # Threshold: 80%
# Get HR Manager email
hr_manager = frappe.db.get_value(
"User" ,
{ "role" : "HR Manager" },
"email"
)
if hr_manager:
frappe.sendmail(
recipients = [hr_manager],
subject = _( "High-Scoring Applicant: {0} " ).format(doc.applicant_name),
message = _( "Applicant {0} scored {1} /10 on screening questions." ).format(
doc.applicant_name,
doc.custom_score
)
)
Register the hook in your custom app’s hooks.py:
doc_events = {
"Job Applicant" : {
"after_insert" : "custom_app.handlers.notify_high_score_applicant"
}
}
Example: Auto-Reject Low-Scoring Applicants
def auto_reject_low_scores ( doc , method ):
"""Automatically reject applicants with low scores"""
if not doc.custom_score:
return
score = float (doc.custom_score)
if score < 5.0 : # Threshold: 50%
doc.status = "Rejected"
doc.add_comment(
"Comment" ,
"Automatically rejected due to low screening score."
)
doc.save( ignore_permissions = True )
Example: Integration with Custom Scoring System
def calculate_custom_score ( doc , method ):
"""Calculate weighted score based on question importance"""
if not doc.custom_job_question_answers:
return
answers = json.loads(doc.custom_job_question_answers)
qset = frappe.get_doc(
"Job Question Set" ,
frappe.db.get_value( "Job Opening" , doc.job_title, "custom_job_question_set" )
)
total_weight = 0
weighted_score = 0
for q in qset.questions:
# Assume 'weight' is a custom field you've added to Job Question
weight = q.get( "weight" , 1 )
total_weight += weight
if answers.get(q.fieldname) == "Yes" :
weighted_score += weight
doc.custom_score = (weighted_score / total_weight) * 10
doc.save( ignore_permissions = True )
Fixtures
The app uses fixtures to export and import custom fields, scripts, and web forms.
Configuration in hooks.py:
fixtures = [
{
"doctype" : "Custom Field" ,
"filters" : [[ "module" , "=" , "ION Career" ]]
},
{
"doctype" : "Client Script" ,
"filters" : [[ "module" , "=" , "ION Career" ]]
},
{
"doctype" : "Server Script" ,
"filters" : [[ "module" , "=" , "ION Career" ]]
},
{
"doctype" : "Web Form" ,
"filters" : [[ "module" , "=" , "ION Career" ]]
}
]
Exporting Fixtures
bench --site [sitename] export-fixtures
Importing Fixtures
bench --site [sitename] migrate
Client Scripts
The app includes client scripts for automating UI behavior.
Auto Job Question Fieldname
Automatically sets the fieldname when a question is added:
frappe . ui . form . on ( 'Job Question' , {
questions_add ( frm , cdt , cdn ){
frappe . model . set_value ( cdt , cdn , "fieldname" , cdn )
}
})
Auto Job Opening Route
Generates a URL-friendly route from the job title:
frappe . ui . form . on ( 'Job Opening' , {
job_title ( frm ) {
frm . set_value ( 'route' , slugify ( frm . doc . job_title ))
}
})
function slugify ( str ) {
return str
. toLowerCase ()
. trim ()
. replace ( / [ ^ a-z0-9 - ] / g , '' )
. replace ( / \s + / g , '-' )
. replace ( /- + / g , '-' )
. replace ( / ^ - + | - + $ / g , '' );
}
Best Practices
Use Event Hooks Leverage Frappe’s document event system instead of modifying core controller files.
Whitelist API Methods Always use @frappe.whitelist() decorator for methods called from client-side.
Handle Missing Data Check for existence of custom fields and question sets before processing.
Use Fixtures Export customizations as fixtures for easy deployment across environments.
Testing Integration
Test your integration thoroughly:
# ion_career/ion_career/doctype/job_question_set/test_job_question_set.py
import frappe
import unittest
class TestJobQuestionSet ( unittest . TestCase ):
def test_question_creation ( self ):
qset = frappe.get_doc({
"doctype" : "Job Question Set" ,
"title" : "Test Questions" ,
"questions" : [
{
"question" : "Do you have Python experience?" ,
"required" : 1 ,
"order" : 1
}
]
})
qset.insert()
self .assertEqual( len (qset.questions), 1 )
self .assertTrue(qset.questions[ 0 ].required)
Next Steps