# 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-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.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-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): ```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-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** ```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-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: ```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-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** ```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': 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-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.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-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** ```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-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: ```bash 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** ```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-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.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': 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`: ```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-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`: ```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 ``` - [ ] **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-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** ```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-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`: ```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-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** ```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', '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** ```bash 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): ```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-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** ```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': 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** ```bash 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): ```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-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** ```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': 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** ```bash 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: ```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-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** ```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': 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** ```bash 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: ```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-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** ```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', '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** ```bash 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): ```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-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** ```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-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** ```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 ``` - [ ] **Step 2: Reload module** ```bash 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: ```bash 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** ```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-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** ```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-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: > 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-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** ```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 => ``).join("")}
`; 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

`; 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

`; 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}

`; 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-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init 2>&1 | tail -5 ``` Set the enroll password: ```bash 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** ```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-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_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-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** ```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-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: ```bash 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** ```bash 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: 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