From 58d02598dae37e8e6ae335edf20f49403bd6a3b2 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 23 May 2026 00:14:18 -0400 Subject: [PATCH] feat(fusion_plating_shopfloor): /fp/tablet/set_pin + /fp/tablet/reset_pin_for endpoints (P6.1.2) set_pin is self-service: requires old PIN if a hash exists, validates 4-digit format. reset_pin_for is manager-only (enforced server-side via has_group); clears the hash + posts to chatter. Both endpoints log INFO on success and WARNING on access-control denials. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/__init__.py | 1 + .../controllers/tablet_controller.py | 77 +++++++++++++++++++ .../tests/test_tablet_pin.py | 72 +++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py index 5fe813de..722bd359 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py @@ -8,3 +8,4 @@ from . import tank_status from . import move_controller from . import workspace_controller from . import landing_controller +from . import tablet_controller diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py new file mode 100644 index 00000000..100149dd --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""JSON-RPC endpoints for the tablet PIN gate (Phase 6 tablet redesign). + + POST /fp/tablet/tiles — list of tiles for the lock screen + POST /fp/tablet/unlock — verify PIN + clear/increment failure counter + POST /fp/tablet/set_pin — self-service set/change PIN + POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN + POST /fp/tablet/ping — bump server-side last-active timestamp + +Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md +""" +import logging +from datetime import timedelta + +from odoo import _, fields, http +from odoo.exceptions import UserError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _is_manager(env): + """True if calling user is in the fusion_plating manager group.""" + return env.user.has_group('fusion_plating.group_fusion_plating_manager') + + +class FpTabletController(http.Controller): + """Tablet PIN gate endpoints. All require an authenticated Odoo + session (the tablet logs in once as a 'shopfloor service' user). + """ + + # ====================================================================== + # /fp/tablet/set_pin — self-service set or change + # ====================================================================== + @http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user') + def set_pin(self, new_pin, old_pin=None): + env = request.env + user = env.user + existing_hash = user.sudo().x_fc_tablet_pin_hash + if existing_hash: + if not old_pin: + return {'ok': False, 'error': _('Current PIN is required to change it.')} + if not user.verify_tablet_pin(old_pin): + return {'ok': False, 'error': _('Current PIN is incorrect.')} + try: + user.set_tablet_pin(new_pin) + except UserError as e: + return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)} + _logger.info( + "Tablet PIN set/changed for uid %s by self", user.id, + ) + return {'ok': True} + + # ====================================================================== + # /fp/tablet/reset_pin_for — manager-only + # ====================================================================== + @http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user') + def reset_pin_for(self, user_id): + env = request.env + if not _is_manager(env): + _logger.warning( + "Non-manager uid %s attempted to reset PIN for user %s", + env.uid, user_id, + ) + return {'ok': False, 'error': _('Manager privilege required.')} + target = env['res.users'].browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': _('User not found.')} + target.clear_tablet_pin() + _logger.info( + "Tablet PIN reset for uid %s by manager uid %s", + target.id, env.uid, + ) + return {'ok': True} diff --git a/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py b/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py index 73888f32..fc9c97c3 100644 --- a/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py +++ b/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py @@ -6,6 +6,16 @@ import json from odoo.tests.common import HttpCase, TransactionCase, tagged +def _rpc(case, url, **params): + """Helper for HTTP/JSON-RPC tests — wraps Odoo's url_open.""" + res = case.url_open( + url, + data=json.dumps({'jsonrpc': '2.0', 'params': params}), + headers={'Content-Type': 'application/json'}, + ) + return res.json()['result'] + + @tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') class TestTabletPinHash(TransactionCase): """P6.1.1 — model fields + hash helpers + set/verify/clear methods.""" @@ -52,3 +62,65 @@ class TestTabletPinHash(TransactionCase): # Check chatter last_msg = self.user.message_ids[:1] self.assertIn('reset by', (last_msg.body or '').lower()) + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletSetPin(HttpCase): + """P6.1.2 — /fp/tablet/set_pin endpoint.""" + + def setUp(self): + super().setUp() + self.authenticate("admin", "admin") + + def test_set_pin_first_time(self): + res = _rpc(self, '/fp/tablet/set_pin', new_pin='1234') + self.assertTrue(res['ok']) + + def test_set_pin_change_requires_old(self): + admin = self.env.ref('base.user_admin') + admin.set_tablet_pin('1234') + # Change without old — rejected + res = _rpc(self, '/fp/tablet/set_pin', new_pin='5678') + self.assertFalse(res['ok']) + # Change with wrong old — rejected + res = _rpc(self, '/fp/tablet/set_pin', old_pin='9999', new_pin='5678') + self.assertFalse(res['ok']) + # Change with correct old — accepted + res = _rpc(self, '/fp/tablet/set_pin', old_pin='1234', new_pin='5678') + self.assertTrue(res['ok']) + + def test_set_pin_rejects_non_4_digit(self): + for bad in ('123', '12345', 'abcd', ''): + res = _rpc(self, '/fp/tablet/set_pin', new_pin=bad) + self.assertFalse(res['ok'], f'PIN {bad!r} should be rejected') + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletResetPinFor(HttpCase): + """P6.1.2 — /fp/tablet/reset_pin_for endpoint.""" + + def setUp(self): + super().setUp() + self.target = self.env['res.users'].create({ + 'name': 'Reset Target', 'login': 'reset@example.com', + }) + self.target.sudo().set_tablet_pin('1234') + + def test_reset_requires_manager_group(self): + # Plain operator can't reset + op_group = self.env.ref('fusion_plating.group_fusion_plating_operator') + self.env['res.users'].create({ + 'name': 'Op', 'login': 'op@example.com', + 'password': 'op@example.com', + 'group_ids': [(6, 0, [op_group.id])], + }) + self.authenticate('op@example.com', 'op@example.com') + res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id) + self.assertFalse(res['ok']) + + def test_reset_as_admin_clears_hash(self): + self.authenticate('admin', 'admin') + res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id) + self.assertTrue(res['ok']) + self.target.invalidate_recordset(['x_fc_tablet_pin_hash']) + self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)