Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-24-tablet-pin-session-redesign-plan.md
gsinghpal c3bcb4b99d docs(plating): tablet PIN session redesign implementation plan
7 phases (A-G), ~25 tasks. Phase A-E build the new auth flow,
audit model, endpoints, OWL service, and audit UI. Phase F is the
entech rollout (manual, inline by main session per hybrid pattern).
Phase G is the post-overlap cleanup (rip out tablet_tech_id,
delete legacy endpoint, archive shopfloor service user).

Bakes in 7 known gotchas from the permissions overhaul (rules
13c, 13i, 13k, 13m, 13d, AUDIT-1, always-push-to-main) so the
implementer doesn't repeat them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:53:23 -04:00

80 KiB

Tablet PIN Session Redesign — 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: Replace the OWL-overlay-only PIN gate with REAL per-tech Odoo sessions on PIN unlock — every Odoo write naturally attributed via session uid, with idle/ceiling auto-lock and a tamper-resistant audit log.

Architecture: A dedicated kiosk user (fp_tablet_kiosk@enplating.local, near-zero ACL) holds the tablet's session when nobody is unlocked. PIN unlock calls a custom Odoo auth manager (type fp_tablet_pin) that validates the PIN hash and mints a real session as the tech via request.session.authenticate(...). The browser cookie swaps. Lock-back destroys the tech session and re-auths the browser as the kiosk. Every state transition writes an append-only fp.tablet.session.event row. Migration is two-step with a 1-week overlap window controlled by ir.config_parameter['fp.shopfloor.tablet_session_mode'].

Tech Stack: Odoo 19, Python 3.11, PostgreSQL 15, OWL (@odoo/owl), XML data files, custom auth manager hooked into res.users._check_credentials.

Source spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md

Target deployment: entech LXC 111 (pve-worker5), database admin, addons at /mnt/extra-addons/custom/.


Known gotchas to bake in upfront

These bit the permissions-overhaul implementation hard. Build the plan with them in mind so this implementer doesn't repeat:

