Skip to content

Commit

Permalink
Merge pull request frappe#1948 from krantheman/feat-geofencing
Browse files Browse the repository at this point in the history
feat(Employee Checkin): geofencing
  • Loading branch information
krantheman authored Oct 9, 2024
2 parents dea4c5f + 8d34343 commit 6de43f3
Show file tree
Hide file tree
Showing 14 changed files with 408 additions and 73 deletions.
2 changes: 1 addition & 1 deletion frappe-ui
Submodule frappe-ui updated 53 files
+2 −3 package.json
+2 −4 src/components/Autocomplete.vue
+2 −11 src/components/Avatar.vue
+0 −1 src/components/Breadcrumbs.vue
+0 −247 src/components/Calendar.story.md
+0 −138 src/components/Calendar.story.vue
+0 −432 src/components/Calendar/Calendar.vue
+0 −113 src/components/Calendar/CalendarDaily.vue
+0 −512 src/components/Calendar/CalendarEvent.vue
+0 −143 src/components/Calendar/CalendarMonthly.vue
+0 −49 src/components/Calendar/CalendarTimeMarker.vue
+0 −252 src/components/Calendar/CalendarWeekly.vue
+0 −63 src/components/Calendar/EventModalContent.vue
+0 −46 src/components/Calendar/FloatingPopover.vue
+0 −210 src/components/Calendar/NewEventModal.vue
+0 −37 src/components/Calendar/ShowMoreCalendarEvent.vue
+0 −277 src/components/Calendar/calendarUtils.js
+0 −54 src/components/Calendar/composables/useCalendarData.js
+0 −41 src/components/Calendar/composables/useEventModal.js
+4 −7 src/components/Checkbox.vue
+0 −36 src/components/DatePicker.story.vue
+72 −108 src/components/DatePicker.vue
+0 −333 src/components/DateRangePicker.vue
+0 −405 src/components/DateTimePicker.vue
+3 −11 src/components/Dialog.vue
+3 −3 src/components/FeatherIcon.vue
+6 −6 src/components/FileUploader.vue
+4 −4 src/components/ListFilter/ListFilter.vue
+2 −2 src/components/ListFilter/SearchComplete.vue
+1 −1 src/components/ListView/ListHeader.vue
+7 −19 src/components/ListView/ListRow.vue
+2 −2 src/components/ListView/ListView.vue
+10 −19 src/components/Popover.vue
+4 −4 src/components/Switch.vue
+3 −17 src/components/TabButtons.vue
+2 −2 src/components/TextEditor/InsertVideo.vue
+1 −1 src/components/TextEditor/TextEditor.vue
+1 −1 src/components/TextEditor/image-extension.js
+1 −1 src/components/TextEditor/mention.js
+1 −1 src/components/Tooltip/Tooltip.vue
+2 −2 src/components/toast.js
+2 −4 src/fonts/Inter/inter.css
+0 −4 src/index.js
+6 −6 src/resources/documentResource.js
+8 −8 src/resources/listResource.js
+1 −1 src/resources/plugin.js
+1 −1 src/utils/call.js
+1 −1 src/utils/confirmDialog.js
+1 −1 src/utils/debounce.ts
+1 −1 src/utils/frappeRequest.js
+1 −1 src/utils/markdown.js
+1 −1 src/utils/pageMeta.js
+0 −4 src/utils/tailwind.config.js
42 changes: 4 additions & 38 deletions hrms/hr/doctype/employee_checkin/employee_checkin.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ frappe.ui.form.on("Employee Checkin", {
}
},

fetch_geolocation: (frm) => {
hrms.fetch_geolocation(frm);
},

add_fetch_shift_button(frm) {
if (frm.doc.attendace) return;
frm.add_custom_button(__("Fetch Shift"), function () {
Expand All @@ -39,42 +43,4 @@ frappe.ui.form.on("Employee Checkin", {
});
});
},

fetch_geolocation: async (frm) => {
if (!navigator.geolocation) {
frappe.msgprint({
message: __("Geolocation is not supported by your current browser"),
title: __("Geolocation Error"),
indicator: "red",
});
hide_field(["geolocation"]);
return;
}

frappe.dom.freeze(__("Fetching your geolocation") + "...");

navigator.geolocation.getCurrentPosition(
async (position) => {
frm.set_value("latitude", position.coords.latitude);
frm.set_value("longitude", position.coords.longitude);

await frm.call("set_geolocation_from_coordinates");
frm.dirty();
frappe.dom.unfreeze();
},
(error) => {
frappe.dom.unfreeze();

let msg = __("Unable to retrieve your location") + "<br><br>";
if (error) {
msg += __("ERROR({0}): {1}", [error.code, error.message]);
}
frappe.msgprint({
message: msg,
title: __("Geolocation Error"),
indicator: "red",
});
},
);
},
});
58 changes: 39 additions & 19 deletions hrms/hr/doctype/employee_checkin/employee_checkin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,25 @@
from frappe.model.document import Document
from frappe.utils import cint, get_datetime

