2802 lines
104 KiB
Markdown
2802 lines
104 KiB
Markdown
# 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 `</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 => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||
}[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
|