# Rule Where applied
13c res.users.group_ids (NOT groups_id); domain pickers may need all_group_ids for transitive lookups All test files; any ACL-domain code
13i res.users has no message_post; use user.partner_id.message_post(...) Any chatter writes from these new endpoints/cron
13k SELF_WRITEABLE_FIELDS / SELF_READABLE_FIELDS are @property in Odoo 19, NOT class attributes If we ever add user-facing tablet fields beyond PIN (not in this plan)
13m Tablet/kanban/dashboard controllers must sudo() cross-module reads for low-priv roles New endpoints don't render denormalized data — they return tiny JSON; rule probably doesn't apply but be alert
13d post_init_hook only fires on INSTALL, not UPGRADE; use migrations/<v>/post-migrate.py for -u triggers Kiosk user creation needs to fire on -u, not just install
AUDIT-1 When adding ACL for ir.actions.actions or similar admin-only models, scope grant to the lowest plating role that needs it (don't grant to base.group_user) Kiosk's res.users read ACL — scope to group_fp_tablet_kiosk only
AUDIT-2 Always commit + git push origin main per user preference memory:always-push-to-main Every task's final step

File Structure (master list)

New files

Path Responsibility
fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml Idempotent create of fp_tablet_kiosk user + assignment to group_fp_tablet_kiosk. Password = secret from ir.config_parameter['fp.tablet.kiosk_password'].
fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml New res.groups group_fp_tablet_kiosk. NO privilege_id (orthogonal to plating roles).
fusion_plating_shopfloor/migrations/19.0.32.1.0/post-migrate.py Triggers kiosk user/group creation on -u (since post_init_hook only fires on install) + auto-generates kiosk password if ir.config_parameter key absent.
fusion_plating_shopfloor/models/fp_tablet_session_event.py New model fp.tablet.session.event — append-only audit log.
fusion_plating_shopfloor/models/__init__.py (extend) from . import fp_tablet_session_event (after existing imports).
fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml List + form views + Owner-only menu + smart button on res.users.
fusion_plating_shopfloor/data/fp_tablet_cron.xml New cron _cron_force_lock_stale_sessions (every 5 min).
fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js OWL service: idle timer + DOM event listeners + lock-back invocation.
fusion_plating_shopfloor/tests/test_tablet_session_event_model.py Unit tests for the audit model (append-only, ACL).
fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py Tests for the fp_tablet_pin credential type.
fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py HTTP tests for the two new endpoints.
fusion_plating_shopfloor/tests/test_kiosk_user_acl.py Kiosk user can do ONLY what the spec allows (read res.users, call unlock); nothing else.

Modified files

Path Change
fusion_plating_shopfloor/__manifest__.py Bump version to 19.0.33.0.0. Add new data XML + cron XML + views + security XML to data.
fusion_plating_shopfloor/models/res_users.py (existing) Override _check_credentials to handle type='fp_tablet_pin'.
fusion_plating_shopfloor/security/ir.model.access.csv Add 3 rows: kiosk read on res.users + kiosk read on ir.config_parameter + Owner read on fp.tablet.session.event.
fusion_plating_shopfloor/controllers/tablet_controller.py Add /fp/tablet/unlock_session and /fp/tablet/lock_session. Keep /fp/tablet/unlock alive (deprecated) during overlap.
fusion_plating_shopfloor/static/src/js/tablet_lock.js (existing) Wire to new tablet_session_manager service. Check tablet_session_mode feature flag from server bootstrap; if 'session_swap', use new flow + window.location.reload() on transitions.
fusion_plating_shopfloor/static/src/js/services/fp_rpc.js (existing) When tablet_session_mode='session_swap', skip tablet_tech_id injection (no longer needed).

Deleted in Phase F cleanup (post-overlap)

Path Action
fusion_plating_shopfloor/controllers/_tablet_audit.py Delete (env_for_tablet_tech no longer used).
fusion_plating_shopfloor/static/src/js/services/fp_rpc.js Delete if no remaining callers, OR strip auto-injection logic.
fusion_plating_shopfloor/static/src/js/services/fp_shopfloor_tech_store.js (if exists) Delete (replaced by tablet_session_manager).
All ~15 endpoints with tablet_tech_id kwarg Remove the kwarg + env = env_for_tablet_tech(...) line.
/fp/tablet/unlock route in tablet_controller.py Delete the OLD endpoint.

Phase A — Kiosk User + Group + Audit ACL Foundation

Task A1: Bump shopfloor manifest version + register data files

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/__manifest__.py

  • Step 1: Open the manifest and bump version + add new files

Change the version line to '19.0.33.0.0'. In the data list, ADD these entries in this order (file load order matters — security before data before views):

'data': [
    'security/fp_tablet_kiosk_security.xml',          # NEW
    # ... existing security entries ...
    'security/ir.model.access.csv',                   # already there
    'data/fp_tablet_kiosk_user.xml',                  # NEW (depends on security)
    'data/fp_tablet_cron.xml',                        # NEW
    # ... existing data entries ...
    'views/fp_tablet_session_event_views.xml',        # NEW
    # ... existing view entries ...
],
  • Step 2: Commit + push
cd 'K:/Github/Odoo-Modules' && git add fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(shopfloor): bump to 19.0.33.0.0 for tablet PIN session redesign

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task A2: Define the kiosk group (security XML)

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml

  • Step 1: Write the file

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <!-- Tablet kiosk group — orthogonal to the Fusion Plating role hierarchy.
         NO privilege_id (would clutter the role picker). This group holds
         the bare minimum ACL for the lock screen to render and accept PIN:
            - read res.users (tile grid)
            - read ir.config_parameter (idle/ceiling settings)
         Nothing else. The dedicated fp_tablet_kiosk user is the only
         expected member. -->
    <record id="group_fp_tablet_kiosk" model="res.groups">
        <field name="name">Tablet Kiosk Session</field>
        <field name="sequence">100</field>
    </record>
</odoo>
  • Step 2: Verify XML parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml'); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml
git commit -m "feat(shopfloor-sec): group_fp_tablet_kiosk for tablet kiosk session

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task A3: Add kiosk ACL rows + Owner read on audit log

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv

  • Step 1: Append 3 rows to the CSV

access_res_users_kiosk,res.users.kiosk.read,base.model_res_users,fusion_plating_shopfloor.group_fp_tablet_kiosk,1,0,0,0
access_ir_config_param_kiosk,ir.config_parameter.kiosk.read,base.model_ir_config_parameter,fusion_plating_shopfloor.group_fp_tablet_kiosk,1,0,0,0
access_fp_tablet_session_event_owner,fp.tablet.session.event.owner.read,model_fp_tablet_session_event,fusion_plating.group_fp_owner,1,0,0,0
  • Step 2: Verify no duplicate xmlid IDs in the CSV (the existing file may already have access_res_users_*-like rows — check)
cd 'K:/Github/Odoo-Modules'
grep -c '^access_res_users_kiosk,' fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
# Expected: 1
grep -c '^access_fp_tablet_session_event_owner,' fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
# Expected: 1
  • Step 3: Commit + push
git add fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
git commit -m "feat(shopfloor-sec): kiosk ACL — read res.users + ir.config_parameter

Owner gets read on fp.tablet.session.event (audit log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task A4: Create the kiosk user via data XML

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml

  • Step 1: Write the file

<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
    <!-- Tablet kiosk user. noupdate="1" so manual password changes via
         the user form aren't reverted on -u. The post-migrate hook
         generates a random password on first install and stores it
         in ir.config_parameter for sysadmin retrieval. -->
    <record id="user_fp_tablet_kiosk" model="res.users">
        <field name="login">fp_tablet_kiosk@enplating.local</field>
        <field name="name">Tablet Kiosk</field>
        <field name="active" eval="True"/>
        <field name="share" eval="False"/>
        <field name="group_ids" eval="[
            (4, ref('base.group_user')),
            (4, ref('fusion_plating_shopfloor.group_fp_tablet_kiosk')),
        ]"/>
    </record>
</odoo>
  • Step 2: Verify XML parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml'); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml
git commit -m "feat(shopfloor): create fp_tablet_kiosk user

Kiosk holds the tablet session when no tech is PIN-unlocked.
Password is auto-generated by the post-migrate hook (Task A5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task A5: Post-migrate hook for kiosk password initialization

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py

  • Step 1: Create the migration directory + file

cd 'K:/Github/Odoo-Modules'
mkdir -p fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0

Then write migrations/19.0.33.0.0/post-migrate.py:

# -*- coding: utf-8 -*-
"""Tablet PIN session redesign — generate kiosk password on first deploy.

Runs on every -u (post_init_hook only fires on fresh install per
CLAUDE.md rule 13d). Idempotent: only writes the password if the
ir.config_parameter key is absent.

After this hook runs, retrieve the kiosk password via:
    odoo-shell -d admin -c "print(env['ir.config_parameter'].sudo().get_param(
        'fp.tablet.kiosk_password'))"

Then sysadmin enters that password ONCE in the tablet browser to log
the kiosk session in. Browser cookie persists per the configured
session_db.session_lifetime.
"""
import logging
import secrets

_logger = logging.getLogger(__name__)


def migrate(cr, version):
    from odoo import api, SUPERUSER_ID
    env = api.Environment(cr, SUPERUSER_ID, {})

    ICP = env['ir.config_parameter'].sudo()
    KEY = 'fp.tablet.kiosk_password'

    existing = ICP.get_param(KEY)
    if existing:
        _logger.info(
            'fp.tablet.kiosk_password already set; leaving it alone (idempotent)'
        )
        return

    new_pwd = secrets.token_urlsafe(32)
    ICP.set_param(KEY, new_pwd)
    _logger.info(
        'fp.tablet.kiosk_password generated; retrieve via odoo-shell '
        "env['ir.config_parameter'].sudo().get_param('%s')",
        KEY,
    )

    # Also write the password onto the user so they can log in via /web/login.
    user = env.ref(
        'fusion_plating_shopfloor.user_fp_tablet_kiosk',
        raise_if_not_found=False,
    )
    if user:
        user.password = new_pwd
        _logger.info('fp_tablet_kiosk user password set to generated value')
    else:
        _logger.warning(
            'fp_tablet_kiosk user not found via xmlid; password not applied to user record'
        )
  • Step 2: Verify Python parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py
git commit -m "feat(shopfloor): post-migrate hook for kiosk password init

Generates a random kiosk password on first deploy, stores in
ir.config_parameter for sysadmin retrieval. Idempotent — re-runs
on subsequent -u leave the password alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task A6: Test kiosk user ACL

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/tests/test_kiosk_user_acl.py

  • Step 1: Write the test file

from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessError


@tagged('-at_install', 'post_install', 'fp_tablet')
class TestKioskUserAcl(TransactionCase):
    """Kiosk user can do ONLY what the lock screen needs:
    read res.users (tile grid) + read ir.config_parameter (settings).
    EVERYTHING else MUST raise AccessError."""

    def setUp(self):
        super().setUp()
        kiosk = self.env.ref(
            'fusion_plating_shopfloor.user_fp_tablet_kiosk',
            raise_if_not_found=False,
        )
        if not kiosk:
            self.skipTest('fp_tablet_kiosk user not yet provisioned')
        self.kiosk = kiosk

    def test_kiosk_can_read_users(self):
        Users = self.env['res.users'].with_user(self.kiosk)
        Users.check_access_rights('read')  # raises if denied

    def test_kiosk_can_read_config_param(self):
        ICP = self.env['ir.config_parameter'].with_user(self.kiosk)
        ICP.check_access_rights('read')

    def test_kiosk_cannot_write_users(self):
        Users = self.env['res.users'].with_user(self.kiosk)
        with self.assertRaises(AccessError):
            Users.check_access_rights('write')

    def test_kiosk_cannot_read_jobs(self):
        Jobs = self.env['fp.job'].with_user(self.kiosk)
        with self.assertRaises(AccessError):
            Jobs.check_access_rights('read')

    def test_kiosk_cannot_read_sale_orders(self):
        SO = self.env['sale.order'].with_user(self.kiosk)
        with self.assertRaises(AccessError):
            SO.check_access_rights('read')

    def test_kiosk_cannot_read_certificates(self):
        Cert = self.env['fp.certificate'].with_user(self.kiosk)
        with self.assertRaises(AccessError):
            Cert.check_access_rights('read')

    def test_kiosk_cannot_read_part_catalog(self):
        Part = self.env['fp.part.catalog'].with_user(self.kiosk)
        with self.assertRaises(AccessError):
            Part.check_access_rights('read')
  • Step 2: Add to tests/init.py

Check fusion_plating/fusion_plating_shopfloor/tests/__init__.py and add the import line if not present:

from . import test_kiosk_user_acl
  • Step 3: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/tests/test_kiosk_user_acl.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): kiosk user ACL has near-zero access

7 tests covering allowed reads (res.users, ir.config_parameter)
and forbidden everything else (fp.job, sale.order, fp.certificate,
fp.part.catalog, res.users write).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Phase B — Audit Model + Custom Auth Manager

Task B1: Define fp.tablet.session.event model

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py

  • Modify: fusion_plating/fusion_plating_shopfloor/models/__init__.py

  • Step 1: Write the model

# -*- coding: utf-8 -*-
"""Append-only audit log of tablet session lifecycle events.

Every PIN unlock / lock-back / failed attempt / cron-force-lock
writes a row here. The model has read-only ACL (Owner only); even
write/unlink/edit are forbidden to anyone except root via direct SQL.

Schema lives in CLAUDE.md ALREADY would be premature — wait until
this lands and is referenced from external code.

Spec section 4: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
"""
from odoo import api, fields, models


class FpTabletSessionEvent(models.Model):
    _name = 'fp.tablet.session.event'
    _description = 'Tablet Session Event (audit log)'
    _order = 'create_date desc'
    _rec_name = 'event_type'

    event_type = fields.Selection(
        [
            ('unlock',        'Unlock (PIN success)'),
            ('failed_unlock', 'Failed PIN attempt'),
            ('manual_lock',   'Manual lock (Hand-Off button)'),
            ('idle_lock',     'Idle timeout lock'),
            ('ceiling_lock',  '8-hour ceiling lock'),
            ('force_lock',    'Force lock (cron, stale session)'),
            ('admin_reset',   'Admin force-reset PIN'),
        ],
        required=True,
        readonly=True,
        index=True,
    )

    user_id = fields.Many2one(
        'res.users',
        string='Tech',
        readonly=True,
        ondelete='restrict',
        help='The tech whose session was unlocked/locked. Empty for failed '
             'attempts where the tile was tapped but unlock never succeeded.',
    )
    attempted_user_id = fields.Many2one(
        'res.users',
        string='Attempted Tech',
        readonly=True,
        ondelete='restrict',
        help='For failed_unlock: which tile was tapped. user_id stays empty.',
    )

    session_id_hash = fields.Char(
        string='Session ID (hashed)',
        readonly=True,
        help='sha256 hash of the Odoo session sid. Correlates events for '
             'the same session without storing the raw token.',
    )
    session_started_at = fields.Datetime(readonly=True)
    session_ended_at = fields.Datetime(readonly=True)
    duration_seconds = fields.Integer(
        string='Duration (s)',
        readonly=True,
        help='Filled on lock events from session_started_at delta.',
    )

    ip_address = fields.Char(readonly=True)
    user_agent = fields.Char(
        readonly=True,
        help='Trimmed to 256 chars to prevent oversize logs.',
    )

    failure_reason = fields.Selection(
        [
            ('wrong_pin',     'Wrong PIN'),
            ('locked_out',    'Locked out (too many failures)'),
            ('no_pin_set',    'No PIN configured'),
            ('user_inactive', 'User archived or disabled'),
            ('no_role',       'User has no shop-branch role'),
        ],
        readonly=True,
    )

    acting_uid = fields.Many2one(
        'res.users',
        string='Acting User',
        readonly=True,
        help='The user the SERVER saw at request time. Usually '
             'fp_tablet_kiosk for unlocks; the manager for admin_reset; '
             'base.user_root for cron-driven events.',
    )

    notes = fields.Text(readonly=True)
  • Step 2: Register in init.py

Check fusion_plating/fusion_plating_shopfloor/models/__init__.py and add:

from . import fp_tablet_session_event
  • Step 3: Verify Python parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py fusion_plating/fusion_plating_shopfloor/models/__init__.py
git commit -m "feat(shopfloor): fp.tablet.session.event append-only audit log

Captures unlock / failed_unlock / manual_lock / idle_lock /
ceiling_lock / force_lock / admin_reset events with session hash,
ip, user-agent, duration, failure reason, acting uid.

Read-only ACL granted to Owner in Phase A; no write/unlink anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task B2: Test the audit model is append-only

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py

  • Step 1: Write the test

from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessError


@tagged('-at_install', 'post_install', 'fp_tablet')
class TestTabletSessionEventAppendOnly(TransactionCase):

    def setUp(self):
        super().setUp()
        Users = self.env['res.users'].with_context(no_reset_password=True)
        self.owner = Users.create({
            'login': 'audit_owner', 'name': 'Audit Owner',
            'email': 'audit_owner@example.com',
            'group_ids': [(6, 0, [
                self.env.ref('fusion_plating.group_fp_owner').id
            ])],
        })
        self.tech = Users.create({
            'login': 'audit_tech', 'name': 'Audit Tech',
            'email': 'audit_tech@example.com',
            'group_ids': [(6, 0, [
                self.env.ref('fusion_plating.group_fp_technician').id
            ])],
        })

    def test_owner_can_read(self):
        event = self.env['fp.tablet.session.event'].sudo().create({
            'event_type': 'unlock',
            'user_id': self.tech.id,
        })
        # Owner reads via their own user
        e = self.env['fp.tablet.session.event'].with_user(self.owner).browse(event.id)
        self.assertEqual(e.user_id, self.tech)

    def test_technician_cannot_read(self):
        event = self.env['fp.tablet.session.event'].sudo().create({
            'event_type': 'unlock',
            'user_id': self.tech.id,
        })
        with self.assertRaises(AccessError):
            self.env['fp.tablet.session.event'].with_user(self.tech).browse(event.id).event_type

    def test_owner_cannot_write(self):
        event = self.env['fp.tablet.session.event'].sudo().create({
            'event_type': 'unlock',
            'user_id': self.tech.id,
        })
        with self.assertRaises(AccessError):
            self.env['fp.tablet.session.event'].with_user(self.owner).browse(event.id).write({
                'event_type': 'failed_unlock',
            })

    def test_owner_cannot_unlink(self):
        event = self.env['fp.tablet.session.event'].sudo().create({
            'event_type': 'unlock',
            'user_id': self.tech.id,
        })
        with self.assertRaises(AccessError):
            self.env['fp.tablet.session.event'].with_user(self.owner).browse(event.id).unlink()
  • Step 2: Register in tests/init.py + commit + push
cd 'K:/Github/Odoo-Modules'
# Add 'from . import test_tablet_session_event_model' to tests/__init__.py
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): fp.tablet.session.event is append-only