from hrms.hr.doctype.shift_assignment.shift_assignment import (
get_actual_start_end_datetime_of_shift,
from hrms.hr.doctype.shift_assignment.shift_assignment import get_actual_start_end_datetime_of_shift
from hrms.hr.utils import (
get_distance_between_coordinates,
set_geolocation_from_coordinates,
validate_active_employee,
)
from hrms.hr.utils import validate_active_employee


class CheckinRadiusExceededError(frappe.ValidationError):
pass


class EmployeeCheckin(Document):
def validate(self):
validate_active_employee(self.employee)
self.validate_duplicate_log()
self.fetch_shift()
self.set_geolocation_from_coordinates()
set_geolocation_from_coordinates(self)
self.validate_distance_from_shift_location()

def validate_duplicate_log(self):
doc = frappe.db.exists(
Expand Down Expand Up @@ -64,27 +71,40 @@ def fetch_shift(self):
self.shift_start = shift_actual_timings.start_datetime
self.shift_end = shift_actual_timings.end_datetime

@frappe.whitelist()
def set_geolocation_from_coordinates(self):
def validate_distance_from_shift_location(self):
if not frappe.db.get_single_value("HR Settings", "allow_geolocation_tracking"):
return

if not (self.latitude and self.longitude):
if not (self.latitude or self.longitude):
frappe.throw(_("Latitude and longitude values are required for checking in."))

assignment_locations = frappe.get_all(
"Shift Assignment",
filters={
"employee": self.employee,
"shift_type": self.shift,
"start_date": ["<=", self.time],
"shift_location": ["is", "set"],
"docstatus": 1,
},
or_filters=[["end_date", ">=", self.time], ["end_date", "is", "not set"]],
pluck="shift_location",
)
if not assignment_locations:
return

self.geolocation = frappe.json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
# geojson needs coordinates in reverse order: long, lat instead of lat, long
"geometry": {"type": "Point", "coordinates": [self.longitude, self.latitude]},
}
],
}
checkin_radius, latitude, longitude = frappe.db.get_value(
"Shift Location", assignment_locations[0], ["checkin_radius", "latitude", "longitude"]
)
if checkin_radius <= 0:
return

distance = get_distance_between_coordinates(latitude, longitude, self.latitude, self.longitude)
if distance > checkin_radius:
frappe.throw(
_("You must be within {0} meters of your shift location to check in.").format(checkin_radius),
exc=CheckinRadiusExceededError,
)


@frappe.whitelist()
Expand Down
86 changes: 80 additions & 6 deletions hrms/hr/doctype/employee_checkin/test_employee_checkin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@
)

from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.setup.doctype.holiday_list.test_holiday_list import set_holiday_list

from hrms.hr.doctype.employee_checkin.employee_checkin import (
CheckinRadiusExceededError,
add_log_based_on_employee_field,
bulk_fetch_shift,
calculate_working_hours,
mark_attendance_and_link_log,
)
from hrms.hr.doctype.leave_application.test_leave_application import get_first_sunday
from hrms.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
from hrms.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list

Expand All @@ -39,6 +38,8 @@ def setUp(self):
to_date = get_year_ending(getdate())
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)

frappe.db.set_single_value("HR Settings", "allow_geolocation_tracking", 0)

def test_geolocation_tracking(self):
employee = make_employee("[email protected]")
checkin = make_checkin(employee)
Expand All @@ -49,9 +50,7 @@ def test_geolocation_tracking(self):
# geolocation tracking is disabled
self.assertIsNone(checkin.geolocation)

hr_settings = frappe.get_single("HR Settings")
hr_settings.allow_geolocation_tracking = 1
hr_settings.save()
frappe.db.set_single_value("HR Settings", "allow_geolocation_tracking", 1)

