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,162 +22,169 @@
</div> </div>
{% if employee %} {% if employee %}
<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"> {% if is_mobile_attendance_device %}
Click Punch In/Punch Out. The app will ask for browser location before submitting attendance. <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> Click Punch In/Punch Out. The app will ask for mobile browser location before submitting attendance.
<div class="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800"> </div>
When branch geofence is enabled, browser location is mandatory. If location is denied/unavailable, attendance will not be submitted. <div class="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
</div> Attendance marking is allowed only from mobile. Browser location/GPS is mandatory. If location is denied/unavailable, attendance will not be submitted.
</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">
<form method="post" action="/employee/attendance/punch-in" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4"> <form method="post" action="/employee/attendance/punch-in" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="latitude"> <input type="hidden" name="latitude">
<input type="hidden" name="longitude"> <input type="hidden" name="longitude">
<input type="hidden" name="accuracy_meters"> <input type="hidden" name="accuracy_meters">
<label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label> <label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks"> <input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
<button class="mt-3 w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" {% if today_attendance and today_attendance.punch_in_utc %}disabled{% endif %}>Punch In</button> <button class="mt-3 w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" {% if today_attendance and today_attendance.punch_in_utc %}disabled{% endif %}>Punch In</button>
</form> </form>
<form method="post" action="/employee/attendance/punch-out" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4"> <form method="post" action="/employee/attendance/punch-out" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="latitude"> <input type="hidden" name="latitude">
<input type="hidden" name="longitude"> <input type="hidden" name="longitude">
<input type="hidden" name="accuracy_meters"> <input type="hidden" name="accuracy_meters">
<label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label> <label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks"> <input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
<button class="mt-3 w-full 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="mt-3 w-full 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>
<div id="employee-location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 px-4" aria-hidden="true"> <div id="employee-location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 px-4" aria-hidden="true">
<div class="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl"> <div class="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
<div class="flex gap-4"> <div class="flex gap-4">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">📍</div> <div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">📍</div>
<div> <div>
<h3 class="text-lg font-semibold text-slate-900">Enable location for attendance</h3> <h3 class="text-lg font-semibold text-slate-900">Enable location for attendance</h3>
<p class="mt-2 text-sm leading-6 text-slate-600"> <p class="mt-2 text-sm leading-6 text-slate-600">
To mark attendance, this app needs your current location to verify whether you are within your branch geofence. To mark attendance, this app needs your current location to verify whether you are within your branch geofence.
Click Continue, then choose <strong>Allow</strong> in the browser location popup. Click Continue, then choose <strong>Allow</strong> in the browser location popup.
</p> </p>
<p class="mt-3 text-xs leading-5 text-slate-500"> <p class="mt-3 text-xs leading-5 text-slate-500">
If you are outside the branch, attendance will require approval. If browser location is denied/unavailable while branch geofence is enabled, attendance will not be submitted. If you are outside the branch, attendance will require approval. If browser location is denied/unavailable while branch geofence is enabled, attendance will not be submitted.
</p> </p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button type="button" id="employee-location-modal-cancel" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="button" id="employee-location-modal-continue" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Continue</button>
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3">
<button type="button" id="employee-location-modal-cancel" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="button" id="employee-location-modal-continue" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Continue</button>
</div>
</div> </div>
</div>
<script>
window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
function updateStatus(message, tone) {
var status = document.getElementById('employee-geo-status');
if (!status) return;
status.textContent = message;
status.className = 'mt-4 rounded-xl border px-3 py-2 text-xs ' + (
tone === 'ok' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' :
tone === 'warn' ? 'border-amber-200 bg-amber-50 text-amber-700' :
'border-slate-200 bg-slate-50 text-slate-600'
);
}
function setHidden(form, lat, lon, acc) {
var latInput = form.querySelector('input[name="latitude"]');
var lonInput = form.querySelector('input[name="longitude"]');
var accInput = form.querySelector('input[name="accuracy_meters"]');
if (latInput) latInput.value = lat || '';
if (lonInput) lonInput.value = lon || '';
if (accInput) accInput.value = acc || '';
}
function openLocationModal() {
return new Promise(function (resolve) {
var modal = document.getElementById('employee-location-consent-modal');
var modalContinue = document.getElementById('employee-location-modal-continue');
var modalCancel = document.getElementById('employee-location-modal-cancel');
if (!modal || !modalContinue || !modalCancel) {
resolve(true);
return;
}
function cleanup(result) {
modal.classList.add('hidden');
modal.classList.remove('flex');
modal.setAttribute('aria-hidden', 'true');
modalContinue.removeEventListener('click', onContinue);
modalCancel.removeEventListener('click', onCancel);
resolve(result);
}
function onContinue() { cleanup(true); }
function onCancel() {
updateStatus('Attendance punch cancelled. Location permission was not requested.', 'warn');
cleanup(false);
}
modalContinue.addEventListener('click', onContinue);
modalCancel.addEventListener('click', onCancel);
modal.classList.remove('hidden');
modal.classList.add('flex');
modal.setAttribute('aria-hidden', 'false');
modalContinue.focus();
});
}
function captureLocation(form) {
return new Promise(function (resolve) {
if (!navigator.geolocation) {
setHidden(form, '', '', '');
updateStatus('Browser geolocation is not available. Attendance was not submitted. Enable browser/device location and try again.', 'warn');
resolve(false);
return;
}
updateStatus('Browser location popup opened. Please choose Allow to capture location...', 'info');
navigator.geolocation.getCurrentPosition(function (pos) {
var c = pos.coords || {};
setHidden(form, c.latitude, c.longitude, c.accuracy);
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
resolve(true);
}, function (err) {
setHidden(form, '', '', '');
var reason = 'Location permission denied or unavailable.';
if (err && err.code === 1) reason = 'Location permission denied. Allow location for this site in browser settings.';
if (err && err.code === 2) reason = 'Location unavailable. Enable device location service and try again.';
if (err && err.code === 3) reason = 'Location capture timed out. Try again with stable GPS/location service.';
updateStatus(reason + ' Attendance was not submitted. Please allow browser location/GPS and try again.', 'warn');
resolve(false);
}, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
});
}
function bind() {
document.querySelectorAll('.employee-attendance-geo-form').forEach(function (form) {
if (form.dataset.geoBound === '1') return;
form.dataset.geoBound = '1';
form.addEventListener('submit', async function (event) {
if (form.dataset.geoSubmitted === '1') return;
event.preventDefault();
var proceed = await openLocationModal();
if (!proceed) return;
var captured = await captureLocation(form);
if (!captured) {
updateStatus('Attendance not submitted because location was not captured.', 'warn');
return;
}
form.dataset.geoSubmitted = '1';
form.submit();
});
});
}
return {bind: bind};
})();
window.EmployeePortalGeo.bind();
</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 %} {% endif %}
</div> </div>
<script>
window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
function updateStatus(message, tone) {
var status = document.getElementById('employee-geo-status');
if (!status) return;
status.textContent = message;
status.className = 'mt-4 rounded-xl border px-3 py-2 text-xs ' + (
tone === 'ok' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' :
tone === 'warn' ? 'border-amber-200 bg-amber-50 text-amber-700' :
'border-slate-200 bg-slate-50 text-slate-600'
);
}
function setHidden(form, lat, lon, acc) {
var latInput = form.querySelector('input[name="latitude"]');
var lonInput = form.querySelector('input[name="longitude"]');
var accInput = form.querySelector('input[name="accuracy_meters"]');
if (latInput) latInput.value = lat || '';
if (lonInput) lonInput.value = lon || '';
if (accInput) accInput.value = acc || '';
}
function openLocationModal() {
return new Promise(function (resolve) {
var modal = document.getElementById('employee-location-consent-modal');
var modalContinue = document.getElementById('employee-location-modal-continue');
var modalCancel = document.getElementById('employee-location-modal-cancel');
if (!modal || !modalContinue || !modalCancel) {
resolve(true);
return;
}
function cleanup(result) {
modal.classList.add('hidden');
modal.classList.remove('flex');
modal.setAttribute('aria-hidden', 'true');
modalContinue.removeEventListener('click', onContinue);
modalCancel.removeEventListener('click', onCancel);
resolve(result);
}
function onContinue() { cleanup(true); }
function onCancel() {
updateStatus('Attendance punch cancelled. Location permission was not requested.', 'warn');
cleanup(false);
}
modalContinue.addEventListener('click', onContinue);
modalCancel.addEventListener('click', onCancel);
modal.classList.remove('hidden');
modal.classList.add('flex');
modal.setAttribute('aria-hidden', 'false');
modalContinue.focus();
});
}
function captureLocation(form) {
return new Promise(function (resolve) {
if (!navigator.geolocation) {
setHidden(form, '', '', '');
updateStatus('Browser geolocation is not available. Attendance was not submitted. Enable browser/device location and try again.', 'warn');
resolve(false);
return;
}
updateStatus('Browser location popup opened. Please choose Allow to capture location...', 'info');
navigator.geolocation.getCurrentPosition(function (pos) {
var c = pos.coords || {};
setHidden(form, c.latitude, c.longitude, c.accuracy);
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
resolve(true);
}, function (err) {
setHidden(form, '', '', '');
var reason = 'Location permission denied or unavailable.';
if (err && err.code === 1) reason = 'Location permission denied. Allow location for this site in browser settings.';
if (err && err.code === 2) reason = 'Location unavailable. Enable device location service and try again.';
if (err && err.code === 3) reason = 'Location capture timed out. Try again with stable GPS/location service.';
updateStatus(reason + ' Attendance was not submitted. Please allow browser location/GPS and try again.', 'warn');
resolve(false);
}, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
});
}
function bind() {
document.querySelectorAll('.employee-attendance-geo-form').forEach(function (form) {
if (form.dataset.geoBound === '1') return;
form.dataset.geoBound = '1';
form.addEventListener('submit', async function (event) {
if (form.dataset.geoSubmitted === '1') return;
event.preventDefault();
var proceed = await openLocationModal();
if (!proceed) return;
var captured = await captureLocation(form);
if (!captured) {
updateStatus('Attendance not submitted because location was not captured.', 'warn');
return;
}
form.dataset.geoSubmitted = '1';
form.submit();
});
});
}
return {bind: bind};
})();
window.EmployeePortalGeo.bind();
</script>
@@ -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,