feat(fusion_clock): NFC kiosk — enrollment, manager page, sounds, lock, profile photos

Kiosk work across this session (19.0.3.6.0 -> 19.0.3.10.0):
- Program-from-unknown-tap: amber prompt -> Manager PIN -> pick/create employee
  -> binds the captured UID (no re-tap). Reassign moves a card between employees.
- Manager page (gear, when unlocked): search employees + tag status; assign/re-tag,
  clear tag, archive employee, + new employee. Server-gated by the enroll password.
- Screen lock: kiosk starts locked (tap-only); Unlock -> Manager PIN, Lock button;
  PIN remembered for the session so the gear never re-prompts.
- Sounds: pleasant + loud sine chimes (rising in / descending out) + a low "denied"
  tone for wrong/unknown taps. Gated by fusion_clock.enable_sounds.
- Guided profile-photo capture for employees with no picture (clock-in or enroll):
  live camera + oval face guide -> capture -> preview -> save to hr.employee.
- PIN no longer re-renders per digit; centered result card; 12h time; clock-out shows
  "Worked Xh Ym this shift"; modern clock idle icon; faster animations/result timers;
  session keep-alive so the kiosk login never expires.
- New endpoints: create_employee, clear_tag, delete_employee (archive), verify_pin,
  save_profile_photo; enroll gains force-reassign.
- Docs: fusion_clock is now developed in Claude Code (dropped Cursor references).

Spec/plan under fusion_clock/docs/superpowers/. Deployed live on entech
(odoo-entech / LXC 111 on pve-worker5), v19.0.3.10.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-30 17:21:33 -04:00
parent 2a16f80d8d
commit 55898dd1d4
10 changed files with 1002 additions and 84 deletions

View File

@@ -93,6 +93,7 @@ class FusionClockNfcKiosk(http.Controller):
'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',
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
}
return request.render('fusion_clock.nfc_kiosk_page', values)
@@ -141,8 +142,9 @@ class FusionClockNfcKiosk(http.Controller):
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."""
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', force=False, **kw):
"""Bind an NFC card UID to an employee. Manager-gated, password-gated.
With force=True, a card already held by another employee is moved (reassigned)."""
user = request.env.user
if not _is_kiosk_operator(user):
return {'error': 'access_denied'}
@@ -164,10 +166,12 @@ class FusionClockNfcKiosk(http.Controller):
('id', '!=', target.id),
], limit=1)
if existing:
return {
'error': 'card_already_assigned',
'existing_employee': existing.name,
}
if not force:
return {
'error': 'card_already_assigned',
'existing_employee': existing.name,
}
existing.x_fclk_nfc_card_uid = False # reassign: clear the previous holder
target.x_fclk_nfc_card_uid = normalized
@@ -181,10 +185,87 @@ class FusionClockNfcKiosk(http.Controller):
return {
'success': True,
'employee_id': target.id,
'employee_name': target.name,
'card_uid': normalized,
'needs_photo': not target.image_1920,
}
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
def nfc_create_employee(self, name='', enroll_password='', **kw):
"""Create a minimal hr.employee from the kiosk; the caller then enrolls the card.
Manager/Kiosk-Operator gated + enroll-password gated. Creates the employee via
sudo with just a name, clock enabled, and the current company — HR fills in the
rest (department, contract, etc.) later.
"""
user = request.env.user
if not _is_kiosk_operator(user):
return {'error': 'access_denied'}
if not self._check_enroll_password(request.env, enroll_password):
return {'error': 'invalid_password'}
clean = (name or '').strip()
if len(clean) < 2:
return {'error': 'invalid_name'}
employee = request.env['hr.employee'].sudo().create({
'name': clean,
'x_fclk_enable_clock': True,
'company_id': request.env.company.id,
})
return {'employee_id': employee.id, 'employee_name': employee.name}
@http.route('/fusion_clock/kiosk/nfc/clear_tag', type='jsonrpc', auth='user', methods=['POST'])
def nfc_clear_tag(self, employee_id=0, enroll_password='', **kw):
"""Unbind the NFC card from an employee. Manager/operator + password gated."""
if not _is_kiosk_operator(request.env.user):
return {'error': 'access_denied'}
if not self._check_enroll_password(request.env, enroll_password):
return {'error': 'invalid_password'}
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
if not emp.exists():
return {'error': 'employee_not_found'}
emp.x_fclk_nfc_card_uid = False
return {'success': True, 'employee_name': emp.name}
@http.route('/fusion_clock/kiosk/nfc/delete_employee', type='jsonrpc', auth='user', methods=['POST'])
def nfc_delete_employee(self, employee_id=0, enroll_password='', **kw):
"""Archive an employee (active=False) and clear their tag — a safe 'delete' that
preserves attendance history. Manager/operator + password gated."""
if not _is_kiosk_operator(request.env.user):
return {'error': 'access_denied'}
if not self._check_enroll_password(request.env, enroll_password):
return {'error': 'invalid_password'}
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
if not emp.exists():
return {'error': 'employee_not_found'}
name = emp.name
emp.x_fclk_nfc_card_uid = False
emp.active = False
return {'success': True, 'employee_name': name}
@http.route('/fusion_clock/kiosk/nfc/save_profile_photo', type='jsonrpc', auth='user', methods=['POST'])
def nfc_save_profile_photo(self, employee_id=0, photo_b64='', **kw):
"""Save a captured photo to the employee's profile image. Operator-gated (the
trusted kiosk device); no separate PIN, so it also works on self clock-in."""
if not _is_kiosk_operator(request.env.user):
return {'error': 'access_denied'}
photo = _strip_data_url_prefix(photo_b64)
if not photo:
return {'error': 'no_photo'}
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
if not emp.exists():
return {'error': 'employee_not_found'}
emp.image_1920 = photo
return {'success': True}
@http.route('/fusion_clock/kiosk/nfc/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
def nfc_verify_pin(self, pin='', **kw):
"""Verify the Manager PIN (enroll password) — used to unlock the kiosk screen.
Returns only a boolean so the PIN itself never reaches the client."""
if not _is_kiosk_operator(request.env.user):
return {'ok': False}
return {'ok': self._check_enroll_password(request.env, pin)}
@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."""
@@ -268,10 +349,12 @@ class FusionClockNfcKiosk(http.Controller):
return {
'success': True,
'action': 'clock_in',
'employee_id': employee.id,
'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,
'needs_photo': not employee.image_1920,
}
else:
attendance.sudo().write({
@@ -292,10 +375,12 @@ class FusionClockNfcKiosk(http.Controller):
return {
'success': True,
'action': 'clock_out',
'employee_id': employee.id,
'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),
'needs_photo': not employee.image_1920,
}
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])