Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-pin-gate-plan.md
gsinghpal a6546ac858 docs(fusion_plating_shopfloor): implementation plan for Phase 6 PIN gate
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>
2026-05-23 00:05:45 -04:00

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; add from . 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 — verify from . import models exists

  • 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
  1. Open https://enplating.com → log in → Plating → Shop Floor → Workstation.
  2. Expected: tile grid appears with operator avatars (or "No operators configured" if the operator group is empty).
  3. 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.
  4. Set a PIN via Preferences → modal opens, PinPad renders, set 1234 → confirm 1234 → "PIN updated" → close.
  5. Back on Workstation lock screen → tap your tile → PinPad → enter 1234 → unlocked, Workstation renders.
  6. Wait 5 minutes idle without touching → yellow border + "Locking in 30s" → 30s countdown → tile grid returns.
  7. Tap Hand-Off button in header while in Workspace → confirm → tile grid returns immediately.
  8. 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=None to each action endpoint

The action endpoints in shopfloor_controller (the ones that WRITE):

  • start_wo
  • stop_wo
  • start_bake
  • end_bake
  • bump_qty_done
  • bump_qty_scrapped
  • log_chemistry
  • log_thickness_reading
  • quality_hold
  • mark_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_worker
  • assign_tank
  • take_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 rpc import with fpRpc for 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
  1. Tech A unlocks tablet → opens a WO → starts a step.
  2. Tech A taps Hand Off → tile screen.
  3. Tech B unlocks → finishes the step.
  4. 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"
  5. 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 (FpTabletLock only re-mounts on full lock; OWL state preserved otherwise. SignaturePad throw-away handled by state.selectedTileUserId reset.)
  • §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?