feat: Organizational Chart (#26893)

* feat: Organizational Chart

* feat: org chart mobile interactions

* feat(mobile): sibling node group expansion and rendering

* fix: expanded node group interactions and visibility

* feat: connectors for mobile node cards

* fix: don't refresh connections for same node

- remove all connectors while expanding a group node

* chore: create separate files for Desktop and Mobile view and bundle assets

* refactor: add options to chart

- method to return the node data

- wrapper for showing the hierarchy

* feat: setup node edit action

* fix: revert changes in employee descendants query

* refactor: use arcs instead of bezier curves for cleaner connectors

* feat: add arc to connectors in mobile view

* fix: edit node button overflowing

* fix: sider

* fix: removing orphaned connectors

* fix: unnecessary variables

* feat: handle multiple root / orphan nodes

* perf: Optimise Rendering

- optimise get_children function

- use promises instead of callbacks

- optimise selectors

- use const wherever possible

- use pure js instead of jquery for connectors for faster rendering

* fix: do not sort by number of connections

* feat: use icon for connections on mobile view

* fix: exclude active node while fetching sibling group

* fix: sibling group expansion not working for root nodes

* fix(mobile): collapsed nodes not expanding

* fix: sider

* test: UI tests for org chart desktop

* test: UI tests for org chart mobile

fix(mobile): detach node before emptying hierarchy

fix(mobile): sibling group not rendering for first level

* fix: sider

* ci(cypress): use env variable for key

documentation ref: https://docs.cypress.io/guides/guides/command-line\#cypress-run

* fix(tests): clear filter before typing

* fix(tests): apply filters correctly

* fix: tests

* fix: tests

* fix: tests

* fix(test): increase timeout for record creation

* fix: sider

* fix: sider

* feat: Expand All nodes option in Desktop view

* feat: add html2canvas for easily exporting html to images using canvas

* feat: Export chart option in desktop view

* fix(style): longer titles overflowing

* fix: remove unnecessary imports

* fix: test

* fix: make bundled assets for hierarchy chart

* fix(style): apply svg container margin only in desktop view

* fix: Nest `.level` class style under `.hierarchy` class (#26905)

fix: Nest `.level` class style under `.hierarchy` class

* fix: add z-index to filter to avoid svg wrapper overlapping

* fix: expand all nodes not working when there are only 2 levels

- added dom freeze while expanding all nodes and exporting

* fix: test

Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com>
diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index 9cc4663..b4a4ba1 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -32,11 +32,15 @@
 
 	if response.ok:
 		payload = response.json()
-		title = payload.get("title", "").lower()
+		title = payload.get("title", "").lower().strip()
 		head_sha = payload.get("head", {}).get("sha")
 		body = payload.get("body", "").lower()
 
-		if title.startswith("feat") and head_sha and "no-docs" not in body:
+		if (title.startswith("feat")
+			and head_sha
+			and "no-docs" not in body
+			and "backport" not in body
+		):
 			if docs_link_exists(body):
 				print("Documentation Link Found. You're Awesome! 🎉")
 
diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
index f1b231b..9f0eee8 100644
--- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
+++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
@@ -38,8 +38,8 @@
 				GROUP BY parent''',{'dimension':[dimension]})
 			if DCC_allocation:
 				filters['budget_against_filter'] = [DCC_allocation[0][0]]
-				cam_map = get_dimension_account_month_map(filters)
-				dimension_items = cam_map.get(DCC_allocation[0][0])
+				ddc_cam_map = get_dimension_account_month_map(filters)
+				dimension_items = ddc_cam_map.get(DCC_allocation[0][0])
 				if dimension_items:
 					data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation[0][1])
 
@@ -48,7 +48,6 @@
 	return columns, data, None, chart
 
 def get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation):
-
 	for account, monthwise_data in iteritems(dimension_items):
 		row = [dimension, account]
 		totals = [0, 0, 0]
diff --git a/erpnext/hr/doctype/leave_type/leave_type.json b/erpnext/hr/doctype/leave_type/leave_type.json
index fc577ef..8f2ae6e 100644
--- a/erpnext/hr/doctype/leave_type/leave_type.json
+++ b/erpnext/hr/doctype/leave_type/leave_type.json
@@ -214,7 +214,7 @@
  "icon": "fa fa-flag",
  "idx": 1,
  "links": [],
- "modified": "2021-03-02 11:22:33.776320",
+ "modified": "2021-08-12 16:10:36.464690",
  "modified_by": "Administrator",
  "module": "HR",
  "name": "Leave Type",
@@ -248,5 +248,6 @@
   }
  ],
  "sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "track_changes": 1
 }
\ No newline at end of file
diff --git a/erpnext/hr/doctype/training_event/test_training_event.py b/erpnext/hr/doctype/training_event/test_training_event.py
index 313f90e..9b32136 100644
--- a/erpnext/hr/doctype/training_event/test_training_event.py
+++ b/erpnext/hr/doctype/training_event/test_training_event.py
@@ -11,21 +11,34 @@
 class TestTrainingEvent(unittest.TestCase):
 	def setUp(self):
 		create_training_program("Basic Training")
-		self.employee = make_employee("robert_loan@trainig.com")
-		self.employee2 = make_employee("suzie.tan@trainig.com")
+		employee = make_employee("robert_loan@trainig.com")
+		employee2 = make_employee("suzie.tan@trainig.com")
+		self.attendees = [
+			{"employee": employee},
+			{"employee": employee2}
+		]
 
-	def test_create_training_event(self):
-		if not frappe.db.get_value("Training Event", "Basic Training Event"):
-			frappe.get_doc({
-				"doctype": "Training Event",
-				"event_name": "Basic Training Event",
-				"training_program": "Basic Training",
-				"location": "Union Square",
-				"start_time": add_days(today(), 5),
-				"end_time": add_days(today(), 6),
-				"introduction": "Welcome to the Basic Training Event",
-				"employees": get_attendees(self.employee, self.employee2)
-			}).insert()
+	def test_training_event_status_update(self):
+		training_event = create_training_event(self.attendees)
+		training_event.submit()
+
+		training_event.event_status = "Completed"
+		training_event.save()
+		training_event.reload()
+
+		for entry in training_event.employees:
+			self.assertEqual(entry.status, "Completed")
+
+		training_event.event_status = "Scheduled"
+		training_event.save()
+		training_event.reload()
+
+		for entry in training_event.employees:
+			self.assertEqual(entry.status, "Open")
+
+	def tearDown(self):
+		frappe.db.rollback()
+
 
 def create_training_program(training_program):
 	if not frappe.db.get_value("Training Program", training_program):
@@ -35,8 +48,14 @@
 			"description": training_program
 		}).insert()
 
-def get_attendees(employee, employee2):
-	return [
-		{"employee": employee},
-		{"employee": employee2}
-	]
\ No newline at end of file
+def create_training_event(attendees):
+	return frappe.get_doc({
+		"doctype": "Training Event",
+		"event_name": "Basic Training Event",
+		"training_program": "Basic Training",
+		"location": "Union Square",
+		"start_time": add_days(today(), 5),
+		"end_time": add_days(today(), 6),
+		"introduction": "Welcome to the Basic Training Event",
+		"employees": attendees
+	}).insert()
\ No newline at end of file
diff --git a/erpnext/hr/doctype/training_event/training_event.py b/erpnext/hr/doctype/training_event/training_event.py
index 5064f03..e2c30cb 100644
--- a/erpnext/hr/doctype/training_event/training_event.py
+++ b/erpnext/hr/doctype/training_event/training_event.py
@@ -14,10 +14,25 @@
 		self.set_employee_emails()
 		self.validate_period()
 
+	def on_update_after_submit(self):
+		self.set_status_for_attendees()
+
 	def set_employee_emails(self):
 		self.employee_emails = ', '.join(get_employee_emails([d.employee
 			for d in self.employees]))
 
 	def validate_period(self):
 		if time_diff_in_seconds(self.end_time, self.start_time) <= 0:
-			frappe.throw(_('End time cannot be before start time'))
\ No newline at end of file
+			frappe.throw(_('End time cannot be before start time'))
+
+	def set_status_for_attendees(self):
+		if self.event_status == 'Completed':
+			for employee in self.employees:
+				if employee.attendance == 'Present' and employee.status != 'Feedback Submitted':
+					employee.status = 'Completed'
+
+		elif self.event_status == 'Scheduled':
+			for employee in self.employees:
+				employee.status = 'Open'
+
+		self.db_update_all()
diff --git a/erpnext/hr/doctype/training_feedback/test_training_feedback.py b/erpnext/hr/doctype/training_feedback/test_training_feedback.py
index 3455998..c30a3ad 100644
--- a/erpnext/hr/doctype/training_feedback/test_training_feedback.py
+++ b/erpnext/hr/doctype/training_feedback/test_training_feedback.py
@@ -5,8 +5,63 @@
 
 import frappe
 import unittest
-
-# test_records = frappe.get_test_records('Training Feedback')
-
+from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_employee
+from erpnext.hr.doctype.training_event.test_training_event import create_training_program, create_training_event
 class TestTrainingFeedback(unittest.TestCase):
-	pass
+	def setUp(self):
+		create_training_program("Basic Training")
+		self.employee = make_employee("robert_loan@trainig.com")
+		self.employee2 = make_employee("suzie.tan@trainig.com")
+		self.attendees = [{"employee": self.employee}]
+
+	def test_employee_validations_for_feedback(self):
+		training_event = create_training_event(self.attendees)
+		training_event.submit()
+
+		training_event.event_status = "Completed"
+		training_event.save()
+		training_event.reload()
+
+		# should not allow creating feedback since employee2 was not part of the event
+		feedback = create_training_feedback(training_event.name, self.employee2)
+		self.assertRaises(frappe.ValidationError, feedback.save)
+
+		# cannot record feedback for absent employee
+		employee = frappe.db.get_value("Training Event Employee", {
+			"parent": training_event.name,
+			"employee": self.employee
+		}, "name")
+
+		frappe.db.set_value("Training Event Employee", employee, "attendance", "Absent")
+		feedback = create_training_feedback(training_event.name, self.employee)
+		self.assertRaises(frappe.ValidationError, feedback.save)
+
+	def test_training_feedback_status(self):
+		training_event = create_training_event(self.attendees)
+		training_event.submit()
+
+		training_event.event_status = "Completed"
+		training_event.save()
+		training_event.reload()
+
+		feedback = create_training_feedback(training_event.name, self.employee)
+		feedback.submit()
+
+		status = frappe.db.get_value("Training Event Employee", {
+			"parent": training_event.name,
+			"employee": self.employee
+		}, "status")
+
+		self.assertEqual(status, "Feedback Submitted")
+
+	def tearDown(self):
+		frappe.db.rollback()
+
+
+def create_training_feedback(event, employee):
+	return frappe.get_doc({
+		"doctype": "Training Feedback",
+		"training_event": event,
+		"employee": employee,
+		"feedback": "Test"
+	})
\ No newline at end of file
diff --git a/erpnext/hr/doctype/training_feedback/training_feedback.py b/erpnext/hr/doctype/training_feedback/training_feedback.py
index 1a33450..0d32de7 100644
--- a/erpnext/hr/doctype/training_feedback/training_feedback.py
+++ b/erpnext/hr/doctype/training_feedback/training_feedback.py
@@ -11,15 +11,35 @@
 	def validate(self):
 		training_event = frappe.get_doc("Training Event", self.training_event)
 		if training_event.docstatus != 1:
-			frappe.throw(_('{0} must be submitted').format(_('Training Event')))
+			frappe.throw(_("{0} must be submitted").format(_("Training Event")))
+
+		emp_event_details = frappe.db.get_value("Training Event Employee", {
+			"parent": self.training_event,
+			"employee": self.employee
+		}, ["name", "attendance"], as_dict=True)
+
+		if not emp_event_details:
+			frappe.throw(_("Employee {0} not found in Training Event Participants.").format(
+				frappe.bold(self.employee_name)))
+
+		if emp_event_details.attendance == "Absent":
+			frappe.throw(_("Feedback cannot be recorded for an absent Employee."))
 
 	def on_submit(self):
-		training_event = frappe.get_doc("Training Event", self.training_event)
-		event_status = None
-		for e in training_event.employees:
-			if e.employee == self.employee:
-				event_status = 'Feedback Submitted'
-				break
+		employee = frappe.db.get_value("Training Event Employee", {
+			"parent": self.training_event,
+			"employee": self.employee
+		})
 
-		if event_status:
-			frappe.db.set_value("Training Event", self.training_event, "event_status", event_status)
+		if employee:
+			frappe.db.set_value("Training Event Employee", employee, "status", "Feedback Submitted")
+
+	def on_cancel(self):
+		employee = frappe.db.get_value("Training Event Employee", {
+			"parent": self.training_event,
+			"employee": self.employee
+		})
+
+		if employee:
+			frappe.db.set_value("Training Event Employee", employee, "status", "Completed")
+
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 86356e3..0fc50c5 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -296,7 +296,7 @@
 erpnext.patches.v13_0.update_amt_in_work_order_required_items
 erpnext.patches.v12_0.show_einvoice_irn_cancelled_field
 erpnext.patches.v13_0.delete_orphaned_tables
-erpnext.patches.v13_0.update_export_type_for_gst
+erpnext.patches.v13_0.update_export_type_for_gst #2021-08-16
 erpnext.patches.v13_0.update_tds_check_field #3
 erpnext.patches.v13_0.add_custom_field_for_south_africa #2
 erpnext.patches.v13_0.shopify_deprecation_warning
diff --git a/erpnext/patches/v13_0/update_export_type_for_gst.py b/erpnext/patches/v13_0/update_export_type_for_gst.py
index 478a2a6..3e20212 100644
--- a/erpnext/patches/v13_0/update_export_type_for_gst.py
+++ b/erpnext/patches/v13_0/update_export_type_for_gst.py
@@ -8,11 +8,19 @@
 	# Update custom fields
 	fieldname = frappe.db.get_value('Custom Field', {'dt': 'Customer', 'fieldname': 'export_type'})
 	if fieldname:
-		frappe.db.set_value('Custom Field', fieldname, 'default', '')
+		frappe.db.set_value('Custom Field', fieldname, 
+			{
+				'default': '',
+				'mandatory_depends_on': 'eval:in_list(["SEZ", "Overseas", "Deemed Export"], doc.gst_category)'
+			})
 
 	fieldname = frappe.db.get_value('Custom Field', {'dt': 'Supplier', 'fieldname': 'export_type'})
 	if fieldname:
-		frappe.db.set_value('Custom Field', fieldname, 'default', '')
+		frappe.db.set_value('Custom Field', fieldname, 
+			{
+				'default': '',
+				'mandatory_depends_on': 'eval:in_list(["SEZ", "Overseas"], doc.gst_category)'
+			})
 
 	# Update Customer/Supplier Masters
 	frappe.db.sql("""
diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py
index b4f146c..2d6b913 100644
--- a/erpnext/regional/india/setup.py
+++ b/erpnext/regional/india/setup.py
@@ -642,7 +642,8 @@
 				'fieldtype': 'Select',
 				'insert_after': 'gst_category',
 				'depends_on':'eval:in_list(["SEZ", "Overseas"], doc.gst_category)',
-				'options': '\nWith Payment of Tax\nWithout Payment of Tax'
+				'options': '\nWith Payment of Tax\nWithout Payment of Tax',
+				'mandatory_depends_on': 'eval:in_list(["SEZ", "Overseas"], doc.gst_category)'
 			}
 		],
 		'Customer': [
@@ -660,7 +661,8 @@
 				'fieldtype': 'Select',
 				'insert_after': 'gst_category',
 				'depends_on':'eval:in_list(["SEZ", "Overseas", "Deemed Export"], doc.gst_category)',
-				'options': '\nWith Payment of Tax\nWithout Payment of Tax'
+				'options': '\nWith Payment of Tax\nWithout Payment of Tax',
+				'mandatory_depends_on': 'eval:in_list(["SEZ", "Overseas", "Deemed Export"], doc.gst_category)'
 			}
 		],
 		'Member': [