Owner reads. Technician cannot read. Owner cannot write or unlink.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task B3: Custom auth manager — fp_tablet_pin credential type

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/models/res_users.py

  • Step 1: Add the _check_credentials override at the bottom of the existing ResUsers class

Find the existing class ResUsers(models.Model) block (it already holds x_fc_tablet_pin_hash, set_tablet_pin, etc.). Add this method:

def _check_credentials(self, credential, env):
    """Custom auth manager: accept `type='fp_tablet_pin'` credential.

    Validates the PIN hash, that the user is active, and that they hold
    at least one shop-branch plating role. On success, returns the auth
    info dict Odoo's session expects. On failure, raises AccessDenied
    so the standard auth chain returns a 401.

    See docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
    Section 2 — Auth path.
    """
    from odoo.exceptions import AccessDenied
    if isinstance(credential, dict) and credential.get('type') == 'fp_tablet_pin':
        login = credential.get('login')
        pin = credential.get('pin')
        if not login or not pin:
            raise AccessDenied()
        user_sudo = self.sudo().search([('login', '=', login)], limit=1)
        if not user_sudo or not user_sudo.active:
            raise AccessDenied()
        # Must hold a shop-branch role (otherwise they can't operate the tablet)
        shop_branch_xmlids = (
            'fusion_plating.group_fp_technician',
            'fusion_plating.group_fp_shop_manager_v2',
            'fusion_plating.group_fp_manager',
            'fusion_plating.group_fp_quality_manager',
            'fusion_plating.group_fp_owner',
        )
        shop_branch_ids = {
            g.id for g in (
                self.env.ref(x, raise_if_not_found=False)
                for x in shop_branch_xmlids
            ) if g
        }
        user_group_ids = set(user_sudo.group_ids.ids)
        if not (shop_branch_ids & user_group_ids):
            raise AccessDenied()
        # Verify the PIN hash. verify_tablet_pin already exists.
        if not user_sudo.verify_tablet_pin(pin):
            raise AccessDenied()
        return {
            'uid': user_sudo.id,
            'auth_method': 'fp_tablet_pin',
            'mfa': 'default',
        }
    return super()._check_credentials(credential, env)
  • Step 2: Verify Python parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/res_users.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/models/res_users.py
git commit -m "feat(shopfloor): fp_tablet_pin custom auth manager

Validates PIN hash + shop-branch role membership when the credential
type is fp_tablet_pin. Goes through Odoo's standard _check_credentials
chain so future 2FA / IP-gate modules layer cleanly on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task B4: Test the auth manager

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py

  • Step 1: Write the test

from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessDenied


