Compare commits
7 Commits
a6546ac858
...
1d6184dd2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6184dd2f | ||
|
|
88a473e7eb | ||
|
|
08ababc2c7 | ||
|
|
59ad77839a | ||
|
|
a594431eb6 | ||
|
|
58d02598da | ||
|
|
395bd4949e |
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.29.0.0',
|
||||
'version': '19.0.30.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -45,7 +45,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_sequence_data.xml',
|
||||
'data/fp_cron_data.xml',
|
||||
'data/fp_tablet_config_data.xml',
|
||||
'views/fp_shopfloor_station_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/fp_bake_oven_views.xml',
|
||||
'views/fp_bake_window_views.xml',
|
||||
'views/fp_first_piece_gate_views.xml',
|
||||
|
||||
@@ -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,211 @@
|
||||
# -*- 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}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/unlock — verify PIN + manage failure counter / lockout
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/unlock', type='jsonrpc', auth='user')
|
||||
def unlock(self, user_id, pin):
|
||||
env = request.env
|
||||
Users = env['res.users'].sudo() # need sudo to read hash field
|
||||
target = Users.browse(int(user_id))
|
||||
if not target.exists():
|
||||
return {'ok': False, 'error': _('User not found.')}
|
||||
|
||||
# No PIN set yet — caller must set one first
|
||||
if not target.x_fc_tablet_pin_hash:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('No PIN set. Set one in Preferences first.'),
|
||||
'needs_setup': True,
|
||||
}
|
||||
|
||||
# Currently locked out?
|
||||
now = fields.Datetime.now()
|
||||
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Account locked. Try again in a few minutes.'),
|
||||
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
|
||||
}
|
||||
|
||||
if target.verify_tablet_pin(pin):
|
||||
# Reset failure state on success
|
||||
target.write({
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
_logger.info(
|
||||
"Tablet unlocked by uid %s (session uid %s)",
|
||||
target.id, env.uid,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'current_tech_id': target.id,
|
||||
'current_tech_name': target.name,
|
||||
}
|
||||
|
||||
# Wrong PIN — increment and check threshold
|
||||
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
|
||||
threshold = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_threshold', 5,
|
||||
))
|
||||
lockout_min = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5,
|
||||
))
|
||||
vals = {'x_fc_tablet_pin_failed_count': new_count}
|
||||
if new_count >= threshold:
|
||||
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
|
||||
target.write(vals)
|
||||
_logger.warning(
|
||||
"Tablet PIN failure for uid %s (count=%d, locked=%s)",
|
||||
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
|
||||
)
|
||||
if vals.get('x_fc_tablet_locked_until'):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min,
|
||||
'locked_until': vals['x_fc_tablet_locked_until'].isoformat(),
|
||||
}
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Incorrect PIN.'),
|
||||
'attempts_remaining': threshold - new_count,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/tiles — lock-screen tile grid
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
|
||||
def tiles(self, station_id=None):
|
||||
env = request.env
|
||||
op_group = env.ref(
|
||||
'fusion_plating.group_fusion_plating_operator',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not op_group:
|
||||
return {'ok': False, 'error': 'operator group missing'}
|
||||
|
||||
# Determine candidate users — station roster wins if non-empty
|
||||
users = op_group.user_ids
|
||||
if station_id:
|
||||
Station = env['fusion.plating.shopfloor.station']
|
||||
station = Station.browse(int(station_id))
|
||||
if (station.exists()
|
||||
and 'x_fc_authorised_user_ids' in station._fields
|
||||
and station.x_fc_authorised_user_ids):
|
||||
users = station.x_fc_authorised_user_ids
|
||||
|
||||
# has_pin needs sudo-read on the hash field
|
||||
clocked_ids = set()
|
||||
if 'hr.employee' in env and hasattr(
|
||||
env['hr.employee'], '_fp_clocked_in_user_ids',
|
||||
):
|
||||
clocked_ids = env['hr.employee']._fp_clocked_in_user_ids() or set()
|
||||
|
||||
users_sorted = users.sorted('name')
|
||||
users_sudo = users_sorted.sudo()
|
||||
tiles = []
|
||||
for u, u_sudo in zip(users_sorted, users_sudo):
|
||||
tiles.append({
|
||||
'user_id': u.id,
|
||||
'name': u.name,
|
||||
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
|
||||
'is_clocked_in': u.id in clocked_ids,
|
||||
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
|
||||
})
|
||||
# Clocked-in first, then alphabetical within bucket
|
||||
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
|
||||
return {'ok': True, 'tiles': tiles}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/ping — heartbeat used by the OWL component on every action
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/ping', type='jsonrpc', auth='user')
|
||||
def ping(self, current_tech_id=None):
|
||||
"""Lightweight heartbeat. Used by the OWL component to confirm
|
||||
the server-side session is alive AND to log the tech-of-record
|
||||
every few minutes so the server has forensic visibility into
|
||||
which tech was 'driving' the tablet at any moment.
|
||||
"""
|
||||
if current_tech_id:
|
||||
_logger.debug(
|
||||
"Tablet ping: session uid %s carrying tablet_tech_id=%s",
|
||||
request.env.uid, current_tech_id,
|
||||
)
|
||||
return {'ok': True, 'server_time': fields.Datetime.now().isoformat()}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Phase 6 tablet PIN gate — default knobs.
|
||||
All overridable via Settings → Technical → Parameters → System Parameters.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="ir_config_param_tablet_idle_lock_minutes" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_idle_lock_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_pin_fail_threshold" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_pin_fail_threshold</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_pin_fail_lockout_minutes" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_pin_fail_lockout_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_warn_seconds_before_lock" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_warn_seconds_before_lock</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -8,3 +8,4 @@ from . import fp_bake_window
|
||||
from . import fp_first_piece_gate
|
||||
from . import fp_operator_queue
|
||||
from . import fp_tank
|
||||
from . import res_users
|
||||
|
||||
@@ -73,6 +73,26 @@ class FpShopfloorStation(models.Model):
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# Phase 6 tablet PIN gate — per-station roster + idle override.
|
||||
x_fc_authorised_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
relation='fp_shopfloor_station_authorised_user_rel',
|
||||
column1='station_id',
|
||||
column2='user_id',
|
||||
string='Authorised Operators',
|
||||
help='If set, the tablet lock screen only shows tiles for these '
|
||||
'users. Empty = all operator-group users are shown. Use to '
|
||||
'restrict a tablet at a specialised station (e.g. EN Plating) '
|
||||
'to techs trained on that station.',
|
||||
)
|
||||
x_fc_idle_lock_minutes = fields.Integer(
|
||||
string='Idle Lock (minutes)',
|
||||
help='Per-station override for the auto-lock idle threshold. '
|
||||
'Leave blank to use the global default '
|
||||
'(ir.config_parameter fp.shopfloor.tablet_idle_lock_minutes, '
|
||||
'default 5).',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_shopfloor_station_code_uniq',
|
||||
|
||||
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
|
||||
|
||||
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
|
||||
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
|
||||
hash; never plaintext.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
|
||||
# against brute-force even if the DB leaks.
|
||||
_PBKDF2_ITERATIONS = 200_000
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
x_fc_tablet_pin_hash = fields.Char(
|
||||
string='Tablet PIN (hashed)',
|
||||
groups='fusion_plating.group_fusion_plating_manager',
|
||||
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
|
||||
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
|
||||
'non-managers; never logged.',
|
||||
)
|
||||
x_fc_tablet_pin_set_date = fields.Datetime(
|
||||
string='Tablet PIN Set Date',
|
||||
help='When the current PIN was last set or changed.',
|
||||
)
|
||||
x_fc_tablet_pin_failed_count = fields.Integer(
|
||||
string='Failed PIN Attempts',
|
||||
default=0,
|
||||
help='Sequential failed unlock attempts since the last success. '
|
||||
'Resets to 0 on a correct PIN.',
|
||||
)
|
||||
x_fc_tablet_locked_until = fields.Datetime(
|
||||
string='Tablet Lockout Until',
|
||||
help='Wall-clock time at which the per-user lockout expires. '
|
||||
'Null when not locked. Set after the configured fail '
|
||||
'threshold (default 5) is reached.',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hash_tablet_pin(pin, salt=None):
|
||||
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||
)
|
||||
return f"{salt.hex()}${digest.hex()}"
|
||||
|
||||
@staticmethod
|
||||
def _verify_tablet_pin_hash(pin, stored):
|
||||
"""Constant-time verify of `pin` against a stored hash string."""
|
||||
if not stored or '$' not in stored:
|
||||
return False
|
||||
salt_hex, expected_hex = stored.split('$', 1)
|
||||
try:
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||
)
|
||||
return secrets.compare_digest(digest.hex(), expected_hex)
|
||||
|
||||
def set_tablet_pin(self, pin):
|
||||
"""Set or change this user's tablet PIN. Requires sudo OR self.
|
||||
|
||||
Caller is responsible for verifying the OLD pin separately if a
|
||||
hash already exists — this method just writes the new one.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not pin or not pin.isdigit() or len(pin) != 4:
|
||||
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
|
||||
self.sudo().write({
|
||||
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
|
||||
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
return True
|
||||
|
||||
def verify_tablet_pin(self, pin):
|
||||
"""Return True if `pin` matches this user's stored hash."""
|
||||
self.ensure_one()
|
||||
if not pin:
|
||||
return False
|
||||
# sudo: even non-manager callers may need to verify their OWN PIN.
|
||||
# The hash field has manager-only read; sudo bypasses that.
|
||||
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
|
||||
|
||||
def clear_tablet_pin(self):
|
||||
"""Manager-side reset. Clears hash so target must set a new PIN.
|
||||
Posts to chatter for audit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
manager_name = self.env.user.name
|
||||
self.sudo().write({
|
||||
'x_fc_tablet_pin_hash': False,
|
||||
'x_fc_tablet_pin_set_date': False,
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
self.message_post(
|
||||
body=_('Tablet PIN reset by %s. User must set a new PIN '
|
||||
'on next unlock attempt.') % manager_name,
|
||||
)
|
||||
return True
|
||||
|
||||
def action_open_tablet_pin_setup(self):
|
||||
"""Trigger the FpPinSetup OWL modal from the Preferences form.
|
||||
The Phase 6.2 OWL component intercepts this action tag.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_tablet_pin_setup',
|
||||
'name': 'Set Tablet PIN',
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_workspace_controller
|
||||
from . import test_landing_kanban
|
||||
from . import test_tablet_pin
|
||||
|
||||
216
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
Normal file
216
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""Phase 6 — Tablet PIN gate tests."""
|
||||
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."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = self.env['res.users'].create({
|
||||
'name': 'Pin Test',
|
||||
'login': 'pintest@example.com',
|
||||
})
|
||||
|
||||
def test_set_pin_stores_salted_hash(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
stored = self.user.sudo().x_fc_tablet_pin_hash
|
||||
self.assertTrue(stored)
|
||||
self.assertIn('$', stored, 'hash must include salt separator')
|
||||
# Hash is non-deterministic — setting same PIN twice gives different stored values
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash)
|
||||
|
||||
def test_verify_correct_pin(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.assertTrue(self.user.sudo().verify_tablet_pin('1234'))
|
||||
|
||||
def test_verify_wrong_pin(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.assertFalse(self.user.sudo().verify_tablet_pin('0000'))
|
||||
|
||||
def test_verify_pin_no_hash_set(self):
|
||||
self.assertFalse(self.user.sudo().verify_tablet_pin('1234'))
|
||||
|
||||
def test_set_pin_rejects_invalid_format(self):
|
||||
from odoo.exceptions import UserError
|
||||
for bad in ('123', '12345', 'abcd', '', None):
|
||||
with self.assertRaises(UserError):
|
||||
self.user.sudo().set_tablet_pin(bad)
|
||||
|
||||
def test_clear_pin_wipes_hash_and_posts_chatter(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.user.clear_tablet_pin()
|
||||
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
|
||||
self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash)
|
||||
self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date)
|
||||
# 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)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletUnlock(HttpCase):
|
||||
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.target = self.env['res.users'].create({
|
||||
'name': 'Unlock Target', 'login': 'unlock@example.com',
|
||||
})
|
||||
self.target.sudo().set_tablet_pin('1234')
|
||||
|
||||
def _unlock(self, pin):
|
||||
return _rpc(self, '/fp/tablet/unlock',
|
||||
user_id=self.target.id, pin=pin)
|
||||
|
||||
def test_unlock_correct_pin(self):
|
||||
res = self._unlock('1234')
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['current_tech_id'], self.target.id)
|
||||
self.assertEqual(res['current_tech_name'], 'Unlock Target')
|
||||
|
||||
def test_unlock_correct_pin_resets_fail_counter(self):
|
||||
self._unlock('0000') # fail once
|
||||
self._unlock('1234') # succeed
|
||||
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
|
||||
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0)
|
||||
|
||||
def test_unlock_wrong_pin_increments_counter(self):
|
||||
self._unlock('0000')
|
||||
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
|
||||
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1)
|
||||
|
||||
def test_lockout_after_5_fails(self):
|
||||
for _ in range(5):
|
||||
self._unlock('0000')
|
||||
res = self._unlock('0000') # 6th
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('locked', res['error'].lower())
|
||||
self.target.invalidate_recordset(['x_fc_tablet_locked_until'])
|
||||
self.assertTrue(self.target.sudo().x_fc_tablet_locked_until)
|
||||
|
||||
def test_lockout_blocks_even_correct_pin(self):
|
||||
for _ in range(5):
|
||||
self._unlock('0000')
|
||||
# Even the correct PIN now rejected
|
||||
res = self._unlock('1234')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('locked', res['error'].lower())
|
||||
|
||||
def test_unlock_no_pin_set(self):
|
||||
self.target.clear_tablet_pin()
|
||||
res = self._unlock('1234')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertTrue(res.get('needs_setup'))
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletTiles(HttpCase):
|
||||
"""P6.1.4 — /fp/tablet/tiles endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
|
||||
self.alice = self.env['res.users'].create({
|
||||
'name': 'Alice Tile', 'login': 'alice_tile@example.com',
|
||||
'group_ids': [(6, 0, [self.op_group.id])],
|
||||
})
|
||||
self.bob = self.env['res.users'].create({
|
||||
'name': 'Bob Tile', 'login': 'bob_tile@example.com',
|
||||
'group_ids': [(6, 0, [self.op_group.id])],
|
||||
})
|
||||
self.alice.sudo().set_tablet_pin('1111')
|
||||
|
||||
def test_tiles_returns_all_operators_without_station(self):
|
||||
res = _rpc(self, '/fp/tablet/tiles')
|
||||
self.assertTrue(res['ok'])
|
||||
names = [t['name'] for t in res['tiles']]
|
||||
self.assertIn('Alice Tile', names)
|
||||
self.assertIn('Bob Tile', names)
|
||||
|
||||
def test_tile_has_pin_flag(self):
|
||||
res = _rpc(self, '/fp/tablet/tiles')
|
||||
alice_tile = next(t for t in res['tiles'] if t['name'] == 'Alice Tile')
|
||||
bob_tile = next(t for t in res['tiles'] if t['name'] == 'Bob Tile')
|
||||
self.assertTrue(alice_tile['has_pin'])
|
||||
self.assertFalse(bob_tile['has_pin'])
|
||||
@@ -49,6 +49,13 @@
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Tablet PIN Gate (Phase 6)">
|
||||
<field name="x_fc_authorised_user_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_idle_lock_minutes"
|
||||
placeholder="default 5 (system parameter)"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Phase 6 tablet PIN gate — surfaces:
|
||||
(a) self-service "Set/Change PIN" on the user's Preferences form
|
||||
(b) manager-only "Reset Tablet PIN" header button on res.users form
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- =================================================================
|
||||
(a) Preferences form — Set/Change Tablet PIN
|
||||
The actual modal is OWL-side (FpPinSetup, Phase 6.2). Here we
|
||||
just surface a status indicator + the button under a group.
|
||||
================================================================= -->
|
||||
<record id="view_users_form_preferences_tablet_pin" model="ir.ui.view">
|
||||
<field name="name">res.users.preferences.tablet.pin</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Tablet PIN" name="fp_tablet_pin_group">
|
||||
<field name="x_fc_tablet_pin_set_date" readonly="1"
|
||||
string="PIN Last Set"/>
|
||||
<button name="action_open_tablet_pin_setup"
|
||||
type="object"
|
||||
string="Set / Change Tablet PIN"
|
||||
class="btn-secondary"
|
||||
icon="fa-key"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- =================================================================
|
||||
(b) res.users form — Manager-only "Reset Tablet PIN" header
|
||||
================================================================= -->
|
||||
<record id="view_users_form_reset_tablet_pin" model="ir.ui.view">
|
||||
<field name="name">res.users.form.reset.tablet.pin</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="clear_tablet_pin"
|
||||
type="object"
|
||||
string="Reset Tablet PIN"
|
||||
class="btn-warning"
|
||||
icon="fa-eraser"
|
||||
groups="fusion_plating.group_fusion_plating_manager"
|
||||
invisible="not x_fc_tablet_pin_set_date"
|
||||
confirm="Reset this user's tablet PIN? They'll need to set a new one on their next unlock."/>
|
||||
<field name="x_fc_tablet_pin_set_date" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user