Files
Odoo-Modules/fusion_clock/tests/test_pending_reason_exempt.py
gsinghpal 78fa8f07ee fix(fusion_clock): stop stale missed-clock-in nag; add Owner role + attendance exemption
The "explain your missed clock-out" dialog (driven by hr.employee.
x_fclk_pending_reason) was set by the absence + auto-clock-out crons but only
cleared by the systray reason dialog -- never by the kiosk/NFC clock paths that
staff actually use. During the kiosk rollout the absence cron flagged the whole
company (hundreds of "absent" logs); those stale flags then nagged everyone
forever, even while currently clocked in.

Fixes:
- Clear x_fclk_pending_reason on every successful clock-in (portal, systray,
  PIN kiosk, NFC kiosk). Back on the clock => no nag.
- get_status / dashboard never report pending while checked-in or exempt; the
  systray also guards the dialog client-side.
- Absence detection no longer sets x_fclk_pending_reason (an absence has no
  "departure time" to explain). It still logs 'absent' + notifies the office.
- One-time migration (19.0.4.2.0) clears existing stale flags.

Owner / attendance exemption:
- New "Owner" role (top of the Fusion Clock access dropdown, implies Manager)
  plus a per-employee "Exempt from Attendance" checkbox.
- hr.employee._fclk_is_attendance_exempt(); the absence, auto-clock-out,
  reminder and weekly-summary crons all skip exempt employees, and the dialog
  is suppressed for them.

Tests: tests/test_pending_reason_exempt.py (13 cases). Full fusion_clock suite
green except pre-existing env-sensitive failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:54:00 -04:00