@tagged('-at_install', 'post_install', 'fp_tablet')
class TestTabletPinAuthManager(TransactionCase):

    def setUp(self):
        super().setUp()
        Users = self.env['res.users'].with_context(no_reset_password=True)
        self.tech = Users.create({
            'login': 'authmgr_tech@example.com', 'name': 'AuthMgr Tech',
            'email': 'authmgr_tech@example.com',
            'group_ids': [(6, 0, [
                self.env.ref('fusion_plating.group_fp_technician').id
            ])],
        })
        self.tech.sudo().set_tablet_pin('1234')

        self.no_role_user = Users.create({
            'login': 'authmgr_norole@example.com', 'name': 'NoRole',
            'email': 'authmgr_norole@example.com',
        })
        # Set a PIN but no shop-branch role
        # (set_tablet_pin doesn't care about roles)
        self.no_role_user.sudo().set_tablet_pin('5555')

    def _check(self, login, pin):
        return self.env['res.users'].sudo()._check_credentials(
            {'type': 'fp_tablet_pin', 'login': login, 'pin': pin},
            self.env,
        )

    def test_correct_pin_succeeds(self):
        result = self._check('authmgr_tech@example.com', '1234')
        self.assertEqual(result['uid'], self.tech.id)
        self.assertEqual(result['auth_method'], 'fp_tablet_pin')

    def test_wrong_pin_raises_access_denied(self):
        with self.assertRaises(AccessDenied):
            self._check('authmgr_tech@example.com', '0000')

    def test_missing_pin_raises_access_denied(self):
        with self.assertRaises(AccessDenied):
            self._check('authmgr_tech@example.com', '')

    def test_missing_login_raises_access_denied(self):
        with self.assertRaises(AccessDenied):
            self._check('', '1234')

    def test_unknown_login_raises_access_denied(self):
        with self.assertRaises(AccessDenied):
            self._check('nobody@example.com', '1234')

    def test_inactive_user_raises_access_denied(self):
        self.tech.sudo().active = False
        with self.assertRaises(AccessDenied):
            self._check('authmgr_tech@example.com', '1234')

    def test_no_shop_branch_role_raises_access_denied(self):
        with self.assertRaises(AccessDenied):
            self._check('authmgr_norole@example.com', '5555')

    def test_other_credential_types_pass_through_to_super(self):
        # Standard password credential should still work normally.
        # We don't test the exact result here — just that our override
        # doesn't intercept it.
        # AccessDenied is expected because we provide no password.
        # Key check: it's not OUR AccessDenied (which fires when type
        # is fp_tablet_pin), so the super() chain handles it.
        try:
            self.env['res.users'].sudo()._check_credentials(
                {'type': 'password', 'login': 'authmgr_tech@example.com',
                 'password': 'wrong'},
                self.env,
            )
        except AccessDenied:
            pass  # expected — wrong password
        except Exception as e:
            self.fail(f'Standard password path broken: {e}')
  • Step 2: Register + commit + push
cd 'K:/Github/Odoo-Modules'
# Add 'from . import test_tablet_pin_auth_manager' to tests/__init__.py
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): fp_tablet_pin auth manager handles all cases

8 tests: correct/wrong/missing PIN, missing/unknown login, inactive
user, no shop-branch role, and pass-through of other credential types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Phase C — New Endpoints + Cron + Feature Flag

Task C1: Add the helper module for session-event writes

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py

  • Step 1: Write the helper

# -*- coding: utf-8 -*-
"""Helper for writing fp.tablet.session.event rows from the new
unlock_session / lock_session endpoints and the force-lock cron.

Single source of truth for hashing the session sid, trimming the
user-agent, and capturing forensic ip / acting_uid.
"""
import hashlib

from odoo import fields
from odoo.http import request


def _sha256_session_sid(sid):
    """Return sha256 hex digest of the session sid. Stored in the audit
    log so DB leaks can't be replayed."""
    if not sid:
        return ''
    return hashlib.sha256(sid.encode('utf-8')).hexdigest()


def _trim_ua(ua):
    """Trim user-agent to 256 chars (Odoo's standard Char width)."""
    if not ua:
        return ''
    return ua[:256]


def write_event(env, *, event_type, user_id=None, attempted_user_id=None,
                session_id_hash=None, session_started_at=None,
                session_ended_at=None, duration_seconds=None,
                failure_reason=None, notes=None):
    """Append an fp.tablet.session.event row. All writes sudo'd.

    The acting_uid + ip + ua are pulled from the current request
    automatically so callers never forget them.
    """
    vals = {
        'event_type': event_type,
        'user_id': user_id,
        'attempted_user_id': attempted_user_id,
        'session_id_hash': session_id_hash,
        'session_started_at': session_started_at,
        'session_ended_at': session_ended_at,
        'duration_seconds': duration_seconds,
        'failure_reason': failure_reason,
        'notes': notes,
        'acting_uid': env.uid,
    }
    if request:
        vals['ip_address'] = request.httprequest.remote_addr or ''
        vals['user_agent'] = _trim_ua(
            request.httprequest.headers.get('User-Agent', '')
        )
    return env['fp.tablet.session.event'].sudo().create(vals)
  • Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py
git commit -m "feat(shopfloor): _tablet_session_audit helper for audit-log writes

Single source for sha256(session sid), ua trim, ip/acting_uid capture
from request. Used by unlock_session, lock_session, and force-lock cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task C2: Add /fp/tablet/unlock_session endpoint

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py

  • Step 1: Add the new endpoint AFTER the existing /fp/tablet/unlock route

Find the existing def unlock(self, user_id, pin): method. Below it (still in the FpTabletController class), add:

    # ======================================================================
    # /fp/tablet/unlock_session — verify PIN + mint REAL Odoo session as tech
    # ======================================================================
    @http.route('/fp/tablet/unlock_session', type='jsonrpc', auth='user')
    def unlock_session(self, user_id, pin):
        """Phase 1 of the tablet PIN session redesign.

        Verifies the PIN, then mints a real Odoo session AS the tech
        via the fp_tablet_pin custom auth manager. Browser cookie
        swaps; subsequent requests carry the tech's session uid, so
        create_uid / write_uid / chatter authorship attribute correctly
        without any tablet_tech_id plumbing.

        Spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
        """
        from odoo.exceptions import AccessDenied
        from ._tablet_session_audit import write_event, _sha256_session_sid
        env = request.env
        Users = env['res.users'].sudo()
        target = Users.browse(int(user_id))
        if not target.exists():
            return {'ok': False, 'error': _('User not found.')}

        # No PIN set yet
        if not target.x_fc_tablet_pin_hash:
            write_event(env,
                event_type='failed_unlock',
                attempted_user_id=target.id,
                failure_reason='no_pin_set')
            return {
                'ok': False,
                'error': _('No PIN set. Set one in Preferences first.'),
                'needs_setup': True,
            }

        # Inactive
        if not target.active:
            write_event(env,
                event_type='failed_unlock',
                attempted_user_id=target.id,
                failure_reason='user_inactive')
            return {'ok': False, 'error': _('User is inactive.')}

        # Currently locked out?
        now = fields.Datetime.now()
        if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
            write_event(env,
                event_type='failed_unlock',
                attempted_user_id=target.id,
                failure_reason='locked_out')
            return {
                'ok': False,
                'error': _('Account locked. Try again in a few minutes.'),
                'locked_until': target.x_fc_tablet_locked_until.isoformat(),
            }

        # Attempt the real Odoo session swap via the custom auth manager.
        # session.authenticate validates credentials through _check_credentials,
        # issues a new sid, sets the cookie, returns the user dict.
        try:
            auth_info = request.session.authenticate(
                request.db,
                {'type': 'fp_tablet_pin',
                 'login': target.login,
                 'pin': pin},
            )
        except AccessDenied:
            # Wrong PIN — increment failure counter
            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}
            failure_reason = 'wrong_pin'
            if new_count >= threshold:
                vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
                failure_reason = 'locked_out'
            target.write(vals)
            write_event(env,
                event_type='failed_unlock',
                attempted_user_id=target.id,
                failure_reason=failure_reason)
            _logger.warning(
                'Tablet PIN failure for uid %s (count=%d, locked=%s)',
                target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
            )
            return {'ok': False, 'error': _('Incorrect PIN.')}

        # Success path. session.authenticate already swapped the cookie.
        sid = request.session.sid
        target.write({
            'x_fc_tablet_pin_failed_count': 0,
            'x_fc_tablet_locked_until': False,
        })
        write_event(env,
            event_type='unlock',
            user_id=target.id,
            session_id_hash=_sha256_session_sid(sid),
            session_started_at=now)
        _logger.info(
            'Tablet session minted for uid %s (sid %s..)',
            target.id, sid[:8] if sid else '',
        )
        return {
            'ok': True,
            'tech_id': target.id,
            'tech_name': target.name,
        }
  • Step 2: Make sure the top-of-file imports include timedelta (likely already present — check)

  • Step 3: Commit + push

cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py
git commit -m "feat(shopfloor): /fp/tablet/unlock_session mints real Odoo session

PIN verify -> request.session.authenticate(type=fp_tablet_pin) -> new
session sid, cookie swap, audit event written. Failed attempts also
written to audit log (failed_unlock, failure_reason=wrong_pin or
locked_out or no_pin_set or user_inactive).

OLD /fp/tablet/unlock stays alive during the 1-week overlap window
per spec Section 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task C3: Add /fp/tablet/lock_session endpoint

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py

  • Step 1: Add the lock endpoint below unlock_session

    # ======================================================================
    # /fp/tablet/lock_session — destroy tech session + re-auth as kiosk
    # ======================================================================
    @http.route('/fp/tablet/lock_session', type='jsonrpc', auth='user')
    def lock_session(self, reason='manual'):
        """Lock the tablet — destroy the tech's session and re-auth the
        browser as fp_tablet_kiosk. Audit-log the event.

        `reason` is one of 'manual' / 'idle' / 'ceiling' — controls which
        event_type gets written. The corresponding event_type names:
            manual  -> manual_lock
            idle    -> idle_lock
            ceiling -> ceiling_lock
        Anything else falls back to manual_lock.
        """
        from ._tablet_session_audit import write_event, _sha256_session_sid
        env = request.env
        now = fields.Datetime.now()
        sid = request.session.sid
        tech_id = env.uid

        # Determine the audit event_type
        event_type_map = {
            'manual':  'manual_lock',
            'idle':    'idle_lock',
            'ceiling': 'ceiling_lock',
        }
        event_type = event_type_map.get(reason, 'manual_lock')

        # Find the matching open session_event so we can compute duration.
        # We look for the most recent unlock for this user without a
        # session_ended_at — that's the open one.
        SessionEvent = env['fp.tablet.session.event'].sudo()
        open_event = SessionEvent.search([
            ('event_type', '=', 'unlock'),
            ('user_id', '=', tech_id),
            ('session_ended_at', '=', False),
        ], order='create_date desc', limit=1)

        session_started_at = (
            open_event.session_started_at if open_event else False
        )
        duration_seconds = None
        if session_started_at:
            duration_seconds = int((now - session_started_at).total_seconds())

        # Write the lock event BEFORE destroying the session (we lose
        # env.uid after logout).
        write_event(env,
            event_type=event_type,
            user_id=tech_id,
            session_id_hash=_sha256_session_sid(sid),
            session_started_at=session_started_at,
            session_ended_at=now,
            duration_seconds=duration_seconds)
        _logger.info(
            'Tablet locked (reason=%s) for uid %s (sid %s..) duration=%ss',
            reason, tech_id, sid[:8] if sid else '', duration_seconds,
        )

        # Destroy the tech session
        request.session.logout(keep_db=True)

        # Re-authenticate as the kiosk user. Credential comes from
        # ir.config_parameter (auto-generated on first install in
        # post-migrate.py).
        kiosk_login = 'fp_tablet_kiosk@enplating.local'
        kiosk_password = env['ir.config_parameter'].sudo().get_param(
            'fp.tablet.kiosk_password', ''
        )
        if not kiosk_password:
            _logger.error(
                'fp.tablet.kiosk_password missing from ir.config_parameter; '
                'cannot re-auth tablet as kiosk. The browser will need '
                'manual login.'
            )
            return {'ok': True, 'locked_at': now.isoformat(),
                    'needs_kiosk_relogin': True}

        try:
            request.session.authenticate(
                request.db,
                {'type': 'password',
                 'login': kiosk_login,
                 'password': kiosk_password},
            )
        except Exception as e:
            _logger.exception(
                'Failed to re-auth tablet as kiosk: %s', e)
            return {'ok': True, 'locked_at': now.isoformat(),
                    'needs_kiosk_relogin': True}

        return {'ok': True, 'locked_at': now.isoformat()}
  • Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py
git commit -m "feat(shopfloor): /fp/tablet/lock_session destroys tech session

Writes lock event (manual/idle/ceiling) with duration computed from
the open unlock event. Then logout + re-authenticate as kiosk via
the password stored in ir.config_parameter['fp.tablet.kiosk_password'].

Falls back to 'needs_kiosk_relogin' if the kiosk password is missing
(sysadmin must log in manually). Logs every event for forensic
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task C4: Force-lock cron for stale sessions

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml

  • Modify: fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py (add the cron method)

  • Step 1: Add the cron method on the model

Append this method to the FpTabletSessionEvent class:

    @api.model
    def _cron_force_lock_stale_sessions(self):
        """Belt-and-suspenders cron: find any active unlock event past
        the 8-hour ceiling and mark it force-locked.

        Handles browser crashes, tablet reboots with stale cookies,
        and any path that bypasses /fp/tablet/lock_session.

        Runs every 5 minutes per fp_tablet_cron.xml.
        """
        from datetime import timedelta
        ceiling_hours = int(self.env['ir.config_parameter'].sudo().get_param(
            'fp.tablet.session_ceiling_hours', 8))
        cutoff = fields.Datetime.now() - timedelta(hours=ceiling_hours)
        stale = self.search([
            ('event_type', '=', 'unlock'),
            ('session_ended_at', '=', False),
            ('session_started_at', '<', cutoff),
        ])
        now = fields.Datetime.now()
        for event in stale:
            duration = int((now - event.session_started_at).total_seconds())
            self.sudo().create({
                'event_type': 'force_lock',
                'user_id': event.user_id.id,
                'session_id_hash': event.session_id_hash,
                'session_started_at': event.session_started_at,
                'session_ended_at': now,
                'duration_seconds': duration,
                'notes': 'Cron force-lock: session exceeded %d-hour ceiling' % ceiling_hours,
            })
            # Mark the original unlock event closed so it's not reprocessed
            # next tick. write() is blocked by ACL — use sudo + bypass.
            self.env.cr.execute(
                """UPDATE fp_tablet_session_event
                   SET session_ended_at = %s, duration_seconds = %s
                 WHERE id = %s""",
                (now, duration, event.id),
            )
  • Step 2: Write the cron XML
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="1">
        <record id="ir_cron_force_lock_stale_sessions" model="ir.cron">
            <field name="name">Tablet: Force-lock Stale PIN Sessions</field>
            <field name="model_id" ref="model_fp_tablet_session_event"/>
            <field name="state">code</field>
            <field name="code">model._cron_force_lock_stale_sessions()</field>
            <field name="interval_number">5</field>
            <field name="interval_type">minutes</field>
            <field name="active" eval="True"/>
        </record>
    </data>
</odoo>
  • Step 3: Verify + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml'); print('XML OK')"
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py').read()); print('Py OK')"
git add fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py
git commit -m "feat(shopfloor): force-lock cron for stale tablet sessions

Every 5 minutes, find active unlock events past 8-hour ceiling and
mark them force-locked. SQL bypass of the model's read-only ACL is
the only path that can update existing rows (no Python write() works).

Ceiling configurable via ir.config_parameter[fp.tablet.session_ceiling_hours].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task C5: HTTP-test the unlock + lock endpoints

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py

  • Step 1: Write the HttpCase tests

import json

from odoo.tests.common import HttpCase, tagged


