fix(fusion_clock): settings audit — remove 2 dead knobs, make IP-fallback + all Boolean toggles work

Audit of all 41 settings found 3 that were shown but read nowhere, and 17 Boolean
toggles that couldn't be turned OFF.

- Remove grace_period_minutes (orphaned by the schedule-driven cron rewrite) and
  weekly_overtime_threshold (never implemented): field + view + seed.
- enable_ip_fallback now actually gates _verify_location's IP-whitelist check
  (default ON to preserve current behaviour).
- All 17 fusion_clock Boolean settings now persist explicitly as 'True'/'False'
  via a _FCLK_BOOL_PARAMS loop in get_values/set_values (config_parameter Booleans
  can't store False, so OFF never stuck). Add round-trip tests. Bump 3.15.2 -> 3.16.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 11:51:40 -04:00
parent 31098c4d14
commit d6d6bbe161
8 changed files with 88 additions and 58 deletions

View File

@@ -247,14 +247,12 @@ fusion_clock.default_break_minutes
fusion_clock.auto_deduct_break fusion_clock.auto_deduct_break
fusion_clock.break_threshold_hours fusion_clock.break_threshold_hours
fusion_clock.enable_auto_clockout fusion_clock.enable_auto_clockout
fusion_clock.grace_period_minutes
fusion_clock.max_shift_hours fusion_clock.max_shift_hours
fusion_clock.enable_penalties fusion_clock.enable_penalties
fusion_clock.penalty_grace_minutes fusion_clock.penalty_grace_minutes
fusion_clock.penalty_deduction_minutes fusion_clock.penalty_deduction_minutes
fusion_clock.enable_overtime fusion_clock.enable_overtime
fusion_clock.daily_overtime_threshold fusion_clock.daily_overtime_threshold
fusion_clock.weekly_overtime_threshold
fusion_clock.office_user_id fusion_clock.office_user_id
fusion_clock.very_late_threshold_minutes fusion_clock.very_late_threshold_minutes
fusion_clock.max_monthly_absences fusion_clock.max_monthly_absences
@@ -325,8 +323,9 @@ All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons. - `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field. - Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. - `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`. - Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.)
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present. - `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on).
- **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`.
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`. - NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user. - Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`. - Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Clock', 'name': 'Fusion Clock',
'version': '19.0.3.15.2', 'version': '19.0.3.16.0',
'category': 'Human Resources/Attendances', 'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """ 'description': """

View File

@@ -74,9 +74,11 @@ class FusionClockAPI(http.Controller):
if dist < nearest_distance: if dist < nearest_distance:
nearest_distance = dist nearest_distance = dist
# IP fallback -- try when GPS is unavailable OR GPS is outside all geofences # IP fallback -- only when enabled (default on); try when GPS is
# unavailable OR GPS is outside all geofences.
ICP = request.env['ir.config_parameter'].sudo() ICP = request.env['ir.config_parameter'].sudo()
if client_ip: ip_fallback_enabled = ICP.get_param('fusion_clock.enable_ip_fallback', 'True') == 'True'
if client_ip and ip_fallback_enabled:
for loc in locations: for loc in locations:
if loc.check_ip_whitelist(client_ip): if loc.check_ip_whitelist(client_ip):
return loc, 0, None, 'ip' return loc, 0, None, 'ip'

View File

@@ -25,11 +25,7 @@
<field name="value">4.0</field> <field name="value">4.0</field>
</record> </record>
<!-- Grace Period & Auto Clock-Out --> <!-- Auto Clock-Out -->
<record id="config_grace_period_minutes" model="ir.config_parameter">
<field name="key">fusion_clock.grace_period_minutes</field>
<field name="value">15</field>
</record>
<record id="config_enable_auto_clockout" model="ir.config_parameter"> <record id="config_enable_auto_clockout" model="ir.config_parameter">
<field name="key">fusion_clock.enable_auto_clockout</field> <field name="key">fusion_clock.enable_auto_clockout</field>
<field name="value">True</field> <field name="value">True</field>
@@ -92,15 +88,11 @@
<field name="key">fusion_clock.daily_overtime_threshold</field> <field name="key">fusion_clock.daily_overtime_threshold</field>
<field name="value">8.0</field> <field name="value">8.0</field>
</record> </record>
<record id="config_weekly_overtime_threshold" model="ir.config_parameter">
<field name="key">fusion_clock.weekly_overtime_threshold</field>
<field name="value">40.0</field>
</record>
<!-- Location & Verification --> <!-- Location & Verification -->
<record id="config_enable_ip_fallback" model="ir.config_parameter"> <record id="config_enable_ip_fallback" model="ir.config_parameter">
<field name="key">fusion_clock.enable_ip_fallback</field> <field name="key">fusion_clock.enable_ip_fallback</field>
<field name="value">False</field> <field name="value">True</field>
</record> </record>
<record id="config_enable_photo_verification" model="ir.config_parameter"> <record id="config_enable_photo_verification" model="ir.config_parameter">
<field name="key">fusion_clock.enable_photo_verification</field> <field name="key">fusion_clock.enable_photo_verification</field>

View File

@@ -23,7 +23,6 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_auto_deduct_break = fields.Boolean( fclk_auto_deduct_break = fields.Boolean(
string='Auto-Deduct Break', string='Auto-Deduct Break',
config_parameter='fusion_clock.auto_deduct_break',
default=True, default=True,
help="Automatically deduct break from worked hours on clock-out.", help="Automatically deduct break from worked hours on clock-out.",
) )
@@ -43,15 +42,10 @@ class ResConfigSettings(models.TransientModel):
# ── Attendance Rules ─────────────────────────────────────────────── # ── Attendance Rules ───────────────────────────────────────────────
fclk_enable_auto_clockout = fields.Boolean( fclk_enable_auto_clockout = fields.Boolean(
string='Enable Auto Clock-Out', string='Enable Auto Clock-Out',
config_parameter='fusion_clock.enable_auto_clockout',
default=True, default=True,
help="Automatically clock out employees who forget. Triggers after shift end time plus grace period, or after max shift hours.", help="Automatically clock out employees who forget — closes an attendance "
) "left open past the Max Shift Length safety cap (overtime up to the cap "
fclk_grace_period_minutes = fields.Float( "is never cut off).",
string='Grace Period (min)',
config_parameter='fusion_clock.grace_period_minutes',
default=15.0,
help="Minutes allowed after scheduled end before auto clock-out.",
) )
fclk_max_shift_hours = fields.Float( fclk_max_shift_hours = fields.Float(
string='Max Shift Length (hours)', string='Max Shift Length (hours)',
@@ -64,7 +58,6 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_enable_penalties = fields.Boolean( fclk_enable_penalties = fields.Boolean(
string='Enable Penalty Tracking', string='Enable Penalty Tracking',
config_parameter='fusion_clock.enable_penalties',
default=True, default=True,
help="Deduct minutes from worked hours when employees clock in late or clock out early.", help="Deduct minutes from worked hours when employees clock in late or clock out early.",
) )
@@ -82,9 +75,8 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_enable_overtime = fields.Boolean( fclk_enable_overtime = fields.Boolean(
string='Enable Overtime Tracking', string='Enable Overtime Tracking',
config_parameter='fusion_clock.enable_overtime',
default=True, default=True,
help="Calculate and track overtime when net hours exceed the daily or weekly threshold.", help="Calculate and track overtime when net hours exceed the daily threshold.",
) )
fclk_daily_overtime_threshold = fields.Float( fclk_daily_overtime_threshold = fields.Float(
string='Daily OT Threshold (hours)', string='Daily OT Threshold (hours)',
@@ -92,12 +84,6 @@ class ResConfigSettings(models.TransientModel):
default=8.0, default=8.0,
help="Net hours beyond this threshold count as daily overtime.", help="Net hours beyond this threshold count as daily overtime.",
) )
fclk_weekly_overtime_threshold = fields.Float(
string='Weekly OT Threshold (hours)',
config_parameter='fusion_clock.weekly_overtime_threshold',
default=40.0,
help="Net hours beyond this threshold count as weekly overtime.",
)
# ── Notifications ────────────────────────────────────────────────── # ── Notifications ──────────────────────────────────────────────────
fclk_office_user_id = fields.Many2one( fclk_office_user_id = fields.Many2one(
@@ -119,7 +105,6 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_enable_employee_notifications = fields.Boolean( fclk_enable_employee_notifications = fields.Boolean(
string='Enable Employee Notifications', string='Enable Employee Notifications',
config_parameter='fusion_clock.enable_employee_notifications',
default=True, default=True,
help="Send clock-in/out reminders to employees.", help="Send clock-in/out reminders to employees.",
) )
@@ -137,7 +122,6 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_send_weekly_summary = fields.Boolean( fclk_send_weekly_summary = fields.Boolean(
string='Send Weekly Summary', string='Send Weekly Summary',
config_parameter='fusion_clock.send_weekly_summary',
default=True, default=True,
help="Send weekly attendance summary to each employee on Monday.", help="Send weekly attendance summary to each employee on Monday.",
) )
@@ -145,13 +129,12 @@ class ResConfigSettings(models.TransientModel):
# ── Location & Verification ──────────────────────────────────────── # ── Location & Verification ────────────────────────────────────────
fclk_enable_ip_fallback = fields.Boolean( fclk_enable_ip_fallback = fields.Boolean(
string='Enable IP Fallback', string='Enable IP Fallback',
config_parameter='fusion_clock.enable_ip_fallback', default=True,
default=False, help="Allow IP-whitelist location verification when GPS is unavailable "
help="Allow IP-based location verification when GPS is unavailable.", "or outside all geofences.",
) )
fclk_enable_photo_verification = fields.Boolean( fclk_enable_photo_verification = fields.Boolean(
string='Enable Photo Verification', string='Enable Photo Verification',
config_parameter='fusion_clock.enable_photo_verification',
default=False, default=False,
help="Global toggle for selfie verification on clock-in (per-location control).", help="Global toggle for selfie verification on clock-in (per-location control).",
) )
@@ -163,25 +146,21 @@ class ResConfigSettings(models.TransientModel):
# ── Kiosk & Portal ───────────────────────────────────────────────── # ── Kiosk & Portal ─────────────────────────────────────────────────
fclk_enable_kiosk = fields.Boolean( fclk_enable_kiosk = fields.Boolean(
string='Enable Kiosk Mode', string='Enable Kiosk Mode',
config_parameter='fusion_clock.enable_kiosk',
default=False, default=False,
help="Allow employees to clock in/out from a shared device using their PIN code.", help="Allow employees to clock in/out from a shared device using their PIN code.",
) )
fclk_kiosk_pin_required = fields.Boolean( fclk_kiosk_pin_required = fields.Boolean(
string='Require PIN for Kiosk', string='Require PIN for Kiosk',
config_parameter='fusion_clock.kiosk_pin_required',
default=True, default=True,
help="Require employees to enter a PIN when using kiosk mode.", help="Require employees to enter a PIN when using kiosk mode.",
) )
fclk_enable_correction_requests = fields.Boolean( fclk_enable_correction_requests = fields.Boolean(
string='Enable Correction Requests', string='Enable Correction Requests',
config_parameter='fusion_clock.enable_correction_requests',
default=True, default=True,
help="Allow employees to request timesheet corrections from the portal.", help="Allow employees to request timesheet corrections from the portal.",
) )
fclk_enable_sounds = fields.Boolean( fclk_enable_sounds = fields.Boolean(
string='Enable Clock Sounds', string='Enable Clock Sounds',
config_parameter='fusion_clock.enable_sounds',
default=True, default=True,
help="Play audio confirmation sounds when employees clock in or out.", help="Play audio confirmation sounds when employees clock in or out.",
) )
@@ -211,13 +190,11 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_auto_generate_reports = fields.Boolean( fclk_auto_generate_reports = fields.Boolean(
string='Auto-Generate Reports', string='Auto-Generate Reports',
config_parameter='fusion_clock.auto_generate_reports',
default=True, default=True,
help="Automatically create attendance reports at the end of each pay period.", help="Automatically create attendance reports at the end of each pay period.",
) )
fclk_send_employee_reports = fields.Boolean( fclk_send_employee_reports = fields.Boolean(
string='Send Employee Copies', string='Send Employee Copies',
config_parameter='fusion_clock.send_employee_reports',
default=True, default=True,
help="Send each employee a copy of their individual attendance report.", help="Send each employee a copy of their individual attendance report.",
) )
@@ -243,13 +220,11 @@ class ResConfigSettings(models.TransientModel):
# ── NFC Clock Kiosk ──────────────────────────────────────────────── # ── NFC Clock Kiosk ────────────────────────────────────────────────
fclk_enable_nfc_kiosk = fields.Boolean( fclk_enable_nfc_kiosk = fields.Boolean(
string='Enable NFC Clock Kiosk', string='Enable NFC Clock Kiosk',
config_parameter='fusion_clock.enable_nfc_kiosk',
default=False, default=False,
help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.", help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.",
) )
fclk_nfc_photo_required = fields.Boolean( fclk_nfc_photo_required = fields.Boolean(
string='Require Photo on Tap', string='Require Photo on Tap',
config_parameter='fusion_clock.nfc_photo_required',
default=True, default=True,
help="If enabled, the kiosk rejects taps when the front camera is unavailable. " help="If enabled, the kiosk rejects taps when the front camera is unavailable. "
"Recommended for buddy-punch deterrence.", "Recommended for buddy-punch deterrence.",
@@ -262,7 +237,6 @@ class ResConfigSettings(models.TransientModel):
) )
fclk_nfc_kiosk_debug = fields.Boolean( fclk_nfc_kiosk_debug = fields.Boolean(
string='Debug Mode (overlay + mock-tap)', string='Debug Mode (overlay + mock-tap)',
config_parameter='fusion_clock.nfc_kiosk_debug',
default=False, default=False,
help="Enables two dev/troubleshooting features on the NFC kiosk page: " help="Enables two dev/troubleshooting features on the NFC kiosk page: "
"(1) a green-text debug overlay at the top of the screen logging every NFC and tap event in real time, " "(1) a green-text debug overlay at the top of the screen logging every NFC and tap event in real time, "
@@ -286,9 +260,35 @@ class ResConfigSettings(models.TransientModel):
"Set to 0 to disable the auto-wipe.", "Set to 0 to disable the auto-wipe.",
) )
# Boolean settings persisted explicitly (NOT via config_parameter): Odoo
# deletes a config param when you write a falsy value, so a config_parameter
# Boolean can never be turned OFF (the row vanishes and get_param returns the
# default). Storing 'True'/'False' strings ourselves makes the toggles work.
_FCLK_BOOL_PARAMS = [
('fclk_auto_deduct_break', 'fusion_clock.auto_deduct_break', True),
('fclk_enable_auto_clockout', 'fusion_clock.enable_auto_clockout', True),
('fclk_enable_penalties', 'fusion_clock.enable_penalties', True),
('fclk_enable_overtime', 'fusion_clock.enable_overtime', True),
('fclk_enable_employee_notifications', 'fusion_clock.enable_employee_notifications', True),
('fclk_send_weekly_summary', 'fusion_clock.send_weekly_summary', True),
('fclk_enable_ip_fallback', 'fusion_clock.enable_ip_fallback', True),
('fclk_enable_photo_verification', 'fusion_clock.enable_photo_verification', False),
('fclk_enable_kiosk', 'fusion_clock.enable_kiosk', False),
('fclk_kiosk_pin_required', 'fusion_clock.kiosk_pin_required', True),
('fclk_enable_correction_requests', 'fusion_clock.enable_correction_requests', True),
('fclk_enable_sounds', 'fusion_clock.enable_sounds', True),
('fclk_auto_generate_reports', 'fusion_clock.auto_generate_reports', True),
('fclk_send_employee_reports', 'fusion_clock.send_employee_reports', True),
('fclk_enable_nfc_kiosk', 'fusion_clock.enable_nfc_kiosk', False),
('fclk_nfc_photo_required', 'fusion_clock.nfc_photo_required', True),
('fclk_nfc_kiosk_debug', 'fusion_clock.nfc_kiosk_debug', False),
]
def set_values(self): def set_values(self):
super().set_values() super().set_values()
ICP = self.env['ir.config_parameter'].sudo() ICP = self.env['ir.config_parameter'].sudo()
for fname, key, _default in self._FCLK_BOOL_PARAMS:
ICP.set_param(key, 'True' if self[fname] else 'False')
if self.fclk_office_user_id: if self.fclk_office_user_id:
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id)) ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
else: else:
@@ -308,6 +308,8 @@ class ResConfigSettings(models.TransientModel):
def get_values(self): def get_values(self):
res = super().get_values() res = super().get_values()
ICP = self.env['ir.config_parameter'].sudo() ICP = self.env['ir.config_parameter'].sudo()
for fname, key, default in self._FCLK_BOOL_PARAMS:
res[fname] = ICP.get_param(key, 'True' if default else 'False') == 'True'
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id: if office_user_id:
res['fclk_office_user_id'] = office_user_id res['fclk_office_user_id'] = office_user_id

View File

@@ -7,3 +7,4 @@ from . import test_photo_retention
from . import test_schedule_driven from . import test_schedule_driven
from . import test_dashboard from . import test_dashboard
from . import test_pay_period from . import test_pay_period
from . import test_settings

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from odoo.tests import tagged, TransactionCase
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestFusionClockSettings(TransactionCase):
"""The fusion_clock Boolean settings are persisted explicitly as
'True'/'False' so they can actually be turned OFF (a config_parameter
Boolean can't — Odoo deletes the row on a falsy value)."""
def _save(self, vals):
self.env['res.config.settings'].create(vals).set_values()
def test_boolean_toggle_off_persists(self):
ICP = self.env['ir.config_parameter'].sudo()
self._save({'fclk_enable_overtime': False})
self.assertEqual(ICP.get_param('fusion_clock.enable_overtime'), 'False')
# reopening Settings shows it OFF
self.assertFalse(self.env['res.config.settings'].get_values()['fclk_enable_overtime'])
def test_boolean_toggle_back_on_persists(self):
ICP = self.env['ir.config_parameter'].sudo()
self._save({'fclk_enable_overtime': False})
self._save({'fclk_enable_overtime': True})
self.assertEqual(ICP.get_param('fusion_clock.enable_overtime'), 'True')
self.assertTrue(self.env['res.config.settings'].get_values()['fclk_enable_overtime'])
def test_default_on_boolean_when_param_absent(self):
ICP = self.env['ir.config_parameter'].sudo()
# set_param with a falsy value deletes the row → simulates "never set"
ICP.set_param('fusion_clock.enable_ip_fallback', False)
self.assertTrue(self.env['res.config.settings'].get_values()['fclk_enable_ip_fallback'])
def test_default_off_boolean_when_param_absent(self):
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param('fusion_clock.enable_kiosk', False)
self.assertFalse(self.env['res.config.settings'].get_values()['fclk_enable_kiosk'])
def test_dead_settings_removed(self):
fields = self.env['res.config.settings']._fields
self.assertNotIn('fclk_grace_period_minutes', fields)
self.assertNotIn('fclk_weekly_overtime_threshold', fields)

View File

@@ -52,10 +52,6 @@
<field name="fclk_enable_auto_clockout"/> <field name="fclk_enable_auto_clockout"/>
<div class="content-group" invisible="not fclk_enable_auto_clockout"> <div class="content-group" invisible="not fclk_enable_auto_clockout">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_grace_period_minutes" string="Grace (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_grace_period_minutes"/>
</div>
<div class="row mt8">
<label for="fclk_max_shift_hours" string="Max Shift" class="col-lg-5 o_light_label"/> <label for="fclk_max_shift_hours" string="Max Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_max_shift_hours" widget="float_time"/> <field name="fclk_max_shift_hours" widget="float_time"/>
</div> </div>
@@ -83,10 +79,6 @@
<label for="fclk_daily_overtime_threshold" string="Daily Limit" class="col-lg-5 o_light_label"/> <label for="fclk_daily_overtime_threshold" string="Daily Limit" class="col-lg-5 o_light_label"/>
<field name="fclk_daily_overtime_threshold" widget="float_time"/> <field name="fclk_daily_overtime_threshold" widget="float_time"/>
</div> </div>
<div class="row mt8">
<label for="fclk_weekly_overtime_threshold" string="Weekly Limit" class="col-lg-5 o_light_label"/>
<field name="fclk_weekly_overtime_threshold" widget="float_time"/>
</div>
</div> </div>
</setting> </setting>
</block> </block>