feat: Consider Holiday List in Student Leave Application and Attendance (#23388)

* feat: Consider holiday list in Student Attendance and Leave Application

* feat: Show Holidays as 'H' in Student Monthly Attendance Sheet

* fix: check if date is a holiday in attendance reports

* test: skip attendance record creation for holidays

* fix: holiday list validation

* fix: clean up after test

* fix: codacy

* fix: show date in user format

* fix: remove ununsed imports

* fix: sider

* fix: test

Co-authored-by: Nabin Hait <nabinhait@gmail.com>
diff --git a/erpnext/education/doctype/student_attendance/student_attendance.py b/erpnext/education/doctype/student_attendance/student_attendance.py
index 72a8f55..2e9e6cf 100644
--- a/erpnext/education/doctype/student_attendance/student_attendance.py
+++ b/erpnext/education/doctype/student_attendance/student_attendance.py
@@ -6,8 +6,10 @@
 import frappe
 from frappe.model.document import Document
 from frappe import _
-from frappe.utils import get_link_to_form, getdate
+from frappe.utils import get_link_to_form, getdate, formatdate
+from erpnext import get_default_company
 from erpnext.education.api import get_student_group_students
+from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
 
 class StudentAttendance(Document):
 	def validate(self):
@@ -17,6 +19,7 @@
 		self.set_student_group()
 		self.validate_student()
 		self.validate_duplication()
+		self.validate_is_holiday()
 
 	def set_date(self):
 		if self.course_schedule:
@@ -78,3 +81,18 @@
 			record = get_link_to_form('Student Attendance', attendance_record)
 			frappe.throw(_('Student Attendance record {0} already exists against the Student {1}')
 				.format(record, frappe.bold(self.student)), title=_('Duplicate Entry'))
+
+	def validate_is_holiday(self):
+		holiday_list = get_holiday_list()
+		if is_holiday(holiday_list, self.date):
+			frappe.throw(_('Attendance cannot be marked for {0} as it is a holiday.').format(
+				frappe.bold(formatdate(self.date))))
+
+def get_holiday_list(company=None):
+	if not company:
+		company = get_default_company() or frappe.get_all('Company')[0].name
+
+	holiday_list = frappe.get_cached_value('Company', company,  'default_holiday_list')
+	if not holiday_list:
+		frappe.throw(_('Please set a default Holiday List for Company {0}').format(frappe.bold(get_default_company())))
+	return holiday_list
diff --git a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py
index be26440..028db91 100644
--- a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py
+++ b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py
@@ -20,10 +20,10 @@
 			student_list = frappe.get_list("Student Group Student", fields=["student", "student_name", "group_roll_number"] , \
 			filters={"parent": student_group, "active": 1}, order_by= "group_roll_number")
 
-	if not student_list: 
-		student_list = frappe.get_list("Student Group Student", fields=["student", "student_name", "group_roll_number"] , 
+	if not student_list:
+		student_list = frappe.get_list("Student Group Student", fields=["student", "student_name", "group_roll_number"] ,
 			filters={"parent": student_group, "active": 1}, order_by= "group_roll_number")
-	
+
 	if course_schedule:
 		student_attendance_list= frappe.db.sql('''select student, status from `tabStudent Attendance` where \
 			course_schedule= %s''', (course_schedule), as_dict=1)
@@ -32,7 +32,7 @@
 			student_group= %s and date= %s and \
 			(course_schedule is Null or course_schedule='')''',
 			(student_group, date), as_dict=1)
-	
+
 	for attendance in student_attendance_list:
 		for student in student_list:
 			if student.student == attendance.student:
diff --git a/erpnext/education/doctype/student_leave_application/student_leave_application.json b/erpnext/education/doctype/student_leave_application/student_leave_application.json
index ad53976..31b3da2 100644
--- a/erpnext/education/doctype/student_leave_application/student_leave_application.json
+++ b/erpnext/education/doctype/student_leave_application/student_leave_application.json
@@ -11,6 +11,7 @@
   "column_break_3",
   "from_date",
   "to_date",
+  "total_leave_days",
   "section_break_5",
   "attendance_based_on",
   "student_group",
@@ -110,11 +111,17 @@
   {
    "fieldname": "column_break_11",
    "fieldtype": "Column Break"
+  },
+  {
+   "fieldname": "total_leave_days",
+   "fieldtype": "Float",
+   "label": "Total Leave Days",
+   "read_only": 1
   }
  ],
  "is_submittable": 1,
  "links": [],
- "modified": "2020-07-08 13:22:38.329002",
+ "modified": "2020-09-21 18:10:24.440669",
  "modified_by": "Administrator",
  "module": "Education",
  "name": "Student Leave Application",
diff --git a/erpnext/education/doctype/student_leave_application/student_leave_application.py b/erpnext/education/doctype/student_leave_application/student_leave_application.py
index c8841c9..ef67012 100644
--- a/erpnext/education/doctype/student_leave_application/student_leave_application.py
+++ b/erpnext/education/doctype/student_leave_application/student_leave_application.py
@@ -6,11 +6,14 @@
 import frappe
 from frappe import _
 from datetime import timedelta
-from frappe.utils import get_link_to_form, getdate
+from frappe.utils import get_link_to_form, getdate, date_diff, flt
+from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
+from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list
 from frappe.model.document import Document
 
 class StudentLeaveApplication(Document):
 	def validate(self):
+		self.validate_holiday_list()
 		self.validate_duplicate()
 		self.validate_from_to_dates('from_date', 'to_date')
 
@@ -39,10 +42,19 @@
 			frappe.throw(_('Leave application {0} already exists against the student {1}')
 				.format(link, frappe.bold(self.student)), title=_('Duplicate Entry'))
 
+	def validate_holiday_list(self):
+		holiday_list = get_holiday_list()
+		self.total_leave_days = get_number_of_leave_days(self.from_date, self.to_date, holiday_list)
+
 	def update_attendance(self):
+		holiday_list = get_holiday_list()
+
 		for dt in daterange(getdate(self.from_date), getdate(self.to_date)):
 			date = dt.strftime('%Y-%m-%d')
 
+			if is_holiday(holiday_list, date):
+				continue
+
 			attendance = frappe.db.exists('Student Attendance', {
 				'student': self.student,
 				'date': date,
@@ -89,3 +101,19 @@
 def daterange(start_date, end_date):
 	for n in range(int ((end_date - start_date).days)+1):
 		yield start_date + timedelta(n)
+
+def get_number_of_leave_days(from_date, to_date, holiday_list):
+	number_of_days = date_diff(to_date, from_date) + 1
+
+	holidays = frappe.db.sql("""
+		SELECT
+			COUNT(DISTINCT holiday_date)
+		FROM `tabHoliday` h1,`tabHoliday List` h2
+		WHERE
+			h1.parent = h2.name and
+			h1.holiday_date between %s and %s and
+			h2.name = %s""", (from_date, to_date, holiday_list))[0][0]
+
+	number_of_days = flt(number_of_days) - flt(holidays)
+
+	return number_of_days
diff --git a/erpnext/education/doctype/student_leave_application/test_student_leave_application.py b/erpnext/education/doctype/student_leave_application/test_student_leave_application.py
index e9b568a..fcdd428 100644
--- a/erpnext/education/doctype/student_leave_application/test_student_leave_application.py
+++ b/erpnext/education/doctype/student_leave_application/test_student_leave_application.py
@@ -5,13 +5,15 @@
 
 import frappe
 import unittest
-from frappe.utils import getdate, add_days
+from frappe.utils import getdate, add_days, add_months
+from erpnext import get_default_company
 from erpnext.education.doctype.student_group.test_student_group import get_random_group
 from erpnext.education.doctype.student.test_student import create_student
 
 class TestStudentLeaveApplication(unittest.TestCase):
 	def setUp(self):
 		frappe.db.sql("""delete from `tabStudent Leave Application`""")
+		create_holiday_list()
 
 	def test_attendance_record_creation(self):
 		leave_application = create_leave_application()
@@ -35,20 +37,45 @@
 		attendance_status = frappe.db.get_value('Student Attendance', {'leave_application': leave_application.name}, 'docstatus')
 		self.assertTrue(attendance_status, 2)
 
+	def test_holiday(self):
+		today = getdate()
+		leave_application = create_leave_application(from_date=today, to_date= add_days(today, 1), submit=0)
 
-def create_leave_application(from_date=None, to_date=None, mark_as_present=0):
+		# holiday list validation
+		company = get_default_company() or frappe.get_all('Company')[0].name
+		frappe.db.set_value('Company', company, 'default_holiday_list', '')
+		self.assertRaises(frappe.ValidationError, leave_application.save)
+
+		frappe.db.set_value('Company', company, 'default_holiday_list', 'Test Holiday List for Student')
+		leave_application.save()
+
+		leave_application.reload()
+		self.assertEqual(leave_application.total_leave_days, 1)
+
+		# check no attendance record created for a holiday
+		leave_application.submit()
+		self.assertIsNone(frappe.db.exists('Student Attendance', {'leave_application': leave_application.name, 'date': add_days(today, 1)}))
+
+	def tearDown(self):
+		company = get_default_company() or frappe.get_all('Company')[0].name
+		frappe.db.set_value('Company', company, 'default_holiday_list', '_Test Holiday List')
+
+
+def create_leave_application(from_date=None, to_date=None, mark_as_present=0, submit=1):
 	student = get_student()
 
-	leave_application = frappe.get_doc({
-		'doctype': 'Student Leave Application',
-		'student': student.name,
-		'attendance_based_on': 'Student Group',
-		'student_group': get_random_group().name,
-		'from_date': from_date if from_date else getdate(),
-		'to_date': from_date if from_date else getdate(),
-		'mark_as_present': mark_as_present
-	}).insert()
-	leave_application.submit()
+	leave_application = frappe.new_doc('Student Leave Application')
+	leave_application.student = student.name
+	leave_application.attendance_based_on = 'Student Group'
+	leave_application.student_group = get_random_group().name
+	leave_application.from_date = from_date if from_date else getdate()
+	leave_application.to_date = from_date if from_date else getdate()
+	leave_application.mark_as_present = mark_as_present
+
+	if submit:
+		leave_application.insert()
+		leave_application.submit()
+
 	return leave_application
 
 def create_student_attendance(date=None, status=None):
@@ -67,4 +94,22 @@
 		email='test_student@gmail.com',
 		first_name='Test',
 		last_name='Student'
-	))
\ No newline at end of file
+	))
+
+def create_holiday_list():
+	holiday_list = 'Test Holiday List for Student'
+	today = getdate()
+	if not frappe.db.exists('Holiday List', holiday_list):
+		frappe.get_doc(dict(
+			doctype = 'Holiday List',
+			holiday_list_name = holiday_list,
+			from_date = add_months(today, -6),
+			to_date = add_months(today, 6),
+			holidays = [
+				dict(holiday_date=add_days(today, 1), description = 'Test')
+			]
+		)).insert()
+
+	company = get_default_company() or frappe.get_all('Company')[0].name
+	frappe.db.set_value('Company', company, 'default_holiday_list', holiday_list)
+	return holiday_list
\ No newline at end of file
diff --git a/erpnext/education/report/absent_student_report/absent_student_report.py b/erpnext/education/report/absent_student_report/absent_student_report.py
index 4e57cc6..c3487cc 100644
--- a/erpnext/education/report/absent_student_report/absent_student_report.py
+++ b/erpnext/education/report/absent_student_report/absent_student_report.py
@@ -3,8 +3,10 @@
 
 from __future__ import unicode_literals
 import frappe
-from frappe.utils import cstr, cint, getdate
+from frappe.utils import formatdate
 from frappe import msgprint, _
+from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list
+from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
 
 def execute(filters=None):
 	if not filters: filters = {}
@@ -15,6 +17,11 @@
 	columns = get_columns(filters)
 	date = filters.get("date")
 
+	holiday_list = get_holiday_list()
+	if is_holiday(holiday_list, filters.get("date")):
+		msgprint(_("No attendance has been marked for {0} as it is a Holiday").format(frappe.bold(formatdate(filters.get("date")))))
+
+
 	absent_students = get_absent_students(date)
 	leave_applicants = get_leave_applications(date)
 	if absent_students:
diff --git a/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py b/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py
index c65d233..7793dcf 100644
--- a/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py
+++ b/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py
@@ -3,8 +3,10 @@
 
 from __future__ import unicode_literals
 import frappe
-from frappe.utils import cstr, cint, getdate
+from frappe.utils import formatdate
 from frappe import msgprint, _
+from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list
+from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
 
 def execute(filters=None):
 	if not filters: filters = {}
@@ -12,6 +14,10 @@
 	if not filters.get("date"):
 		msgprint(_("Please select date"), raise_exception=1)
 
+	holiday_list = get_holiday_list()
+	if is_holiday(holiday_list, filters.get("date")):
+		msgprint(_("No attendance has been marked for {0} as it is a Holiday").format(frappe.bold(formatdate(filters.get("date")))))
+
 	columns = get_columns(filters)
 
 	active_student_group = get_active_student_group()
diff --git a/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py b/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py
index d820bfb..04dc8c0 100644
--- a/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py
+++ b/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py
@@ -7,6 +7,8 @@
 from frappe import msgprint, _
 from calendar import monthrange
 from erpnext.education.api import get_student_group_students
+from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list
+from erpnext.support.doctype.issue.issue import get_holidays
 
 def execute(filters=None):
 	if not filters: filters = {}
@@ -19,26 +21,32 @@
 	students_list = get_students_list(students)
 	att_map = get_attendance_list(from_date, to_date, filters.get("student_group"), students_list)
 	data = []
+
 	for stud in students:
 		row = [stud.student, stud.student_name]
 		student_status = frappe.db.get_value("Student", stud.student, "enabled")
 		date = from_date
 		total_p = total_a = 0.0
+
 		for day in range(total_days_in_month):
 			status="None"
+
 			if att_map.get(stud.student):
 				status = att_map.get(stud.student).get(date, "None")
 			elif not student_status:
 				status = "Inactive"
 			else:
 				status = "None"
-			status_map = {"Present": "P", "Absent": "A", "None": "", "Inactive":"-"}
+
+			status_map = {"Present": "P", "Absent": "A", "None": "", "Inactive":"-", "Holiday":"H"}
 			row.append(status_map[status])
+
 			if status == "Present":
 				total_p += 1
 			elif status == "Absent":
 				total_a += 1
 			date = add_days(date, 1)
+
 		row += [total_p, total_a]
 		data.append(row)
 	return columns, data
@@ -63,14 +71,19 @@
 		and date between %s and %s
 		order by student, date''',
 		(student_group, from_date, to_date), as_dict=1)
+
 	att_map = {}
 	students_with_leave_application = get_students_with_leave_application(from_date, to_date, students_list)
 	for d in attendance_list:
 		att_map.setdefault(d.student, frappe._dict()).setdefault(d.date, "")
+
 		if students_with_leave_application.get(d.date) and d.student in students_with_leave_application.get(d.date):
 			att_map[d.student][d.date] = "Present"
 		else:
 			att_map[d.student][d.date] = d.status
+
+	att_map = mark_holidays(att_map, from_date, to_date, students_list)
+
 	return att_map
 
 def get_students_with_leave_application(from_date, to_date, students_list):
@@ -108,3 +121,14 @@
 	if not year_list:
 		year_list = [getdate().year]
 	return "\n".join(str(year) for year in year_list)
+
+def mark_holidays(att_map, from_date, to_date, students_list):
+	holiday_list = get_holiday_list()
+	holidays = get_holidays(holiday_list)
+
+	for dt in daterange(getdate(from_date), getdate(to_date)):
+		if dt in holidays:
+			for student in students_list:
+				att_map.setdefault(student, frappe._dict()).setdefault(dt, "Holiday")
+
+	return att_map