@tagged('-at_install', 'post_install', 'fp_tablet')
class TestUnlockLockSessionEndpoints(HttpCase):

    def setUp(self):
        super().setUp()
        Users = self.env['res.users'].with_context(no_reset_password=True)
        self.tech = Users.create({
            'login': 'http_tech@example.com', 'name': 'HTTP Tech',
            'email': 'http_tech@example.com',
            'group_ids': [(6, 0, [
                self.env.ref('fusion_plating.group_fp_technician').id
            ])],
        })
        self.tech.sudo().set_tablet_pin('1234')
        # Make sure the kiosk password is set so lock_session can re-auth.
        ICP = self.env['ir.config_parameter'].sudo()
        if not ICP.get_param('fp.tablet.kiosk_password'):
            ICP.set_param('fp.tablet.kiosk_password', 'test_kiosk_pwd')
            kiosk = self.env.ref('fusion_plating_shopfloor.user_fp_tablet_kiosk')
            kiosk.sudo().password = 'test_kiosk_pwd'

    def _jsonrpc(self, route, params):
        return self.url_open(
            route,
            data=json.dumps({'jsonrpc': '2.0', 'params': params}),
            headers={'Content-Type': 'application/json'},
        ).json()

    def test_unlock_session_with_correct_pin_returns_ok(self):
        self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
        resp = self._jsonrpc('/fp/tablet/unlock_session', {
            'user_id': self.tech.id, 'pin': '1234',
        })
        self.assertTrue(resp['result']['ok'])
        self.assertEqual(resp['result']['tech_id'], self.tech.id)

    def test_unlock_session_writes_audit_event(self):
        self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
        self._jsonrpc('/fp/tablet/unlock_session', {
            'user_id': self.tech.id, 'pin': '1234',
        })
        events = self.env['fp.tablet.session.event'].sudo().search([
            ('event_type', '=', 'unlock'),
            ('user_id', '=', self.tech.id),
        ])
        self.assertGreater(len(events), 0)
        e = events[0]
        self.assertTrue(e.session_id_hash)
        self.assertTrue(e.session_started_at)

    def test_unlock_session_with_wrong_pin_writes_failed_event(self):
        self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
        resp = self._jsonrpc('/fp/tablet/unlock_session', {
            'user_id': self.tech.id, 'pin': '0000',
        })
        self.assertFalse(resp['result']['ok'])
        events = self.env['fp.tablet.session.event'].sudo().search([
            ('event_type', '=', 'failed_unlock'),
            ('attempted_user_id', '=', self.tech.id),
            ('failure_reason', '=', 'wrong_pin'),
        ])
        self.assertGreater(len(events), 0)

    def test_lock_session_writes_manual_lock_event(self):
        self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
        # Unlock first
        self._jsonrpc('/fp/tablet/unlock_session', {
            'user_id': self.tech.id, 'pin': '1234',
        })
        # Then lock with reason=manual
        resp = self._jsonrpc('/fp/tablet/lock_session', {'reason': 'manual'})
        self.assertTrue(resp['result']['ok'])
        events = self.env['fp.tablet.session.event'].sudo().search([
            ('event_type', '=', 'manual_lock'),
            ('user_id', '=', self.tech.id),
        ])
        self.assertGreater(len(events), 0)
        self.assertIsNotNone(events[0].session_ended_at)
        self.assertGreaterEqual(events[0].duration_seconds, 0)

    def test_lock_session_idle_reason_writes_idle_lock(self):
        self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
        self._jsonrpc('/fp/tablet/unlock_session', {
            'user_id': self.tech.id, 'pin': '1234',
        })
        self._jsonrpc('/fp/tablet/lock_session', {'reason': 'idle'})
        events = self.env['fp.tablet.session.event'].sudo().search([
            ('event_type', '=', 'idle_lock'),
            ('user_id', '=', self.tech.id),
        ])
        self.assertGreater(len(events), 0)
  • Step 2: Register + commit + push
cd 'K:/Github/Odoo-Modules'
# Add 'from . import test_unlock_lock_session_endpoints' to tests/__init__.py
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): HTTP tests for unlock_session + lock_session

5 tests covering correct/wrong PIN, audit event writes, manual/idle
lock reasons. Uses HttpCase to actually drive the JSONRPC endpoint
end-to-end with session cookie handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Phase D — OWL Session Manager + Lock Screen Integration

Task D1: Write the tablet_session_manager OWL service

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js

  • Step 1: Write the service

/** @odoo-module **/
// =============================================================================
// Tablet Session Manager (Phase D of tablet PIN session redesign)
// 
// OWL service that tracks idle time + hard ceiling for an unlocked tech
// session and fires /fp/tablet/lock_session when either threshold trips.
//
// Activity events (click / touchstart / keydown / mousemove) reset the idle
// timer. setInterval polls every 5 seconds.
//
// Spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
// =============================================================================
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";

const DEFAULT_IDLE_MS = 10 * 60 * 1000;           // 10 minutes
const DEFAULT_CEILING_MS = 8 * 60 * 60 * 1000;    // 8 hours
const TICK_MS = 5000;                              // check every 5 seconds

export const tabletSessionManager = {
    dependencies: [],

    start(env) {
        const service = {
            idleMs: DEFAULT_IDLE_MS,
            ceilingMs: DEFAULT_CEILING_MS,
            lastActivity: Date.now(),
            sessionStartedAt: null,
            _tickHandle: null,
            _running: false,

            beginSession(sessionStartedAtMs) {
                this.sessionStartedAt = sessionStartedAtMs || Date.now();
                this.lastActivity = Date.now();
                this._installListeners();
                this._tickHandle = setInterval(() => this._tick(), TICK_MS);
                this._running = true;
            },

            endSession() {
                if (this._tickHandle) clearInterval(this._tickHandle);
                this._tickHandle = null;
                this._removeListeners();
                this._running = false;
                this.sessionStartedAt = null;
            },

            _touchHandler: null,
            _installListeners() {
                this._touchHandler = () => { this.lastActivity = Date.now(); };
                ["click", "touchstart", "keydown", "mousemove"].forEach(ev =>
                    document.addEventListener(ev, this._touchHandler, { passive: true })
                );
            },
            _removeListeners() {
                if (!this._touchHandler) return;
                ["click", "touchstart", "keydown", "mousemove"].forEach(ev =>
                    document.removeEventListener(ev, this._touchHandler)
                );
                this._touchHandler = null;
            },

            async _tick() {
                if (!this._running) return;
                const now = Date.now();
                const idleFor = now - this.lastActivity;
                const sessionAgeMs = this.sessionStartedAt ? now - this.sessionStartedAt : 0;
                let reason = null;
                if (sessionAgeMs > this.ceilingMs) {
                    reason = "ceiling";
                } else if (idleFor > this.idleMs) {
                    reason = "idle";
                }
                if (!reason) return;
                this.endSession();   // stop ticking before the RPC
                await this.lockBack(reason);
            },

            async lockBack(reason) {
                try {
                    await rpc("/fp/tablet/lock_session", { reason });
                } catch (e) {
                    // Even if the RPC fails, force a reload to drop the
                    // current session state — the cron will clean up.
                    console.warn("lock_session RPC failed; reloading anyway", e);
                }
                window.location.reload();
            },
        };
        return service;
    },
};

registry.category("services").add("fp_tablet_session_manager", tabletSessionManager);
  • Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js
git commit -m "feat(shopfloor): tablet_session_manager OWL service

Tracks idle + ceiling timers for an unlocked tech session. Fires
/fp/tablet/lock_session when either trips, then reloads the page so
the browser re-bootstraps under the fresh kiosk session.

Defaults: 10min idle, 8hr ceiling, 5s tick interval. Listens for
click/touchstart/keydown/mousemove as activity signals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task D2: Wire the lock screen to call unlock_session

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js

  • Step 1: Find the existing unlock flow in tablet_lock.js

Look for where the OWL component currently calls /fp/tablet/unlock. There should be an onPinSubmit or similar handler.

  • Step 2: Add feature-flag-gated branch

The component must detect tablet_session_mode (from a server-bootstrapped value or from a static ir.config_parameter read at mount) and route to either the OLD or NEW endpoint:

// In setup() or onWillStart, fetch the mode:
this.sessionMode = await rpc("/web/dataset/call_kw", {
    model: "ir.config_parameter",
    method: "get_param",
    args: ["fp.shopfloor.tablet_session_mode", "legacy"],
    kwargs: {},
});

// In the PIN-submit handler:
async _onPinSubmit() {
    const pin = this.state.pinDigits.join("");
    if (this.sessionMode === "session_swap") {
        const resp = await rpc("/fp/tablet/unlock_session", {
            user_id: this.state.selectedTileUserId,
            pin: pin,
        });
        if (resp.ok) {
            // Browser cookie has swapped — reload so the app re-bootstraps
            // as the tech. The new session manager (Task D1) will kick in
            // on the next page load.
            window.location.reload();
            return;
        }
        this._showError(resp.error || "Unlock failed");
        return;
    }
    // Legacy path: existing /fp/tablet/unlock flow, unchanged
    // ... existing code stays here ...
}

