Files
Odoo-Modules/fusion_clock/controllers/clock_nfc_kiosk.py
gsinghpal 661c8ae227 feat(fusion_clock): NFC card enrollment endpoint
Adds /fusion_clock/kiosk/nfc/enroll (jsonrpc, auth=user) that validates
the enroll password, normalises the card UID, checks for duplicate
assignments, writes x_fclk_nfc_card_uid, and creates a card_enrollment
activity log entry. 4 new tests; 21 total passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:58:25 -04:00

107 lines
4.0 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
_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
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 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)
@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,
}