feat(fusion_clock): NFC card enrollment wizard + employee form field
Adds a tap-driven enrollment workflow so managers can pair NFC/RFID cards to employees using a USB HID reader at their desk: - New wizard model fusion.clock.nfc.enrollment.wizard with auto-focused Card UID field, employee picker, and reassignment warning if the card is already held by someone else. - Two actions: 'Enroll Card' (single) and 'Enroll & Next' (bulk). - Menu entry under Fusion Clock root, manager-gated. - Exposes x_fclk_nfc_card_uid on the Employee form Clock Settings section (next to Kiosk PIN) so it can be inspected/edited directly. - Bumps manifest to 19.0.3.1.0 for asset cache bust. Wizard reuses FusionClockNfcKiosk._normalize_uid so stored format matches what the kiosk /tap endpoint looks up later. Reassignment clears the UID from the previous holder and logs both events to the activity log under 'card_enrollment'.
This commit is contained in:
5
fusion_clock/wizard/__init__.py
Normal file
5
fusion_clock/wizard/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import clock_nfc_enrollment_wizard
|
||||
61
fusion_clock/wizard/clock_nfc_enrollment_views.xml
Normal file
61
fusion_clock/wizard/clock_nfc_enrollment_views.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Enrollment Wizard Form -->
|
||||
<record id="view_fusion_clock_nfc_enrollment_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.nfc.enrollment.wizard.form</field>
|
||||
<field name="model">fusion.clock.nfc.enrollment.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Enroll NFC Card">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>How to enroll:</strong> Tap an NFC card on the USB reader connected
|
||||
to this computer. The reader will type the UID into the field below.
|
||||
Then select the employee and click <b>Enroll Card</b> (or
|
||||
<b>Enroll & Next</b> to keep enrolling).
|
||||
</div>
|
||||
<group>
|
||||
<field name="card_uid"
|
||||
placeholder="Tap card on reader, or paste UID manually"/>
|
||||
<field name="normalized_uid"
|
||||
invisible="not normalized_uid"
|
||||
readonly="1"/>
|
||||
<field name="warning_message"
|
||||
invisible="not warning_message"
|
||||
readonly="1"
|
||||
nolabel="1"
|
||||
colspan="2"/>
|
||||
<field name="existing_employee_id" invisible="1"/>
|
||||
<field name="employee_id"
|
||||
options="{'no_create': True, 'no_create_edit': True}"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_enroll"
|
||||
string="Enroll Card"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="not normalized_uid or not employee_id"/>
|
||||
<button name="action_enroll_and_next"
|
||||
string="Enroll & Next"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="not normalized_uid or not employee_id"/>
|
||||
<button special="cancel"
|
||||
string="Cancel"
|
||||
class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action to open the wizard -->
|
||||
<record id="action_fusion_clock_nfc_enrollment_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Enroll NFC Card</field>
|
||||
<field name="res_model">fusion.clock.nfc.enrollment.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fusion_clock_nfc_enrollment_wizard_form"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
156
fusion_clock/wizard/clock_nfc_enrollment_wizard.py
Normal file
156
fusion_clock/wizard/clock_nfc_enrollment_wizard.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from ..controllers.clock_nfc_kiosk import FusionClockNfcKiosk
|
||||
|
||||
|
||||
class FusionClockNfcEnrollmentWizard(models.TransientModel):
|
||||
"""Tap-driven NFC card enrollment for the fusion_clock kiosk.
|
||||
|
||||
Flow:
|
||||
1. Manager opens this wizard.
|
||||
2. Card UID field is auto-focused.
|
||||
3. Manager taps an NFC card on the USB HID reader; the reader types the
|
||||
UID into the focused field and hits Enter, which advances focus to
|
||||
the Employee picker.
|
||||
4. Manager selects the employee.
|
||||
5. Manager clicks "Enroll Card" (closes wizard) or "Enroll & Next"
|
||||
(resets wizard for the next card).
|
||||
|
||||
The wizard reuses ``FusionClockNfcKiosk._normalize_uid`` so the stored
|
||||
format matches whatever the kiosk's ``/tap`` endpoint will look up later.
|
||||
"""
|
||||
|
||||
_name = 'fusion.clock.nfc.enrollment.wizard'
|
||||
_description = 'NFC Card Enrollment Wizard'
|
||||
|
||||
card_uid = fields.Char(
|
||||
string='Card UID',
|
||||
required=True,
|
||||
help='Tap an NFC card on the USB reader. Most HID readers type the '
|
||||
'UID followed by Enter, which advances focus to the Employee '
|
||||
'field below. You can also paste a UID manually.',
|
||||
)
|
||||
normalized_uid = fields.Char(
|
||||
string='Normalized UID',
|
||||
compute='_compute_normalized_uid',
|
||||
store=False,
|
||||
help='UID after format normalization (uppercase, colon-separated hex). '
|
||||
'This is what gets stored on the employee record.',
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
domain=[('x_fclk_enable_clock', '=', True)],
|
||||
)
|
||||
existing_employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Currently Assigned To',
|
||||
compute='_compute_existing_employee',
|
||||
store=False,
|
||||
)
|
||||
warning_message = fields.Char(
|
||||
compute='_compute_existing_employee',
|
||||
store=False,
|
||||
)
|
||||
|
||||
@api.depends('card_uid')
|
||||
def _compute_normalized_uid(self):
|
||||
for wiz in self:
|
||||
wiz.normalized_uid = FusionClockNfcKiosk._normalize_uid(wiz.card_uid) or ''
|
||||
|
||||
@api.depends('normalized_uid', 'employee_id')
|
||||
def _compute_existing_employee(self):
|
||||
for wiz in self:
|
||||
if not wiz.normalized_uid:
|
||||
wiz.existing_employee_id = False
|
||||
wiz.warning_message = ''
|
||||
continue
|
||||
existing = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_nfc_card_uid', '=', wiz.normalized_uid),
|
||||
], limit=1)
|
||||
if existing and existing != wiz.employee_id:
|
||||
wiz.existing_employee_id = existing
|
||||
wiz.warning_message = _(
|
||||
"⚠ This card is currently assigned to %(name)s. "
|
||||
"Enrolling will reassign it.",
|
||||
name=existing.name,
|
||||
)
|
||||
else:
|
||||
wiz.existing_employee_id = False
|
||||
wiz.warning_message = ''
|
||||
|
||||
def action_enroll(self):
|
||||
"""Enroll the card to the selected employee and close the wizard."""
|
||||
self.ensure_one()
|
||||
self._do_enroll()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Card Enrolled'),
|
||||
'message': _("%(uid)s assigned to %(name)s.",
|
||||
uid=self.normalized_uid, name=self.employee_id.name),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
def action_enroll_and_next(self):
|
||||
"""Enroll the card, then reopen the wizard cleared for the next card."""
|
||||
self.ensure_one()
|
||||
self._do_enroll()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Enroll NFC Card'),
|
||||
'res_model': self._name,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {}, # explicitly empty so no defaults persist
|
||||
}
|
||||
|
||||
def _do_enroll(self):
|
||||
"""Validate, then write the normalized UID to the employee record.
|
||||
|
||||
Reassigns the card from any existing holder. Logs the event in the
|
||||
activity log for audit.
|
||||
"""
|
||||
if not self.normalized_uid:
|
||||
raise ValidationError(_(
|
||||
"Card UID is empty or not valid hex. Tap the card again on "
|
||||
"the reader."
|
||||
))
|
||||
if not self.employee_id:
|
||||
raise ValidationError(_("Please select an employee."))
|
||||
|
||||
# Reassignment: clear the UID from whoever currently holds it
|
||||
if self.existing_employee_id and self.existing_employee_id != self.employee_id:
|
||||
self.existing_employee_id.sudo().x_fclk_nfc_card_uid = False
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.existing_employee_id.id,
|
||||
'log_type': 'card_enrollment',
|
||||
'description': _(
|
||||
"NFC card %(uid)s unassigned by %(user)s (reassigning to %(new)s)",
|
||||
uid=self.normalized_uid,
|
||||
user=self.env.user.name,
|
||||
new=self.employee_id.name,
|
||||
),
|
||||
'source': 'nfc_kiosk',
|
||||
})
|
||||
|
||||
self.employee_id.sudo().x_fclk_nfc_card_uid = self.normalized_uid
|
||||
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'log_type': 'card_enrollment',
|
||||
'description': _(
|
||||
"NFC card %(uid)s enrolled by %(user)s",
|
||||
uid=self.normalized_uid, user=self.env.user.name,
|
||||
),
|
||||
'source': 'nfc_kiosk',
|
||||
})
|
||||
Reference in New Issue
Block a user