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) <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,4 @@ from . import tank_status
|
|||||||
from . import move_controller
|
from . import move_controller
|
||||||
from . import workspace_controller
|
from . import workspace_controller
|
||||||
from . import landing_controller
|
from . import landing_controller
|
||||||
|
from . import tablet_controller
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -6,6 +6,16 @@ import json
|
|||||||
from odoo.tests.common import HttpCase, TransactionCase, tagged
|
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')
|
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||||
class TestTabletPinHash(TransactionCase):
|
class TestTabletPinHash(TransactionCase):
|
||||||
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
|
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
|
||||||
@@ -52,3 +62,65 @@ class TestTabletPinHash(TransactionCase):
|
|||||||
# Check chatter
|
# Check chatter
|
||||||
last_msg = self.user.message_ids[:1]
|
last_msg = self.user.message_ids[:1]
|
||||||
self.assertIn('reset by', (last_msg.body or '').lower())
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user