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:
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user