The exact integration depends on the current tablet_lock.js structure. The PRINCIPLE: branch on sessionMode and reload on success.

  • Step 3: Bootstrap tablet_session_manager on page load

In the OWL service registry consumer (likely the shopfloor landing or job workspace component), call:

this.tabletSessionManager = useService("fp_tablet_session_manager");

onMounted(() => {
    // If we're on a TECH session (uid != kiosk uid), begin the idle timer.
    // Bootstrap data should provide initial_uid + kiosk_uid hints.
    if (env.services.user.userId && env.services.user.userId !== KIOSK_UID) {
        this.tabletSessionManager.beginSession();
    }
});

onWillUnmount(() => {
    this.tabletSessionManager.endSession();
});

Determining KIOSK_UID at runtime: read it once from a server endpoint or static config parameter, OR check env.services.user.userId === kiosk.id via a server-bootstrap value.

  • Step 4: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js
git commit -m "feat(shopfloor): tablet_lock OWL component branches on session_mode

When ir.config_parameter[fp.shopfloor.tablet_session_mode]='session_swap',
PIN submit calls /fp/tablet/unlock_session + reloads the page. The
new session manager service kicks in on next mount.

Legacy mode unchanged — same /fp/tablet/unlock path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task D3: Add a Lock button to the unlocked UI

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml (or wherever the existing Hand-Off button lives)

  • Step 1: Find the existing Hand-Off / Lock button (search for Lock or Hand in tablet_lock.xml / shopfloor_landing.xml / job_workspace.xml)

The CLAUDE.md mentions a Hand-Off button already exists. Wire its t-on-click to call the new lockBack('manual') method on the session manager service:

<button t-on-click="() => this.tabletSessionManager.lockBack('manual')"
        class="btn btn-warning"
        title="Lock the tablet for the next operator">
    <i class="fa fa-lock"/> Hand Off / Lock
</button>
  • Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/static/src/xml/
git commit -m "feat(shopfloor): Hand-Off button calls lockBack('manual')

Wires the existing button to the new session manager service so it
writes a manual_lock audit event and re-authenticates as kiosk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Phase E — Audit Views + Owner Menu + Smart Button

Task E1: List + form views for the audit log

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml

  • Step 1: Write the view file

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data>

        <record id="view_fp_tablet_session_event_list" model="ir.ui.view">
            <field name="name">fp.tablet.session.event.list</field>
            <field name="model">fp.tablet.session.event</field>
            <field name="arch" type="xml">
                <list decoration-success="event_type == 'unlock'"
                      decoration-danger="event_type == 'failed_unlock'"
                      decoration-warning="event_type in ('ceiling_lock','force_lock')"
                      decoration-muted="event_type in ('manual_lock','idle_lock')"
                      create="false" delete="false" edit="false">
                    <field name="create_date" string="When"/>
                    <field name="event_type" widget="badge"/>
                    <field name="user_id"/>
                    <field name="attempted_user_id"
                           optional="hide"/>
                    <field name="failure_reason"
                           optional="hide"/>
                    <field name="duration_seconds"
                           optional="hide" string="Duration (s)"/>
                    <field name="acting_uid"
                           optional="hide" string="Acting"/>
                    <field name="ip_address"
                           optional="hide"/>
                </list>
            </field>
        </record>

        <record id="view_fp_tablet_session_event_form" model="ir.ui.view">
            <field name="name">fp.tablet.session.event.form</field>
            <field name="model">fp.tablet.session.event</field>
            <field name="arch" type="xml">
                <form create="false" delete="false" edit="false">
                    <sheet>
                        <h1><field name="event_type" readonly="1"/></h1>
                        <group>
                            <group>
                                <field name="user_id" readonly="1"/>
                                <field name="attempted_user_id" readonly="1"/>
                                <field name="failure_reason" readonly="1"/>
                                <field name="acting_uid" readonly="1"/>
                            </group>
                            <group>
                                <field name="create_date" readonly="1"/>
                                <field name="session_started_at" readonly="1"/>
                                <field name="session_ended_at" readonly="1"/>
                                <field name="duration_seconds" readonly="1"/>
                            </group>
                        </group>
                        <group string="Forensic">
                            <field name="ip_address" readonly="1"/>
                            <field name="user_agent" readonly="1"/>
                            <field name="session_id_hash" readonly="1"/>
                        </group>
                        <group string="Notes">
                            <field name="notes" readonly="1" nolabel="1"/>
                        </group>
                    </sheet>
                </form>
            </field>
        </record>

        <record id="action_fp_tablet_session_event" model="ir.actions.act_window">
            <field name="name">Tablet Audit Log</field>
            <field name="res_model">fp.tablet.session.event</field>
            <field name="view_mode">list,form</field>
            <field name="context">{'search_default_groupby_event_type': 0}</field>
        </record>

        <menuitem id="menu_fp_tablet_session_event"
                  name="Tablet Audit Log"
                  parent="fusion_plating.menu_fp_config"
                  action="action_fp_tablet_session_event"
                  sequence="20"
                  groups="fusion_plating.group_fp_owner"/>
    </data>
</odoo>
  • Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml'); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml
git commit -m "feat(shopfloor): audit log list+form views, Owner-only menu

Plating > Configuration > Tablet Audit Log. Read-only list with
decoration (green=unlock, red=failed, warning=ceiling/force,
muted=manual/idle). Form shows full forensic detail incl. ip/ua.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task E2: Smart button on res.users for per-user last-7-days events

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml

  • Modify: fusion_plating/fusion_plating_shopfloor/models/res_users.py

  • Step 1: Add a computed count + action method on ResUsers

In res_users.py, append to the existing ResUsers class:

    x_fc_tablet_event_count_7d = fields.Integer(
        string='Tablet Events (7d)',
        compute='_compute_tablet_event_count_7d',
        store=False,
    )

    def _compute_tablet_event_count_7d(self):
        from datetime import timedelta
        cutoff = fields.Datetime.now() - timedelta(days=7)
        for user in self:
            user.x_fc_tablet_event_count_7d = self.env['fp.tablet.session.event'].sudo().search_count([
                '|',
                ('user_id', '=', user.id),
                ('attempted_user_id', '=', user.id),
                ('create_date', '>=', cutoff),
            ])

    def action_view_tablet_events(self):
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'name': _('Tablet Events: %s') % self.name,
            'res_model': 'fp.tablet.session.event',
            'view_mode': 'list,form',
            'domain': [
                '|',
                ('user_id', '=', self.id),
                ('attempted_user_id', '=', self.id),
            ],
        }
  • Step 2: Add the smart button to res_users_views.xml

Find the existing inherited res.users.form view (or create one). Add the button:

<xpath expr="//div[@class='oe_button_box']" position="inside">
    <button name="action_view_tablet_events"
            type="object"
            class="oe_stat_button"
            icon="fa-history"
            groups="fusion_plating.group_fp_owner">
        <field name="x_fc_tablet_event_count_7d" widget="statinfo"
               string="Tablet Events (7d)"/>
    </button>
</xpath>
  • Step 3: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/res_users.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml fusion_plating/fusion_plating_shopfloor/models/res_users.py
git commit -m "feat(shopfloor): per-user 7-day tablet event smart button

