feat(fusion_clock): PIN kiosk — polished photo-tile + PIN clock (opt-in)

A proper shared-device PIN kiosk for clients who don't want NFC: photo-tile grid
(+search) -> tap -> PIN (or first-use create) -> optional master-gated selfie ->
clock, in the NFC kiosk's dark glass + brand-gradient style. Built as an Odoo 19
Interaction; new pin_kiosk.scss (scoped); reworked clock_kiosk.py
(search +avatar/has_pin, verify_pin needs_setup, set_pin, clock via kiosk location).
Drops the redundant kiosk_pin_required (PIN always required); relabels the company
kiosk location; adds a PIN-kiosk app icon. Opt-in via enable_kiosk (off by default).
HttpCase tests added. Bump 19.0.4.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 21:25:32 -04:00
parent b61e159e6f
commit a5ec79013a
13 changed files with 672 additions and 393 deletions

View File

@@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
import json
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestPinKioskIdentity(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'PIN Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'PIN Kiosk Op', 'login': 'pin-kiosk-op', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.withpin = cls.env['hr.employee'].create({
'name': 'Pat WithPin', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
})
cls.nopin = cls.env['hr.employee'].create({
'name': 'Nora NoPin', 'x_fclk_enable_clock': True,
})
def _call(self, route, params):
self.authenticate('pin-kiosk-op', 'kioskpass123')
resp = self.url_open(route, data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': params,
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_search_returns_avatar_and_has_pin(self):
res = self._call('/fusion_clock/kiosk/search', {'query': ''})
rows = {e['name']: e for e in res['employees']}
self.assertIn('Pat WithPin', rows)
self.assertTrue(rows['Pat WithPin']['has_pin'])
self.assertFalse(rows['Nora NoPin']['has_pin'])
self.assertIn('/web/image/hr.employee.public/', rows['Pat WithPin']['avatar_url'])
def test_verify_pin_correct(self):
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '1234'})
self.assertTrue(res.get('success'))
def test_verify_pin_incorrect(self):
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '9999'})
self.assertEqual(res.get('error'), 'invalid_pin')
def test_verify_pin_needs_setup(self):
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.nopin.id, 'pin': ''})
self.assertTrue(res.get('needs_setup'))
def test_set_pin_success_then_required(self):
res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '4321'})
self.assertTrue(res.get('success'))
self.assertEqual(self.nopin.x_fclk_kiosk_pin, '4321')
res2 = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '0000'})
self.assertEqual(res2.get('error'), 'already_set')
def test_set_pin_rejects_bad_format(self):
res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.withpin.id, 'pin': '12'})
self.assertEqual(res.get('error'), 'bad_pin')
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestPinKioskClock(HttpCase):
PNG = ('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC'
'AAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'PIN Plant 2', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'PIN Op2', 'login': 'pin-op2', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Quinn Clock', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
})
def _clock(self, employee_id, photo_b64=''):
self.authenticate('pin-op2', 'kioskpass123')
resp = self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'employee_id': employee_id, 'photo_b64': photo_b64},
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def _latest(self, emp_id):
return self.env['hr.attendance'].search(
[('employee_id', '=', emp_id)], order='check_in desc', limit=1)
def test_clock_in_uses_kiosk_location(self):
res = self._clock(self.emp.id)
self.assertTrue(res.get('success'))
self.assertEqual(res.get('action'), 'clock_in')
att = self._latest(self.emp.id)
self.assertEqual(att.x_fclk_clock_source, 'kiosk')
self.assertEqual(att.x_fclk_location_id, self.location)
def test_photo_off_stores_nothing(self):
self.ICP.set_param('fusion_clock.enable_photo_verification', 'False')
emp = self.env['hr.employee'].create({
'name': 'Quinn Off', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'})
self._clock(emp.id, self.PNG)
self.assertFalse(self._latest(emp.id).x_fclk_check_in_photo)
def test_photo_on_stores_selfie(self):
self.ICP.set_param('fusion_clock.enable_photo_verification', 'True')
emp = self.env['hr.employee'].create({
'name': 'Quinn On', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'})
self._clock(emp.id, self.PNG)
self.assertTrue(self._latest(emp.id).x_fclk_check_in_photo)
def test_no_location_configured(self):
self.env.company.x_fclk_nfc_kiosk_location_id = False
emp = self.env['hr.employee'].create({
'name': 'Quinn NoLoc', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'})
res = self._clock(emp.id)
self.assertEqual(res.get('error'), 'no_location_configured')