diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index b67b4046..6c3f4da2 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -4,7 +4,7 @@ import logging import re -from odoo import http +from odoo import fields, http from odoo.http import request _logger = logging.getLogger(__name__) @@ -104,3 +104,94 @@ class FusionClockNfcKiosk(http.Controller): 'employee_name': target.name, 'card_uid': normalized, } + + @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 user.has_group('fusion_clock.group_fusion_clock_manager'): + 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'} + + company = request.env.company + 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' + now = fields.Datetime.now() + today = now.date() + + 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', + }) + 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', + ) + 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_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, + } + else: + attendance.sudo().write({ + 'x_fclk_out_distance': 0.0, + }) + api._apply_break_deduction(attendance, employee) + _, 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_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), + } diff --git a/fusion_clock/tests/test_clock_nfc_kiosk.py b/fusion_clock/tests/test_clock_nfc_kiosk.py index 4f77ebf4..9a1be663 100644 --- a/fusion_clock/tests/test_clock_nfc_kiosk.py +++ b/fusion_clock/tests/test_clock_nfc_kiosk.py @@ -151,3 +151,71 @@ class TestEnrollEndpoint(HttpCase): 'enroll_password': '1234', }) self.assertEqual(result.get('error'), 'invalid_uid') + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestTapEndpointHappyPath(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ICP = cls.env['ir.config_parameter'].sudo() + cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') + cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False') + cls.location = cls.env['fusion.clock.location'].create({ + 'name': 'Tap Plant', + 'latitude': 43.65, + 'longitude': -79.38, + 'radius': 100, + }) + cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id + cls.kiosk_user = cls.env['res.users'].create({ + 'name': 'Tap Kiosk User', + 'login': 'nfc-kiosk-tap', + 'password': 'kioskpass123', + 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], + }) + cls.alice = cls.env['hr.employee'].create({ + 'name': 'Alice T', + 'x_fclk_enable_clock': True, + 'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90', + }) + + def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''): + self.authenticate('nfc-kiosk-tap', 'kioskpass123') + response = self.url_open( + '/fusion_clock/kiosk/nfc/tap', + data=json.dumps({ + 'jsonrpc': '2.0', + 'method': 'call', + 'params': {'card_uid': card_uid, 'photo_b64': photo_b64}, + }), + headers={'Content-Type': 'application/json'}, + ) + return response.json().get('result', {}) + + def test_first_tap_clocks_in(self): + result = self._tap() + self.assertTrue(result.get('success')) + self.assertEqual(result.get('action'), 'clock_in') + self.assertEqual(result.get('employee_name'), 'Alice T') + attendance = self.env['hr.attendance'].search([ + ('employee_id', '=', self.alice.id), + ], order='check_in desc', limit=1) + self.assertTrue(attendance) + self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk') + self.assertEqual(attendance.x_fclk_location_id, self.location) + self.assertFalse(attendance.check_out) + + def test_second_tap_clocks_out(self): + self._tap() + # Wait for debounce window (5s) to elapse + import time + time.sleep(6) + result = self._tap() + self.assertTrue(result.get('success')) + self.assertEqual(result.get('action'), 'clock_out') + attendance = self.env['hr.attendance'].search([ + ('employee_id', '=', self.alice.id), + ], order='check_in desc', limit=1) + self.assertTrue(attendance.check_out)