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 workspace_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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user