242 lines
10 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Regression tests for the missed-clock-out ("pending reason") nag and the
new owner/attendance-exemption.
Root cause these tests pin down:
* The `x_fclk_pending_reason` flag was set by the absence + auto-clock-out
crons but ONLY cleared by the systray reason dialog. The kiosk / NFC clock
paths (how Entech actually clocks in) never cleared it, so a stale flag
nagged employees forever -- even while currently clocked in.
* Owners work but are not "on the clock"; they must be exempt from absence
flagging, auto-clock-out nags and the reason dialog.
"""
import json
from datetime import date, timedelta
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import HttpCase, TransactionCase
try:
from freezegun import freeze_time
except ImportError: # freezegun may be absent on the runtime image
freeze_time = None
MON = date(2026, 6, 1) # Monday
TUE = date(2026, 6, 2) # Tuesday
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestAttendanceExemptHelper(TransactionCase):
"""`hr.employee._fclk_is_attendance_exempt()` truth table."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.owner_group = cls.env.ref('fusion_clock.group_fusion_clock_owner')
def test_plain_employee_not_exempt(self):
emp = self.Employee.create({'name': 'Plain', 'x_fclk_enable_clock': True})
self.assertFalse(emp._fclk_is_attendance_exempt())
def test_checkbox_makes_exempt(self):
emp = self.Employee.create({
'name': 'Flagged', 'x_fclk_enable_clock': True,
'x_fclk_exempt_from_attendance': True,
})
self.assertTrue(emp._fclk_is_attendance_exempt())
def test_owner_group_makes_exempt(self):
user = self.env['res.users'].create({
'name': 'Olivia Owner', 'login': 'olivia-owner-test',
'group_ids': [(4, self.owner_group.id)],
})
emp = self.Employee.create({
'name': 'Olivia Owner', 'x_fclk_enable_clock': True, 'user_id': user.id,
})
self.assertTrue(emp._fclk_is_attendance_exempt())
def test_owner_group_implies_manager(self):
"""The Owner role must carry full Manager permissions."""
user = self.env['res.users'].create({
'name': 'Manager-by-owner', 'login': 'owner-implies-mgr',
'group_ids': [(4, self.owner_group.id)],
})
self.assertTrue(user.has_group('fusion_clock.group_fusion_clock_manager'))
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestCronsRespectExemptAndPending(TransactionCase):
"""Absence + auto-clock-out crons: no more pending nag, owners skipped."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.Schedule = cls.env['fusion.clock.schedule']
cls.Attendance = cls.env['hr.attendance']
cls.Log = cls.env['fusion.clock.activity.log']
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_auto_clockout', 'True')
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
def _post(self, emp, day):
return self.Schedule.create({
'employee_id': emp.id, 'schedule_date': day, 'state': 'posted',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
})
def test_absence_does_not_set_pending_reason(self):
if freeze_time is None:
self.skipTest("freezegun not available")
emp = self.Employee.create({'name': 'NoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
self._post(emp, MON)
with freeze_time("2026-06-02 09:00:00"): # yesterday = scheduled Monday
self.Attendance._cron_fusion_check_absences()
# Absence is still logged ...
self.assertEqual(self.Log.search_count([
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 1)
# ... but it must NOT raise the missed-clock-out reason nag.
self.assertFalse(emp.x_fclk_pending_reason)
def test_absence_skips_exempt_employee(self):
if freeze_time is None:
self.skipTest("freezegun not available")
emp = self.Employee.create({
'name': 'OwnerNoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'x_fclk_exempt_from_attendance': True,
})
self._post(emp, MON)
with freeze_time("2026-06-02 09:00:00"):
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 0)
self.assertFalse(emp.x_fclk_pending_reason)
def test_auto_clockout_skips_exempt_employee(self):
emp = self.Employee.create({
'name': 'OwnerStale', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'x_fclk_exempt_from_attendance': True,
})
now = fields.Datetime.now()
stale = self.Attendance.create({
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertFalse(stale.check_out, "Exempt employee must not be auto-clocked-out.")
self.assertFalse(emp.x_fclk_pending_reason)
def test_auto_clockout_still_flags_normal_employee(self):
emp = self.Employee.create({'name': 'Forgetful', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
now = fields.Datetime.now()
stale = self.Attendance.create({
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")
self.assertTrue(emp.x_fclk_pending_reason, "Forgotten clock-out still asks for a reason.")
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestKioskClearsPendingReason(HttpCase):
"""Clocking in via either kiosk clears a stale pending-reason flag."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Clear Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'Clear Op', 'login': 'clear-op', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.pin_emp = cls.env['hr.employee'].create({
'name': 'Pat Pending', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
'x_fclk_pending_reason': True,
})
cls.nfc_emp = cls.env['hr.employee'].create({
'name': 'Nina Pending', 'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:CC:01', 'x_fclk_pending_reason': True,
})
def setUp(self):
super().setUp()
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_mod
nfc_mod._recent_taps.clear()
def _post(self, route, params):
self.authenticate('clear-op', 'kioskpass123')
resp = self.url_open(route, data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': params,
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_pin_kiosk_clock_in_clears_pending(self):
res = self._post('/fusion_clock/kiosk/clock', {'employee_id': self.pin_emp.id})
self.assertEqual(res.get('action'), 'clock_in')
self.pin_emp.invalidate_recordset()
self.assertFalse(self.pin_emp.x_fclk_pending_reason)
def test_nfc_tap_clock_in_clears_pending(self):
res = self._post('/fusion_clock/kiosk/nfc/tap', {'card_uid': '04:A2:B5:62:CC:01'})
self.assertEqual(res.get('action'), 'clock_in')
self.nfc_emp.invalidate_recordset()
self.assertFalse(self.nfc_emp.x_fclk_pending_reason)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestGetStatusPendingReason(HttpCase):
"""get_status must never raise the dialog for a clocked-in or exempt user."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = cls.env['res.users'].create({
'name': 'Status User', 'login': 'status-user', 'password': 'statuspass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_user').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Status User', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'user_id': cls.user.id, 'x_fclk_pending_reason': True,
})
def _status(self):
self.authenticate('status-user', 'statuspass123')
resp = self.url_open('/fusion_clock/get_status', data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': {},
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_pending_hidden_while_checked_in(self):
self.env['hr.attendance'].create({
'employee_id': self.emp.id, 'check_in': fields.Datetime.now() - timedelta(hours=1),
})
self.emp.invalidate_recordset()
res = self._status()
self.assertTrue(res.get('is_checked_in'))
self.assertFalse(res.get('pending_reason'),
"A currently clocked-in employee must never be nagged.")
def test_pending_hidden_for_exempt(self):
self.emp.write({'x_fclk_exempt_from_attendance': True})
res = self._status()
self.assertFalse(res.get('is_checked_in'))
self.assertFalse(res.get('pending_reason'),
"An exempt (owner) employee must never be nagged.")
def test_pending_shown_for_normal_not_checked_in(self):
"""Sanity: the dialog still works for a genuine forgotten clock-out."""
res = self._status()
self.assertFalse(res.get('is_checked_in'))
self.assertTrue(res.get('pending_reason'))