diff --git a/fusion_plating/docs/superpowers/plans/2026-05-24-tablet-pin-session-redesign-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-24-tablet-pin-session-redesign-plan.md new file mode 100644 index 00000000..815be70c --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-24-tablet-pin-session-redesign-plan.md @@ -0,0 +1,2011 @@ +# 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?**