Files
Odoo-Modules/fusion_clock/models/res_config_settings.py
gsinghpal 2ab59bccde feat(fusion_clock): default clock-in/out times as 12-hour AM/PM dropdowns
People aren't good with 24h. Default Clock-In/Out are now AM/PM dropdowns (15-min
grid) instead of 24h float_time inputs. Stored value stays the float-string
(e.g. '9.0'), so all downstream float(get_param(...)) reads are unchanged;
persisted manually with get-snap for any off-grid value. Bump 19.0.4.0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:47:12 -04:00

364 lines
17 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# ── Work Schedule ──────────────────────────────────────────────────
# 12-hour AM/PM dropdowns (people aren't good with 24h). The selection VALUE
# is the float-as-string the backend stores (e.g. '9.0', '17.5'), so all
# downstream float(get_param(...)) reads are unchanged. Persisted manually in
# get_values/set_values (a 15-min grid; get snaps any off-grid stored value).
fclk_default_clock_in_time = fields.Selection(
selection='_fclk_time_selection',
string='Default Clock-In Time',
default='9.0',
help="Default scheduled clock-in time, used when no shift is assigned.",
)
fclk_default_clock_out_time = fields.Selection(
selection='_fclk_time_selection',
string='Default Clock-Out Time',
default='17.0',
help="Default scheduled clock-out time, used when no shift is assigned.",
)
fclk_auto_deduct_break = fields.Boolean(
string='Auto-Deduct Break',
default=True,
help="Automatically deduct break from worked hours on clock-out.",
)
fclk_default_break_minutes = fields.Float(
string='Default Break Duration (min)',
config_parameter='fusion_clock.default_break_minutes',
default=30.0,
help="Default unpaid break duration in minutes.",
)
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
default=4.0,
help="Only deduct break if shift is longer than this many hours.",
)
# ── Attendance Rules ───────────────────────────────────────────────
fclk_enable_auto_clockout = fields.Boolean(
string='Enable Auto Clock-Out',
default=True,
help="Automatically clock out employees who forget — closes an attendance "
"left open past the Max Shift Length safety cap (overtime up to the cap "
"is never cut off).",
)
fclk_max_shift_hours = fields.Float(
string='Max Shift Length (hours)',
config_parameter='fusion_clock.max_shift_hours',
default=16.0,
help="Safety-net cap: an attendance left open longer than this is "
"auto-clocked-out (assumed forgot-to-clock-out). Overtime up to this "
"cap is never cut off, so set it comfortably above your longest real "
"shift + overtime.",
)
fclk_enable_penalties = fields.Boolean(
string='Enable Penalty Tracking',
default=True,
help="Deduct minutes from worked hours when employees clock in late or clock out early.",
)
fclk_penalty_grace_minutes = fields.Float(
string='Penalty Grace (min)',
config_parameter='fusion_clock.penalty_grace_minutes',
default=5.0,
help="Minutes of grace before a late/early penalty is recorded.",
)
fclk_penalty_deduction_minutes = fields.Float(
string='Penalty Deduction (min)',
config_parameter='fusion_clock.penalty_deduction_minutes',
default=15.0,
help="Minutes deducted from worked hours per penalty occurrence.",
)
fclk_enable_overtime = fields.Boolean(
string='Enable Overtime Tracking',
default=True,
help="Calculate and track overtime when net hours exceed the daily threshold.",
)
fclk_daily_overtime_threshold = fields.Float(
string='Daily OT Threshold (hours)',
config_parameter='fusion_clock.daily_overtime_threshold',
default=8.0,
help="Net hours beyond this threshold count as daily overtime.",
)
# ── Notifications ──────────────────────────────────────────────────
fclk_office_user_id = fields.Many2one(
'res.users',
string='Office User',
help="User who receives activity notifications for attendance issues.",
)
fclk_very_late_threshold_minutes = fields.Float(
string='Very Late Threshold (min)',
config_parameter='fusion_clock.very_late_threshold_minutes',
default=15.0,
help="Minutes late before an activity is scheduled for the office user.",
)
fclk_max_monthly_absences = fields.Integer(
string='Max Monthly Absences',
config_parameter='fusion_clock.max_monthly_absences',
default=3,
help="Alert office user when an employee reaches this many absences in a month.",
)
fclk_enable_employee_notifications = fields.Boolean(
string='Enable Employee Notifications',
default=True,
help="Send clock-in/out reminders to employees.",
)
fclk_reminder_before_shift_minutes = fields.Float(
string='Remind After Shift Start (min)',
config_parameter='fusion_clock.reminder_before_shift_minutes',
default=30.0,
help="Send reminder if employee hasn't clocked in this many minutes after shift start.",
)
fclk_reminder_before_end_minutes = fields.Float(
string='Remind Before Shift End (min)',
config_parameter='fusion_clock.reminder_before_end_minutes',
default=15.0,
help="Send clock-out reminder this many minutes before shift end.",
)
fclk_send_weekly_summary = fields.Boolean(
string='Send Weekly Summary',
default=True,
help="Send weekly attendance summary to each employee on Monday.",
)
# ── Location & Verification ────────────────────────────────────────
fclk_enable_ip_fallback = fields.Boolean(
string='Enable IP Fallback',
default=True,
help="Allow IP-whitelist location verification when GPS is unavailable "
"or outside all geofences.",
)
fclk_enable_photo_verification = fields.Boolean(
string='Enable Photo Verification',
default=False,
help="Master switch for selfie capture. When OFF, no photos are taken on "
"any clock-in/out (portal or NFC kiosk). When ON, the per-location and "
"NFC-kiosk photo settings apply.",
)
fclk_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_clock.google_maps_api_key',
)
# ── Kiosk & Portal ─────────────────────────────────────────────────
fclk_enable_kiosk = fields.Boolean(
string='Enable Kiosk Mode',
default=False,
help="Allow employees to clock in/out from a shared device using their PIN code.",
)
fclk_enable_correction_requests = fields.Boolean(
string='Enable Correction Requests',
default=True,
help="Allow employees to request timesheet corrections from the portal.",
)
fclk_enable_sounds = fields.Boolean(
string='Enable Clock Sounds',
default=True,
help="Play audio confirmation sounds when employees clock in or out.",
)
# ── Pay Period & Reports ───────────────────────────────────────────
fclk_pay_period_type = fields.Selection(
[
('weekly', 'Weekly'),
('biweekly', 'Bi-Weekly'),
('semi_monthly', 'Semi-Monthly'),
('monthly', 'Monthly'),
],
string='Pay Period',
config_parameter='fusion_clock.pay_period_type',
default='biweekly',
help="How often attendance reports are generated.",
)
# NOTE: a real Date field (date picker), but NOT a config_parameter field —
# res.config.settings Date fields don't round-trip via config_parameter in
# Odoo 19, so it is persisted manually in get_values/set_values as a
# 'YYYY-MM-DD' string under fusion_clock.pay_period_start (same pattern as
# fclk_report_recipient_user_ids).
fclk_pay_period_start = fields.Date(
string='Pay Period Anchor Date',
help="The pay-period start date. Reports and the Bi-Weekly Period "
"filter/picker count forward from this anchor.",
)
fclk_auto_generate_reports = fields.Boolean(
string='Auto-Generate Reports',
default=True,
help="Automatically create attendance reports at the end of each pay period.",
)
fclk_send_employee_reports = fields.Boolean(
string='Send Employee Copies',
default=True,
help="Send each employee a copy of their individual attendance report.",
)
fclk_report_recipient_user_ids = fields.Many2many(
'res.users',
'fclk_report_recipient_user_rel',
'config_id',
'user_id',
string='Internal Report Recipients',
help="Internal users who will receive batch reports.",
)
fclk_report_recipient_emails = fields.Char(
string='Report Recipient Emails',
config_parameter='fusion_clock.report_recipient_emails',
help="Comma-separated email addresses for batch report delivery.",
)
fclk_csv_column_mapping = fields.Char(
string='CSV Column Mapping',
config_parameter='fusion_clock.csv_column_mapping',
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
)
# ── NFC Clock Kiosk ────────────────────────────────────────────────
fclk_enable_nfc_kiosk = fields.Boolean(
string='Enable NFC Clock Kiosk',
default=False,
help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.",
)
fclk_nfc_photo_required = fields.Boolean(
string='Require Photo on Tap',
default=True,
help="If enabled, the kiosk rejects taps when the front camera is unavailable. "
"Recommended for buddy-punch deterrence.",
)
fclk_nfc_enroll_password = fields.Char(
string='Enroll Mode Password',
config_parameter='fusion_clock.nfc_enroll_password',
help="Short password the manager types on the kiosk to enter Enroll Mode. "
"Leave empty to fall back to manager-group membership only.",
)
fclk_nfc_kiosk_debug = fields.Boolean(
string='Debug Mode (overlay + mock-tap)',
default=False,
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, "
"and (2) a Ctrl+Shift+T keyboard shortcut that simulates a tap with a configurable UID. "
"Turn OFF in production — the overlay is intrusive for end users.",
)
fclk_nfc_kiosk_location_id = fields.Many2one(
related='company_id.x_fclk_nfc_kiosk_location_id',
readonly=False,
string='NFC Kiosk Location',
help="Which clock location is bound to the NFC kiosk for this company. "
"Required when the kiosk is enabled.",
)
fclk_photo_retention_days = fields.Integer(
string='Auto-Wipe Photos After (days)',
config_parameter='fusion_clock.photo_retention_days',
default=60,
help="Clock-in/out verification photos older than this many days are deleted "
"automatically by a daily cron. The attendance record, worked hours and "
"penalties are kept — only the images are removed, reclaiming storage. "
"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_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),
]
@api.model
def _fclk_time_selection(self):
"""15-minute grid of 12-hour clock times. Each option's VALUE is the
float-as-string the backend stores (e.g. '9.0', '17.5'); the LABEL is
the friendly 12-hour form (e.g. '9:00 AM')."""
opts = []
for i in range(96):
f = i * 0.25
h24 = int(f)
mm = int(round((f - h24) * 60))
ap = 'AM' if h24 < 12 else 'PM'
h12 = h24 % 12 or 12
opts.append((str(f), '%d:%02d %s' % (h12, mm, ap)))
return opts
@staticmethod
def _fclk_snap_time(value, default_float):
"""Snap a stored float-ish time to the nearest 15-min grid key string."""
try:
f = float(value)
except (ValueError, TypeError):
f = default_float
f = round(f * 4) / 4
if f < 0 or f >= 24:
f = default_float
return str(f)
def set_values(self):
super().set_values()
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')
ICP.set_param('fusion_clock.default_clock_in_time', self.fclk_default_clock_in_time or '9.0')
ICP.set_param('fusion_clock.default_clock_out_time', self.fclk_default_clock_out_time or '17.0')
if self.fclk_office_user_id:
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
else:
ICP.set_param('fusion_clock.office_user_id', '0')
if self.fclk_report_recipient_user_ids:
ICP.set_param('fusion_clock.report_recipient_user_ids',
','.join(str(uid) for uid in self.fclk_report_recipient_user_ids.ids))
else:
ICP.set_param('fusion_clock.report_recipient_user_ids', '')
if self.fclk_pay_period_start:
ICP.set_param('fusion_clock.pay_period_start',
fields.Date.to_string(self.fclk_pay_period_start))
else:
ICP.set_param('fusion_clock.pay_period_start', '')
@api.model
def get_values(self):
res = super().get_values()
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'))
if office_user_id:
res['fclk_office_user_id'] = office_user_id
user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
if user_ids_str:
try:
user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
res['fclk_report_recipient_user_ids'] = [(6, 0, user_ids)]
except (ValueError, TypeError):
pass
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
if anchor_str:
try:
# Truncate to 'YYYY-MM-DD' to tolerate any legacy datetime-ish value.
res['fclk_pay_period_start'] = fields.Date.to_date(anchor_str[:10])
except (ValueError, TypeError):
pass
res['fclk_default_clock_in_time'] = self._fclk_snap_time(
ICP.get_param('fusion_clock.default_clock_in_time', '9.0'), 9.0)
res['fclk_default_clock_out_time'] = self._fclk_snap_time(
ICP.get_param('fusion_clock.default_clock_out_time', '17.0'), 17.0)
return res