diff --git a/docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md b/docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md new file mode 100644 index 00000000..4becc9e4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md @@ -0,0 +1,2801 @@ +# 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](../specs/2026-05-13-nfc-clock-kiosk-design.md) + +**Dev environment:** +- Odoo container: `odoo-dev-app` +- Database: `fusion-dev` +- Local URL: http://localhost:8069 +- Module reload: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init` +- Run tests: `docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -i fusion_clock` + +--- + +## File Structure + +**New files:** +- `fusion_clock/models/res_company.py` — `res.company` extension with NFC kiosk location field +- `fusion_clock/controllers/clock_nfc_kiosk.py` — controller with three endpoints +- `fusion_clock/views/kiosk_nfc_templates.xml` — QWeb template for the kiosk page +- `fusion_clock/static/src/scss/nfc_kiosk.scss` — always-dark, high-contrast styling +- `fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine +- `fusion_clock/tests/__init__.py` — test package init (first time) +- `fusion_clock/tests/test_clock_nfc_kiosk.py` — unit tests for the controller +- `fusion_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 assets +- `fusion_clock/__init__.py` — import the new tests package +- `fusion_clock/models/__init__.py` — import `res_company` +- `fusion_clock/models/hr_employee.py` — add `x_fclk_nfc_card_uid` field +- `fusion_clock/models/hr_attendance.py` — add photo fields, extend `x_fclk_clock_source` selection +- `fusion_clock/models/res_config_settings.py` — add the four new ir.config_parameter shortcuts and the per-company location field +- `fusion_clock/views/res_config_settings_views.xml` — add NFC Clock Kiosk block +- `fusion_clock/data/ir_config_parameter_data.xml` — add four new defaults +- `fusion_clock/controllers/__init__.py` — import `clock_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_kiosk` for grep discoverability +- Tests are split: `test_nfc_models.py` (data layer) vs `test_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 existing `x_fclk_kiosk_pin` field, around line 48) +- Modify: `fusion_clock/__init__.py` (add `from . import tests` if not present — actually for Odoo, tests are auto-discovered via `tests/__init__.py`; no module-level import needed) + +- [ ] **Step 1: Create the tests package init** + +Create `fusion_clock/tests/__init__.py`: + +```python +# -*- 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`: + +```python +# -*- 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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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): + +```python + # 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_constraints` already exists on the class, merge the tuple into the existing list rather than redefining it. Search the file for `_sql_constraints` before adding. + +- [ ] **Step 5: Run the test to confirm it passes** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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** + +```bash +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` (extend `x_fclk_clock_source` selection and add two photo fields after the existing `x_fclk_out_distance` at ~line 149) +- Modify: `fusion_clock/models/clock_activity_log.py` (extend `log_type` and `source` selections 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`: + +```python +@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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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: + +```python + 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: + +```python + 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): + +```python + ('card_enrollment', 'Card Enrollment'), + ('unknown_card_tap', 'Unknown Card Tap'), +``` + +Then locate the `source` Selection field (~line 67) and ADD this entry: + +```python + ('nfc_kiosk', 'NFC Kiosk'), +``` + +The two updated fields should look like: + +```python + 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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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** + +```bash +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` (add `from . 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`: + +```python +@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_m': 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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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.location` model may have additional required fields (e.g. `address`, IP whitelist) — if `create({...})` errors with "Required field missing", inspect `fusion_clock/models/clock_location.py` and add the missing keys to the test fixture. + +- [ ] **Step 3: Create the res.company extension** + +Create `fusion_clock/models/res_company.py`: + +```python +# -*- 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: + +```python +from . import res_company +``` + +(append at the bottom, after `from . import clock_correction`) + +- [ ] **Step 5: Run the tests to confirm they pass** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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** + +```bash +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 ``) + +- [ ] **Step 1: Add the four NFC kiosk parameters** + +Open `fusion_clock/data/ir_config_parameter_data.xml`. Find the closing `` tag at the end of the file and insert the following block immediately before it: + +```xml + + + fusion_clock.enable_nfc_kiosk + False + + + fusion_clock.nfc_photo_required + True + + + fusion_clock.nfc_enroll_password + + + + fusion_clock.nfc_kiosk_debug + False + +``` + +- [ ] **Step 2: Reload the module and confirm parameters are present** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -10 +``` + +Expected: clean reload, no errors. + +Then verify in odoo-shell: + +```bash +docker exec -it odoo-dev-app odoo shell -d fusion-dev --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** + +```bash +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 four `config_parameter` shortcuts and a per-company `Many2one`) +- 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: + +```python + # ── 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 `` inside the `` 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 `` element): + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Reload and verify in browser** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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.location` records + +- [ ] **Step 4: Commit** + +```bash +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` (add `from . import clock_nfc_kiosk`) +- Create: `fusion_clock/tests/test_clock_nfc_kiosk.py` +- Modify: `fusion_clock/tests/__init__.py` (add `from . import test_clock_nfc_kiosk`) + +- [ ] **Step 1: Add the test scaffolding** + +Create `fusion_clock/tests/test_clock_nfc_kiosk.py`: + +```python +# -*- 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_m': 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', + 'groups_id': [(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`: + +```python +# -*- coding: utf-8 -*- + +from . import test_nfc_models +from . import test_clock_nfc_kiosk +``` + +- [ ] **Step 3: Run the test to confirm it fails** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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`: + +```python +# -*- 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`: + +```python +# -*- 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 + + + + + + + + + NFC Clock Kiosk + + No NFC kiosk location configured for . Ask your administrator to configure one in Fusion Clock settings. + + + Clock at: + + + + + + +``` + +- [ ] **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: + +```python + '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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30 +``` + +Expected: both new tests pass. + +- [ ] **Step 9: Commit** + +```bash +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_uid` static 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`: + +```python +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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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`: + +```python +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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30 +``` + +Expected: 7 normalization tests pass. + +- [ ] **Step 5: Commit** + +```bash +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` (add `nfc_enroll` method and helper for password check) +- Modify: `fusion_clock/tests/test_clock_nfc_kiosk.py` (add `TestEnrollEndpoint` class) + +- [ ] **Step 1: Write the failing tests** + +Append to `fusion_clock/tests/test_clock_nfc_kiosk.py`: + +```python +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', + 'groups_id': [(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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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): + +```python + @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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40 +``` + +Expected: 4 enroll tests pass. + +- [ ] **Step 5: Commit** + +```bash +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` (add `nfc_tap` method) +- Modify: `fusion_clock/tests/test_clock_nfc_kiosk.py` (add `TestTapEndpointHappyPath`) + +- [ ] **Step 1: Write the failing tests** + +Append to `fusion_clock/tests/test_clock_nfc_kiosk.py`: + +```python +@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_m': 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', + 'groups_id': [(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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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): + +```python + @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 have `http`). Add `fields` if missing. + +- [ ] **Step 4: Run the tests to confirm they pass** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40 +``` + +Expected: both tap tests pass. + +- [ ] **Step 5: Commit** + +```bash +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` (add `TestTapEndpointErrors`) + +- [ ] **Step 1: Write the failing tests** + +Append to `fusion_clock/tests/test_clock_nfc_kiosk.py`: + +```python +@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_m': 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', + 'groups_id': [(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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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: + +```python +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: + +```python + if _is_debounced(normalized): + return {'error': 'debounce'} +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40 +``` + +Expected: all 6 error tests pass. + +- [ ] **Step 5: Commit** + +```bash +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, enforce `nfc_photo_required`) +- Modify: `fusion_clock/tests/test_clock_nfc_kiosk.py` (add `TestTapPhotoHandling`) + +- [ ] **Step 1: Write the failing tests** + +Append to `fusion_clock/tests/test_clock_nfc_kiosk.py`: + +```python +@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_m': 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', + 'groups_id': [(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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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: + +```python +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: + +```python + 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: + +```python + 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: + +```python + 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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -40 +``` + +Expected: all 3 photo tests pass. + +- [ ] **Step 5: Commit** + +```bash +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` (add `nfc_employee_search` that delegates to existing kiosk search) +- Modify: `fusion_clock/tests/test_clock_nfc_kiosk.py` (add `TestEmployeeSearch`) + +- [ ] **Step 1: Write the failing test** + +Append to `fusion_clock/tests/test_clock_nfc_kiosk.py`: + +```python +@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', + 'groups_id': [(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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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): + +```python + @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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-tags fusion_clock --stop-after-init -u fusion_clock 2>&1 | tail -30 +``` + +Expected: search test passes. + +- [ ] **Step 5: Commit** + +```bash +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 in `web.assets_frontend`) + +- [ ] **Step 1: Create the SCSS file** + +Create `fusion_clock/static/src/scss/nfc_kiosk.scss`: + +```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: + +```python + '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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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** + +```bash +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 + + + + + + + + + + + + + + + + --:-- + — + + Clock at: + ⚠ No location configured + + ⚙ + + + + + + Welcome to Fusion Clock NFC Kiosk + Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device. + Tap to enable NFC reader + + + + + + + + + + + +``` + +- [ ] **Step 2: Reload module** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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: + +```bash +docker exec -it odoo-dev-app odoo shell -d fusion-dev --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** + +```bash +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 has `web.assets_frontend` from 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`: + +```javascript +/* @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 = ` + + ⌐■ + Tap your card to clock in or out + + `; + } + + function renderProcessing() { + stateContainer.innerHTML = ` + Reading card… + `; + } + + function renderResult(payload) { + const isError = payload && payload.error; + const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success"; + + if (isError) { + stateContainer.innerHTML = ` + + + ${escapeHtml(payload.message || "Error")} + + + `; + 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 = ` + + + + ${escapeHtml(payload.employee_name)} + ${action} at ${time} + ${hours ? `${hours}` : ""} + + + `; + setTimeout(() => setState(STATE.IDLE), 3000); + } + } + + function renderEnroll(payload) { + // Full implementation lands in Task 18; this stub keeps the state machine valid. + stateContainer.innerHTML = `Enroll mode (filled in by Task 18)`; + } + + 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: + +```python + '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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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** + +```bash +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: + +```javascript + // ────────────────────────────────────────────────────────────── + // 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 = ` + + Setup failed + ${escapeHtml(e.message)} + + `; + } + }); + } + + window.__nfcKiosk = { + setState, STATE, photoRequired, debugEnabled, locationConfigured, + handleTap, // exposed for mock-tap debug (Task 19) + }; +``` + +- [ ] **Step 2: Reload + verify in Chrome on Android** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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: +> 1. Use `http://localhost` directly on the device (NOT supported for NFC on Android — Android requires HTTPS even for localhost) +> 2. Set up `ngrok http 8069` to get an HTTPS tunnel, or +> 3. 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** + +```bash +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 the `capturePhoto` stub 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: + +```javascript + // ────────────────────────────────────────────────────────────── + // 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: + +```javascript + 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 = ` + + Setup failed + ${escapeHtml(e.message)} + + `; + } + }); + } +``` + +- [ ] **Step 2: Reload + smoke test on a real device** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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** + +```bash +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: + +```javascript + // ────────────────────────────────────────────────────────────── + // 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 = ` + + + Enter Enroll Mode Password + ${masked} + + ${[1,2,3,4,5,6,7,8,9].map(n => `${n}`).join("")} + ⌫ + 0 + OK + + + Cancel + + + + `; + 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 = ` + + + Pick the employee to enroll + + + + Cancel + + + + `; + 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 => + `${escapeHtml(e.name)} · ${escapeHtml(e.department || "")}` + ).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 = ` + + + Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card + ⌐■ + Hold the card to the back of the tablet + + Cancel + + + + `; + 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 = ` + + + ${msg} + + Enroll another + Done + + + + `; + 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: + +```javascript + window.__nfcKiosk = { + setState, STATE, photoRequired, debugEnabled, locationConfigured, + handleTap, _onEnrollTap, + }; +``` + +- [ ] **Step 2: Reload + smoke test on real device** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -5 +``` + +Set the enroll password: + +```bash +docker exec -it odoo-dev-app odoo shell -d fusion-dev --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** + +```bash +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: + +```javascript + // ────────────────────────────────────────────────────────────── + // 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** + +```bash +docker exec -it odoo-dev-app odoo shell -d fusion-dev --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_debug` defaults 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** + +```bash +docker exec -it odoo-dev-app odoo shell -d fusion-dev --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** + +```bash +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: + +```python + 'version': '19.0.2.0.0', +``` + +to: + +```python + '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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -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: + +```bash +docker exec -it odoo-dev-app psql -U odoo -d fusion-dev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';" +docker exec odoo-dev-app odoo -d fusion-dev -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** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --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: + +1. Navigate to `https:///fusion_clock/kiosk/nfc` +2. Log in as the kiosk service user +3. Click "Tap to enable NFC reader" — grant NFC + camera permissions +4. Confirm IDLE state shows +5. Click ⚙ → enter enroll password → search for an employee → tap a contactless credit card to enroll it +6. Confirm "✓ Card XX:XX:... enrolled" appears +7. Click "Done" → tap the same card again → confirm RESULT screen shows "Welcome — CLOCKED IN" +8. Wait 6+ seconds, tap again → confirm RESULT shows "CLOCKED OUT" +9. In Odoo backend, navigate to `Attendances`, find the new record, confirm `Clock Source = NFC Kiosk` and the photo fields are populated + +- [ ] **Step 5: Commit the version bump** + +```bash +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.md` has 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_source` extended) +- [ ] 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_clock` so the `--test-tags fusion_clock` command picks them up +- [ ] Module version bumped at the end
Clock at:
Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.
${escapeHtml(e.message)}
Hold the card to the back of the tablet