diff --git a/edge/custom/property_setters/interview.py b/edge/custom/property_setters/interview.py new file mode 100644 index 0000000..b55f572 --- /dev/null +++ b/edge/custom/property_setters/interview.py @@ -0,0 +1,31 @@ +def get_interview_property_setters(): + ''' + Edge specific property setters that need to be added to the Interview DocType + ''' + return [ + { + "doctype_or_field": "DocField", + "doc_type": "Interview", + "field_name": "scheduled_on", + "property": "set_only_once", + "property_type": "Check", + "value":0 + }, + { + "doctype_or_field": "DocField", + "doc_type": "Interview", + "field_name": "from_time", + "property": "set_only_once", + "property_type": "Check", + "value":0 + }, + { + "doctype_or_field": "DocField", + "doc_type": "Interview", + "field_name": "to_time", + "property": "set_only_once", + "property_type": "Check", + "value":0 + }, + + ] \ No newline at end of file diff --git a/edge/edge/doctype/employee_interview_tool/__init__.py b/edge/edge/doctype/employee_interview_tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edge/edge/doctype/employee_interview_tool/employee_interview_tool.js b/edge/edge/doctype/employee_interview_tool/employee_interview_tool.js new file mode 100644 index 0000000..e3debe4 --- /dev/null +++ b/edge/edge/doctype/employee_interview_tool/employee_interview_tool.js @@ -0,0 +1,177 @@ +// Copyright (c) 2025, efeone and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Employee Interview Tool",{ + onload(frm) { + frm.set_value('date', frappe.datetime.get_today()); + frm.toggle_display('job_applicants', false); + !frm.doc.company && frappe.db.get_single_value('Global Defaults','default_company') + .then(value => frm.set_value('company',value)) + }, + refresh : function (frm){ + frm.disable_save() + frm.set_value('interview_round', ''); + frm.set_value('scheduled_on', ''); + frm.set_value('from_time', ''); + frm.set_value('to_time', ''); + frm.set_value('department', ''); + frm.set_value('designation', ''); + frm.set_value('status', ''); + frm.set_value('job_opening', ''); + frm.set_value('location', ''); + frm.clear_table('job_applicants'); + frm.refresh_field('job_applicants'); + frm.toggle_display('job_applicants', false); + fetch_job_applicant(frm); + toggle_create_interview_button(frm); + + } +}); + +/** +* create a button to fetch job applicants +* On click, fetches job applicants based on filters and populates the child table. +*/ +function fetch_job_applicant(frm){ + let create_applicant_button = frm.add_custom_button("Get Job Applicants",function(){ + const filters = {}; + if (frm.doc.job_opening){ + filters.job_title = frm.doc.job_opening; + } + if (frm.doc.designation){ + filters.designation = frm.doc.designation; + } + if (frm.doc.status){ + filters.status = frm.doc.status; + } + frappe.call({ + method:"edge.edge.doctype.employee_interview_tool.employee_interview_tool.fetch_job_applicants", + args:{filters}, + callback:function(r){ + if (r.message){ + frm.clear_table("job_applicants"); + r.message.forEach(function(applicant){ + let row = frm.add_child("job_applicants"); + row.job_applicant = applicant.name; + row.applicant_name = applicant.applicant_name; + row.status = applicant.status; + row.designation = applicant.designation; + }); + frm.refresh_field("job_applicants"); + frm.toggle_display('job_applicants', true); + toggle_create_interview_button(frm); + } + } + + }); + + }) + create_applicant_button.removeClass('btn-default').addClass('btn-primary'); +} + +/** +* create interview for the selected job applicants +* On click, creates interviews or prompts for rescheduling if already exists. +*/ +let create_interview_btn = null; +function toggle_create_interview_button(frm) { + if (create_interview_btn) { + create_interview_btn.remove(); + create_interview_btn = null; + } + create_interview_btn = frm.add_custom_button('Create Interview', function () { + let selected_rows = frm.fields_dict.job_applicants.grid.get_selected_children(); + if (!selected_rows.length) { + frappe.msgprint(__('Please select one or more rows in the Job Applicants table.')); + return; + } + let missing_fields = []; + if (!frm.doc.interview_round) missing_fields.push(__('Interview Round')); + if (!frm.doc.scheduled_on) missing_fields.push(__('Scheduled On')); + if (!frm.doc.from_time) missing_fields.push(__('From Time')); + if (!frm.doc.to_time) missing_fields.push(__('To Time')); + if (missing_fields.length) { + frappe.msgprint({ + title: __('Missing Required Scheduling Fields'), + message: __('Please ensure the following fields are filled:') + + '
' + missing_fields.join(', ') + '', + indicator: 'orange' + }); + return; + } + frappe.call({ + method: 'edge.edge.doctype.employee_interview_tool.employee_interview_tool.create_bulk_interviews', + args: { + applicants: selected_rows.map(row => ({ + job_applicant: row.job_applicant, + applicant_name: row.applicant_name, + designation: row.designation, + interview_round: frm.doc.interview_round, + scheduled_on: frm.doc.scheduled_on, + from_time: frm.doc.from_time, + to_time: frm.doc.to_time + })) + }, + callback: function (r) { + if (!r.exc) { + let data = r.message || {}; + if (Array.isArray(data.created) && data.created.length > 0) { + const created_ids = data.created.map(c => c.job_applicant).join(', '); + frappe.msgprint(__('Interviews created successfully for: ') + created_ids); + } + if (Array.isArray(data.skipped_applicants) && data.skipped_applicants.length > 0) { + frappe.confirm( + __('Interviews already exist for: {0}.
Do you want to reschedule?', [data.skipped_applicants.join(', ')]), + () => { + frappe.prompt([ + { + label: 'Scheduled On', + fieldname: 'scheduled_on', + fieldtype: 'Date', + default: frm.doc.scheduled_on, + reqd: 1 + }, + { + label: 'From Time', + fieldname: 'from_time', + fieldtype: 'Time', + default: frm.doc.from_time, + reqd: 1 + }, + { + label: 'To Time', + fieldname: 'to_time', + fieldtype: 'Time', + default: frm.doc.to_time, + reqd: 1 + } + ], (values) => { + frappe.call({ + method: 'edge.edge.doctype.employee_interview_tool.employee_interview_tool.reschedule_interviews', + args: { + applicants: data.skipped_applicants, + interview_round: frm.doc.interview_round, + scheduled_on: values.scheduled_on, + from_time: values.from_time, + to_time: values.to_time, + }, + callback: function(r) { + if (!r.exc) { + frappe.msgprint(__('Interview rescheduled successfully.')); + frm.refresh(); + } + } + }); + }, __('Reschedule Interviews')); + }, + () => { + frappe.msgprint(__('Reschedule cancelled.')); + } + ); + } + } + } + }); + }); + create_interview_btn.removeClass('btn-default').addClass('btn-primary'); +} \ No newline at end of file diff --git a/edge/edge/doctype/employee_interview_tool/employee_interview_tool.json b/edge/edge/doctype/employee_interview_tool/employee_interview_tool.json new file mode 100644 index 0000000..4f5bdd0 --- /dev/null +++ b/edge/edge/doctype/employee_interview_tool/employee_interview_tool.json @@ -0,0 +1,151 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-12-24 11:14:57.257478", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "date", + "column_break_kdkc", + "company", + "interview_schedule_details_section", + "interview_round", + "scheduled_on", + "column_break_kfim", + "from_time", + "to_time", + "get_job_applicants_section", + "job_opening", + "status", + "location", + "column_break_qwuf", + "designation", + "department", + "section_break_lsta", + "job_applicants" + ], + "fields": [ + { + "fieldname": "date", + "fieldtype": "Date", + "label": "Date" + }, + { + "fieldname": "column_break_kdkc", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "interview_schedule_details_section", + "fieldtype": "Section Break", + "label": "Interview Schedule Details" + }, + { + "fieldname": "interview_round", + "fieldtype": "Link", + "label": "Interview Round", + "options": "Interview Round" + }, + { + "fieldname": "scheduled_on", + "fieldtype": "Date", + "label": "Scheduled On" + }, + { + "fieldname": "column_break_kfim", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_time", + "fieldtype": "Time", + "label": "From Time" + }, + { + "fieldname": "to_time", + "fieldtype": "Time", + "label": "To Time" + }, + { + "collapsible": 1, + "description": "Set filters to fetch Job Applicants", + "fieldname": "get_job_applicants_section", + "fieldtype": "Section Break", + "label": "Get Job Applicants" + }, + { + "fieldname": "job_opening", + "fieldtype": "Link", + "label": "Job Opening", + "options": "Job Opening" + }, + { + "fieldname": "column_break_qwuf", + "fieldtype": "Column Break" + }, + { + "fieldname": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation" + }, + { + "fieldname": "location", + "fieldtype": "Link", + "label": "Location", + "options": "Location" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "\nOpen\nReplied\nRejected\nHold\nAccepted" + }, + { + "fieldname": "section_break_lsta", + "fieldtype": "Section Break" + }, + { + "fieldname": "job_applicants", + "fieldtype": "Table", + "label": "Job Applicant", + "options": "Job Applicant Interview Detail" + }, + { + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2025-12-29 16:03:19.071789", + "modified_by": "Administrator", + "module": "Edge", + "name": "Employee Interview Tool", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/edge/edge/doctype/employee_interview_tool/employee_interview_tool.py b/edge/edge/doctype/employee_interview_tool/employee_interview_tool.py new file mode 100644 index 0000000..9b9e80f --- /dev/null +++ b/edge/edge/doctype/employee_interview_tool/employee_interview_tool.py @@ -0,0 +1,109 @@ +# Copyright (c) 2025, efeone and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +import json +from frappe.utils import get_datetime, now_datetime +from frappe import _ + + +class EmployeeInterviewTool(Document): + pass + + + +@frappe.whitelist() +def fetch_job_applicants(filters): + ''' + Fetch job applicants based on filters like job_title, department, designation, status and job opening. + ''' + if isinstance(filters,str): + filters = frappe.parse_json(filters) + applicants = frappe.get_all( + 'Job Applicant', + filters= filters, + fields= ['name','applicant_name','designation','status','job_title'] + ) + return applicants + + + + +@frappe.whitelist() +def create_bulk_interviews(applicants): + ''' + Creates multiple Interview documents for a list of job applicants, skipping those with existing interviews. + ''' + applicants = json.loads(applicants) + created_interviews = [] + existing_interviews = [] + for app in applicants: + interview_round = app.get('interview_round') + scheduled_on = app.get('scheduled_on') + from_time = app.get('from_time') + to_time = app.get('to_time') + scheduled_datetime = get_datetime(f"{scheduled_on} {from_time}") + now = now_datetime() + if scheduled_datetime < now: + frappe.throw(_("Interview date and time cannot be in the past for applicant: {0}").format(app.get('applicant_name'))) + if not (interview_round and scheduled_on and from_time and to_time): + frappe.throw( + _("Missing required scheduling fields. Please ensure 'Interview Round', 'Scheduled On', 'From Time', and 'To Time' are all filled.") + ) + interview_round_doc = frappe.get_doc('Interview Round', interview_round) + interviewers = interview_round_doc.get('interviewers') + if frappe.db.exists('Interview', { + 'job_applicant': app.get('job_applicant'), + 'interview_round': interview_round + }): + existing_interviews.append(app.get('job_applicant')) + continue + interview = frappe.get_doc({ + 'doctype': 'Interview', + 'job_applicant': app.get('job_applicant'), + 'applicant_name': app.get('applicant_name'), + 'designation': app.get('designation'), + 'interview_round': interview_round, + 'scheduled_on': scheduled_on, + 'from_time': from_time, + 'to_time': to_time + }) + for i in interviewers: + interviewer_id = getattr(i, 'employee', None) or getattr(i, 'user', None) + if interviewer_id: + interview.append('interview_details', { + 'interviewer': interviewer_id + }) + interview.insert() + created_interviews.append({ + "interview": interview.name, + "job_applicant": app.get("job_applicant"), + "applicant_name": app.get("applicant_name") + }) + return { + "created": created_interviews, + "skipped_applicants": existing_interviews + } + + +@frappe.whitelist() +def reschedule_interviews(applicants, interview_round, scheduled_on, from_time, to_time): + """ + Reschedules existing Interview documents for given job applicants and interview round. + """ + if isinstance(applicants, str): + applicants = json.loads(applicants) + rescheduled = [] + for app_id in applicants: + interviews = frappe.get_all("Interview", filters={ + "job_applicant": app_id, + "interview_round": interview_round + }) + for i in interviews: + doc = frappe.get_doc("Interview", i.name) + doc.scheduled_on = scheduled_on + doc.from_time = from_time + doc.to_time = to_time + doc.save() + rescheduled.append(i.name) diff --git a/edge/edge/doctype/employee_interview_tool/test_employee_interview_tool.py b/edge/edge/doctype/employee_interview_tool/test_employee_interview_tool.py new file mode 100644 index 0000000..125c663 --- /dev/null +++ b/edge/edge/doctype/employee_interview_tool/test_employee_interview_tool.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, efeone and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestEmployeeInterviewTool(FrappeTestCase): + pass diff --git a/edge/edge/doctype/job_applicant_interview_detail/__init__.py b/edge/edge/doctype/job_applicant_interview_detail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edge/edge/doctype/job_applicant_interview_detail/job_applicant_interview_detail.json b/edge/edge/doctype/job_applicant_interview_detail/job_applicant_interview_detail.json new file mode 100644 index 0000000..b290572 --- /dev/null +++ b/edge/edge/doctype/job_applicant_interview_detail/job_applicant_interview_detail.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-12-24 13:23:40.270222", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "column_break_bsjp", + "job_applicant", + "applicant_name", + "designation", + "status" + ], + "fields": [ + { + "fieldname": "job_applicant", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Job Applicant", + "options": "Job Applicant" + }, + { + "fetch_from": "job_applicant.applicant_name", + "fieldname": "applicant_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Applicant Name" + }, + { + "fieldname": "column_break_bsjp", + "fieldtype": "Column Break" + }, + { + "fetch_from": "job_applicant.designation", + "fieldname": "designation", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Designation" + }, + { + "fetch_from": "job_applicant.status", + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-24 15:34:21.189315", + "modified_by": "Administrator", + "module": "Edge", + "name": "Job Applicant Interview Detail", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/edge/edge/doctype/job_applicant_interview_detail/job_applicant_interview_detail.py b/edge/edge/doctype/job_applicant_interview_detail/job_applicant_interview_detail.py new file mode 100644 index 0000000..00336ab --- /dev/null +++ b/edge/edge/doctype/job_applicant_interview_detail/job_applicant_interview_detail.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, efeone and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class JobApplicantInterviewDetail(Document): + pass diff --git a/edge/hooks.py b/edge/hooks.py index 2f5062c..ad80ecc 100644 --- a/edge/hooks.py +++ b/edge/hooks.py @@ -85,12 +85,13 @@ # ------------ # before_install = "edge.install.before_install" -# after_install = "edge.install.after_install" +after_install = "edge.setup.after_install" +after_migrate = "edge.setup.after_migrate" # Uninstallation # ------------ -# before_uninstall = "edge.uninstall.before_uninstall" +before_uninstall = "edge.setup.before_uninstall" # after_uninstall = "edge.uninstall.after_uninstall" # Integration Setup diff --git a/edge/setup.py b/edge/setup.py new file mode 100644 index 0000000..d175017 --- /dev/null +++ b/edge/setup.py @@ -0,0 +1,54 @@ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +import frappe + + +#Custom Property Setter imports for Edge +from edge.custom.property_setters.interview import get_interview_property_setters + +def after_install(): + #Creating edge specific Property Setters + create_property_setters(get_property_setters()) + + +def after_migrate(): + after_install() + + + +def delete_custom_fields(custom_fields: dict): + ''' + Method to Delete custom fields + args: + custom_fields: a dict like `{'Task': [{fieldname: 'your_fieldname', ...}]}` + ''' + for doctype, fields in custom_fields.items(): + frappe.db.delete( + "Custom Field", + { + "fieldname": ("in", [field["fieldname"] for field in fields]), + "dt": doctype, + }, + ) + frappe.clear_cache(doctype=doctype) + + +def create_property_setters(property_setter_datas): + ''' + Method to create custom property setters + args: + property_setter_datas : list of dict of property setter obj + ''' + for property_setter_data in property_setter_datas: + if frappe.db.exists("Property Setter", property_setter_data): + continue + property_setter = frappe.new_doc("Property Setter") + property_setter.update(property_setter_data) + property_setter.flags.ignore_permissions = True + property_setter.insert() + +def get_property_setters(): + ''' + PW IOI specific property setters that need to be added to the Standard DocTypes + ''' + property_setters = (get_interview_property_setters()) + return property_setters \ No newline at end of file