3-sub-phase TDD plan executing the spec at docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md: - Phase 6.1 (Backend): res.users PIN fields + PBKDF2-SHA256 hash helpers, 5 /fp/tablet/* endpoints (tiles/unlock/set_pin/reset_pin_for/ ping), per-user lockout after 5 failures, station roster + idle-override fields, ir.config_parameter defaults, Preferences Set/Change PIN button, manager Reset PIN header button. Tests cover hash safety, lockout edge cases, manager-only enforcement, tile filtering. - Phase 6.2 (Frontend lock screen): tech_store + activity_tracker OWL services, FpPinPad + FpIdleWarning + FpPinSetup components, FpTabletLock outer wrapper, wire into Landing/Workspace/Manager Dashboard with Hand-Off button injection. - Phase 6.3 (Audit propagation): fpRpc wrapper auto-injects tablet_tech_id, env_for_tablet_tech server helper, all action endpoints (workspace + shopfloor + manager) accept the kwarg and rebind env via env.with_user() so writes carry the right operator. Each sub-phase ships independently per spec §9. Plan follows the established workflow: write tests + commit, verify on entech (local docker doesn't have fusion_plating mounted). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92 KiB
Shop Floor PIN Gate + Auto-Lock Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a tile-grid lock screen + 4-digit PIN per tech + auto-lock after idle so multiple techs sharing one tablet get correctly-attributed audit trails on every shop-floor action.
Architecture: Single Odoo session per tablet, PIN-gated overlay on top via a new FpTabletLock OWL wrapper. Backend stores PBKDF2-SHA256 hashed PINs on res.users; lockout per-user after 5 failures; idle detection via global pointer/touch/keydown listeners. Audit propagation via optional tablet_tech_id kwarg added to existing endpoints — server uses env.with_user(...) so writes carry the right uid without JS bundle reload.
Tech Stack: Odoo 19 (Python controllers, models, data XML), OWL 2.x (static template, static props = ["*"], standalone rpc(), services registry), PBKDF2-SHA256 (Python stdlib), SCSS with $o-webclient-color-scheme dark-mode branch.
Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md
Local dev note: The Phase 1-5 deploy established that fusion_plating is NOT mounted in the local odoo-modsdev-app container. Tests are written per task and committed but verified on the live entech LXC (ssh pve-worker5 'pct exec 111 -- ...'). The plan's "verify test fails / passes" steps describe the expected entech behaviour for the executing agent to confirm after deploy.
Module version bumps (apply with each phase's final task)
| Module | Current | Phase 6.1 | Phase 6.2 | Phase 6.3 |
|---|---|---|---|---|
fusion_plating_shopfloor |
19.0.29.0.0 | 19.0.30.0.0 (backend) | 19.0.30.1.0 (frontend lock) | 19.0.30.2.0 (audit kwarg) |
File structure
NEW files
fusion_plating_shopfloor/
models/
res_users.py [P6.1]
controllers/
tablet_controller.py [P6.1]
views/
res_users_views.xml [P6.1]
data/
fp_tablet_config_data.xml [P6.1]
tests/
test_tablet_pin.py [P6.1]
static/src/js/services/
tech_store.js [P6.2]
activity_tracker.js [P6.2]
fp_rpc.js [P6.3]
static/src/js/components/
pin_pad.js [P6.2]
pin_setup.js [P6.2]
idle_warning.js [P6.2]
static/src/xml/components/
pin_pad.xml [P6.2]
pin_setup.xml [P6.2]
idle_warning.xml [P6.2]
static/src/scss/components/
_pin_pad.scss [P6.2]
_idle_warning.scss [P6.2]
static/src/js/
tablet_lock.js [P6.2]
static/src/xml/
tablet_lock.xml [P6.2]
static/src/scss/
tablet_lock.scss [P6.2]
MODIFIED files
fusion_plating_shopfloor/
__init__.py [P6.1: + models, controllers/tablet_controller]
__manifest__.py [P6.1, 6.2, 6.3 — version + data + assets]
models/__init__.py [P6.1: + res_users]
controllers/__init__.py [P6.1: + tablet_controller]
models/fp_shopfloor_station.py [P6.1: + authorised_user_ids, idle_lock_minutes]
views/fp_shopfloor_station_views.xml [P6.1: + 2 new fields in form]
controllers/workspace_controller.py [P6.3: accept tablet_tech_id]
controllers/shopfloor_controller.py [P6.3: accept tablet_tech_id on action endpoints]
controllers/manager_controller.py [P6.3: accept tablet_tech_id on action endpoints]
static/src/js/shopfloor_landing.js [P6.2: wrap in FpTabletLock + Hand-Off | P6.3: use fpRpc]
static/src/js/job_workspace.js [P6.2: wrap + Hand-Off | P6.3: use fpRpc]
static/src/js/manager_dashboard.js [P6.2: wrap + Hand-Off | P6.3: use fpRpc]
static/src/xml/shopfloor_landing.xml [P6.2: Hand-Off button slot]
static/src/xml/job_workspace.xml [P6.2: Hand-Off button slot]
static/src/xml/manager_dashboard.xml [P6.2: Hand-Off button slot]
Phase 6.1 — Backend (model + endpoints + prefs)
Goal: Ship invisible PIN infrastructure. Techs can set PINs via Preferences, manager can reset PINs, all five /fp/tablet/* endpoints exist and work. No UI lock screen yet — Phase 6.2 turns it on.
Task 6.1.1 — res.users PIN model + hash helpers
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/models/__init__.py— verify exists; addfrom . import res_users -
Create:
fusion_plating/fusion_plating_shopfloor/models/res_users.py -
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py -
Modify:
fusion_plating/fusion_plating_shopfloor/__init__.py— verifyfrom . import modelsexists -
Step 1: Write the failing test
Create fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py:
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Phase 6.1 — res.users PIN fields + hash helpers."""
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessError, UserError
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletPinHash(TransactionCase):
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'))
- Step 2: Verify test fails on entech
After deploy: odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --test-tags fp_tablet_pin --stop-after-init
Expected: FAIL with AttributeError: 'res.users' object has no attribute 'set_tablet_pin'.
- Step 3: Implement res.users model
Create fusion_plating/fusion_plating_shopfloor/models/res_users.py:
# -*- 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
Verify fusion_plating/fusion_plating_shopfloor/models/__init__.py includes from . import res_users. If file doesn't exist yet, create it:
# -*- coding: utf-8 -*-
from . import fp_bake_oven
from . import fp_bake_window
from . import fp_first_piece_gate
from . import fp_operator_queue
from . import fp_shopfloor_station
from . import fp_tank
from . import res_users
(The first 6 imports likely already exist — only res_users is new.)
Verify fusion_plating/fusion_plating_shopfloor/__init__.py includes from . import models. If not, add it.
Create fusion_plating/fusion_plating_shopfloor/tests/__init__.py line for the new test file. Append:
from . import test_tablet_pin
- Step 4: Verify test passes on entech
After deploy: odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --test-tags fp_tablet_pin --stop-after-init
Expected: 4 tests pass.
- Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/models/__init__.py \
fusion_plating/fusion_plating_shopfloor/models/res_users.py \
fusion_plating/fusion_plating_shopfloor/__init__.py \
fusion_plating/fusion_plating_shopfloor/tests/__init__.py \
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
git commit -m "feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)"
Task 6.1.2 — /fp/tablet/set_pin + /fp/tablet/reset_pin_for endpoints
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/__init__.py— add import -
Modify:
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py— add HTTP tests -
Step 1: Write the failing tests
Append to test_tablet_pin.py:
import json
from odoo.tests.common import HttpCase
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletSetPin(HttpCase):
def setUp(self):
super().setUp()
# admin auth so the test can call /fp/tablet/set_pin against
# the currently-authenticated user.
self.authenticate("admin", "admin")
def _rpc(self, url, **params):
res = self.url_open(
url,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
)
return res.json()['result']
def test_set_pin_first_time(self):
res = self._rpc('/fp/tablet/set_pin', new_pin='1234')
self.assertTrue(res['ok'])
def test_set_pin_change_requires_old(self):
# Seed a pin
admin = self.env.ref('base.user_admin')
admin.set_tablet_pin('1234')
# Change without old — rejected
res = self._rpc('/fp/tablet/set_pin', new_pin='5678')
self.assertFalse(res['ok'])
# Change with wrong old — rejected
res = self._rpc('/fp/tablet/set_pin', old_pin='9999', new_pin='5678')
self.assertFalse(res['ok'])
# Change with correct old — accepted
res = self._rpc('/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 = self._rpc('/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):
def setUp(self):
super().setUp()
self.target = self.env['res.users'].create({
'name': 'Reset Target', 'login': 'reset@example.com',
})
self.target.set_tablet_pin('1234')
def _rpc_as(self, login, url, **params):
self.authenticate(login, login)
res = self.url_open(
url,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
)
return res.json()['result']
def test_reset_requires_manager_group(self):
# Plain operator can't reset
operator = self.env['res.users'].create({
'name': 'Op', 'login': 'op@example.com',
'group_ids': [(6, 0, [self.env.ref('fusion_plating.group_fusion_plating_operator').id])],
})
self.authenticate('op@example.com', 'op@example.com')
res = self.url_open(
'/fp/tablet/reset_pin_for',
data=json.dumps({'jsonrpc': '2.0', 'params': {'user_id': self.target.id}}),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertFalse(res['ok'])
def test_reset_as_admin_clears_hash(self):
self.authenticate('admin', 'admin')
res = self.url_open(
'/fp/tablet/reset_pin_for',
data=json.dumps({'jsonrpc': '2.0', 'params': {'user_id': self.target.id}}),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertTrue(res['ok'])
self.target.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
- Step 2: Verify tests fail on entech
Run the test. Expected: 404 errors (endpoint missing).
- Step 3: Implement controller
Create fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py:
# -*- 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 odoo import _, fields, http
from odoo.exceptions import AccessError, 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}
Add to fusion_plating/fusion_plating_shopfloor/controllers/__init__.py:
from . import tablet_controller
-
Step 4: Verify tests pass on entech
-
Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py \
fusion_plating/fusion_plating_shopfloor/controllers/__init__.py \
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/set_pin + reset_pin_for endpoints (P6.1.2)"
Task 6.1.3 — /fp/tablet/unlock endpoint with lockout
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py -
Step 1: Write failing tests
Append to test_tablet_pin.py:
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletUnlock(HttpCase):
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.set_tablet_pin('1234')
def _unlock(self, pin):
return self.url_open(
'/fp/tablet/unlock',
data=json.dumps({'jsonrpc': '2.0', 'params': {
'user_id': self.target.id, 'pin': pin,
}}),
headers={'Content-Type': 'application/json'},
).json()['result']
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.assertIn('not set', res['error'].lower())
-
Step 2: Verify tests fail
-
Step 3: Implement endpoint
Append to tablet_controller.py:
# ======================================================================
# /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:
from datetime import timedelta
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,
}
-
Step 4: Verify tests pass
-
Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py \
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/unlock endpoint with lockout (P6.1.3)"
Task 6.1.4 — /fp/tablet/tiles endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py -
Step 1: Write failing test
Append to test_tablet_pin.py:
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletTiles(HttpCase):
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', 'login': 'alice@example.com',
'group_ids': [(6, 0, [self.op_group.id])],
})
self.bob = self.env['res.users'].create({
'name': 'Bob', 'login': 'bob@example.com',
'group_ids': [(6, 0, [self.op_group.id])],
})
self.alice.set_tablet_pin('1111')
def _tiles(self, station_id=None):
return self.url_open(
'/fp/tablet/tiles',
data=json.dumps({'jsonrpc': '2.0', 'params': {
'station_id': station_id,
}}),
headers={'Content-Type': 'application/json'},
).json()['result']
def test_tiles_returns_all_operators_without_station(self):
res = self._tiles()
self.assertTrue(res['ok'])
names = [t['name'] for t in res['tiles']]
self.assertIn('Alice', names)
self.assertIn('Bob', names)
def test_tile_has_pin_flag(self):
res = self._tiles()
alice_tile = next(t for t in res['tiles'] if t['name'] == 'Alice')
bob_tile = next(t for t in res['tiles'] if t['name'] == 'Bob')
self.assertTrue(alice_tile['has_pin'])
self.assertFalse(bob_tile['has_pin'])
-
Step 2: Verify test fails
-
Step 3: Implement endpoint
Append to tablet_controller.py:
# ======================================================================
# /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
users_sudo = users.sudo()
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()
tiles = []
for u, u_sudo in zip(users.sorted('name'), users_sudo.sorted('name')):
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}
-
Step 4: Verify test passes
-
Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py \
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/tiles endpoint (P6.1.4)"
Task 6.1.5 — /fp/tablet/ping endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py -
Step 1: Implement (no test — pure no-op acknowledgement used for forensic logging)
Append to tablet_controller.py:
# ======================================================================
# /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.
Returns {'ok': True} as a cheap liveness ack.
"""
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()}
- Step 2: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py
git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/ping heartbeat endpoint (P6.1.5)"
Task 6.1.6 — Station extras + config-parameter defaults
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/models/fp_shopfloor_station.py -
Create:
fusion_plating/fusion_plating_shopfloor/data/fp_tablet_config_data.xml -
Modify:
fusion_plating/fusion_plating_shopfloor/views/fp_shopfloor_station_views.xml -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Add the two station fields
In models/fp_shopfloor_station.py, locate the field block and add after the last existing field:
# 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).',
)
- Step 2: Create the config-parameter data file
Create fusion_plating/fusion_plating_shopfloor/data/fp_tablet_config_data.xml:
<?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>
- Step 3: Surface the new fields on the station form
In views/fp_shopfloor_station_views.xml, locate the form's field group (somewhere with name="code", facility_id, etc.) and add inside:
<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 (from system parameter)"/>
</group>
If the form doesn't have explicit <group> containers, place the two <field> lines inside the existing <sheet> near the other config fields.
- Step 4: Register the data file in the manifest
Edit fusion_plating/fusion_plating_shopfloor/__manifest__.py — find the 'data': list and add the new file BEFORE 'views/fp_menu.xml':
'data/fp_tablet_config_data.xml',
(Order matters — data files load top-to-bottom; this needs to land before any menu/view that might reference the parameters.)
- Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/models/fp_shopfloor_station.py \
fusion_plating/fusion_plating_shopfloor/data/fp_tablet_config_data.xml \
fusion_plating/fusion_plating_shopfloor/views/fp_shopfloor_station_views.xml \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): station roster + idle override + config defaults (P6.1.6)"
Task 6.1.7 — Profile prefs Set/Change PIN button + Manager Reset button
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Create the view file
<?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 new fields 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 to open FpPinSetup modal client-side.
The OWL component watches for this action tag
via the action service. -->
<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_hash"
confirm="Reset this user's tablet PIN? They'll need to set a new one on their next unlock."/>
</xpath>
</field>
</record>
</odoo>
- Step 2: Add the OWL trigger action method
In models/res_users.py, append to the ResUsers class:
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',
}
- Step 3: Register the view in the manifest
Add to the 'data' list in __manifest__.py (after the existing view files):
'views/res_users_views.xml',
- Step 4: Commit
git add fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml \
fusion_plating/fusion_plating_shopfloor/models/res_users.py \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): Preferences PIN button + manager Reset button (P6.1.7)"
Task 6.1.8 — Phase 6.1 version bump + tag + push
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Bump version
Change 'version': '19.0.29.0.0' to 'version': '19.0.30.0.0'.
- Step 2: Commit + tag + push
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(fusion_plating_shopfloor): bump 19.0.30.0.0 for Phase 6.1 — PIN backend"
git tag -f phase6_1-pin-backend
git push origin main phase6_1-pin-backend
- Step 3: Deploy on entech
# Tar 4 modules and pipe through SSH (same pattern as Phase 1-5 deploy)
cd /k/Github/Odoo-Modules/fusion_plating
tar -czf - --exclude='__pycache__' --exclude='*.pyc' fusion_plating_shopfloor | \
ssh pve-worker5 'pct exec 111 -- bash -c "cd /mnt/extra-addons/custom && tar -xzf -"'
# Run -u
ssh pve-worker5 'pct exec 111 -- bash -c "systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --stop-after-init\" && systemctl start odoo"'
- Step 4: Smoke test on entech
Open Settings → Users → Preferences → look for "Tablet PIN" group with "Set / Change Tablet PIN" button. Click → modal should error gracefully ("OWL component not implemented yet") since Phase 6.2 hasn't shipped. Verify the action returns no traceback.
Open Settings → Users → any user → header should show "Reset Tablet PIN" button (manager-only). Set a PIN on a test user first via SQL or Python shell:
-- in postgres
UPDATE res_users SET x_fc_tablet_pin_hash = 'dummy' WHERE id = <test_id>;
Then click Reset → confirm → button disappears (the invisible="not x_fc_tablet_pin_hash" guard fires).
Phase 6.2 — Frontend lock screen
Goal: Lock screen goes live. Every visit to Landing / Workspace / Manager Dashboard renders the tile grid first; tap a tile → PIN → unlocked. Idle 5 min → lock. Hand-Off button → instant lock.
Task 6.2.1 — tech_store + activity_tracker services
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/services/tech_store.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/services/activity_tracker.js -
Step 1: Create tech_store service
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tech Store (shared OWL service)
//
// Holds the "current tech of record" for the locked tablet. Set by
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
// and pass it through fpRpc() so server actions credit the right user.
// =============================================================================
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const fpShopfloorTechStore = {
start() {
const state = reactive({
currentTechId: null,
currentTechName: "",
lockedAt: null,
});
return {
get currentTechId() { return state.currentTechId; },
get currentTechName() { return state.currentTechName; },
get isLocked() { return !state.currentTechId; },
setTech(id, name) {
state.currentTechId = id;
state.currentTechName = name;
state.lockedAt = null;
},
lock() {
state.currentTechId = null;
state.currentTechName = "";
state.lockedAt = Date.now();
},
state, // exposed for OWL reactive subscriptions
};
},
};
registry
.category("services")
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);
- Step 2: Create activity_tracker service
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Activity Tracker (shared OWL service)
//
// Watches the document for pointer/touch/keydown/visibility events and
// tracks lastActiveAt. FpTabletLock reads getSecondsUntilLock() once per
// second to drive the idle warning + auto-lock transitions.
//
// Threshold reads from /web/dataset/call_kw on ir.config_parameter at
// service start; refreshes every 5 min in case the manager changed it.
// =============================================================================
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const DEFAULT_IDLE_MIN = 5;
const DEFAULT_WARN_SEC = 30;
export const fpShopfloorActivityTracker = {
async start(env) {
let lastActiveAt = Date.now();
let idleThresholdMs = DEFAULT_IDLE_MIN * 60 * 1000;
let warnThresholdSec = DEFAULT_WARN_SEC;
// Fetch the configured idle threshold up front + every 5 minutes
async function refreshThreshold() {
try {
const result = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_idle_lock_minutes", String(DEFAULT_IDLE_MIN)],
kwargs: {},
});
const minutes = parseInt(result, 10) || DEFAULT_IDLE_MIN;
idleThresholdMs = minutes * 60 * 1000;
const warn = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_warn_seconds_before_lock", String(DEFAULT_WARN_SEC)],
kwargs: {},
});
warnThresholdSec = parseInt(warn, 10) || DEFAULT_WARN_SEC;
} catch (e) {
// Keep defaults if RPC fails
}
}
await refreshThreshold();
setInterval(refreshThreshold, 5 * 60 * 1000);
// Activity = explicit user input. Mouse-move alone DOES NOT count
// because something brushing the screen (a stray glove, a tool
// resting on the tablet) could otherwise keep the session alive.
const bump = () => { lastActiveAt = Date.now(); };
document.addEventListener("pointerdown", bump, { capture: true });
document.addEventListener("touchstart", bump, { capture: true, passive: true });
document.addEventListener("keydown", bump, { capture: true });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") bump();
});
return {
bump,
getSecondsUntilLock() {
return Math.max(0, Math.floor((lastActiveAt + idleThresholdMs - Date.now()) / 1000));
},
getWarnThresholdSec() { return warnThresholdSec; },
getIdleThresholdMs() { return idleThresholdMs; },
getLastActiveAt() { return lastActiveAt; },
};
},
};
registry
.category("services")
.add("fp_shopfloor_activity", fpShopfloorActivityTracker);
- Step 3: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/services/
git commit -m "feat(fusion_plating_shopfloor): tech_store + activity_tracker OWL services (P6.2.1)"
Task 6.2.2 — FpPinPad component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_pad.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss -
Step 1: Create JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinPad (shared OWL service)
//
// Numeric 4-digit PIN pad. Auto-submits on the 4th digit via onSubmit
// callback. Used by FpTabletLock unlock flow AND FpPinSetup change flow.
//
// Props:
// onSubmit : (pin: string) => Promise<{ok: boolean, error?: string}>
// title : optional header text
// subtitle : optional smaller text
// onCancel : optional cancel callback (e.g. close modal)
// =============================================================================
import { Component, useState } from "@odoo/owl";
export class FpPinPad extends Component {
static template = "fusion_plating_shopfloor.PinPad";
static props = {
onSubmit: { type: Function },
title: { type: String, optional: true },
subtitle: { type: String, optional: true },
onCancel: { type: Function, optional: true },
};
setup() {
this.state = useState({
pin: "",
submitting: false,
error: "",
shake: false,
});
}
async _press(digit) {
if (this.state.submitting) return;
if (this.state.pin.length >= 4) return;
this.state.pin = this.state.pin + digit;
this.state.error = "";
if (this.state.pin.length === 4) {
await this._submit();
}
}
_clear() {
this.state.pin = "";
this.state.error = "";
}
async _submit() {
this.state.submitting = true;
try {
const result = await this.props.onSubmit(this.state.pin);
if (result && !result.ok) {
this.state.error = result.error || "Incorrect PIN";
this.state.shake = true;
setTimeout(() => { this.state.shake = false; }, 400);
this.state.pin = "";
}
} catch (err) {
this.state.error = err.message || String(err);
this.state.pin = "";
} finally {
this.state.submitting = false;
}
}
get dots() {
// Render 4 dot slots: filled if typed, empty otherwise
return [0, 1, 2, 3].map((i) => this.state.pin.length > i);
}
}
- Step 2: Create template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinPad">
<div t-att-class="'o_fp_pin_pad' + (state.shake ? ' o_fp_pin_shake' : '')">
<div t-if="props.title" class="o_fp_pin_title" t-esc="props.title"/>
<div t-if="props.subtitle" class="o_fp_pin_subtitle" t-esc="props.subtitle"/>
<div class="o_fp_pin_dots">
<t t-foreach="dots" t-as="filled" t-key="filled_index">
<span t-att-class="'o_fp_pin_dot' + (filled ? ' filled' : '')"/>
</t>
</div>
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
<div class="o_fp_pin_grid">
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
<button class="o_fp_pin_key"
t-on-click="() => this._press(String(d))"
t-att-disabled="state.submitting">
<t t-esc="d"/>
</button>
</t>
<button class="o_fp_pin_key o_fp_pin_key_clear"
t-on-click="_clear">Clear</button>
<button class="o_fp_pin_key"
t-on-click="() => this._press('0')"
t-att-disabled="state.submitting">0</button>
<button t-if="props.onCancel"
class="o_fp_pin_key o_fp_pin_key_cancel"
t-on-click="() => this.props.onCancel()">Cancel</button>
</div>
</div>
</t>
</templates>
- Step 3: Create SCSS
// =============================================================================
// FpPinPad — numeric keypad for tablet lock screen + PIN setup
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_pin-bg-hex: #ffffff;
$_pin-key-bg-hex: #f3f4f6;
$_pin-key-hover-hex: #e5e7eb;
$_pin-border-hex: #d8dadd;
$_pin-dot-hex: #d8dadd;
$_pin-dot-fill-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_pin-bg-hex: #22262d !global;
$_pin-key-bg-hex: #2d3138 !global;
$_pin-key-hover-hex: #3a3f48 !global;
$_pin-border-hex: #424245 !global;
$_pin-dot-fill-hex: #f5f5f7 !global;
}
.o_fp_pin_pad {
background: $_pin-bg-hex;
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
min-width: 280px;
}
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
.o_fp_pin_dots {
display: flex;
gap: 0.8rem;
margin: 0.5rem 0;
}
.o_fp_pin_dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: $_pin-dot-hex;
transition: background 0.1s ease;
&.filled { background: $_pin-dot-fill-hex; }
}
.o_fp_pin_error {
color: #ff3b30;
font-size: 0.85rem;
min-height: 1.2rem;
}
.o_fp_pin_grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
width: 100%;
}
.o_fp_pin_key {
background: $_pin-key-bg-hex;
border: 1px solid $_pin-border-hex;
border-radius: 10px;
padding: 1rem 0;
font-size: 1.5rem;
font-weight: 500;
cursor: pointer;
transition: background 0.1s ease, transform 0.05s ease;
&:hover { background: $_pin-key-hover-hex; }
&:active { transform: scale(0.97); }
&:disabled { opacity: 0.5; cursor: wait; }
}
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
@keyframes o_fp_pin_shake_kf {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
50% { transform: translateX(8px); }
75% { transform: translateX(-4px); }
}
.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; }
- Step 4: Register assets in manifest
Edit __manifest__.py assets.web.assets_backend, add (after the existing component lines):
# ---- Phase 6.2 tablet PIN gate ----
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
- Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_pad.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): FpPinPad OWL component (P6.2.2)"
Task 6.2.3 — FpIdleWarning component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/idle_warning.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss -
Step 1: Create JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpIdleWarning (shared OWL service)
//
// Yellow-border overlay + countdown toast shown during the last
// (default 30) seconds before auto-lock. Any pointer/touch event on
// the document elsewhere resets the activity tracker, which causes
// this component's parent (FpTabletLock) to hide the warning.
// =============================================================================
import { Component } from "@odoo/owl";
export class FpIdleWarning extends Component {
static template = "fusion_plating_shopfloor.IdleWarning";
static props = {
secondsRemaining: { type: Number },
};
}
- Step 2: Create template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.IdleWarning">
<div class="o_fp_idle_warning_overlay">
<div class="o_fp_idle_warning_toast">
<i class="fa fa-clock-o"/>
Locking in <strong t-esc="props.secondsRemaining"/>s · tap anywhere to stay
</div>
</div>
</t>
</templates>
- Step 3: Create SCSS
// =============================================================================
// FpIdleWarning — yellow-border countdown overlay before auto-lock
// =============================================================================
.o_fp_idle_warning_overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
box-shadow: inset 0 0 0 4px #ff9f0a;
animation: o_fp_idle_pulse 1s ease-in-out infinite alternate;
}
@keyframes o_fp_idle_pulse {
from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); }
to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); }
}
.o_fp_idle_warning_toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: #1d1d1f;
color: #ffd585;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9999;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
strong { color: #ffb84d; margin: 0 0.2rem; }
> i { margin-right: 0.4rem; }
}
- Step 4: Register assets
Add to __manifest__.py after the pin_pad lines:
'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss',
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
'fusion_plating_shopfloor/static/src/js/components/idle_warning.js',
- Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/idle_warning.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): FpIdleWarning overlay (P6.2.3)"
Task 6.2.4 — FpTabletLock wrapper component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss -
Step 1: Create JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpTabletLock (top-level wrapper)
//
// Mounted by Landing / Workspace / Manager Dashboard as their outermost
// element. Renders the lock screen (tile grid + PIN pad) when no tech
// is signed in; renders <t t-slot="default"/> (the wrapped client
// action) otherwise. Also drives the auto-lock countdown + idle warning.
//
// Usage in a parent template:
//
// <FpTabletLock>
// <div class="o_fp_landing"> ...your existing tree... </div>
// </FpTabletLock>
//
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { FpPinPad } from "./components/pin_pad";
import { FpIdleWarning } from "./components/idle_warning";
export class FpTabletLock extends Component {
static template = "fusion_plating_shopfloor.TabletLock";
static components = { FpPinPad, FpIdleWarning };
static props = {
slots: { type: Object, optional: true },
};
setup() {
this.techStore = useService("fp_shopfloor_tech_store");
this.activity = useService("fp_shopfloor_activity");
this.notification = useService("notification");
this.state = useState({
tiles: [],
selectedTileUserId: null,
idleSecondsRemaining: null,
loadingTiles: false,
});
onMounted(async () => {
await this._loadTiles();
this._tick = setInterval(() => this._checkIdle(), 1000);
// Heartbeat ping every 60s — for forensic visibility
this._ping = setInterval(() => {
if (this.techStore.currentTechId) {
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
.catch(() => {});
}
}, 60000);
});
onWillUnmount(() => {
if (this._tick) clearInterval(this._tick);
if (this._ping) clearInterval(this._ping);
});
}
get isLocked() {
return this.techStore.isLocked;
}
async _loadTiles() {
this.state.loadingTiles = true;
try {
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) {
this.state.tiles = res.tiles;
}
} finally {
this.state.loadingTiles = false;
}
}
_checkIdle() {
if (!this.techStore.currentTechId) {
this.state.idleSecondsRemaining = null;
return;
}
const remaining = this.activity.getSecondsUntilLock();
const warnThreshold = this.activity.getWarnThresholdSec();
if (remaining <= 0) {
this.handOff();
} else if (remaining <= warnThreshold) {
this.state.idleSecondsRemaining = remaining;
} else if (this.state.idleSecondsRemaining !== null) {
this.state.idleSecondsRemaining = null;
}
}
onTileClick(userId) {
this.state.selectedTileUserId = userId;
// PIN pad renders for this user; PinPad's onSubmit calls unlock()
}
_selectedTileName() {
const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId);
return tile ? tile.name : "";
}
async unlock(pin) {
try {
const res = await rpc("/fp/tablet/unlock", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
this.activity.bump();
this.state.selectedTileUserId = null;
return { ok: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
} catch (err) {
return { ok: false, error: err.message || String(err) };
}
}
onPinCancel() {
this.state.selectedTileUserId = null;
}
handOff() {
this.techStore.lock();
this.state.selectedTileUserId = null;
this.state.idleSecondsRemaining = null;
this._loadTiles();
}
}
- Step 2: Create template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.TabletLock">
<t t-if="isLocked">
<div class="o_fp_tablet_lock">
<div class="o_fp_tablet_lock_header">
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
</div>
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
<t t-if="!state.tiles.length">
<div class="o_fp_tablet_lock_empty">
No operators configured.
</div>
</t>
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
<button class="o_fp_tablet_lock_tile"
t-on-click="() => this.onTileClick(tile.user_id)">
<img class="o_fp_tablet_lock_tile_avatar"
t-att-src="tile.avatar_url"
t-att-alt="tile.name"/>
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
● Clocked in
</span>
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
PIN required
</span>
</button>
</t>
</div>
<div t-else="" class="o_fp_tablet_lock_pinwrap">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"
onCancel.bind="onPinCancel"/>
</div>
</div>
</t>
<t t-else="">
<t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/>
</t>
</t>
</templates>
- Step 3: Create SCSS
// =============================================================================
// FpTabletLock — lock screen with tile grid + PIN pad overlay
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_lock-bg-hex: #f3f4f6;
$_lock-card-hex: #ffffff;
$_lock-border-hex: #d8dadd;
$_lock-ink-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_lock-bg-hex: #1a1d21 !global;
$_lock-card-hex: #22262d !global;
$_lock-border-hex: #424245 !global;
$_lock-ink-hex: #f5f5f7 !global;
}
.o_fp_tablet_lock {
position: fixed;
inset: 0;
background: $_lock-bg-hex;
color: $_lock-ink-hex;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
z-index: 9000;
overflow-y: auto;
}
.o_fp_tablet_lock_header {
h1 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
}
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
margin: 2rem auto;
color: var(--text-secondary, #666);
}
.o_fp_tablet_lock_tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
max-width: 900px;
width: 100%;
}
.o_fp_tablet_lock_tile {
background: $_lock-card-hex;
border: 2px solid $_lock-border-hex;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: border-color 0.1s ease, transform 0.05s ease;
&:hover { border-color: #0071e3; }
&:active { transform: scale(0.98); }
}
.o_fp_tablet_lock_tile_avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.o_fp_tablet_lock_tile_name {
font-weight: 600;
text-align: center;
}
.o_fp_tablet_lock_tile_clocked {
color: #34c759;
font-size: 0.75rem;
}
.o_fp_tablet_lock_tile_nopin {
color: #ff9f0a;
font-size: 0.75rem;
}
.o_fp_tablet_lock_pinwrap {
margin-top: 2rem;
}
- Step 4: Register assets
In __manifest__.py, add after the IdleWarning lines:
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
- Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): FpTabletLock wrapper component (P6.2.4)"
Task 6.2.5 — Wire FpTabletLock into Landing / Workspace / Manager + Hand-Off buttons
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js— import + components -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml— wrap root + Hand-Off button -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js— import + components -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml— wrap root + Hand-Off button -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js— import + components -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml— wrap root + Hand-Off button -
Step 1: Update shopfloor_landing.js — add FpTabletLock to imports + components
Find the existing imports + components and add:
import { FpTabletLock } from "./tablet_lock";
In static components = {...}, add FpTabletLock.
In setup(), add a tech-store ref:
this.techStore = useService("fp_shopfloor_tech_store");
Add a handOff() method:
handOff() {
this.techStore.lock();
}
- Step 2: Update shopfloor_landing.xml — wrap root in FpTabletLock + Hand-Off button
Wrap the entire <div class="o_fp_landing">...</div> in a <FpTabletLock> so the lock screen renders above when locked:
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_landing">
<!-- existing content -->
</div>
</t>
</FpTabletLock>
</t>
Inside the existing header's .o_fp_landing_head_actions block, add the Hand-Off button (before the refresh indicator):
<button class="btn btn-sm btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
- Step 3: Repeat for job_workspace.js + .xml
Same shape: import FpTabletLock, add to components, add techStore + handOff(). Wrap the <div class="o_fp_ws"> in <FpTabletLock>. Add Hand-Off button in the header's right side (next to existing pills/chips).
- Step 4: Repeat for manager_dashboard.js + .xml
Same pattern. Wrap <div class="o_fp_manager">. Add Hand-Off button in o_fp_manager_head_actions.
- Step 5: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml \
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml
git commit -m "feat(fusion_plating_shopfloor): wire FpTabletLock + Hand-Off into Landing/Workspace/Manager (P6.2.5)"
Task 6.2.6 — FpPinSetup modal + client action for Preferences button
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_setup.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml -
Step 1: Create JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`)
//
// Modal flow for setting OR changing the user's tablet PIN. Triggered
// from res.users preferences via action_open_tablet_pin_setup. Three
// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new.
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { FpPinPad } from "./pin_pad";
export class FpPinSetup extends Component {
static template = "fusion_plating_shopfloor.PinSetup";
static components = { FpPinPad };
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.user = useService("user");
this.state = useState({
stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done'
newPin: "",
hasExistingPin: false,
});
onMounted(() => this._init());
}
async _init() {
// Check whether the user currently has a hash via a cheap RPC
const has = await rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_count",
args: [[
["id", "=", this.user.userId],
["x_fc_tablet_pin_set_date", "!=", false],
]],
kwargs: {},
});
this.state.hasExistingPin = has > 0;
this.state.stage = this.state.hasExistingPin ? "old" : "new";
}
async onOldPinSubmit(pin) {
// We don't verify the OLD pin separately — set_pin verifies it
// server-side. Stash it for the final call.
this._oldPin = pin;
this.state.stage = "new";
return { ok: true };
}
async onNewPinSubmit(pin) {
this.state.newPin = pin;
this.state.stage = "confirm";
return { ok: true };
}
async onConfirmPinSubmit(pin) {
if (pin !== this.state.newPin) {
return { ok: false, error: "PINs don't match. Try again." };
}
const params = { new_pin: this.state.newPin };
if (this._oldPin) params.old_pin = this._oldPin;
const res = await rpc("/fp/tablet/set_pin", params);
if (res && res.ok) {
this.notification.add("Tablet PIN updated.", { type: "success" });
this.state.stage = "done";
setTimeout(() => this._close(), 1500);
return { ok: true };
}
// Reset back to start on hard error so user can retry cleanly
this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" });
this._oldPin = null;
this.state.newPin = "";
this.state.stage = this.state.hasExistingPin ? "old" : "new";
return { ok: false, error: (res && res.error) || "Failed" };
}
_close() {
this.action.doAction({ type: "ir.actions.act_window_close" });
}
onCancel() {
this._close();
}
}
registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup);
- Step 2: Create template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinSetup">
<div class="o_fp_pin_setup">
<div t-if="state.stage === 'loading'" class="o_fp_pin_setup_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<FpPinPad t-if="state.stage === 'old'"
onSubmit.bind="onOldPinSubmit"
title="'Enter your current PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'new'"
onSubmit.bind="onNewPinSubmit"
title="'Choose a new 4-digit PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'confirm'"
onSubmit.bind="onConfirmPinSubmit"
title="'Confirm your new PIN'"
subtitle="'Enter it again to confirm'"
onCancel.bind="onCancel"/>
<div t-if="state.stage === 'done'" class="o_fp_pin_setup_done">
<i class="fa fa-check-circle text-success fa-3x"/>
<h3>PIN updated</h3>
</div>
</div>
</t>
</templates>
- Step 3: Register assets
In __manifest__.py, add after the tablet_lock lines:
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
- Step 4: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_setup.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): FpPinSetup modal + fp_tablet_pin_setup action (P6.2.6)"
Task 6.2.7 — Phase 6.2 version bump + deploy + smoke test
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Bump version to 19.0.30.1.0
-
Step 2: Commit + tag + push
git add fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(fusion_plating_shopfloor): bump 19.0.30.1.0 for Phase 6.2 — lock screen"
git tag -f phase6_2-lock-screen
git push origin main phase6_2-lock-screen
- Step 3: Deploy on entech
cd /k/Github/Odoo-Modules/fusion_plating
tar -czf - --exclude='__pycache__' --exclude='*.pyc' fusion_plating_shopfloor | \
ssh pve-worker5 'pct exec 111 -- bash -c "cd /mnt/extra-addons/custom && tar -xzf -"'
ssh pve-worker5 'pct exec 111 -- bash -c "systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --stop-after-init\" && systemctl start odoo"'
# Clear asset cache so the new OWL bundles compile fresh
ssh pve-worker5 'pct exec 111 -- bash -c "su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\""'
- Step 4: Smoke test on entech
- Open https://enplating.com → log in → Plating → Shop Floor → Workstation.
- Expected: tile grid appears with operator avatars (or "No operators configured" if the operator group is empty).
- Click an operator who has no PIN → "PIN required" message under their name. Click the tile anyway → expect graceful "Set one in Preferences first" message.
- Set a PIN via Preferences → modal opens, PinPad renders, set 1234 → confirm 1234 → "PIN updated" → close.
- Back on Workstation lock screen → tap your tile → PinPad → enter 1234 → unlocked, Workstation renders.
- Wait 5 minutes idle without touching → yellow border + "Locking in 30s" → 30s countdown → tile grid returns.
- Tap Hand-Off button in header while in Workspace → confirm → tile grid returns immediately.
- Enter PIN wrong 5 times → 6th attempt shows lockout message; tile other tech to confirm they can still unlock (per-user lockout, not per-tablet).
Phase 6.3 — Audit kwarg propagation
Goal: Existing shop-floor action endpoints accept an optional tablet_tech_id kwarg from the OWL components. Server uses env.with_user(tech_id) so chatter posts + write_uid records carry the right operator identity.
Task 6.3.1 — fpRpc wrapper + server-side helper
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/services/fp_rpc.js -
Create:
fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py— helper utility for controllers -
Step 1: Create the client-side wrapper
/** @odoo-module **/
// =============================================================================
// Fusion Plating — fpRpc() wrapper
//
// Drop-in replacement for the standard `rpc()` import. Automatically
// injects the current tablet_tech_id from the tech_store into every
// call, so server-side endpoints can attribute the action to the right
// user via env.with_user().
//
// USE for any RPC that WRITES (start step, finish step, hold create,
// sign-off, milestone advance). For read-only loads (kanban, workspace
// load, manager funnel), plain rpc() is fine.
//
// Example:
// import { fpRpc } from "../services/fp_rpc";
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
//
// =============================================================================
import { rpc as baseRpc } from "@web/core/network/rpc";
let _techStore = null;
function _getTechStore() {
if (_techStore) return _techStore;
// Lazy-load to avoid circular service init issues
try {
const env = odoo.__WOWL_DEBUG__?.root?.env;
if (env && env.services && env.services.fp_shopfloor_tech_store) {
_techStore = env.services.fp_shopfloor_tech_store;
}
} catch (e) {
// ignore — defensive against debug API not being available
}
return _techStore;
}
export function fpRpc(url, params = {}) {
const techStore = _getTechStore();
if (techStore && techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}
- Step 2: Create the server-side helper
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Helper for audit-credit propagation (Phase 6.3 tablet redesign).
Controllers that accept an optional `tablet_tech_id` kwarg use this
helper to switch their `env` to the tech-of-record before performing
writes. The result: chatter posts + create_uid/write_uid carry the
unlocked tech's identity, not the tablet's persistent session user.
"""
import logging
from odoo.http import request
_logger = logging.getLogger(__name__)
def env_for_tablet_tech(env, tablet_tech_id):
"""Return an env scoped to `tablet_tech_id` if it's a valid user;
otherwise return the original env unchanged.
Validation: the user must exist and be active. We deliberately do
NOT cross-check that they actually unlocked recently — the OWL
component is the source of truth for "who's at the tablet right
now", and the only path that produces a tablet_tech_id is a
successful /fp/tablet/unlock followed by an active session in the
OWL tech_store.
"""
if not tablet_tech_id:
return env
try:
tech_id = int(tablet_tech_id)
except (TypeError, ValueError):
return env
User = env['res.users'].sudo()
tech = User.browse(tech_id)
if not tech.exists() or not tech.active:
_logger.warning(
"tablet_tech_id %s invalid (not found or inactive); "
"falling back to session uid %s",
tablet_tech_id, env.uid,
)
return env
return env(user=tech_id)
- Step 3: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/services/fp_rpc.js \
fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py
git commit -m "feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)"
Task 6.3.2 — Wire tablet_tech_id into workspace endpoints
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py -
Step 1: Import the helper at the top of the file
Add to the imports section:
from odoo.addons.fusion_plating_shopfloor.controllers._tablet_audit import env_for_tablet_tech
- Step 2: Accept tablet_tech_id on each ACTION endpoint and rebind env
For each of hold, sign_off, advance_milestone (the action endpoints — NOT load which is read-only), change the signature to add tablet_tech_id=None and rebind env at the top:
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
part_ref='', step_id=None, mark_for_scrap=False,
photo_data=None, photo_filename=None, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
# ... rest of body uses `env` instead of `request.env`
Repeat for sign_off:
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
# ... use env throughout
And advance_milestone:
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
def advance_milestone(self, job_id, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
# ... use env throughout
In each body, replace request.env with env everywhere the function does writes. request.env is fine for read-only queries; the audit-relevant calls are Hold.create(...), step.button_finish(), job.action_advance_next_milestone().
- Step 3: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
git commit -m "feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)"
Task 6.3.3 — Wire tablet_tech_id into shopfloor action endpoints
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py -
Step 1: Import the helper
Same import line as Task 6.3.2.
- Step 2: Add
tablet_tech_id=Noneto each action endpoint
The action endpoints in shopfloor_controller (the ones that WRITE):
start_wostop_wostart_bakeend_bakebump_qty_donebump_qty_scrappedlog_chemistrylog_thickness_readingquality_holdmark_gate
Read-only endpoints (scan, tablet_overview, plant_overview, queue) DON'T need the kwarg.
For each, add tablet_tech_id=None to the signature and put this line at the top of the body before any write:
env = env_for_tablet_tech(request.env, tablet_tech_id)
Then replace request.env with env in the write paths (step.button_start(), step.button_finish(), Hold.create(), Reading.create(), etc.).
- Step 3: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
git commit -m "feat(fusion_plating_shopfloor): wire tablet_tech_id into shopfloor action endpoints (P6.3.3)"
Task 6.3.4 — Wire tablet_tech_id into manager action endpoints
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py -
Step 1: Apply the same pattern to manager's WRITE endpoints
Action endpoints in manager_controller:
assign_workerassign_tanktake_over
Read endpoints (overview, funnel, approval_inbox, at_risk) don't need it.
Same pattern: add tablet_tech_id=None, rebind env, swap request.env for env in writes.
- Step 2: Commit
git add fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
git commit -m "feat(fusion_plating_shopfloor): wire tablet_tech_id into manager action endpoints (P6.3.4)"
Task 6.3.5 — Update OWL clients to use fpRpc for action calls
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js -
Step 1: Replace
rpcimport withfpRpcfor action calls
In each file, add the import:
import { fpRpc } from "./services/fp_rpc";
(Keep the existing import { rpc } from "@web/core/network/rpc"; — we still use plain rpc for read-only loads.)
In each component, search for every rpc( call. For calls to ACTION endpoints (anything that writes — start_wo, stop_wo, hold, sign_off, advance_milestone, assign_worker, assign_tank, take_over, bump_qty_done, bump_qty_scrapped, log_thickness_reading, log_chemistry, mark_gate, start_bake, end_bake, plant_overview/move_card), replace rpc( with fpRpc(.
For read-only loads (workspace/load, landing/kanban, manager/overview, manager/funnel, manager/approval_inbox, manager/at_risk, tablet/tiles), keep rpc(.
- Step 2: Commit
git add fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js \
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js
git commit -m "feat(fusion_plating_shopfloor): clients use fpRpc for action calls (P6.3.5)"
Task 6.3.6 — Phase 6.3 version bump + deploy + smoke test
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Bump version to 19.0.30.2.0
-
Step 2: Commit + tag + push
git add fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(fusion_plating_shopfloor): bump 19.0.30.2.0 for Phase 6.3 — audit kwarg"
git tag -f phase6_3-audit-kwarg
git push origin main phase6_3-audit-kwarg
- Step 3: Deploy on entech
(Same tar-pipe + -u + asset-cache-clear pattern as before.)
- Step 4: Smoke test audit propagation
- Tech A unlocks tablet → opens a WO → starts a step.
- Tech A taps Hand Off → tile screen.
- Tech B unlocks → finishes the step.
- Open the WO in back-office → chatter should show:
- "Step 5 started by Tech A at HH:MM:SS"
- "Step 5 finished by Tech B at HH:MM:SS"
- SQL spot-check:
SELECT write_uid FROM fp_job_step WHERE id = <step_id>;returns Tech B's uid, not the tablet's session uid.
Self-Review
Spec coverage check:
- §4.1 Lock screen tile grid → P6.1.4 (endpoint), P6.2.4 (UI)
- §4.2 PIN pad → P6.2.2
- §4.3 Hand-Off button → P6.2.5
- §4.4 Idle warning → P6.2.3
- §4.5 Session continuity → P6.2.4 (
FpTabletLockonly re-mounts on full lock; OWL state preserved otherwise. SignaturePad throw-away handled bystate.selectedTileUserIdreset.) - §4.6 Profile prefs → P6.1.7 + P6.2.6
- §4.7 Manager reset → P6.1.7
- §5.1 res.users fields → P6.1.1
- §5.2 Station extras → P6.1.6
- §5.3 ir.config_parameter defaults → P6.1.6
- §5.4 All 5 endpoints → P6.1.2 (set/reset), P6.1.3 (unlock), P6.1.4 (tiles), P6.1.5 (ping)
- §5.5 Hash algorithm → P6.1.1
- §5.6 Audit propagation → P6.3.1-P6.3.5
- §6 Frontend → P6.2.1-P6.2.7
- §7 Edge cases (no PIN, manager reset, network drop, mid-job lock, lockout per-user) → all handled in P6.1.3 and P6.2.4
- §8 Testing → tests in P6.1.1-P6.1.4
- §9 Build sequence → matches plan structure
- §10 Backwards compat → optional kwarg in P6.3 keeps old callers working
- §11 Rollback → version bumps allow per-phase rollback
Placeholder scan: No TBD/TODO/"add validation" patterns. Every step has actual code or a concrete entech command.
Type consistency: currentTechId (camelCase) used consistently in JS; current_tech_id (snake_case) used consistently in JSON payloads; tablet_tech_id is the canonical kwarg name everywhere across server + client.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-22-shopfloor-pin-gate-plan.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?