104 KiB
NFC Clock Kiosk Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Web NFC tap-to-clock kiosk to fusion_clock so employees can clock in/out with an NFC card on a wall-mounted Android tablet, with silent photo verification on every tap.
Architecture: New /fusion_clock/kiosk/nfc route + dedicated QWeb template + JS state machine using the Web NFC NDEFReader API and getUserMedia for the camera. The Python controller calls into the existing FusionClockAPI helpers (_attendance_action_change, _log_activity, _check_and_create_penalty, _apply_break_deduction) so all geofencing/penalty/activity logic is shared with the existing PIN kiosk. Single station per company; location is resolved from a new field on res.company.
Tech stack: Odoo 19 (Python 3.12, OWL/QWeb), Web NFC API (NDEFReader, Chrome ≥89 on Android), getUserMedia Camera API, SCSS (per-bundle dark/light branching), Odoo HttpCase tests.
Reference spec: docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Dev environment:
- Odoo container:
odoo-modsdev-app - Database:
modsdev - Local URL: http://localhost:8069
- Module reload:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init - Run tests:
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -i fusion_clock
File Structure
New files:
fusion_clock/models/res_company.py—res.companyextension with NFC kiosk location fieldfusion_clock/controllers/clock_nfc_kiosk.py— controller with three endpointsfusion_clock/views/kiosk_nfc_templates.xml— QWeb template for the kiosk pagefusion_clock/static/src/scss/nfc_kiosk.scss— always-dark, high-contrast stylingfusion_clock/static/src/js/fusion_clock_nfc_kiosk.js— Web NFC + camera + state machinefusion_clock/tests/__init__.py— test package init (first time)fusion_clock/tests/test_clock_nfc_kiosk.py— unit tests for the controllerfusion_clock/tests/test_nfc_models.py— unit tests for the model fields
Modified files:
fusion_clock/__manifest__.py— bump version, register new files in data and assetsfusion_clock/__init__.py— import the new tests packagefusion_clock/models/__init__.py— importres_companyfusion_clock/models/hr_employee.py— addx_fclk_nfc_card_uidfieldfusion_clock/models/hr_attendance.py— add photo fields, extendx_fclk_clock_sourceselectionfusion_clock/models/res_config_settings.py— add the four new ir.config_parameter shortcuts and the per-company location fieldfusion_clock/views/res_config_settings_views.xml— add NFC Clock Kiosk blockfusion_clock/data/ir_config_parameter_data.xml— add four new defaultsfusion_clock/controllers/__init__.py— importclock_nfc_kiosk
Each file has one responsibility:
- Model files extend their respective Odoo models with NFC-specific fields only
- The controller is single-purpose (NFC kiosk endpoints)
- The template, SCSS, and JS form a coherent frontend triple — all named
nfc_kioskfor grep discoverability - Tests are split:
test_nfc_models.py(data layer) vstest_clock_nfc_kiosk.py(HTTP/controller)
Task 1: Add x_fclk_nfc_card_uid field to hr.employee
Files:
-
Create:
fusion_clock/tests/__init__.py -
Create:
fusion_clock/tests/test_nfc_models.py -
Modify:
fusion_clock/models/hr_employee.py(add field after the existingx_fclk_kiosk_pinfield, around line 48) -
Modify:
fusion_clock/__init__.py(addfrom . import testsif not present — actually for Odoo, tests are auto-discovered viatests/__init__.py; no module-level import needed) -
Step 1: Create the tests package init
Create fusion_clock/tests/__init__.py:
# -*- coding: utf-8 -*-
from . import test_nfc_models
- Step 2: Write the failing test for the card UID field
Create fusion_clock/tests/test_nfc_models.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import ValidationError
from psycopg2 import IntegrityError
from odoo.tools.misc import mute_logger
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcModels(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.alice = cls.Employee.create({'name': 'Alice NFC', 'x_fclk_enable_clock': True})
cls.bob = cls.Employee.create({'name': 'Bob NFC', 'x_fclk_enable_clock': True})
def test_card_uid_is_writable(self):
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
def test_card_uid_is_unique_when_set(self):
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self.bob.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
self.bob.flush_recordset(['x_fclk_nfc_card_uid'])
def test_card_uid_can_be_null_for_multiple_employees(self):
self.alice.x_fclk_nfc_card_uid = False
self.bob.x_fclk_nfc_card_uid = False
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
- Step 3: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -i fusion_clock 2>&1 | tail -40
Expected: errors about x_fclk_nfc_card_uid not being a valid field on hr.employee.
- Step 4: Add the field to hr.employee
In fusion_clock/models/hr_employee.py, insert after the existing x_fclk_kiosk_pin field (around line 48):
# NFC card (kiosk identification)
x_fclk_nfc_card_uid = fields.Char(
string='NFC Card UID',
index=True,
copy=False,
groups="fusion_clock.group_fusion_clock_manager",
help="Hex UID of the NFC card assigned to this employee. "
"Format: uppercase, colon-separated, e.g. 04:A2:B5:62:C1:80. "
"Same card the employee uses for door access.",
)
_sql_constraints = [
(
'fclk_nfc_card_uid_unique',
'UNIQUE(x_fclk_nfc_card_uid)',
'This NFC card is already assigned to another employee.',
),
]
Note: if
_sql_constraintsalready exists on the class, merge the tuple into the existing list rather than redefining it. Search the file for_sql_constraintsbefore adding.
- Step 5: Run the test to confirm it passes
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: 0 failed, 0 error(s) for the three test methods.
- Step 6: Commit
git add fusion_clock/models/hr_employee.py fusion_clock/tests/__init__.py fusion_clock/tests/test_nfc_models.py
git commit -m "feat(fusion_clock): add x_fclk_nfc_card_uid to hr.employee"
Task 2: Add photo fields, 'nfc_kiosk' source, and activity-log selections
Files:
-
Modify:
fusion_clock/models/hr_attendance.py(extendx_fclk_clock_sourceselection and add two photo fields after the existingx_fclk_out_distanceat ~line 149) -
Modify:
fusion_clock/models/clock_activity_log.py(extendlog_typeandsourceselections to support NFC kiosk events) -
Modify:
fusion_clock/tests/test_nfc_models.py(add a new test class) -
Step 1: Write the failing test
Append to fusion_clock/tests/test_nfc_models.py:
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcAttendanceFields(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.employee = cls.env['hr.employee'].create({
'name': 'NFC Test Employee',
'x_fclk_enable_clock': True,
})
def test_clock_source_includes_nfc_kiosk(self):
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': '2026-05-13 08:00:00',
'x_fclk_clock_source': 'nfc_kiosk',
})
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
def test_photo_fields_accept_binary(self):
# 1x1 transparent PNG as base64
png_b64 = (
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
b'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
)
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': '2026-05-13 08:00:00',
'x_fclk_check_in_photo': png_b64,
})
self.assertTrue(attendance.x_fclk_check_in_photo)
def test_activity_log_accepts_new_selections(self):
log = self.env['fusion.clock.activity.log'].create({
'employee_id': self.employee.id,
'log_type': 'card_enrollment',
'source': 'nfc_kiosk',
'description': 'Test enrollment log',
})
self.assertEqual(log.log_type, 'card_enrollment')
self.assertEqual(log.source, 'nfc_kiosk')
log2 = self.env['fusion.clock.activity.log'].create({
'employee_id': self.employee.id,
'log_type': 'unknown_card_tap',
'source': 'nfc_kiosk',
'description': 'Test unknown card log',
})
self.assertEqual(log2.log_type, 'unknown_card_tap')
- Step 2: Update tests/init.py imports if needed
Tests are in the same file, no additional import needed.
- Step 3: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: nfc_kiosk not a valid Selection value; x_fclk_check_in_photo not a valid field.
- Step 4: Extend the clock_source selection and add photo fields
In fusion_clock/models/hr_attendance.py, replace the existing x_fclk_clock_source field (lines 126–139) with:
x_fclk_clock_source = fields.Selection(
[
('portal', 'Portal'),
('portal_fab', 'Portal FAB'),
('systray', 'Systray'),
('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'),
('nfc_kiosk', 'NFC Kiosk'),
('manual', 'Manual'),
('auto', 'Auto Clock-Out'),
],
string='Clock Source',
tracking=True,
help="How this attendance was recorded.",
)
Then, after the existing x_fclk_out_distance field (~line 149), add:
x_fclk_check_in_photo = fields.Binary(
string='Check-In Photo',
attachment=True,
help="Front-camera photo captured at NFC kiosk clock-in.",
)
x_fclk_check_out_photo = fields.Binary(
string='Check-Out Photo',
attachment=True,
help="Front-camera photo captured at NFC kiosk clock-out.",
)
- Step 5: Extend the activity log selections
In fusion_clock/models/clock_activity_log.py, locate the log_type Selection field (~line 21) and ADD these two entries to the list (anywhere is fine; add at the end of the existing tuples):
('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'),
Then locate the source Selection field (~line 67) and ADD this entry:
('nfc_kiosk', 'NFC Kiosk'),
The two updated fields should look like:
log_type = fields.Selection(
[
('clock_in', 'Clock In'),
('clock_out', 'Clock Out'),
('late_clock_in', 'Late Clock-In'),
('early_clock_out', 'Early Clock-Out'),
('outside_geofence', 'Outside Geofence'),
('auto_clock_out', 'Auto Clock-Out'),
('missed_clock_out', 'Missed Clock-Out'),
('absent', 'Absent'),
('leave_request', 'Leave Request'),
('reason_provided', 'Reason Provided'),
('overtime', 'Overtime'),
('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'),
('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'),
],
...
)
source = fields.Selection(
[
('portal', 'Portal'),
('portal_fab', 'Portal FAB'),
('systray', 'Systray'),
('backend_fab', 'Backend FAB'),
('kiosk', 'Kiosk'),
('nfc_kiosk', 'NFC Kiosk'),
('system', 'System (Cron)'),
],
string='Source',
)
(Preserve any existing fields between log_type and source — only the inner tuple lists change.)
- Step 6: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: 6 tests pass (3 from Task 1 + 3 new).
- Step 7: Commit
git add fusion_clock/models/hr_attendance.py fusion_clock/models/clock_activity_log.py fusion_clock/tests/test_nfc_models.py
git commit -m "feat(fusion_clock): NFC kiosk attendance fields + activity-log selections"
Task 3: Add x_fclk_nfc_kiosk_location_id to res.company
Files:
-
Create:
fusion_clock/models/res_company.py -
Modify:
fusion_clock/models/__init__.py(addfrom . import res_company) -
Modify:
fusion_clock/tests/test_nfc_models.py(add a test class) -
Step 1: Write the failing test
Append to fusion_clock/tests/test_nfc_models.py:
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcKioskCompanyField(TransactionCase):
def test_company_has_nfc_kiosk_location(self):
company = self.env.company
location = self.env['fusion.clock.location'].create({
'name': 'Plant 1',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
company.x_fclk_nfc_kiosk_location_id = location.id
self.assertEqual(company.x_fclk_nfc_kiosk_location_id, location)
def test_company_field_defaults_to_false(self):
new_company = self.env['res.company'].create({'name': 'Test Co NFC'})
self.assertFalse(new_company.x_fclk_nfc_kiosk_location_id)
- Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: x_fclk_nfc_kiosk_location_id not a valid field on res.company.
Heads-up: the
fusion.clock.locationmodel may have additional required fields (e.g.address, IP whitelist) — ifcreate({...})errors with "Required field missing", inspectfusion_clock/models/clock_location.pyand add the missing keys to the test fixture.
- Step 3: Create the res.company extension
Create fusion_clock/models/res_company.py:
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResCompany(models.Model):
_inherit = 'res.company'
x_fclk_nfc_kiosk_location_id = fields.Many2one(
'fusion.clock.location',
string='NFC Kiosk Location',
help="Designates which fusion.clock.location is bound to the NFC kiosk "
"for this company. Required when NFC kiosk is enabled.",
)
- Step 4: Register the new model file
In fusion_clock/models/__init__.py, add:
from . import res_company
(append at the bottom, after from . import clock_correction)
- Step 5: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: 8 tests pass (6 from previous tasks + 2 new).
- Step 6: Commit
git add fusion_clock/models/res_company.py fusion_clock/models/__init__.py fusion_clock/tests/test_nfc_models.py
git commit -m "feat(fusion_clock): add NFC kiosk location to res.company"
Task 4: Add ir.config_parameter defaults
Files:
-
Modify:
fusion_clock/data/ir_config_parameter_data.xml(append to the bottom before</odoo>) -
Step 1: Add the four NFC kiosk parameters
Open fusion_clock/data/ir_config_parameter_data.xml. Find the closing </odoo> tag at the end of the file and insert the following block immediately before it:
<!-- NFC Clock Kiosk -->
<record id="config_enable_nfc_kiosk" model="ir.config_parameter">
<field name="key">fusion_clock.enable_nfc_kiosk</field>
<field name="value">False</field>
</record>
<record id="config_nfc_photo_required" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_photo_required</field>
<field name="value">True</field>
</record>
<record id="config_nfc_enroll_password" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_enroll_password</field>
<field name="value"></field>
</record>
<record id="config_nfc_kiosk_debug" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_kiosk_debug</field>
<field name="value">False</field>
</record>
- Step 2: Reload the module and confirm parameters are present
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -10
Expected: clean reload, no errors.
Then verify in odoo-shell:
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
ICP = env['ir.config_parameter'].sudo()
for k in ['fusion_clock.enable_nfc_kiosk', 'fusion_clock.nfc_photo_required', 'fusion_clock.nfc_enroll_password', 'fusion_clock.nfc_kiosk_debug']:
print(k, '=', repr(ICP.get_param(k)))
EOF
Expected output:
fusion_clock.enable_nfc_kiosk = 'False'
fusion_clock.nfc_photo_required = 'True'
fusion_clock.nfc_enroll_password = ''
fusion_clock.nfc_kiosk_debug = 'False'
- Step 3: Commit
git add fusion_clock/data/ir_config_parameter_data.xml
git commit -m "feat(fusion_clock): add NFC kiosk ir.config_parameter defaults"
Task 5: Extend res.config.settings + view
Files:
-
Modify:
fusion_clock/models/res_config_settings.py(add fourconfig_parametershortcuts and a per-companyMany2one) -
Modify:
fusion_clock/views/res_config_settings_views.xml(add an "NFC Clock Kiosk" block) -
Step 1: Add settings fields
Open fusion_clock/models/res_config_settings.py. At the bottom of the ResConfigSettings class (before the closing of the class), add:
# ── NFC Clock Kiosk ────────────────────────────────────────────────
fclk_enable_nfc_kiosk = fields.Boolean(
string='Enable NFC Clock Kiosk',
config_parameter='fusion_clock.enable_nfc_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',
config_parameter='fusion_clock.nfc_photo_required',
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='Enable Mock-Tap Debug',
config_parameter='fusion_clock.nfc_kiosk_debug',
default=False,
help="Enables a Ctrl+Shift+T keyboard shortcut on the kiosk page for "
"simulating a tap with a configurable UID. Off in production.",
)
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.",
)
- Step 2: Add the settings block to the view
Open fusion_clock/views/res_config_settings_views.xml. Find the last </block> inside the <app> element (look for the closing of an existing block near the end of the file), and add the following block immediately after it (still inside the <app> element):
<!-- ============================================================ -->
<!-- NFC Clock Kiosk -->
<!-- ============================================================ -->
<block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
<setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
<field name="fclk_enable_nfc_kiosk"/>
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
<div class="row mt16">
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_location_id"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_photo_required"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_enroll_password" password="True"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_kiosk_debug" string="Mock-Tap Debug" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_debug"/>
</div>
</div>
</setting>
</block>
- Step 3: Reload and verify in browser
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
Then in browser, navigate to http://localhost:8069/odoo/settings → Fusion Clock app → scroll to "NFC Clock Kiosk" block. Confirm:
-
The "Enable NFC Kiosk" toggle is visible
-
Toggling it on reveals the Location, Require Photo, Enroll Password, Mock-Tap Debug fields
-
The Location picker shows existing
fusion.clock.locationrecords -
Step 4: Commit
git add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml
git commit -m "feat(fusion_clock): add NFC Clock Kiosk settings block"
Task 6: Create controller scaffold + page render route
Files:
-
Create:
fusion_clock/controllers/clock_nfc_kiosk.py -
Modify:
fusion_clock/controllers/__init__.py(addfrom . import clock_nfc_kiosk) -
Create:
fusion_clock/tests/test_clock_nfc_kiosk.py -
Modify:
fusion_clock/tests/__init__.py(addfrom . import test_clock_nfc_kiosk) -
Step 1: Add the test scaffolding
Create fusion_clock/tests/test_clock_nfc_kiosk.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestNfcKioskController(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Test Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
# Create a kiosk service user in the manager group
cls.kiosk_user = cls.env['res.users'].create({
'name': 'NFC Kiosk User',
'login': 'nfc-kiosk-test',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
def test_kiosk_page_redirects_when_disabled(self):
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
self.authenticate('nfc-kiosk-test', 'kioskpass123')
response = self.url_open('/fusion_clock/kiosk/nfc', allow_redirects=False)
self.assertIn(response.status_code, (301, 302, 303))
def test_kiosk_page_renders_when_enabled(self):
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
self.authenticate('nfc-kiosk-test', 'kioskpass123')
response = self.url_open('/fusion_clock/kiosk/nfc')
self.assertEqual(response.status_code, 200)
self.assertIn('NFC Clock Kiosk', response.text)
- Step 2: Register the test in tests/init.py
Edit fusion_clock/tests/__init__.py:
# -*- coding: utf-8 -*-
from . import test_nfc_models
from . import test_clock_nfc_kiosk
- Step 3: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: 404 errors on /fusion_clock/kiosk/nfc (route not yet defined).
- Step 4: Create the controller scaffold
Create fusion_clock/controllers/clock_nfc_kiosk.py:
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockNfcKiosk(http.Controller):
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
@http.route('/fusion_clock/kiosk/nfc', type='http', auth='user', website=True)
def nfc_kiosk_page(self, **kw):
"""Render the NFC kiosk page for a wall-mounted tablet."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return request.redirect('/my')
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
return request.redirect('/my')
company = request.env.company
location = company.x_fclk_nfc_kiosk_location_id
values = {
'page_name': 'nfc_kiosk',
'company_name': company.name,
'location_name': location.name if location else 'No location configured',
'location_configured': bool(location),
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
}
return request.render('fusion_clock.nfc_kiosk_page', values)
- Step 5: Register the controller in controllers/init.py
Edit fusion_clock/controllers/__init__.py:
# -*- coding: utf-8 -*-
from . import portal_clock
from . import clock_api
from . import clock_kiosk
from . import clock_nfc_kiosk
- Step 6: Add a placeholder template so the render call has something to find
Create fusion_clock/views/kiosk_nfc_templates.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="nfc_kiosk_root" class="nfc-kiosk">
<h1>NFC Clock Kiosk</h1>
<div t-if="not location_configured" class="alert alert-warning">
No NFC kiosk location configured for <t t-esc="company_name"/>. Ask your administrator to configure one in Fusion Clock settings.
</div>
<div t-else="">
<p>Clock at: <span t-esc="location_name"/></p>
</div>
</div>
</t>
</template>
</odoo>
- Step 7: Register the template + controller in the manifest
Edit fusion_clock/__manifest__.py. In the 'data' list, add 'views/kiosk_nfc_templates.xml' immediately after the existing 'views/kiosk_templates.xml' entry. The new line should read:
'views/kiosk_nfc_templates.xml',
(The controllers don't need to be registered in the manifest — Odoo discovers them via the Python imports.)
- Step 8: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: both new tests pass.
- Step 9: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/controllers/__init__.py fusion_clock/views/kiosk_nfc_templates.xml fusion_clock/__manifest__.py fusion_clock/tests/test_clock_nfc_kiosk.py fusion_clock/tests/__init__.py
git commit -m "feat(fusion_clock): NFC kiosk page render route"
Task 7: UID normalization helper
Files:
-
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py(add a_normalize_uidstatic helper on the controller class) -
Modify:
fusion_clock/tests/test_clock_nfc_kiosk.py(add a TransactionCase test class for the helper) -
Step 1: Write the failing test
Append to fusion_clock/tests/test_clock_nfc_kiosk.py:
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_clock.controllers.clock_nfc_kiosk import FusionClockNfcKiosk
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestUidNormalization(TransactionCase):
def test_lowercase_input_uppercased(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid('04:a2:b5:62:c1:80'),
'04:A2:B5:62:C1:80',
)
def test_no_separator_input_gets_colons(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid('04A2B562C180'),
'04:A2:B5:62:C1:80',
)
def test_dash_separator_replaced(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid('04-A2-B5-62-C1-80'),
'04:A2:B5:62:C1:80',
)
def test_whitespace_stripped(self):
self.assertEqual(
FusionClockNfcKiosk._normalize_uid(' 04:A2:B5:62:C1:80 '),
'04:A2:B5:62:C1:80',
)
def test_empty_input_returns_none(self):
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(''))
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(None))
def test_invalid_chars_returns_none(self):
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('not-a-uid'))
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04:A2:ZZ:62:C1:80'))
def test_odd_length_returns_none(self):
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04A2B562C18'))
- Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: AttributeError: type object 'FusionClockNfcKiosk' has no attribute '_normalize_uid'.
- Step 3: Add the helper to the controller
In fusion_clock/controllers/clock_nfc_kiosk.py, add the following imports and method to the class. Place the method definition before nfc_kiosk_page:
import re
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
class FusionClockNfcKiosk(http.Controller):
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
@staticmethod
def _normalize_uid(uid):
"""Normalize an NFC card UID to canonical hex (uppercase, colon-separated).
Returns None if the input is empty or not valid hex.
"""
if not uid:
return None
cleaned = uid.strip().upper().replace('-', '').replace(':', '').replace(' ', '')
if not cleaned or not _UID_HEX_PATTERN.match(cleaned):
return None
if len(cleaned) % 2 != 0:
return None
# Re-insert colons every 2 chars
return ':'.join(cleaned[i:i+2] for i in range(0, len(cleaned), 2))
(Make sure import re is at the top of the file with the other imports.)
- Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: 7 normalization tests pass.
- Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC card UID normalization helper"
Task 8: Enroll endpoint
Files:
-
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py(addnfc_enrollmethod and helper for password check) -
Modify:
fusion_clock/tests/test_clock_nfc_kiosk.py(addTestEnrollEndpointclass) -
Step 1: Write the failing tests
Append to fusion_clock/tests/test_clock_nfc_kiosk.py:
import json
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestEnrollEndpoint(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234')
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Enroll Kiosk User',
'login': 'nfc-kiosk-enroll',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.alice = cls.env['hr.employee'].create({'name': 'Alice E', 'x_fclk_enable_clock': True})
cls.bob = cls.env['hr.employee'].create({'name': 'Bob E', 'x_fclk_enable_clock': True})
def _call(self, payload):
self.authenticate('nfc-kiosk-enroll', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/enroll',
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_enroll_success(self):
result = self._call({
'employee_id': self.alice.id,
'card_uid': '04:a2:b5:62:c1:80',
'enroll_password': '1234',
})
self.assertTrue(result.get('success'))
self.assertEqual(result.get('card_uid'), '04:A2:B5:62:C1:80')
self.alice.invalidate_recordset()
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
def test_enroll_wrong_password(self):
result = self._call({
'employee_id': self.alice.id,
'card_uid': '04:A2:B5:62:C1:81',
'enroll_password': 'wrong',
})
self.assertEqual(result.get('error'), 'invalid_password')
self.alice.invalidate_recordset()
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
def test_enroll_card_already_assigned(self):
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:82'
result = self._call({
'employee_id': self.bob.id,
'card_uid': '04:A2:B5:62:C1:82',
'enroll_password': '1234',
})
self.assertEqual(result.get('error'), 'card_already_assigned')
self.assertEqual(result.get('existing_employee'), 'Alice E')
self.bob.invalidate_recordset()
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
def test_enroll_invalid_uid(self):
result = self._call({
'employee_id': self.alice.id,
'card_uid': 'not-a-uid',
'enroll_password': '1234',
})
self.assertEqual(result.get('error'), 'invalid_uid')
- Step 2: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: 404 on /fusion_clock/kiosk/nfc/enroll.
- Step 3: Add the enroll endpoint
Append to fusion_clock/controllers/clock_nfc_kiosk.py (inside the FusionClockNfcKiosk class):
@staticmethod
def _check_enroll_password(env, supplied):
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
configured = env['ir.config_parameter'].sudo().get_param('fusion_clock.nfc_enroll_password', '')
if not configured:
return True
return (supplied or '') == configured
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'access_denied'}
if not self._check_enroll_password(request.env, enroll_password):
return {'error': 'invalid_password'}
normalized = self._normalize_uid(card_uid)
if not normalized:
return {'error': 'invalid_uid'}
Employee = request.env['hr.employee'].sudo()
target = Employee.browse(int(employee_id or 0))
if not target.exists():
return {'error': 'employee_not_found'}
existing = Employee.search([
('x_fclk_nfc_card_uid', '=', normalized),
('id', '!=', target.id),
], limit=1)
if existing:
return {
'error': 'card_already_assigned',
'existing_employee': existing.name,
}
target.x_fclk_nfc_card_uid = normalized
# Activity log (uses 'card_enrollment' + 'nfc_kiosk' selections added in Task 2)
request.env['fusion.clock.activity.log'].sudo().create({
'employee_id': target.id,
'log_type': 'card_enrollment',
'description': f"NFC card {normalized} enrolled by {user.name}",
'source': 'nfc_kiosk',
})
return {
'success': True,
'employee_name': target.name,
'card_uid': normalized,
}
- Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: 4 enroll tests pass.
- Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC card enrollment endpoint"
Task 9: Tap endpoint — happy path
Files:
-
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py(addnfc_tapmethod) -
Modify:
fusion_clock/tests/test_clock_nfc_kiosk.py(addTestTapEndpointHappyPath) -
Step 1: Write the failing tests
Append to fusion_clock/tests/test_clock_nfc_kiosk.py:
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapEndpointHappyPath(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
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': 'Tap Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Tap Kiosk User',
'login': 'nfc-kiosk-tap',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.alice = cls.env['hr.employee'].create({
'name': 'Alice T',
'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90',
})
def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''):
self.authenticate('nfc-kiosk-tap', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/tap',
data=json.dumps({
'jsonrpc': '2.0',
'method': 'call',
'params': {'card_uid': card_uid, 'photo_b64': photo_b64},
}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_first_tap_clocks_in(self):
result = self._tap()
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_in')
self.assertEqual(result.get('employee_name'), 'Alice T')
# Verify attendance record
attendance = self.env['hr.attendance'].search([
('employee_id', '=', self.alice.id),
], order='check_in desc', limit=1)
self.assertTrue(attendance)
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
self.assertEqual(attendance.x_fclk_location_id, self.location)
self.assertFalse(attendance.check_out)
def test_second_tap_clocks_out(self):
# First tap (clock in)
self._tap()
# Second tap (clock out) — bypass debounce by manipulating last-tap state
# Wait long enough that the 5s debounce window has elapsed
import time
time.sleep(6)
result = self._tap()
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_out')
attendance = self.env['hr.attendance'].search([
('employee_id', '=', self.alice.id),
], order='check_in desc', limit=1)
self.assertTrue(attendance.check_out)
- Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: 404 on /fusion_clock/kiosk/nfc/tap.
- Step 3: Add the tap endpoint (happy path only — error cases come in Task 10)
Append to fusion_clock/controllers/clock_nfc_kiosk.py (inside the FusionClockNfcKiosk class):
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
def nfc_tap(self, card_uid='', photo_b64='', **kw):
"""Toggle attendance state for the employee owning this card UID."""
from odoo import fields
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'access_denied'}
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
return {'error': 'kiosk_disabled'}
normalized = self._normalize_uid(card_uid)
if not normalized:
return {'error': 'invalid_uid'}
company = request.env.company
location = company.x_fclk_nfc_kiosk_location_id
if not location:
return {'error': 'no_location_configured'}
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('x_fclk_nfc_card_uid', '=', normalized)], limit=1)
if not employee:
# Log the unknown tap so a manager can investigate.
# employee_id is required by the model — link to a sentinel "system" employee
# if one exists, otherwise just write the description with a 0 employee_id
# via raw SQL. Simplest: skip the log when no employee, fall back to logger.
_logger.warning("[nfc-kiosk] Unknown NFC card tapped: %s", normalized)
return {'error': 'card_unknown', 'message': 'Card not enrolled. See your manager.'}
if not employee.x_fclk_enable_clock:
return {'error': 'clock_disabled', 'message': 'Clock disabled for this account.'}
# Reuse the existing kiosk's clock logic via FusionClockAPI
from .clock_api import FusionClockAPI
api = FusionClockAPI()
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = now.date()
geo_info = {
'latitude': 0,
'longitude': 0,
'browser': 'nfc_kiosk',
'ip_address': request.httprequest.remote_addr or '',
}
attendance = employee.sudo()._attendance_action_change(geo_info)
if not is_checked_in:
# Just clocked IN
attendance.sudo().write({
'x_fclk_location_id': location.id,
'x_fclk_in_distance': 0.0,
'x_fclk_clock_source': 'nfc_kiosk',
})
api._log_activity(
employee, 'clock_in',
f"NFC kiosk clock-in at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
'action': 'clock_in',
'employee_name': employee.name,
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
'message': f'{employee.name} clocked in at {location.name}',
'net_hours_today': 0.0,
}
else:
# Just clocked OUT
attendance.sudo().write({
'x_fclk_out_distance': 0.0,
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
return {
'success': True,
'action': 'clock_out',
'employee_name': employee.name,
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
'message': f'{employee.name} clocked out',
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
}
Verify the imports at the top of the file include
from odoo import http, fields(the existing scaffold may only havehttp). Addfieldsif missing.
- Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: both tap tests pass.
- Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC tap endpoint (happy path)"
Task 10: Tap endpoint — error handling and debounce
Files:
-
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py(add module-level debounce dict + check) -
Modify:
fusion_clock/tests/test_clock_nfc_kiosk.py(addTestTapEndpointErrors) -
Step 1: Write the failing tests
Append to fusion_clock/tests/test_clock_nfc_kiosk.py:
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapEndpointErrors(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
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': 'Err Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Err Kiosk User',
'login': 'nfc-kiosk-err',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.disabled_emp = cls.env['hr.employee'].create({
'name': 'Disabled E',
'x_fclk_enable_clock': False,
'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD',
})
cls.active_emp = cls.env['hr.employee'].create({
'name': 'Active E',
'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01',
})
def _tap(self, card_uid):
self.authenticate('nfc-kiosk-err', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/tap',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'card_uid': card_uid, 'photo_b64': ''},
}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_unknown_card(self):
result = self._tap('04:00:00:00:00:00')
self.assertEqual(result.get('error'), 'card_unknown')
def test_disabled_employee(self):
result = self._tap('04:A2:B5:62:DE:AD')
self.assertEqual(result.get('error'), 'clock_disabled')
def test_no_location_configured(self):
self.env.company.x_fclk_nfc_kiosk_location_id = False
result = self._tap('04:A2:B5:62:AC:01')
self.assertEqual(result.get('error'), 'no_location_configured')
def test_kiosk_disabled(self):
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
result = self._tap('04:A2:B5:62:AC:01')
self.assertEqual(result.get('error'), 'kiosk_disabled')
def test_invalid_uid(self):
result = self._tap('not-a-uid')
self.assertEqual(result.get('error'), 'invalid_uid')
def test_debounce_silent_second_tap(self):
first = self._tap('04:A2:B5:62:AC:01')
self.assertTrue(first.get('success'))
# Immediately tap again — should be debounced
second = self._tap('04:A2:B5:62:AC:01')
self.assertEqual(second.get('error'), 'debounce')
- Step 2: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: most error tests pass already (the controller already returns card_unknown, clock_disabled, etc.) but test_debounce_silent_second_tap fails because debounce isn't implemented yet.
- Step 3: Add module-level debounce state and check
Edit fusion_clock/controllers/clock_nfc_kiosk.py. At the top of the file, after the imports, add:
import time
import threading
_DEBOUNCE_WINDOW_SECONDS = 5.0
_recent_taps = {} # {card_uid: monotonic_ts}
_recent_taps_lock = threading.Lock()
def _is_debounced(uid):
"""Return True if this UID was tapped within the debounce window."""
now = time.monotonic()
with _recent_taps_lock:
last = _recent_taps.get(uid, 0)
if now - last < _DEBOUNCE_WINDOW_SECONDS:
return True
_recent_taps[uid] = now
# Opportunistic GC: drop entries older than 60s
stale_keys = [k for k, t in _recent_taps.items() if now - t > 60]
for k in stale_keys:
_recent_taps.pop(k, None)
return False
Then in the nfc_tap method, immediately AFTER the normalized = self._normalize_uid(card_uid) block (before resolving the location), insert:
if _is_debounced(normalized):
return {'error': 'debounce'}
- Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: all 6 error tests pass.
- Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC tap endpoint debounce + error handling tests"
Task 11: Tap endpoint — photo handling
Files:
-
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py(decode photo_b64, save to attendance, enforcenfc_photo_required) -
Modify:
fusion_clock/tests/test_clock_nfc_kiosk.py(addTestTapPhotoHandling) -
Step 1: Write the failing tests
Append to fusion_clock/tests/test_clock_nfc_kiosk.py:
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestTapPhotoHandling(HttpCase):
SAMPLE_PNG_DATAURL = (
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Photo Plant',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Photo Kiosk User',
'login': 'nfc-kiosk-photo',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Photo Emp',
'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:F0:01',
})
def _tap(self, photo_b64=''):
self.authenticate('nfc-kiosk-photo', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/tap',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'card_uid': '04:A2:B5:62:F0:01', 'photo_b64': photo_b64},
}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_photo_saved_on_clock_in(self):
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
result = self._tap(self.SAMPLE_PNG_DATAURL)
self.assertTrue(result.get('success'))
attendance = self.env['hr.attendance'].search([
('employee_id', '=', self.emp.id),
], order='check_in desc', limit=1)
self.assertTrue(attendance.x_fclk_check_in_photo)
def test_photo_required_rejects_when_missing(self):
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
result = self._tap(photo_b64='')
self.assertEqual(result.get('error'), 'photo_required')
def test_photo_optional_succeeds_without_photo(self):
self.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
result = self._tap(photo_b64='')
self.assertTrue(result.get('success'))
- Step 2: Run the tests to confirm they fail
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: photo_required rejection test fails because the endpoint doesn't enforce it; photo-saved test fails because photo isn't decoded/saved.
- Step 3: Add a photo-stripping helper and enforce the requirement
In fusion_clock/controllers/clock_nfc_kiosk.py, after the _is_debounced function, add:
def _strip_data_url_prefix(b64):
"""Strip 'data:image/...;base64,' prefix from a data URL, returning raw base64."""
if not b64:
return b''
if isinstance(b64, str) and b64.startswith('data:'):
comma = b64.find(',')
if comma >= 0:
return b64[comma + 1:].encode('ascii', errors='ignore')
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
Then in the nfc_tap method, immediately AFTER the debounce check and BEFORE resolving the location, add the photo-required gate:
photo_required = ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'
if photo_required and not photo_b64:
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
Then, in the clock-in branch (where the existing code does attendance.sudo().write({...x_fclk_clock_source...})), update the dict to include the photo:
attendance.sudo().write({
'x_fclk_location_id': location.id,
'x_fclk_in_distance': 0.0,
'x_fclk_clock_source': 'nfc_kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
And in the clock-out branch:
attendance.sudo().write({
'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
- Step 4: Run the tests to confirm they pass
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40
Expected: all 3 photo tests pass.
- Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC tap photo capture + photo-required gate"
Task 12: Employee search endpoint (for Enroll Mode UI)
Files:
-
Modify:
fusion_clock/controllers/clock_nfc_kiosk.py(addnfc_employee_searchthat delegates to existing kiosk search) -
Modify:
fusion_clock/tests/test_clock_nfc_kiosk.py(addTestEmployeeSearch) -
Step 1: Write the failing test
Append to fusion_clock/tests/test_clock_nfc_kiosk.py:
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestEmployeeSearch(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.kiosk_user = cls.env['res.users'].create({
'name': 'Search Kiosk User',
'login': 'nfc-kiosk-search',
'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.env['hr.employee'].create({'name': 'Searchable Steve', 'x_fclk_enable_clock': True})
def test_search_returns_matching_employees(self):
self.authenticate('nfc-kiosk-search', 'kioskpass123')
response = self.url_open(
'/fusion_clock/kiosk/nfc/employee_search',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'query': 'Steve'},
}),
headers={'Content-Type': 'application/json'},
)
result = response.json().get('result', {})
self.assertIn('employees', result)
names = [e['name'] for e in result['employees']]
self.assertIn('Searchable Steve', names)
- Step 2: Run the test to confirm it fails
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: 404 on /fusion_clock/kiosk/nfc/employee_search.
- Step 3: Add the search endpoint
Append to fusion_clock/controllers/clock_nfc_kiosk.py (inside the class):
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
def nfc_employee_search(self, query='', **kw):
"""Delegate to the existing kiosk search to avoid duplication."""
from .clock_kiosk import FusionClockKiosk
return FusionClockKiosk().kiosk_search(query=query)
- Step 4: Run the test to confirm it passes
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30
Expected: search test passes.
- Step 5: Commit
git add fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_clock_nfc_kiosk.py
git commit -m "feat(fusion_clock): NFC kiosk employee search endpoint"
Task 13: SCSS styling (always-dark, high-contrast)
Files:
-
Create:
fusion_clock/static/src/scss/nfc_kiosk.scss -
Modify:
fusion_clock/__manifest__.py(register the SCSS inweb.assets_frontend) -
Step 1: Create the SCSS file
Create fusion_clock/static/src/scss/nfc_kiosk.scss:
// NFC Clock Kiosk — always-dark, high-contrast.
// Per CLAUDE.md: shop-floor kiosks need explicit hex (no var(--bs-*) which drift)
// and we deliberately do NOT branch on $o-webclient-color-scheme — this is a
// frontend bundle (not backend), and the kiosk is intentionally always dark
// regardless of the user's color scheme preference.
$nfc-bg: #0b0d10;
$nfc-panel: #15191f;
$nfc-text: #ffffff;
$nfc-text-muted: #9ba3ad;
$nfc-success: #18a957;
$nfc-error: #d9374e;
$nfc-accent: #3b82f6;
$nfc-border: #2a3038;
html, body {
background: $nfc-bg !important;
color: $nfc-text;
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.o_main_navbar, header, footer { display: none !important; }
.nfc-kiosk {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
box-sizing: border-box;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.nfc-kiosk__company {
position: absolute;
top: 1.5rem;
left: 50%;
transform: translateX(-50%);
font-size: 1.25rem;
color: $nfc-text-muted;
}
.nfc-kiosk__time {
position: absolute;
top: 1.5rem;
right: 2rem;
font-size: 2rem;
font-weight: 600;
color: $nfc-text;
font-variant-numeric: tabular-nums;
}
.nfc-kiosk__date {
position: absolute;
top: 4.5rem;
right: 2rem;
font-size: 1rem;
color: $nfc-text-muted;
}
.nfc-kiosk__location {
position: absolute;
bottom: 1.5rem;
left: 2rem;
font-size: 0.95rem;
color: $nfc-text-muted;
}
.nfc-kiosk__settings {
position: absolute;
bottom: 1rem;
right: 1rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: transparent;
color: $nfc-text-muted;
border: 1px solid $nfc-border;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
&:hover { color: $nfc-text; }
}
// IDLE state
.nfc-kiosk__idle {
text-align: center;
}
.nfc-kiosk__icon {
font-size: 8rem;
color: $nfc-accent;
animation: nfc-pulse 2s ease-in-out infinite;
}
@keyframes nfc-pulse {
0%, 100% { opacity: 0.85; transform: scale(1); }
50% { opacity: 1.0; transform: scale(1.06); }
}
.nfc-kiosk__prompt {
font-size: 2rem;
font-weight: 500;
margin-top: 2rem;
}
// PROCESSING state
.nfc-kiosk__processing {
text-align: center;
font-size: 1.5rem;
color: $nfc-text-muted;
}
// RESULT state — success/error panels
.nfc-kiosk__result {
width: min(80vw, 700px);
padding: 2.5rem 3rem;
border-radius: 1rem;
display: flex;
align-items: center;
gap: 2rem;
&--success { background: $nfc-success; }
&--error { background: $nfc-error; }
}
.nfc-kiosk__avatar {
width: 7rem;
height: 7rem;
border-radius: 50%;
background-size: cover;
background-position: center;
background-color: rgba(255,255,255,0.2);
flex-shrink: 0;
}
.nfc-kiosk__result-text {
flex: 1;
.name { font-size: 2rem; font-weight: 700; }
.action { font-size: 1.5rem; margin-top: 0.5rem; }
.hours { font-size: 1.1rem; opacity: 0.9; margin-top: 0.25rem; }
}
// One-time setup wizard
.nfc-kiosk__setup {
text-align: center;
max-width: 600px;
h2 { font-size: 2rem; margin-bottom: 1rem; }
p { color: $nfc-text-muted; margin-bottom: 2rem; }
button {
font-size: 1.5rem;
padding: 1rem 3rem;
background: $nfc-accent;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
}
}
// Enroll Mode
.nfc-kiosk__enroll-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.85);
z-index: 1000;
display: flex; align-items: center; justify-content: center;
padding: 2rem;
}
.nfc-kiosk__enroll-panel {
background: $nfc-panel;
border: 1px solid $nfc-border;
border-radius: 1rem;
padding: 2.5rem;
width: min(80vw, 700px);
h2 { font-size: 1.5rem; margin: 0 0 1.5rem; }
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin: 1rem 0;
button {
font-size: 2rem; padding: 1.5rem 0;
background: $nfc-bg; color: $nfc-text;
border: 1px solid $nfc-border; border-radius: 0.5rem;
cursor: pointer;
}
}
.pin-display {
font-size: 2.5rem; letter-spacing: 0.5rem;
text-align: center; margin: 1rem 0;
font-variant-numeric: tabular-nums;
}
.employee-search {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1.25rem;
background: $nfc-bg; color: $nfc-text;
border: 1px solid $nfc-border;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.employee-list {
max-height: 40vh;
overflow-y: auto;
.employee-row {
padding: 0.75rem 1rem;
border-bottom: 1px solid $nfc-border;
cursor: pointer;
font-size: 1.1rem;
&:hover { background: $nfc-bg; }
}
}
.actions {
display: flex; gap: 1rem; justify-content: flex-end;
margin-top: 1.5rem;
button {
font-size: 1rem; padding: 0.75rem 1.5rem;
border-radius: 0.5rem; cursor: pointer; border: none;
}
.cancel { background: $nfc-border; color: $nfc-text; }
.confirm { background: $nfc-accent; color: white; }
}
}
- Step 2: Register the SCSS in the manifest
Edit fusion_clock/__manifest__.py. In the 'web.assets_frontend' list, add the line for the SCSS file. The block should look like:
'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css',
'fusion_clock/static/src/scss/nfc_kiosk.scss',
'fusion_clock/static/src/js/fusion_clock_portal.js',
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
],
- Step 3: Reload module and verify SCSS compiles
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | grep -i -E "(error|warning|nfc_kiosk)" | head -20
Expected: no SCSS compilation errors. If you see "could not be parsed", check for missing semicolons or typos.
- Step 4: Commit
git add fusion_clock/static/src/scss/nfc_kiosk.scss fusion_clock/__manifest__.py
git commit -m "feat(fusion_clock): NFC kiosk SCSS (always-dark, high-contrast)"
Task 14: Full QWeb template
Files:
-
Modify:
fusion_clock/views/kiosk_nfc_templates.xml(replace the placeholder with the full template) -
Step 1: Replace the placeholder template
Open fusion_clock/views/kiosk_nfc_templates.xml and REPLACE the entire file content with:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
</t>
<div id="nfc_kiosk_root" class="nfc-kiosk"
t-att-data-photo-required="'1' if photo_required else '0'"
t-att-data-debug-enabled="'1' if debug_enabled else '0'"
t-att-data-location-configured="'1' if location_configured else '0'">
<!-- Static chrome (always visible) -->
<div class="nfc-kiosk__company" t-esc="company_name"/>
<div class="nfc-kiosk__time" id="nfc_clock_time">--:--</div>
<div class="nfc-kiosk__date" id="nfc_clock_date">—</div>
<div class="nfc-kiosk__location">
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
</div>
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode">⚙</button>
<!-- Dynamic state container (JS swaps inner HTML) -->
<div id="nfc_state_container">
<!-- Initially: One-time setup wizard step 1 -->
<div class="nfc-kiosk__setup">
<h2>Welcome to Fusion Clock NFC Kiosk</h2>
<p>Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.</p>
<button id="nfc_setup_start">Tap to enable NFC reader</button>
</div>
</div>
<!-- Hidden video element for camera feed -->
<video id="nfc_camera_feed" autoplay="autoplay" playsinline="playsinline" muted="muted"
style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"/>
<canvas id="nfc_camera_canvas" style="display:none;"/>
</div>
</t>
</template>
</odoo>
- Step 2: Reload module
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
- Step 3: Smoke test in browser
In Chrome, open http://localhost:8069 and log in as an admin/manager. Then enable the kiosk via odoo-shell:
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.enable_nfc_kiosk', 'True')
env.cr.commit()
EOF
Navigate to http://localhost:8069/fusion_clock/kiosk/nfc.
Expected: full-screen dark page, "Welcome to Fusion Clock NFC Kiosk" wizard visible, time display in top-right shows "--:--", "Clock at: ..." or warning in bottom-left, ⚙ in bottom-right.
- Step 4: Commit
git add fusion_clock/views/kiosk_nfc_templates.xml
git commit -m "feat(fusion_clock): NFC kiosk QWeb template"
Task 15: JS scaffold + state machine + IDLE rendering + clock display
Files:
-
Create:
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js -
Modify:
fusion_clock/__manifest__.py(already hasweb.assets_frontendfrom Task 13; add the JS) -
Step 1: Create the JS file with state machine + initial setup binding + clock display
Create fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js:
/* @odoo-module */
// NFC Clock Kiosk — Web NFC + camera + state machine.
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
// element #nfc_kiosk_root only exists on that page, so the module is
// inert elsewhere).
(function() {
"use strict";
const root = document.getElementById("nfc_kiosk_root");
if (!root) return; // not on the kiosk page
const stateContainer = document.getElementById("nfc_state_container");
const photoRequired = root.dataset.photoRequired === "1";
const debugEnabled = root.dataset.debugEnabled === "1";
const locationConfigured = root.dataset.locationConfigured === "1";
// ──────────────────────────────────────────────────────────────
// State machine
// ──────────────────────────────────────────────────────────────
const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
let currentState = STATE.SETUP;
function setState(next, payload) {
currentState = next;
if (next === STATE.IDLE) renderIdle();
else if (next === STATE.PROCESSING) renderProcessing();
else if (next === STATE.RESULT) renderResult(payload);
else if (next === STATE.ENROLL) renderEnroll(payload);
}
// ──────────────────────────────────────────────────────────────
// Rendering helpers
// ──────────────────────────────────────────────────────────────
function renderIdle() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__idle">
<div class="nfc-kiosk__icon">⌐■</div>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
`;
}
function renderProcessing() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__processing">Reading card…</div>
`;
}
function renderResult(payload) {
const isError = payload && payload.error;
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
if (isError) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.message || "Error")}</div>
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 4000);
} else {
const avatar = payload.employee_avatar_url || "";
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
const hours = payload.action === "clock_out" && payload.net_hours_today
? `${payload.net_hours_today.toFixed(1)}h today`
: "";
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.employee_name)}</div>
<div class="action">${action} at ${time}</div>
${hours ? `<div class="hours">${hours}</div>` : ""}
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 3000);
}
}
function renderEnroll(payload) {
// Full implementation lands in Task 18; this stub keeps the state machine valid.
stateContainer.innerHTML = `<div class="nfc-kiosk__processing">Enroll mode (filled in by Task 18)</div>`;
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, c => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
}[c]));
}
// ──────────────────────────────────────────────────────────────
// Clock display (top-right time + date)
// ──────────────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
const timeEl = document.getElementById("nfc_clock_time");
const dateEl = document.getElementById("nfc_clock_date");
if (timeEl) timeEl.textContent = `${hh}:${mm}`;
if (dateEl) dateEl.textContent = dateStr;
}
updateClock();
setInterval(updateClock, 1000);
// ──────────────────────────────────────────────────────────────
// Setup wizard
// ──────────────────────────────────────────────────────────────
const setupBtn = document.getElementById("nfc_setup_start");
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
// Web NFC + camera activation lives in Tasks 16 + 17
setState(STATE.IDLE);
});
}
// Expose a tiny API for later tasks
window.__nfcKiosk = {
setState,
STATE,
photoRequired,
debugEnabled,
locationConfigured,
};
})();
- Step 2: Register the JS in the manifest
Edit fusion_clock/__manifest__.py. The web.assets_frontend list should now include both the SCSS (from Task 13) and the JS:
'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css',
'fusion_clock/static/src/scss/nfc_kiosk.scss',
'fusion_clock/static/src/js/fusion_clock_portal.js',
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
],
- Step 3: Reload + smoke test in browser
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
Hard-refresh http://localhost:8069/fusion_clock/kiosk/nfc (Ctrl+Shift+R). Expected:
-
Wizard "Welcome to Fusion Clock NFC Kiosk" visible
-
Click "Tap to enable NFC reader" → page transitions to IDLE state showing the NFC icon and "Tap your card to clock in or out"
-
Top-right time updates every second
-
Step 4: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js fusion_clock/__manifest__.py
git commit -m "feat(fusion_clock): NFC kiosk JS scaffold + state machine + clock display"
Task 16: Web NFC integration (NDEFReader scan + reading event)
Files:
-
Modify:
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js(add NFC scanning + tap dispatcher) -
Step 1: Add NFC scanning to the setup activation handler
In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, REPLACE the setup wizard click handler (the setupBtn.addEventListener("click", async () => { setState(STATE.IDLE); });) and the trailing window.__nfcKiosk = {...} block with:
// ──────────────────────────────────────────────────────────────
// Web NFC reader
// ──────────────────────────────────────────────────────────────
let ndefReader = null;
let nfcReady = false;
async function startNfcReader() {
if (!("NDEFReader" in window)) {
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
}
ndefReader = new NDEFReader();
await ndefReader.scan();
ndefReader.addEventListener("reading", onNfcReading);
ndefReader.addEventListener("readingerror", () => {
console.warn("[nfc-kiosk] reading error; reader still active");
});
nfcReady = true;
}
function onNfcReading(event) {
// event.serialNumber is the card UID — works for raw MIFARE access cards
const uid = (event.serialNumber || "").toUpperCase();
if (!uid) return;
if (currentState === STATE.ENROLL) {
// Enroll Mode handles taps differently (wired up in Task 18)
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
return;
}
if (currentState !== STATE.IDLE) return; // ignore taps mid-result
handleTap(uid);
}
async function handleTap(uid) {
setState(STATE.PROCESSING);
let photoB64 = "";
try {
photoB64 = await capturePhoto();
} catch (e) {
console.warn("[nfc-kiosk] camera capture failed", e);
// Server enforces photo_required if needed
}
try {
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
if (result.error === "debounce") {
// silent — back to IDLE
setState(STATE.IDLE);
return;
}
setState(STATE.RESULT, result);
} catch (e) {
setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
}
}
async function postJson(url, params) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
});
const json = await res.json();
return json.result || {};
}
// ──────────────────────────────────────────────────────────────
// Camera capture (real implementation in Task 17, stub for now)
// ──────────────────────────────────────────────────────────────
async function capturePhoto() {
return ""; // overridden in Task 17
}
// ──────────────────────────────────────────────────────────────
// Setup wizard activation
// ──────────────────────────────────────────────────────────────
const setupBtn = document.getElementById("nfc_setup_start");
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
try {
await startNfcReader();
setState(STATE.IDLE);
} catch (e) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
}
});
}
window.__nfcKiosk = {
setState, STATE, photoRequired, debugEnabled, locationConfigured,
handleTap, // exposed for mock-tap debug (Task 19)
};
- Step 2: Reload + verify in Chrome on Android
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
Open the kiosk URL in Chrome on an Android phone (HTTPS required — see note below). Click "Tap to enable NFC reader". Browser will prompt for NFC permission → grant it. Tap any contactless card.
HTTPS requirement: Web NFC requires a secure origin. For dev, either:
- Use
http://localhostdirectly on the device (NOT supported for NFC on Android — Android requires HTTPS even for localhost)- Set up
ngrok http 8069to get an HTTPS tunnel, or- Use the production HTTPS domain Without HTTPS the
await ndefReader.scan()call throws.
Expected: tap fires → page enters PROCESSING → server returns card_unknown (no enrollment yet) → red RESULT screen "Card not recognized. See your manager." → back to IDLE after 4s.
- Step 3: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): Web NFC integration on kiosk page"
Task 17: Camera capture (getUserMedia + canvas frame grab)
Files:
-
Modify:
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js(replace thecapturePhotostub with a real implementation; activate the camera as part of setup) -
Step 1: Add camera activation + real capture
In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, REPLACE the entire capturePhoto stub (async function capturePhoto() { return ""; }) and the startNfcReader function with:
// ──────────────────────────────────────────────────────────────
// Camera
// ──────────────────────────────────────────────────────────────
let cameraStream = null;
const videoEl = document.getElementById("nfc_camera_feed");
const canvasEl = document.getElementById("nfc_camera_canvas");
async function startCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera not supported on this browser/device.");
}
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
videoEl.srcObject = cameraStream;
await videoEl.play();
}
async function capturePhoto() {
if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
const w = videoEl.videoWidth;
const h = videoEl.videoHeight;
canvasEl.width = w;
canvasEl.height = h;
const ctx = canvasEl.getContext("2d");
ctx.drawImage(videoEl, 0, 0, w, h);
// ~30-60 KB at quality 0.7 for 640x480
return canvasEl.toDataURL("image/jpeg", 0.7);
}
async function startNfcReader() {
if (!("NDEFReader" in window)) {
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
}
ndefReader = new NDEFReader();
await ndefReader.scan();
ndefReader.addEventListener("reading", onNfcReading);
ndefReader.addEventListener("readingerror", () => {
console.warn("[nfc-kiosk] reading error; reader still active");
});
nfcReady = true;
}
Then UPDATE the setup button click handler to also start the camera:
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
try {
await startNfcReader();
try {
await startCamera();
} catch (camErr) {
if (photoRequired) throw camErr;
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
setState(STATE.IDLE);
} catch (e) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
}
});
}
- Step 2: Reload + smoke test on a real device
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
On the Android phone with NFC, hard-refresh the kiosk page. Click activation button. Browser prompts for NFC, then for Camera. Grant both. Tap a contactless card.
Expected: tap fires, page processes, server returns card_unknown (no enrollment yet) — but check that the tap payload includes photo_b64 (open browser DevTools → Network tab → inspect the /tap request body; it should contain a data:image/jpeg;base64,... value).
- Step 3: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): camera capture on NFC kiosk"
Task 18: Enroll Mode UI
Files:
-
Modify:
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js(add the full Enroll Mode flow: ⚙ button → numpad password → employee picker → tap-to-enroll) -
Step 1: Add the Enroll Mode flow
In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, REPLACE the placeholder renderEnroll(payload) function with the full implementation, AND add the supporting variables and the ⚙ click handler. Insert the new code immediately after the existing renderResult(payload) function:
// ──────────────────────────────────────────────────────────────
// Enroll Mode
// ──────────────────────────────────────────────────────────────
let enrollPassword = "";
let enrollSelectedEmployee = null;
let enrollIdleTimer = null;
function resetEnrollIdleTimer() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = setTimeout(() => {
// 60s of inactivity in Enroll Mode → exit
exitEnrollMode();
}, 60000);
}
function exitEnrollMode() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = null;
enrollPassword = "";
enrollSelectedEmployee = null;
setState(STATE.IDLE);
}
function renderEnroll(payload) {
const phase = (payload && payload.phase) || "password";
resetEnrollIdleTimer();
if (phase === "password") {
const masked = "•".repeat(enrollPassword.length);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Enter Enroll Mode Password</h2>
<div class="pin-display">${masked}</div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
<button data-n="0">0</button>
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
btn.addEventListener("click", async () => {
resetEnrollIdleTimer();
const n = btn.dataset.n;
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
else if (n === "ok") {
// Try to advance to employee picker — server validates the password
// when the actual enroll request is made; here we just trust the
// user entered something and move on. If the enroll later fails
// with invalid_password we reset.
if (enrollPassword.length === 0) return;
renderEnroll({ phase: "search" });
return;
}
else enrollPassword += n;
renderEnroll({ phase: "password" });
});
});
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "search") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Pick the employee to enroll</h2>
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="enroll_list"></div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
const searchEl = document.getElementById("enroll_search");
const listEl = document.getElementById("enroll_list");
let debounceTimer = null;
searchEl.addEventListener("input", () => {
resetEnrollIdleTimer();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
listEl.innerHTML = (result.employees || []).map(e =>
`<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
).join("");
listEl.querySelectorAll(".employee-row").forEach(row => {
row.addEventListener("click", () => {
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
renderEnroll({ phase: "tap" });
});
});
}, 200);
});
searchEl.focus();
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "tap") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
<div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
<p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "result") {
const ok = !payload.error;
const msg = ok
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
: (payload.error === "invalid_password"
? "Wrong password. Try again."
: payload.error === "card_already_assigned"
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
: `Enroll failed: ${escapeHtml(payload.error)}`);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
<div class="actions" style="justify-content:center">
<button class="confirm" id="enroll_another">Enroll another</button>
<button class="cancel" id="enroll_done">Done</button>
</div>
</div>
</div>
`;
document.getElementById("enroll_another").addEventListener("click", () => {
enrollSelectedEmployee = null;
renderEnroll({ phase: ok ? "search" : "password" });
});
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
}
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: enrollSelectedEmployee.id,
card_uid: uid,
enroll_password: enrollPassword,
});
renderEnroll({ phase: "result", ...result });
}
// ⚙ button → enter Enroll Mode
const settingsBtn = document.getElementById("nfc_settings_btn");
if (settingsBtn) {
settingsBtn.addEventListener("click", () => {
if (currentState !== STATE.IDLE) return;
enrollPassword = "";
enrollSelectedEmployee = null;
setState(STATE.ENROLL, { phase: "password" });
});
}
Then UPDATE the window.__nfcKiosk exposure at the bottom to include the enroll callback:
window.__nfcKiosk = {
setState, STATE, photoRequired, debugEnabled, locationConfigured,
handleTap, _onEnrollTap,
};
- Step 2: Reload + smoke test on real device
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5
Set the enroll password:
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_enroll_password', '1234')
env.cr.commit()
EOF
On the kiosk page, click ⚙ → enter 1234 → click OK → search for an employee → click their name → tap a card. Expected: success result panel with "✓ Card XX:XX:... enrolled to ". Click "Done" → returns to IDLE. Then tap the same card → should clock that employee in.
- Step 3: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): NFC kiosk Enroll Mode UI"
Task 19: Mock-tap debug shortcut
Files:
-
Modify:
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js(add Ctrl+Shift+T handler when debug flag is set) -
Step 1: Add the debug keyboard shortcut
In fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js, immediately BEFORE the final window.__nfcKiosk = {...} block, add:
// ──────────────────────────────────────────────────────────────
// Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
// ──────────────────────────────────────────────────────────────
if (debugEnabled) {
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
e.preventDefault();
const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
if (!uid) return;
localStorage.setItem("nfc_mock_uid", uid);
if (currentState === STATE.ENROLL) {
_onEnrollTap(uid.toUpperCase());
} else if (currentState === STATE.IDLE) {
handleTap(uid.toUpperCase());
}
}
});
console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
}
- Step 2: Enable debug mode and verify
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_kiosk_debug', 'True')
env.cr.commit()
EOF
Hard-refresh the kiosk page. Click activation. Once at IDLE, press Ctrl+Shift+T. A prompt asks for a UID. Enter any enrolled UID. Expected: page enters PROCESSING → RESULT showing the employee's name.
Note:
nfc_kiosk_debugdefaults to False — must be explicitly toggled on. The shortcut handler is only attached when the flag is True at page-render time.
- Step 3: Disable debug mode again
docker exec -it odoo-modsdev-app odoo shell -d modsdev --no-http << 'EOF'
env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_kiosk_debug', 'False')
env.cr.commit()
EOF
Refresh. Confirm Ctrl+Shift+T no longer fires the prompt.
- Step 4: Commit
git add fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
git commit -m "feat(fusion_clock): NFC kiosk mock-tap debug shortcut"
Task 20: Manifest version bump + final smoke test
Files:
-
Modify:
fusion_clock/__manifest__.py(bump version) -
Step 1: Bump the module version
In fusion_clock/__manifest__.py, change:
'version': '19.0.2.0.0',
to:
'version': '19.0.3.0.0',
(The bump signals to Odoo to invalidate cached asset bundles. Per CLAUDE.md, this is the cleanest cache-bust.)
- Step 2: Reload one final time
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -10
If the asset cache appears stale (CSS not updating, old JS), follow the CLAUDE.md escalation:
docker exec -it odoo-modsdev-app psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init
Then in the browser, DevTools → right-click refresh → "Empty Cache and Hard Reload".
- Step 3: Run the full test suite one last time
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -50
Expected: 0 failures, 0 errors across all NFC tests.
- Step 4: End-to-end manual smoke test on Android phone
On an Android phone with NFC, in Chrome over HTTPS:
- Navigate to
https://<odoo-domain>/fusion_clock/kiosk/nfc - Log in as the kiosk service user
- Click "Tap to enable NFC reader" — grant NFC + camera permissions
- Confirm IDLE state shows
- Click ⚙ → enter enroll password → search for an employee → tap a contactless credit card to enroll it
- Confirm "✓ Card XX:XX:... enrolled" appears
- Click "Done" → tap the same card again → confirm RESULT screen shows "Welcome — CLOCKED IN"
- Wait 6+ seconds, tap again → confirm RESULT shows "CLOCKED OUT"
- In Odoo backend, navigate to
Attendances, find the new record, confirmClock Source = NFC Kioskand the photo fields are populated
- Step 5: Commit the version bump
git add fusion_clock/__manifest__.py
git commit -m "chore(fusion_clock): bump version to 19.0.3.0.0 for NFC kiosk feature"
Self-Review Checklist (run before declaring complete)
- Every spec section in
2026-05-13-nfc-clock-kiosk-design.mdhas a corresponding task above - No placeholder text in any task — every step shows actual code or commands
- Field names used in tests match field names defined in tasks (
x_fclk_nfc_card_uid,x_fclk_nfc_kiosk_location_id,x_fclk_check_in_photo,x_fclk_check_out_photo,x_fclk_clock_sourceextended) - Endpoint URLs are consistent:
/fusion_clock/kiosk/nfc,/fusion_clock/kiosk/nfc/tap,/fusion_clock/kiosk/nfc/enroll,/fusion_clock/kiosk/nfc/employee_search - Error code strings are consistent:
card_unknown,clock_disabled,no_location_configured,kiosk_disabled,invalid_uid,debounce,photo_required,invalid_password,card_already_assigned,employee_not_found,access_denied - Manifest registers all new XML and JS files
- Tests are tagged
fusion_clockso the--test-tags fusion_clockcommand picks them up - Module version bumped at the end