# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import json import logging import re import time import threading from odoo import fields, http from odoo.http import request from odoo.addons.fusion_clock.models.tz_utils import get_local_today _logger = logging.getLogger(__name__) _UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$') _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 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 def _is_kiosk_operator(user): """Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator.""" return (user.has_group('fusion_clock.group_fusion_clock_manager') or user.has_group('fusion_clock.group_fusion_clock_kiosk_app')) 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 return ':'.join(cleaned[i:i+2] for i in range(0, len(cleaned), 2)) @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 _is_kiosk_operator(user): 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.sudo() location = company.x_fclk_nfc_kiosk_location_id company_logo_url = ( '/web/image/res.company/%s/logo' % company.id if company.logo else '' ) values = { 'page_name': 'nfc_kiosk', 'company_name': company.name, 'company_logo_url': company_logo_url, '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', 'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True', } return request.render('fusion_clock.nfc_kiosk_page', values) @http.route('/fusion_clock/kiosk/nfc/manifest.webmanifest', type='http', auth='public') def nfc_kiosk_manifest(self, **kw): """Web App Manifest so the NFC kiosk installs as a full-screen home-screen app. On a wall tablet, 'Install' (Chrome) / 'Add to Home Screen' (Safari) then launches the kiosk standalone -- no address bar or browser tabs, like Odoo's own PWA. Public so the icon/splash can load without a session. """ company = request.env.company.sudo() # Square icons via Odoo's on-the-fly resizer (placeholder if the company has no logo). icon_192 = '/web/image/res.company/%s/logo/192x192' % company.id icon_512 = '/web/image/res.company/%s/logo/512x512' % company.id manifest = { 'name': 'Fusion Clock Kiosk', 'short_name': 'Clock Kiosk', 'description': 'Tap-to-clock NFC kiosk', 'start_url': '/fusion_clock/kiosk/nfc', 'scope': '/', 'display': 'fullscreen', 'display_override': ['fullscreen', 'standalone'], 'background_color': '#0e1116', 'theme_color': '#0e1116', 'orientation': 'any', 'icons': [ {'src': icon_192, 'sizes': '192x192', 'type': 'image/png'}, {'src': icon_512, 'sizes': '512x512', 'type': 'image/png'}, ], } return request.make_response( json.dumps(manifest), headers=[ ('Content-Type', 'application/manifest+json; charset=utf-8'), ('Cache-Control', 'public, max-age=3600'), ], ) @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='', 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'} 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: 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 # 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_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 # Also push to the linked user's partner image, which is the image Odoo # shows on the user's profile/preferences avatar (res.users delegates # image_1920 to res.partner). Employees with no user are HR-only photos. if emp.user_id and emp.user_id.partner_id: emp.user_id.partner_id.sudo().write({'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.""" user = request.env.user if not _is_kiosk_operator(user): 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'} if _is_debounced(normalized): return {'error': 'debounce'} 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'' company = request.env.company.sudo() 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: _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.'} from .clock_api import FusionClockAPI api = FusionClockAPI() is_checked_in = employee.attendance_state == 'checked_in' # Cache-buster: /web/image is browser-cached, so without a unique token a # freshly-saved profile photo never shows. write_date bumps on every # write (incl. saving image_1920), so it refreshes exactly when needed. avatar_unique = employee.write_date.strftime('%Y%m%d%H%M%S') if employee.write_date else '' # PUBLIC model: the kiosk runs as a non-HR operator who can't read # hr.employee images (ACL) — /web/image would serve a placeholder. # hr.employee.public exposes the same avatar to any internal user # (verified readable as the kiosk operator, uid 141). avatar_url = f'/web/image/hr.employee.public/{employee.id}/avatar_128?unique={avatar_unique}' now = fields.Datetime.now() today = get_local_today(request.env, employee) day_plan = employee._get_fclk_day_plan(today) is_scheduled_off = not day_plan.get('scheduled') 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: 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, }) 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', ) if is_scheduled_off: api._log_activity( employee, 'unscheduled_shift', f"NFC kiosk clock-in on an unscheduled day at {location.name}", attendance=attendance, location=location, latitude=0, longitude=0, distance=0, source='nfc_kiosk', ) else: 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_id': employee.id, 'employee_name': employee.name, 'employee_avatar_url': avatar_url, 'message': f'{employee.name} clocked in at {location.name}', 'worked_hours': 0.0, 'needs_photo': not employee.image_1920, } else: attendance.sudo().write({ 'x_fclk_out_distance': 0.0, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, }) api._apply_break_deduction(attendance, employee) if not is_scheduled_off: _, 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_id': employee.id, 'employee_name': employee.name, 'employee_avatar_url': avatar_url, 'message': f'{employee.name} clocked out', # GROSS time between clock-in and clock-out (what the employee # expects to see). x_fclk_net_hours subtracts break + early-out # penalty minutes, which zeroed short shifts — that's for payroll. 'worked_hours': attendance.worked_hours or 0.0, 'needs_photo': not employee.image_1920, } @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)