From b1f8ad1a67bc25b2a0b3f04b6c44f63a137976da Mon Sep 17 00:00:00 2001 From: Joy Banerjee Date: Sat, 14 Mar 2026 17:09:46 +0530 Subject: [PATCH] feat(education): Add Bulk Approve & Enroll action to Student Applicant list - Add bulk_enroll.py with whitelisted bulk_approve_enroll API method - Processes students in batches of 200 with single db.commit() per batch - Carries student_category from Student Applicant into Student and Program Enrollment - Add Approve & Enroll action item to Student Applicant list view - Shows confirmation dialog, real-time progress bar and completion report - Skips already admitted/approved students automatically --- education/bulk_enroll.py | 140 ++++++++++++++++++ .../student_applicant_list.js | 93 ++++++++++-- education/public/js/education.bundle.js | 2 + 3 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 education/bulk_enroll.py diff --git a/education/bulk_enroll.py b/education/bulk_enroll.py new file mode 100644 index 00000000..2494bacf --- /dev/null +++ b/education/bulk_enroll.py @@ -0,0 +1,140 @@ +import frappe +from frappe import _ + +BATCH_SIZE = 200 + +@frappe.whitelist() +def bulk_approve_enroll(names): + import json + if isinstance(names, str): + names = json.loads(names) + + all_applicants = frappe.get_all( + 'Student Applicant', + filters=[['name', 'in', names]], + fields=['name', 'application_status', 'first_name', 'last_name', + 'program', 'academic_year', 'academic_term', 'date_of_birth', + 'gender', 'student_email_id', 'student_mobile_number', + 'student_category'] + ) + + applicant_map = {a.name: a for a in all_applicants} + + already_enrolled = [n for n in names if applicant_map.get(n) + and applicant_map[n].application_status in ('Admitted', 'Approved')] + + to_process = [n for n in names if applicant_map.get(n) + and applicant_map[n].application_status not in ('Admitted', 'Approved')] + + if not to_process: + return { + 'queued': False, + 'message': _('All selected students are already enrolled.'), + 'already_enrolled_count': len(already_enrolled), + 'to_process_count': 0 + } + + success = [] + failed = [] + + default_academic_year = frappe.db.get_single_value( + 'Education Settings', 'current_academic_year' + ) or frappe.db.get_value('Academic Year', {}, 'name', + order_by='year_start_date desc') + + default_academic_term = frappe.db.get_single_value( + 'Education Settings', 'current_academic_term' + ) or frappe.db.get_value('Academic Term', {}, 'name', + order_by='term_start_date desc') + + existing_students = frappe.get_all('Student', + filters=[['student_applicant', 'in', to_process]], + fields=['name', 'student_applicant']) + + existing_student_map = {s.student_applicant: s.name + for s in existing_students} + + term_cache = {} + + def get_term_for_year(year): + if year not in term_cache: + term_cache[year] = frappe.db.get_value( + 'Academic Term', {'academic_year': year}, 'name', + order_by='term_start_date asc') or default_academic_term + return term_cache[year] + + for i in range(0, len(to_process), BATCH_SIZE): + batch = to_process[i:i + BATCH_SIZE] + + for name in batch: + try: + doc_data = applicant_map[name] + yr = doc_data.academic_year or default_academic_year + term = doc_data.academic_term or get_term_for_year(yr) + + # STEP 1: Approve + doc = frappe.get_doc('Student Applicant', name) + doc.application_status = 'Approved' + doc.academic_year = yr + doc.academic_term = term + doc.save(ignore_permissions=True, ignore_version=True) + + # STEP 2: Create Student record + if name in existing_student_map: + student_name = existing_student_map[name] + else: + s = frappe.new_doc('Student') + s.first_name = doc_data.first_name + s.last_name = doc_data.last_name or '' + s.student_applicant = name + if doc_data.date_of_birth: + s.date_of_birth = doc_data.date_of_birth + if doc_data.gender: + s.gender = doc_data.gender + if doc_data.student_email_id: + s.student_email_id = doc_data.student_email_id + if doc_data.student_mobile_number: + s.student_mobile_number = doc_data.student_mobile_number + if doc_data.student_category: + s.student_category = doc_data.student_category + s.insert(ignore_permissions=True) + student_name = s.name + existing_student_map[name] = student_name + + # STEP 3: Program Enrollment + if doc_data.program: + if not frappe.db.get_value('Program Enrollment', + {'student': student_name, 'program': doc_data.program}, + 'name'): + e = frappe.new_doc('Program Enrollment') + e.student = student_name + e.program = doc_data.program + e.academic_year = yr + e.academic_term = term + e.enrollment_date = frappe.utils.today() + if doc_data.student_category: + e.student_category = doc_data.student_category + e.insert(ignore_permissions=True) + + # STEP 4: Reload then mark as Admitted + doc.reload() + doc.application_status = 'Admitted' + doc.save(ignore_permissions=True, ignore_version=True) + + success.append(name) + + except Exception as ex: + frappe.db.rollback() + frappe.log_error(title='Bulk Enroll Failed', message=str(ex)) + failed.append({'name': name, 'error': str(ex)}) + + frappe.db.commit() + + return { + 'queued': True, 'success': success, 'failed': failed, + 'already_enrolled_count': len(already_enrolled), + 'to_process_count': len(to_process), + 'total_selected': len(names), + 'success_count': len(success), + 'failed_count': len(failed) + } diff --git a/education/education/doctype/student_applicant/student_applicant_list.js b/education/education/doctype/student_applicant/student_applicant_list.js index 34917470..8e1de570 100644 --- a/education/education/doctype/student_applicant/student_applicant_list.js +++ b/education/education/doctype/student_applicant/student_applicant_list.js @@ -1,17 +1,80 @@ frappe.listview_settings['Student Applicant'] = { - add_fields: ['application_status', 'paid'], - has_indicator_for_draft: 1, - get_indicator: function (doc) { - if (doc.paid) { - return [__('Paid'), 'green', 'paid,=,Yes'] - } else if (doc.application_status == 'Applied') { - return [__('Applied'), 'orange', 'application_status,=,Applied'] - } else if (doc.application_status == 'Approved') { - return [__('Approved'), 'green', 'application_status,=,Approved'] - } else if (doc.application_status == 'Rejected') { - return [__('Rejected'), 'red', 'application_status,=,Rejected'] - } else if (doc.application_status == 'Admitted') { - return [__('Admitted'), 'blue', 'application_status,=,Admitted'] + add_fields: ['application_status'], + get_indicator: function(doc) { + if (doc.application_status === 'Admitted') + return [__('Admitted'), 'green', 'application_status,=,Admitted']; + if (doc.application_status === 'Approved') + return [__('Approved'), 'blue', 'application_status,=,Approved']; + if (doc.application_status === 'Rejected') + return [__('Rejected'), 'red', 'application_status,=,Rejected']; + return [__('Applied'), 'orange', 'application_status,=,Applied']; + }, + onload: function(listview) { + listview.page.add_action_item(__('Approve & Enroll'), function() { + let selected = listview.get_checked_items(); + if (!selected.length) { + frappe.msgprint({ title: 'No Students Selected', + message: 'Please select students first.', + indicator: 'orange' }); return; + } + let already = selected.filter(r => + ['Admitted','Approved'].includes(r.application_status)); + let toEnroll = selected.filter(r => + !['Admitted','Approved'].includes(r.application_status)); + if (!toEnroll.length) { + frappe.msgprint({ title: 'Nothing to Process', + message: 'All selected are already enrolled.', + indicator: 'orange' }); return; + } + let msg = ''; + msg += ''; + msg += ''; + msg += ''; + msg += '
Total selected' + selected.length + '
Already enrolled (skip)' + already.length + '
Will be enrolled' + toEnroll.length + '
'; + let confirmDialog = new frappe.ui.Dialog({ + title: 'Confirm Bulk Enrollment', + fields: [{ fieldtype: 'HTML', options: msg }], + primary_action_label: 'Yes, Enroll', + primary_action: function() { + confirmDialog.hide(); + frappe.show_progress('Enrolling Students...', 0, 100, 'Starting...'); + frappe.call({ + method: 'education.bulk_enroll.bulk_approve_enroll', + args: { names: JSON.stringify(selected.map(d => d.name)) }, + timeout: 300000, + callback: function(r) { + frappe.show_progress('Enrolling Students...', 100, 100, 'Done!'); + setTimeout(() => frappe.hide_progress(), 800); + let res = r.message; + let out = ''; + if (res.success_count > 0) out += ''; + if (res.already_enrolled_count > 0) out += ''; + if (res.failed_count > 0) out += ''; + out += '
Enrolled' + res.success_count + ' students
Skipped' + res.already_enrolled_count + '
Failed' + res.failed_count + '
'; + if (res.failed && res.failed.length) { + out += '

Failed students:

'; + } + listview.refresh(); + let d = new frappe.ui.Dialog({ + title: res.failed_count > 0 ? 'Enrollment Complete with Errors' : 'Enrollment Complete!', + fields: [{ fieldtype: 'HTML', options: out }], + primary_action_label: 'Done', + primary_action: () => d.hide() }); + d.show(); + }, + error: function(xhr) { + frappe.hide_progress(); + frappe.msgprint({ title: 'Error', + message: 'Something went wrong. Check error logs.', + indicator: 'red' }); + } + }); + }, + secondary_action_label: 'Cancel' + }); + confirmDialog.show(); + }); } - }, -} +}; \ No newline at end of file diff --git a/education/public/js/education.bundle.js b/education/public/js/education.bundle.js index d62da9cc..b6a640dd 100644 --- a/education/public/js/education.bundle.js +++ b/education/public/js/education.bundle.js @@ -1 +1,3 @@ import './assessment_result_tool.html' + +import './student_applicant_list.js'