Owner-only smart button on res.users form. Click opens the audit log
filtered to that user (both user_id and attempted_user_id, so
failed unlock attempts against a tile show up too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Phase F — Entech Rollout (inline by main session)

This phase is not delegated to subagents — the main session deploys, verifies, and manually validates each tablet per the spec's two-step plan with 1-week overlap.

Task F1: Pre-deploy backup

  • Step 1: pg_dump entech admin DB
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"pg_dump admin > /tmp/admin_pre_tablet_pin_session_$(date +%Y%m%d_%H%M).sql\" && ls -lh /tmp/admin_pre_tablet_pin_session_*.sql | tail -1'"

Expected: ~115MB file.

Task F2: Sync files + deploy with -u

  • Step 1: Sync changed files via git diff + tar
cd 'K:/Github/Odoo-Modules'
git diff --name-only main~10..main | grep -v '^fusion_plating/CLAUDE.md$' | grep -v '^fusion_plating/docs/' > /tmp/sync_files.txt
tar czf /tmp/perms_sync.tar.gz --files-from=/tmp/sync_files.txt
base64 /tmp/perms_sync.tar.gz | ssh pve-worker5 "pct exec 111 -- bash -c 'cat | base64 -d > /tmp/perms_sync.tar.gz && cd /mnt/extra-addons/custom && tar xzf /tmp/perms_sync.tar.gz --strip-components=1 --no-same-owner --no-same-permissions && chown -R odoo:odoo /mnt/extra-addons/custom/fusion_plating_shopfloor'"
  • Step 2: Update shopfloor module
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 2>&1 | tail -10\" && systemctl start odoo'"

Expected: clean module load, no errors.

Task F3: Set feature flag to legacy first, confirm OLD path still works

  • Step 1: Verify feature flag is 'legacy' by default
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"SELECT value FROM ir_config_parameter WHERE key = '\\''fp.shopfloor.tablet_session_mode'\\'';\\\"\"'"

Expected: empty (no row) — means default 'legacy' applies.

  • Step 2: Open the tablet, PIN-unlock with the OLD path, confirm everything still works

Manual smoke test: open enplating.com on a tablet, tap your tile, enter PIN, verify the kanban loads normally.

Task F4: Flip flag to session_swap on one test tablet, validate

  • Step 1: Set flag
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"INSERT INTO ir_config_parameter (key, value, create_date, write_date) VALUES ('\\''fp.shopfloor.tablet_session_mode'\\'', '\\''session_swap'\\'', NOW(), NOW()) ON CONFLICT (key) DO UPDATE SET value = '\\''session_swap'\\'', write_date = NOW();\\\"\"'"
  • Step 2: Retrieve the kiosk password
ssh pve-worker5 "pct exec 111 -- bash -c \"echo 'print(env[\\\"ir.config_parameter\\\"].sudo().get_param(\\\"fp.tablet.kiosk_password\\\"))' | su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http 2>&1 | tail -2'\""
  • Step 3: Re-authenticate the tablet browser as the kiosk user

In the tablet's browser: log out, log back in as fp_tablet_kiosk@enplating.local with the retrieved password. Bookmark the lock screen URL.

  • Step 4: Manual full-cycle test
  1. Tap your tile, enter your PIN → page reloads, you're logged in as YOU
  2. Click a job card, do some action (start step, add note)
  3. Verify in DB: SELECT create_uid FROM mail_message ORDER BY create_date DESC LIMIT 5; should show YOUR user id
  4. Click Hand-Off / Lock button → page reloads to lock screen
  5. Wait 10 minutes idle on next unlock → confirm idle_lock fires automatically

Task F5: Roll out to remaining tablets + observe for 7 days

  • Step 1: Repeat F4 step 3 (re-auth as kiosk) on each remaining tablet

  • Step 2: Check audit log daily for the first week

ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"SELECT event_type, COUNT(*) FROM fp_tablet_session_event WHERE create_date > NOW() - INTERVAL '\\''1 day'\\'' GROUP BY event_type ORDER BY 2 DESC;\\\"\"'"

Look for unexpected force_lock or failed_unlock patterns.

Task F6: Decision gate — keep or rollback

After 7 days of observation:

  • Keep if no anomalies: proceed to Phase G cleanup.
  • Rollback if issues:
    ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"UPDATE ir_config_parameter SET value = '\\''legacy'\\'', write_date = NOW() WHERE key = '\\''fp.shopfloor.tablet_session_mode'\\'';\\\"\"'"
    
    Re-authenticate tablets as the legacy "shopfloor service" user. File a bug report and re-spec.

Phase G — Step 3 Cleanup (post-overlap, post-decision)

ONLY run this phase after Phase F6 = "Keep" decision and at least 7 days of observation.

Task G1: Strip tablet_tech_id from all endpoints

  • Step 1: Find all callers
cd 'K:/Github/Odoo-Modules'
grep -rn 'tablet_tech_id\|env_for_tablet_tech' fusion_plating/fusion_plating_shopfloor/ --include='*.py' | grep -v '\.pyc'
  • Step 2: For each match, remove the kwarg + env = env_for_tablet_tech(...) line

The endpoint body still uses env; just point env = request.env directly (no wrapping):

# BEFORE
def my_endpoint(self, foo, tablet_tech_id=None):
    env = env_for_tablet_tech(request.env, tablet_tech_id)
    ...

# AFTER
def my_endpoint(self, foo):
    env = request.env
    ...
  • Step 3: Remove env_for_tablet_tech import line at the top of each file

  • Step 4: Commit + push

git add -A
git commit -m "cleanup(shopfloor): remove tablet_tech_id kwarg from all endpoints

Session swap (Phase F) makes attribution automatic via request.env.user.
The tablet_tech_id plumbing is dead code after the cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task G2: Delete _tablet_audit.py and OLD endpoint

Files:

  • Delete: fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py (remove /fp/tablet/unlock)

  • Step 1: Delete the helper file

cd 'K:/Github/Odoo-Modules'
git rm fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py
  • Step 2: Remove the legacy /fp/tablet/unlock route from tablet_controller.py

Find the @http.route('/fp/tablet/unlock', ...) decorator and the def unlock(self, user_id, pin): method below it. Delete the whole block.

  • Step 3: Sync + restart + verify nothing imports the deleted module
# Quick sanity-check:
cd 'K:/Github/Odoo-Modules'
grep -rn '_tablet_audit\|env_for_tablet_tech' fusion_plating/ --include='*.py'
# Expected: empty
  • Step 4: Commit + push
git add -A
git commit -m "cleanup(shopfloor): remove legacy /fp/tablet/unlock + _tablet_audit helper

Session-swap rollout complete and stable. Old attribution path is dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main

Task G3: Archive the legacy shopfloor service user

  • Step 1: Identify the user

The legacy "shopfloor service" user likely has login matching shopfloor* or tablet*. Find it:

ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"SELECT id, login, active FROM res_users WHERE login ILIKE '\\''%shopfloor%'\\'' OR login ILIKE '\\''%tablet%'\\'' AND login != '\\''fp_tablet_kiosk@enplating.local'\\'';\\\"\"'"
  • Step 2: Archive (active=False) — DO NOT delete
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"UPDATE res_users SET active = false WHERE id = <ID_FROM_STEP_1> AND login != '\\''fp_tablet_kiosk@enplating.local'\\'';\\\"\"'"

Archiving (not deleting) keeps the audit trail of historical actions intact.


Self-Review

Spec coverage (each section → tasks):

  • Section 1 architecture → Phase A (groups + ACL), Phase D (OWL state machine)
  • Section 2 components & files → covered by Phase A-E task file lists
  • Section 3 lifecycle → Phase C endpoints + Phase D session manager
  • Section 4 audit log → Phase B (model + auth manager) + E (views + smart button)
  • Section 5 migration → Phase F (rollout) + Phase G (cleanup)
  • Section 6 acceptance criteria → covered by tests in Phase A-C + manual validation in Phase F

Placeholder scan: No TBD / TODO / "add validation" — all steps show code or commands.

Type consistency:

  • event_type Selection values consistent (unlock, failed_unlock, manual_lock, idle_lock, ceiling_lock, force_lock, admin_reset) across model, endpoint writes, cron, tests
  • group_ids (Odoo 19) used in all test files (NOT groups_id)
  • user_id vs attempted_user_id semantics consistent (former for success path, latter for failed attempts)
  • Auth credential type fp_tablet_pin consistent across auth manager + endpoint call

Inline fix applied during review: Originally I had a typo in F2 — referenced main~10..main which is right for syncing the recent 10 commits of work. Keep it as-is, but note that the engineer running this should adjust the range based on what's actually been committed since their last sync. Kept the commit-range pattern; will be self-evident at execution time.


Plan complete and saved to K:\Github\Odoo-Modules\fusion_plating\docs\superpowers\plans\2026-05-24-tablet-pin-session-redesign-plan.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration. Worked well for the permissions overhaul. Phase F (entech rollout) stays inline.

2. Inline Execution — Execute all tasks in this session using executing-plans, batch with checkpoints.

Which approach?