Skip to main content
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

1
Job Applicant Creation
2
When a Job Applicant is created via the web form, it includes:
3
  • Standard applicant fields (name, email, phone, etc.)
  • custom_job_question_answers: JSON string of answers
  • 4
    Event Trigger
    5
    The after_insert event triggers process_job_questions
    6
    Answer Processing
    7
    The handler:
    8
  • 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
  • 9
    Score Calculation
    10
    Score is calculated on a 0-10 scale:
    11
    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

    Build docs developers (and LLMs) love