Restrict staff attendance punching to mobile devices

This commit is contained in:
A R R R Associates
2026-07-07 17:26:27 +05:30
parent 3ed669563a
commit ae06956b3b
3 changed files with 250 additions and 168 deletions
@@ -22,11 +22,12 @@
</div> </div>
{% if employee %} {% if employee %}
{% if is_mobile_attendance_device %}
<div id="employee-geo-status" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600"> <div id="employee-geo-status" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
Click Punch In/Punch Out. The app will ask for browser location before submitting attendance. Click Punch In/Punch Out. The app will ask for mobile browser location before submitting attendance.
</div> </div>
<div class="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800"> <div class="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
When branch geofence is enabled, browser location is mandatory. If location is denied/unavailable, attendance will not be submitted. Attendance marking is allowed only from mobile. Browser location/GPS is mandatory. If location is denied/unavailable, attendance will not be submitted.
</div> </div>
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-1"> <div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-1">
@@ -71,8 +72,6 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div>
<script> <script>
window.EmployeePortalGeo = window.EmployeePortalGeo || (function () { window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
@@ -181,3 +180,11 @@ window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
})(); })();
window.EmployeePortalGeo.bind(); window.EmployeePortalGeo.bind();
</script> </script>
{% else %}
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div class="font-semibold">Attendance marking is mobile-only.</div>
<p class="mt-1">{{ attendance_punch_blocked_reason or 'Please open the ERP on your mobile phone to mark punch-in or punch-out. Desktop access is available only for attendance history.' }}</p>
</div>
{% endif %}
{% endif %}
</div>
@@ -37,7 +37,8 @@
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"> <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Today's Action</h3> <h3 class="font-semibold text-slate-900">Today's Action</h3>
<p class="mt-1 text-xs text-slate-500">Location will be captured when your browser allows it. If you are outside the branch geofence, the attendance is saved as pending approval for OD/client visit review.</p> {% if is_mobile_attendance_device %}
<p class="mt-1 text-xs text-slate-500">Location will be captured when your mobile browser allows it. If you are outside the branch geofence, the attendance is saved as pending approval for OD/client visit review.</p>
<div id="geo-status" class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">Location not captured yet.</div> <div id="geo-status" class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">Location not captured yet.</div>
<div class="mt-4 grid gap-4 md:grid-cols-2"> <div class="mt-4 grid gap-4 md:grid-cols-2">
<form method="post" action="/employee/attendance/punch-in" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4"> <form method="post" action="/employee/attendance/punch-in" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
@@ -59,6 +60,12 @@
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button> <button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button>
</form> </form>
</div> </div>
{% else %}
<div class="mt-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div class="font-semibold">Attendance marking is mobile-only.</div>
<p class="mt-1">{{ attendance_punch_blocked_reason or 'Please open the ERP on your mobile phone to mark punch-in or punch-out. You can continue viewing attendance history from desktop.' }}</p>
</div>
{% endif %}
</div> </div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
@@ -104,6 +111,7 @@
{% endif %} {% endif %}
</div> </div>
{% if employee and is_mobile_attendance_device %}
<div id="location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 p-4" aria-hidden="true"> <div id="location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 p-4" aria-hidden="true">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl"> <div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -249,4 +257,5 @@
}); });
})(); })();
</script> </script>
{% endif %}
{% endblock %} {% endblock %}
+81 -15
View File
@@ -1,4 +1,4 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from io import BytesIO from io import BytesIO
@@ -195,6 +195,80 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None return request.client.host if request.client else None
_ATTENDANCE_MOBILE_ONLY_MESSAGE = (
"Attendance punch-in and punch-out are allowed only from a mobile browser with location/GPS enabled. "
"Please open the ERP on your mobile phone to mark attendance. Desktop access is view-only for attendance history."
)
def _is_mobile_attendance_device(request: Request) -> bool:
"""Server-side guard for staff self-attendance punch actions.
This intentionally does not replace GPS/geofence validation. It only blocks
desktop/laptop browsers from submitting punch-in or punch-out requests.
The existing attendance service still performs employee status, geo/IP,
timing, duplicate punch and approval checks.
"""
user_agent = (request.headers.get("user-agent") or "").strip().lower()
if not user_agent:
return False
mobile_indicators = (
"mobi",
"android",
"iphone",
"ipod",
"ipad",
"windows phone",
"blackberry",
"bb10",
"opera mini",
"opera mobi",
"mobile safari",
)
desktop_indicators = (
"windows nt",
"macintosh",
"x11",
"cros",
"linux x86_64",
)
has_mobile_signal = any(token in user_agent for token in mobile_indicators)
has_desktop_signal = any(token in user_agent for token in desktop_indicators)
if has_mobile_signal:
return True
if has_desktop_signal:
return False
return False
def _attendance_mobile_context(request: Request) -> dict:
is_mobile = _is_mobile_attendance_device(request)
return {
"is_mobile_attendance_device": is_mobile,
"attendance_punch_blocked_reason": None if is_mobile else _ATTENDANCE_MOBILE_ONLY_MESSAGE,
}
def _render_self_attendance_page(request: Request, db, current_user, *, errors: list[str] | None = None):
employee = get_employee_for_user(db, current_user)
rows = list_own_attendance(db, current_user)
return _render(
request,
"modules/employees/templates/employees/self_attendance.html",
db,
current_user,
title="My Attendance",
employee=employee,
rows=rows,
today_attendance=get_today_attendance_for_user(db, current_user),
attendance_rows=rows[:7],
errors=errors or [],
**_attendance_mobile_context(request),
)
def _active_financial_year(request: Request) -> str | None: def _active_financial_year(request: Request) -> str | None:
@@ -2527,6 +2601,7 @@ def _employee_portal_context(request: Request, db, current_user, *, active_tab:
"payslips": list_own_payslips(db, current_user)[:5], "payslips": list_own_payslips(db, current_user)[:5],
"own_documents": list_own_employee_documents(db, current_user)[:8], "own_documents": list_own_employee_documents(db, current_user)[:8],
"work_payload": work_payload, "work_payload": work_payload,
**_attendance_mobile_context(request),
} }
@@ -2586,20 +2661,7 @@ def employee_self_attendance(request: Request):
require_permission(db, current_user, "employees.attendance.view_self") require_permission(db, current_user, "employees.attendance.view_self")
except Exception: except Exception:
return _redirect_denied() return _redirect_denied()
employee = get_employee_for_user(db, current_user) return _render_self_attendance_page(request, db, current_user, errors=[])
rows = list_own_attendance(db, current_user)
return _render(
request,
"modules/employees/templates/employees/self_attendance.html",
db,
current_user,
title="My Attendance",
employee=employee,
rows=rows,
today_attendance=get_today_attendance_for_user(db, current_user),
attendance_rows=list_own_attendance(db, current_user)[:7],
errors=[],
)
finally: finally:
db.close() db.close()
@@ -2626,6 +2688,8 @@ def employee_punch_in_submit(
require_permission(db, current_user, "employees.attendance.punch") require_permission(db, current_user, "employees.attendance.punch")
except Exception: except Exception:
return _redirect_denied() return _redirect_denied()
if not _is_mobile_attendance_device(request):
return _render_self_attendance_page(request, db, current_user, errors=[_ATTENDANCE_MOBILE_ONLY_MESSAGE])
punch_in_attendance( punch_in_attendance(
db, db,
current_user, current_user,
@@ -2662,6 +2726,8 @@ def employee_punch_out_submit(
require_permission(db, current_user, "employees.attendance.punch") require_permission(db, current_user, "employees.attendance.punch")
except Exception: except Exception:
return _redirect_denied() return _redirect_denied()
if not _is_mobile_attendance_device(request):
return _render_self_attendance_page(request, db, current_user, errors=[_ATTENDANCE_MOBILE_ONLY_MESSAGE])
punch_out_attendance( punch_out_attendance(
db, db,
current_user, current_user,