Files
Odoo-Modules/docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
2026-05-14 07:07:45 -04:00

2802 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 126139) 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 `</odoo>`)
- [ ] **Step 1: Add the four NFC kiosk parameters**
Open `fusion_clock/data/ir_config_parameter_data.xml`. Find the closing `</odoo>` tag at the end of the file and insert the following block immediately before it:
```xml
<!-- NFC Clock Kiosk -->
<record id="config_enable_nfc_kiosk" model="ir.config_parameter">
<field name="key">fusion_clock.enable_nfc_kiosk</field>
<field name="value">False</field>
</record>
<record id="config_nfc_photo_required" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_photo_required</field>
<field name="value">True</field>
</record>
<record id="config_nfc_enroll_password" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_enroll_password</field>
<field name="value"></field>
</record>
<record id="config_nfc_kiosk_debug" model="ir.config_parameter">
<field name="key">fusion_clock.nfc_kiosk_debug</field>
<field name="value">False</field>
</record>
```
- [ ] **Step 2: Reload the module and confirm parameters are present**
```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 `</block>` inside the `<app>` element (look for the closing of an existing block near the end of the file), and add the following block immediately after it (still inside the `<app>` element):
```xml
<!-- ============================================================ -->
<!-- NFC Clock Kiosk -->
<!-- ============================================================ -->
<block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
<setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
<field name="fclk_enable_nfc_kiosk"/>
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
<div class="row mt16">
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_location_id"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_photo_required"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_enroll_password" password="True"/>
</div>
<div class="row mt8">
<label for="fclk_nfc_kiosk_debug" string="Mock-Tap Debug" class="col-lg-5 o_light_label"/>
<field name="fclk_nfc_kiosk_debug"/>
</div>
</div>
</setting>
</block>
```
- [ ] **Step 3: Reload and verify in browser**
```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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="nfc_kiosk_root" class="nfc-kiosk">
<h1>NFC Clock Kiosk</h1>
<div t-if="not location_configured" class="alert alert-warning">
No NFC kiosk location configured for <t t-esc="company_name"/>. Ask your administrator to configure one in Fusion Clock settings.
</div>
<div t-else="">
<p>Clock at: <span t-esc="location_name"/></p>
</div>
</div>
</t>
</template>
</odoo>
```
- [ ] **Step 7: Register the template + controller in the manifest**
Edit `fusion_clock/__manifest__.py`. In the `'data'` list, add `'views/kiosk_nfc_templates.xml'` immediately after the existing `'views/kiosk_templates.xml'` entry. The new line should read:
```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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
</t>
<div id="nfc_kiosk_root" class="nfc-kiosk"
t-att-data-photo-required="'1' if photo_required else '0'"
t-att-data-debug-enabled="'1' if debug_enabled else '0'"
t-att-data-location-configured="'1' if location_configured else '0'">
<!-- Static chrome (always visible) -->
<div class="nfc-kiosk__company" t-esc="company_name"/>
<div class="nfc-kiosk__time" id="nfc_clock_time">--:--</div>
<div class="nfc-kiosk__date" id="nfc_clock_date"></div>
<div class="nfc-kiosk__location">
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
</div>
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode"></button>
<!-- Dynamic state container (JS swaps inner HTML) -->
<div id="nfc_state_container">
<!-- Initially: One-time setup wizard step 1 -->
<div class="nfc-kiosk__setup">
<h2>Welcome to Fusion Clock NFC Kiosk</h2>
<p>Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.</p>
<button id="nfc_setup_start">Tap to enable NFC reader</button>
</div>
</div>
<!-- Hidden video element for camera feed -->
<video id="nfc_camera_feed" autoplay="autoplay" playsinline="playsinline" muted="muted"
style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"/>
<canvas id="nfc_camera_canvas" style="display:none;"/>
</div>
</t>
</template>
</odoo>
```
- [ ] **Step 2: Reload module**
```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 = `
<div class="nfc-kiosk__idle">
<div class="nfc-kiosk__icon">⌐■</div>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
`;
}
function renderProcessing() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__processing">Reading card…</div>
`;
}
function renderResult(payload) {
const isError = payload && payload.error;
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
if (isError) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.message || "Error")}</div>
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 4000);
} else {
const avatar = payload.employee_avatar_url || "";
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
const hours = payload.action === "clock_out" && payload.net_hours_today
? `${payload.net_hours_today.toFixed(1)}h today`
: "";
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.employee_name)}</div>
<div class="action">${action} at ${time}</div>
${hours ? `<div class="hours">${hours}</div>` : ""}
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 3000);
}
}
function renderEnroll(payload) {
// Full implementation lands in Task 18; this stub keeps the state machine valid.
stateContainer.innerHTML = `<div class="nfc-kiosk__processing">Enroll mode (filled in by Task 18)</div>`;
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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 = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
}
});
}
window.__nfcKiosk = {
setState, STATE, photoRequired, debugEnabled, locationConfigured,
handleTap, // exposed for mock-tap debug (Task 19)
};
```
- [ ] **Step 2: Reload + verify in Chrome on Android**
```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 = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
}
});
}
```
- [ ] **Step 2: Reload + smoke test on a real device**
```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 = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Enter Enroll Mode Password</h2>
<div class="pin-display">${masked}</div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
<button data-n="0">0</button>
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
btn.addEventListener("click", async () => {
resetEnrollIdleTimer();
const n = btn.dataset.n;
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
else if (n === "ok") {
// Try to advance to employee picker — server validates the password
// when the actual enroll request is made; here we just trust the
// user entered something and move on. If the enroll later fails
// with invalid_password we reset.
if (enrollPassword.length === 0) return;
renderEnroll({ phase: "search" });
return;
}
else enrollPassword += n;
renderEnroll({ phase: "password" });
});
});
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "search") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Pick the employee to enroll</h2>
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="enroll_list"></div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
const searchEl = document.getElementById("enroll_search");
const listEl = document.getElementById("enroll_list");
let debounceTimer = null;
searchEl.addEventListener("input", () => {
resetEnrollIdleTimer();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
listEl.innerHTML = (result.employees || []).map(e =>
`<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
).join("");
listEl.querySelectorAll(".employee-row").forEach(row => {
row.addEventListener("click", () => {
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
renderEnroll({ phase: "tap" });
});
});
}, 200);
});
searchEl.focus();
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "tap") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
<div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
<p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "result") {
const ok = !payload.error;
const msg = ok
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
: (payload.error === "invalid_password"
? "Wrong password. Try again."
: payload.error === "card_already_assigned"
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
: `Enroll failed: ${escapeHtml(payload.error)}`);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
<div class="actions" style="justify-content:center">
<button class="confirm" id="enroll_another">Enroll another</button>
<button class="cancel" id="enroll_done">Done</button>
</div>
</div>
</div>
`;
document.getElementById("enroll_another").addEventListener("click", () => {
enrollSelectedEmployee = null;
renderEnroll({ phase: ok ? "search" : "password" });
});
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
}
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: enrollSelectedEmployee.id,
card_uid: uid,
enroll_password: enrollPassword,
});
renderEnroll({ phase: "result", ...result });
}
// ⚙ button → enter Enroll Mode
const settingsBtn = document.getElementById("nfc_settings_btn");
if (settingsBtn) {
settingsBtn.addEventListener("click", () => {
if (currentState !== STATE.IDLE) return;
enrollPassword = "";
enrollSelectedEmployee = null;
setState(STATE.ENROLL, { phase: "password" });
});
}
```
Then UPDATE the `window.__nfcKiosk` exposure at the bottom to include the enroll callback:
```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 <Name>". 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://<odoo-domain>/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 <Name> — 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