checkin.save()
self.assertEqual(
Expand Down Expand Up @@ -491,6 +490,65 @@ def test_consecutive_shift_assignments_overlapping_within_grace_period(self):
log = make_checkin(employee, timestamp)
self.assertEqual(log.shift, shift2.name)

@change_settings("HR Settings", {"allow_multiple_shift_assignments": 1})
@change_settings("HR Settings", {"allow_geolocation_tracking": 1})
def test_geofencing(self):
employee = make_employee("[email protected]", company="_Test Company")

# 8 - 12
shift1 = setup_shift_type()
# 15 - 19
shift2 = setup_shift_type(shift_type="Consecutive Shift", start_time="15:00:00", end_time="19:00:00")

date = getdate()
location1 = make_shift_location("Loc A", 24, 72)
location2 = make_shift_location("Loc B", 25, 75, checkin_radius=2000)
make_shift_assignment(shift1.name, employee, date, shift_location=location1.name)
make_shift_assignment(shift2.name, employee, date, shift_location=location2.name)

timestamp = datetime.combine(add_days(date, -1), get_time("11:00:00"))
# allowed as it is before the shift start date
make_checkin(employee, timestamp, 20, 65)

timestamp = datetime.combine(date, get_time("06:00:00"))
# allowed as it is before the shift start time
make_checkin(employee, timestamp, 20, 65)

timestamp = datetime.combine(date, get_time("10:00:00"))
# allowed as distance (150m) is within checkin radius (500m)
make_checkin(employee, timestamp, 24.001, 72.001)

timestamp = datetime.combine(date, get_time("10:30:00"))
log = frappe.get_doc(
{
"doctype": "Employee Checkin",
"employee": employee,
"time": timestamp,
"latitude": 24.01,
"longitude": 72.01,
}
)
# not allowed as distance (1506m) is not within checkin radius
self.assertRaises(CheckinRadiusExceededError, log.insert)

# to ensure that the correct shift assignment is considered
timestamp = datetime.combine(date, get_time("16:00:00"))
# allowed as distance (1506m) is within checkin radius (2000m)
make_checkin(employee, timestamp, 25.01, 75.01)

timestamp = datetime.combine(date, get_time("16:30:00"))
log = frappe.get_doc(
{
"doctype": "Employee Checkin",
"employee": employee,
"time": timestamp,
"latitude": 25.1,
"longitude": 75.1,
}
)
# not allowed as distance (15004m) is not within checkin radius
self.assertRaises(CheckinRadiusExceededError, log.insert)

def test_bulk_fetch_shift(self):
emp1 = make_employee("[email protected]", company="_Test Company")
emp2 = make_employee("[email protected]", company="_Test Company")
Expand Down Expand Up @@ -533,7 +591,7 @@ def make_n_checkins(employee, n, hours_to_reverse=1):
return logs


def make_checkin(employee, time=None):
def make_checkin(employee, time=None, latitude=None, longitude=None):
if not time:
time = now_datetime()

Expand All @@ -544,6 +602,22 @@ def make_checkin(employee, time=None):
"time": time,
"device_id": "device1",
"log_type": "IN",
"latitude": latitude,
"longitude": longitude,
}
).insert()
return log


def make_shift_location(location_name, latitude, longitude, checkin_radius=500):
shift_location = frappe.get_doc(
{
"doctype": "Shift Location",
"location_name": location_name,
"latitude": latitude,
"longitude": longitude,
"checkin_radius": checkin_radius,
}
).insert()

return shift_location
35 changes: 30 additions & 5 deletions hrms/hr/doctype/shift_assignment/shift_assignment.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"employee_details_section",
"employee",
"employee_name",
"shift_type",
"status",
"schedule",
"column_break_3",
"company",
"department",
"shift_details_section",
"shift_type",
"shift_location",
"status",
"column_break_brkq",
"start_date",
"end_date",
"shift_request",
"department",
"schedule",
"amended_from"
],
"fields": [
Expand Down Expand Up @@ -60,6 +64,7 @@
"fieldtype": "Column Break"
},
{
"fetch_from": "employee.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
Expand Down Expand Up @@ -103,6 +108,26 @@
"label": "Status",
"options": "Active\nInactive"
},
{
"fieldname": "employee_details_section",
"fieldtype": "Section Break",
"label": "Employee Details"
},
{
"fieldname": "shift_details_section",
"fieldtype": "Section Break",
"label": "Shift Details"
},
{
"fieldname": "shift_location",
"fieldtype": "Link",
"label": "Shift Location",
"options": "Shift Location"
},
{
"fieldname": "column_break_brkq",
"fieldtype": "Column Break"
},
{
"fieldname": "schedule",
"fieldtype": "Link",
Expand All @@ -113,7 +138,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-05-31 16:41:32.869130",
"modified": "2024-09-16 15:29:41.502080",
"modified_by": "Administrator",
"module": "HR",
"name": "Shift Assignment",
Expand Down
Empty file.
24 changes: 24 additions & 0 deletions hrms/hr/doctype/shift_location/shift_location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt

frappe.ui.form.on("Shift Location", {
refresh: async () => {
const allow_geolocation_tracking = await frappe.db.get_single_value(
"HR Settings",
"allow_geolocation_tracking",
);

if (!allow_geolocation_tracking)
hide_field([
"checkin_radius",
"fetch_geolocation",
"latitude",
"longitude",
"geolocation",
]);
},

fetch_geolocation: (frm) => {
hrms.fetch_geolocation(frm);
},
});
Loading

0 comments on commit 6de43f3

Please sign in to comment.