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

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

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

2012 lines
80 KiB
Markdown

# 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/<v>/post-migrate.py` for `-u` triggers | Kiosk user creation needs to fire on `-u`, not just install |
| AUDIT-1 | When adding ACL for ir.actions.actions or similar admin-only models, scope grant to the lowest plating role that needs it (don't grant to base.group_user) | Kiosk's res.users read ACL — scope to `group_fp_tablet_kiosk` only |
| AUDIT-2 | Always commit `+ git push origin main` per user preference [[memory:always-push-to-main]] | Every task's final step |
---
## File Structure (master list)
### New files
| Path | Responsibility |
|---|---|
| `fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml` | Idempotent create of `fp_tablet_kiosk` user + assignment to `group_fp_tablet_kiosk`. Password = secret from `ir.config_parameter['fp.tablet.kiosk_password']`. |
| `fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml` | New `res.groups` `group_fp_tablet_kiosk`. NO `privilege_id` (orthogonal to plating roles). |
| `fusion_plating_shopfloor/migrations/19.0.32.1.0/post-migrate.py` | Triggers kiosk user/group creation on `-u` (since `post_init_hook` only fires on install) + auto-generates kiosk password if `ir.config_parameter` key absent. |
| `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | New model `fp.tablet.session.event` — append-only audit log. |
| `fusion_plating_shopfloor/models/__init__.py` (extend) | `from . import fp_tablet_session_event` (after existing imports). |
| `fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml` | List + form views + Owner-only menu + smart button on res.users. |
| `fusion_plating_shopfloor/data/fp_tablet_cron.xml` | New cron `_cron_force_lock_stale_sessions` (every 5 min). |
| `fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js` | OWL service: idle timer + DOM event listeners + lock-back invocation. |
| `fusion_plating_shopfloor/tests/test_tablet_session_event_model.py` | Unit tests for the audit model (append-only, ACL). |
| `fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py` | Tests for the `fp_tablet_pin` credential type. |
| `fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py` | HTTP tests for the two new endpoints. |
| `fusion_plating_shopfloor/tests/test_kiosk_user_acl.py` | Kiosk user can do ONLY what the spec allows (read res.users, call unlock); nothing else. |
### Modified files
| Path | Change |
|---|---|
| `fusion_plating_shopfloor/__manifest__.py` | Bump version to `19.0.33.0.0`. Add new data XML + cron XML + views + security XML to `data`. |
| `fusion_plating_shopfloor/models/res_users.py` (existing) | Override `_check_credentials` to handle `type='fp_tablet_pin'`. |
| `fusion_plating_shopfloor/security/ir.model.access.csv` | Add 3 rows: kiosk read on res.users + kiosk read on ir.config_parameter + Owner read on fp.tablet.session.event. |
| `fusion_plating_shopfloor/controllers/tablet_controller.py` | Add `/fp/tablet/unlock_session` and `/fp/tablet/lock_session`. Keep `/fp/tablet/unlock` alive (deprecated) during overlap. |
| `fusion_plating_shopfloor/static/src/js/tablet_lock.js` (existing) | Wire to new `tablet_session_manager` service. Check `tablet_session_mode` feature flag from server bootstrap; if `'session_swap'`, use new flow + `window.location.reload()` on transitions. |
| `fusion_plating_shopfloor/static/src/js/services/fp_rpc.js` (existing) | When `tablet_session_mode='session_swap'`, skip `tablet_tech_id` injection (no longer needed). |
### Deleted in Phase F cleanup (post-overlap)
| Path | Action |
|---|---|
| `fusion_plating_shopfloor/controllers/_tablet_audit.py` | Delete (env_for_tablet_tech no longer used). |
| `fusion_plating_shopfloor/static/src/js/services/fp_rpc.js` | Delete if no remaining callers, OR strip auto-injection logic. |
| `fusion_plating_shopfloor/static/src/js/services/fp_shopfloor_tech_store.js` (if exists) | Delete (replaced by tablet_session_manager). |
| All ~15 endpoints with `tablet_tech_id` kwarg | Remove the kwarg + `env = env_for_tablet_tech(...)` line. |
| `/fp/tablet/unlock` route in `tablet_controller.py` | Delete the OLD endpoint. |
---
## Phase A — Kiosk User + Group + Audit ACL Foundation
### Task A1: Bump shopfloor manifest version + register data files
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py`
- [ ] **Step 1: Open the manifest and bump version + add new files**
Change the `version` line to `'19.0.33.0.0'`. In the `data` list, ADD these entries in this order (file load order matters — security before data before views):
```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) <noreply@anthropic.com>"
git push origin main
```
### Task A2: Define the kiosk group (security XML)
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml`
- [ ] **Step 1: Write the file**
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tablet kiosk group — orthogonal to the Fusion Plating role hierarchy.
NO privilege_id (would clutter the role picker). This group holds
the bare minimum ACL for the lock screen to render and accept PIN:
- read res.users (tile grid)
- read ir.config_parameter (idle/ceiling settings)
Nothing else. The dedicated fp_tablet_kiosk user is the only
expected member. -->
<record id="group_fp_tablet_kiosk" model="res.groups">
<field name="name">Tablet Kiosk Session</field>
<field name="sequence">100</field>
</record>
</odoo>
```
- [ ] **Step 2: Verify XML parses + commit + push**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task A3: Add kiosk ACL rows + Owner read on audit log
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv`
- [ ] **Step 1: Append 3 rows to the CSV**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task A4: Create the kiosk user via data XML
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml`
- [ ] **Step 1: Write the file**
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Tablet kiosk user. noupdate="1" so manual password changes via
the user form aren't reverted on -u. The post-migrate hook
generates a random password on first install and stores it
in ir.config_parameter for sysadmin retrieval. -->
<record id="user_fp_tablet_kiosk" model="res.users">
<field name="login">fp_tablet_kiosk@enplating.local</field>
<field name="name">Tablet Kiosk</field>
<field name="active" eval="True"/>
<field name="share" eval="False"/>
<field name="group_ids" eval="[
(4, ref('base.group_user')),
(4, ref('fusion_plating_shopfloor.group_fp_tablet_kiosk')),
]"/>
</record>
</odoo>
```
- [ ] **Step 2: Verify XML parses + commit + push**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task A5: Post-migrate hook for kiosk password initialization
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py`
- [ ] **Step 1: Create the migration directory + file**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task A6: Test kiosk user ACL
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/tests/test_kiosk_user_acl.py`
- [ ] **Step 1: Write the test file**
```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) <noreply@anthropic.com>"
git push origin main
```
---
## Phase B — Audit Model + Custom Auth Manager
### Task B1: Define fp.tablet.session.event model
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py`
- Modify: `fusion_plating/fusion_plating_shopfloor/models/__init__.py`
- [ ] **Step 1: Write the model**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task B2: Test the audit model is append-only
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py`
- [ ] **Step 1: Write the test**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task B3: Custom auth manager — fp_tablet_pin credential type
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/models/res_users.py`
- [ ] **Step 1: Add the _check_credentials override at the bottom of the existing ResUsers class**
Find the existing `class ResUsers(models.Model)` block (it already holds `x_fc_tablet_pin_hash`, `set_tablet_pin`, etc.). Add this method:
```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) <noreply@anthropic.com>"
git push origin main
```
### Task B4: Test the auth manager
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py`
- [ ] **Step 1: Write the test**
```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) <noreply@anthropic.com>"
git push origin main
```
---
## Phase C — New Endpoints + Cron + Feature Flag
### Task C1: Add the helper module for session-event writes
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py`
- [ ] **Step 1: Write the helper**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task C2: Add /fp/tablet/unlock_session endpoint
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py`
- [ ] **Step 1: Add the new endpoint AFTER the existing `/fp/tablet/unlock` route**
Find the existing `def unlock(self, user_id, pin):` method. Below it (still in the FpTabletController class), add:
```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) <noreply@anthropic.com>"
git push origin main
```
### Task C3: Add /fp/tablet/lock_session endpoint
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py`
- [ ] **Step 1: Add the lock endpoint below unlock_session**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task C4: Force-lock cron for stale sessions
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml`
- Modify: `fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py` (add the cron method)
- [ ] **Step 1: Add the cron method on the model**
Append this method to the `FpTabletSessionEvent` class:
```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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_force_lock_stale_sessions" model="ir.cron">
<field name="name">Tablet: Force-lock Stale PIN Sessions</field>
<field name="model_id" ref="model_fp_tablet_session_event"/>
<field name="state">code</field>
<field name="code">model._cron_force_lock_stale_sessions()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>
```
- [ ] **Step 3: Verify + commit + push**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task C5: HTTP-test the unlock + lock endpoints
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py`
- [ ] **Step 1: Write the HttpCase tests**
```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) <noreply@anthropic.com>"
git push origin main
```
---
## Phase D — OWL Session Manager + Lock Screen Integration
### Task D1: Write the tablet_session_manager OWL service
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js`
- [ ] **Step 1: Write the service**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task D2: Wire the lock screen to call unlock_session
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js`
- [ ] **Step 1: Find the existing unlock flow in tablet_lock.js**
Look for where the OWL component currently calls `/fp/tablet/unlock`. There should be an `onPinSubmit` or similar handler.
- [ ] **Step 2: Add feature-flag-gated branch**
The component must detect `tablet_session_mode` (from a server-bootstrapped value or from a static ir.config_parameter read at mount) and route to either the OLD or NEW endpoint:
```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) <noreply@anthropic.com>"
git push origin main
```
### Task D3: Add a Lock button to the unlocked UI
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` (or wherever the existing Hand-Off button lives)
- [ ] **Step 1: Find the existing Hand-Off / Lock button** (search for `Lock` or `Hand` in tablet_lock.xml / shopfloor_landing.xml / job_workspace.xml)
The CLAUDE.md mentions a Hand-Off button already exists. Wire its `t-on-click` to call the new `lockBack('manual')` method on the session manager service:
```xml
<button t-on-click="() => this.tabletSessionManager.lockBack('manual')"
class="btn btn-warning"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off / Lock
</button>
```
- [ ] **Step 2: Commit + push**
```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) <noreply@anthropic.com>"
git push origin main
```
---
## Phase E — Audit Views + Owner Menu + Smart Button
### Task E1: List + form views for the audit log
**Files:**
- Create: `fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml`
- [ ] **Step 1: Write the view file**
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_fp_tablet_session_event_list" model="ir.ui.view">
<field name="name">fp.tablet.session.event.list</field>
<field name="model">fp.tablet.session.event</field>
<field name="arch" type="xml">
<list decoration-success="event_type == 'unlock'"
decoration-danger="event_type == 'failed_unlock'"
decoration-warning="event_type in ('ceiling_lock','force_lock')"
decoration-muted="event_type in ('manual_lock','idle_lock')"
create="false" delete="false" edit="false">
<field name="create_date" string="When"/>
<field name="event_type" widget="badge"/>
<field name="user_id"/>
<field name="attempted_user_id"
optional="hide"/>
<field name="failure_reason"
optional="hide"/>
<field name="duration_seconds"
optional="hide" string="Duration (s)"/>
<field name="acting_uid"
optional="hide" string="Acting"/>
<field name="ip_address"
optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_tablet_session_event_form" model="ir.ui.view">
<field name="name">fp.tablet.session.event.form</field>
<field name="model">fp.tablet.session.event</field>
<field name="arch" type="xml">
<form create="false" delete="false" edit="false">
<sheet>
<h1><field name="event_type" readonly="1"/></h1>
<group>
<group>
<field name="user_id" readonly="1"/>
<field name="attempted_user_id" readonly="1"/>
<field name="failure_reason" readonly="1"/>
<field name="acting_uid" readonly="1"/>
</group>
<group>
<field name="create_date" readonly="1"/>
<field name="session_started_at" readonly="1"/>
<field name="session_ended_at" readonly="1"/>
<field name="duration_seconds" readonly="1"/>
</group>
</group>
<group string="Forensic">
<field name="ip_address" readonly="1"/>
<field name="user_agent" readonly="1"/>
<field name="session_id_hash" readonly="1"/>
</group>
<group string="Notes">
<field name="notes" readonly="1" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_tablet_session_event" model="ir.actions.act_window">
<field name="name">Tablet Audit Log</field>
<field name="res_model">fp.tablet.session.event</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_groupby_event_type': 0}</field>
</record>
<menuitem id="menu_fp_tablet_session_event"
name="Tablet Audit Log"
parent="fusion_plating.menu_fp_config"
action="action_fp_tablet_session_event"
sequence="20"
groups="fusion_plating.group_fp_owner"/>
</data>
</odoo>
```
- [ ] **Step 2: Commit + push**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task E2: Smart button on res.users for per-user last-7-days events
**Files:**
- Modify: `fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml`
- Modify: `fusion_plating/fusion_plating_shopfloor/models/res_users.py`
- [ ] **Step 1: Add a computed count + action method on ResUsers**
In `res_users.py`, append to the existing ResUsers class:
```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
<xpath expr="//div[@class='oe_button_box']" position="inside">
<button name="action_view_tablet_events"
type="object"
class="oe_stat_button"
icon="fa-history"
groups="fusion_plating.group_fp_owner">
<field name="x_fc_tablet_event_count_7d" widget="statinfo"
string="Tablet Events (7d)"/>
</button>
</xpath>
```
- [ ] **Step 3: Commit + push**
```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) <noreply@anthropic.com>"
git push origin main
```
---
## Phase F — Entech Rollout (inline by main session)
This phase is **not delegated to subagents** — the main session deploys, verifies, and manually validates each tablet per the spec's two-step plan with 1-week overlap.
### Task F1: Pre-deploy backup
- [ ] **Step 1: pg_dump entech admin DB**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task G2: Delete _tablet_audit.py and OLD endpoint
**Files:**
- Delete: `fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py`
- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py` (remove /fp/tablet/unlock)
- [ ] **Step 1: Delete the helper file**
```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) <noreply@anthropic.com>"
git push origin main
```
### Task G3: Archive the legacy shopfloor service user
- [ ] **Step 1: Identify the user**
The legacy "shopfloor service" user likely has login matching `shopfloor*` or `tablet*`. Find it:
```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 = <ID_FROM_STEP_1> AND login != '\\''fp_tablet_kiosk@enplating.local'\\'';\\\"\"'"
```
Archiving (not deleting) keeps the audit trail of historical actions intact.
---
## Self-Review
**Spec coverage (each section → tasks):**
- Section 1 architecture → Phase A (groups + ACL), Phase D (OWL state machine)
- Section 2 components & files → covered by Phase A-E task file lists
- Section 3 lifecycle → Phase C endpoints + Phase D session manager
- Section 4 audit log → Phase B (model + auth manager) + E (views + smart button)
- Section 5 migration → Phase F (rollout) + Phase G (cleanup)
- Section 6 acceptance criteria → covered by tests in Phase A-C + manual validation in Phase F
**Placeholder scan:** No TBD / TODO / "add validation" — all steps show code or commands.
**Type consistency:**
- `event_type` Selection values consistent (`unlock`, `failed_unlock`, `manual_lock`, `idle_lock`, `ceiling_lock`, `force_lock`, `admin_reset`) across model, endpoint writes, cron, tests
- `group_ids` (Odoo 19) used in all test files (NOT `groups_id`)
- `user_id` vs `attempted_user_id` semantics consistent (former for success path, latter for failed attempts)
- Auth credential type `fp_tablet_pin` consistent across auth manager + endpoint call
**Inline fix applied during review:** Originally I had a typo in F2 — referenced `main~10..main` which is right for syncing the recent 10 commits of work. Keep it as-is, but note that the engineer running this should adjust the range based on what's actually been committed since their last sync. Kept the commit-range pattern; will be self-evident at execution time.
---
**Plan complete and saved to `K:\Github\Odoo-Modules\fusion_plating\docs\superpowers\plans\2026-05-24-tablet-pin-session-redesign-plan.md`. Two execution options:**
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Worked well for the permissions overhaul. Phase F (entech rollout) stays inline.
**2. Inline Execution** — Execute all tasks in this session using executing-plans, batch with checkpoints.
**Which approach?**