# 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`](../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//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): ```python '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** ```bash 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) " 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 Tablet Kiosk Session 100 ``` - [ ] **Step 2: Verify XML parses + commit + push** ```bash 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) " 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** ```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) ```bash 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** ```bash 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) " 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 fp_tablet_kiosk@enplating.local Tablet Kiosk ``` - [ ] **Step 2: Verify XML parses + commit + push** ```bash 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) " 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** ```bash 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`: ```python # -*- 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** ```bash 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) " 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** ```python 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: ```python from . import test_kiosk_user_acl ``` - [ ] **Step 3: Commit + push** ```bash 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) " 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** ```python # -*- 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: ```python from . import fp_tablet_session_event ``` - [ ] **Step 3: Verify Python parses + commit + push** ```bash 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) " 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** ```python 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** ```bash 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) " 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: ```python 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** ```bash 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) " 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** ```python 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** ```bash 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) " 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** ```python # -*- 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** ```bash 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) " 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: ```python # ====================================================================== # /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** ```bash 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) " 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** ```python # ====================================================================== # /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** ```bash 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) " 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: ```python @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 Tablet: Force-lock Stale PIN Sessions code model._cron_force_lock_stale_sessions() 5 minutes ``` - [ ] **Step 3: Verify + commit + push** ```bash 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) " 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** ```python 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** ```bash 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) " 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** ```javascript /** @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** ```bash 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) " 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: ```javascript // 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: ```javascript 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** ```bash 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) " 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: ```xml ``` - [ ] **Step 2: Commit + push** ```bash 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) " 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 fp.tablet.session.event.list fp.tablet.session.event fp.tablet.session.event.form fp.tablet.session.event

Tablet Audit Log fp.tablet.session.event list,form {'search_default_groupby_event_type': 0}
``` - [ ] **Step 2: Commit + push** ```bash 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) " 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: ```python 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: ```xml ``` - [ ] **Step 3: Commit + push** ```bash 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) " 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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: ```bash 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** ```bash 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): ```python # 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** ```bash 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) " 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** ```bash 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** ```bash # 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** ```bash 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) " 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: ```bash 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** ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"UPDATE res_users SET active = false WHERE id = 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?**