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>
80 KiB
Tablet PIN Session Redesign — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the OWL-overlay-only PIN gate with REAL per-tech Odoo sessions on PIN unlock — every Odoo write naturally attributed via session uid, with idle/ceiling auto-lock and a tamper-resistant audit log.
Architecture: A dedicated kiosk user (fp_tablet_kiosk@enplating.local, near-zero ACL) holds the tablet's session when nobody is unlocked. PIN unlock calls a custom Odoo auth manager (type fp_tablet_pin) that validates the PIN hash and mints a real session as the tech via request.session.authenticate(...). The browser cookie swaps. Lock-back destroys the tech session and re-auths the browser as the kiosk. Every state transition writes an append-only fp.tablet.session.event row. Migration is two-step with a 1-week overlap window controlled by ir.config_parameter['fp.shopfloor.tablet_session_mode'].
Tech Stack: Odoo 19, Python 3.11, PostgreSQL 15, OWL (@odoo/owl), XML data files, custom auth manager hooked into res.users._check_credentials.
Source spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
Target deployment: entech LXC 111 (pve-worker5), database admin, addons at /mnt/extra-addons/custom/.
Known gotchas to bake in upfront
These bit the permissions-overhaul implementation hard. Build the plan with them in mind so this implementer doesn't repeat:
| # | Rule | Where applied |
|---|---|---|
| 13c | res.users.group_ids (NOT groups_id); domain pickers may need all_group_ids for transitive lookups |
All test files; any ACL-domain code |
| 13i | res.users has no message_post; use user.partner_id.message_post(...) |
Any chatter writes from these new endpoints/cron |
| 13k | SELF_WRITEABLE_FIELDS / SELF_READABLE_FIELDS are @property in Odoo 19, NOT class attributes |
If we ever add user-facing tablet fields beyond PIN (not in this plan) |
| 13m | Tablet/kanban/dashboard controllers must sudo() cross-module reads for low-priv roles |
New endpoints don't render denormalized data — they return tiny JSON; rule probably doesn't apply but be alert |
| 13d | post_init_hook only fires on INSTALL, not UPGRADE; use migrations/<v>/post-migrate.py for -u triggers |
Kiosk user creation needs to fire on -u, not just install |
| AUDIT-1 | When adding ACL for ir.actions.actions or similar admin-only models, scope grant to the lowest plating role that needs it (don't grant to base.group_user) | Kiosk's res.users read ACL — scope to group_fp_tablet_kiosk only |
| AUDIT-2 | Always commit + git push origin main per user preference memory:always-push-to-main |
Every task's final step |
File Structure (master list)
New files
| Path | Responsibility |
|---|---|
fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml |
Idempotent create of fp_tablet_kiosk user + assignment to group_fp_tablet_kiosk. Password = secret from ir.config_parameter['fp.tablet.kiosk_password']. |
fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml |
New res.groups group_fp_tablet_kiosk. NO privilege_id (orthogonal to plating roles). |
fusion_plating_shopfloor/migrations/19.0.32.1.0/post-migrate.py |
Triggers kiosk user/group creation on -u (since post_init_hook only fires on install) + auto-generates kiosk password if ir.config_parameter key absent. |
fusion_plating_shopfloor/models/fp_tablet_session_event.py |
New model fp.tablet.session.event — append-only audit log. |
fusion_plating_shopfloor/models/__init__.py (extend) |
from . import fp_tablet_session_event (after existing imports). |
fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml |
List + form views + Owner-only menu + smart button on res.users. |
fusion_plating_shopfloor/data/fp_tablet_cron.xml |
New cron _cron_force_lock_stale_sessions (every 5 min). |
fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js |
OWL service: idle timer + DOM event listeners + lock-back invocation. |
fusion_plating_shopfloor/tests/test_tablet_session_event_model.py |
Unit tests for the audit model (append-only, ACL). |
fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py |
Tests for the fp_tablet_pin credential type. |
fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py |
HTTP tests for the two new endpoints. |
fusion_plating_shopfloor/tests/test_kiosk_user_acl.py |
Kiosk user can do ONLY what the spec allows (read res.users, call unlock); nothing else. |
Modified files
| Path | Change |
|---|---|
fusion_plating_shopfloor/__manifest__.py |
Bump version to 19.0.33.0.0. Add new data XML + cron XML + views + security XML to data. |
fusion_plating_shopfloor/models/res_users.py (existing) |
Override _check_credentials to handle type='fp_tablet_pin'. |
fusion_plating_shopfloor/security/ir.model.access.csv |
Add 3 rows: kiosk read on res.users + kiosk read on ir.config_parameter + Owner read on fp.tablet.session.event. |
fusion_plating_shopfloor/controllers/tablet_controller.py |
Add /fp/tablet/unlock_session and /fp/tablet/lock_session. Keep /fp/tablet/unlock alive (deprecated) during overlap. |
fusion_plating_shopfloor/static/src/js/tablet_lock.js (existing) |
Wire to new tablet_session_manager service. Check tablet_session_mode feature flag from server bootstrap; if 'session_swap', use new flow + window.location.reload() on transitions. |
fusion_plating_shopfloor/static/src/js/services/fp_rpc.js (existing) |
When tablet_session_mode='session_swap', skip tablet_tech_id injection (no longer needed). |
Deleted in Phase F cleanup (post-overlap)
| Path | Action |
|---|---|
fusion_plating_shopfloor/controllers/_tablet_audit.py |
Delete (env_for_tablet_tech no longer used). |
fusion_plating_shopfloor/static/src/js/services/fp_rpc.js |
Delete if no remaining callers, OR strip auto-injection logic. |
fusion_plating_shopfloor/static/src/js/services/fp_shopfloor_tech_store.js (if exists) |
Delete (replaced by tablet_session_manager). |
All ~15 endpoints with tablet_tech_id kwarg |
Remove the kwarg + env = env_for_tablet_tech(...) line. |
/fp/tablet/unlock route in tablet_controller.py |
Delete the OLD endpoint. |
Phase A — Kiosk User + Group + Audit ACL Foundation
Task A1: Bump shopfloor manifest version + register data files
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Open the manifest and bump version + add new files
Change the version line to '19.0.33.0.0'. In the data list, ADD these entries in this order (file load order matters — security before data before views):
'data': [
'security/fp_tablet_kiosk_security.xml', # NEW
# ... existing security entries ...
'security/ir.model.access.csv', # already there
'data/fp_tablet_kiosk_user.xml', # NEW (depends on security)
'data/fp_tablet_cron.xml', # NEW
# ... existing data entries ...
'views/fp_tablet_session_event_views.xml', # NEW
# ... existing view entries ...
],
- Step 2: Commit + push
cd 'K:/Github/Odoo-Modules' && git add fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(shopfloor): bump to 19.0.33.0.0 for tablet PIN session redesign
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task A2: Define the kiosk group (security XML)
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml -
Step 1: Write the file
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tablet kiosk group — orthogonal to the Fusion Plating role hierarchy.
NO privilege_id (would clutter the role picker). This group holds
the bare minimum ACL for the lock screen to render and accept PIN:
- read res.users (tile grid)
- read ir.config_parameter (idle/ceiling settings)
Nothing else. The dedicated fp_tablet_kiosk user is the only
expected member. -->
<record id="group_fp_tablet_kiosk" model="res.groups">
<field name="name">Tablet Kiosk Session</field>
<field name="sequence">100</field>
</record>
</odoo>
- Step 2: Verify XML parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml'); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml
git commit -m "feat(shopfloor-sec): group_fp_tablet_kiosk for tablet kiosk session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task A3: Add kiosk ACL rows + Owner read on audit log
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv -
Step 1: Append 3 rows to the CSV
access_res_users_kiosk,res.users.kiosk.read,base.model_res_users,fusion_plating_shopfloor.group_fp_tablet_kiosk,1,0,0,0
access_ir_config_param_kiosk,ir.config_parameter.kiosk.read,base.model_ir_config_parameter,fusion_plating_shopfloor.group_fp_tablet_kiosk,1,0,0,0
access_fp_tablet_session_event_owner,fp.tablet.session.event.owner.read,model_fp_tablet_session_event,fusion_plating.group_fp_owner,1,0,0,0
- Step 2: Verify no duplicate xmlid IDs in the CSV (the existing file may already have
access_res_users_*-like rows — check)
cd 'K:/Github/Odoo-Modules'
grep -c '^access_res_users_kiosk,' fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
# Expected: 1
grep -c '^access_fp_tablet_session_event_owner,' fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
# Expected: 1
- Step 3: Commit + push
git add fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
git commit -m "feat(shopfloor-sec): kiosk ACL — read res.users + ir.config_parameter
Owner gets read on fp.tablet.session.event (audit log).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task A4: Create the kiosk user via data XML
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml -
Step 1: Write the file
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Tablet kiosk user. noupdate="1" so manual password changes via
the user form aren't reverted on -u. The post-migrate hook
generates a random password on first install and stores it
in ir.config_parameter for sysadmin retrieval. -->
<record id="user_fp_tablet_kiosk" model="res.users">
<field name="login">fp_tablet_kiosk@enplating.local</field>
<field name="name">Tablet Kiosk</field>
<field name="active" eval="True"/>
<field name="share" eval="False"/>
<field name="group_ids" eval="[
(4, ref('base.group_user')),
(4, ref('fusion_plating_shopfloor.group_fp_tablet_kiosk')),
]"/>
</record>
</odoo>
- Step 2: Verify XML parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml'); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml
git commit -m "feat(shopfloor): create fp_tablet_kiosk user
Kiosk holds the tablet session when no tech is PIN-unlocked.
Password is auto-generated by the post-migrate hook (Task A5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task A5: Post-migrate hook for kiosk password initialization
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py -
Step 1: Create the migration directory + file
cd 'K:/Github/Odoo-Modules'
mkdir -p fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0
Then write migrations/19.0.33.0.0/post-migrate.py:
# -*- coding: utf-8 -*-
"""Tablet PIN session redesign — generate kiosk password on first deploy.
Runs on every -u (post_init_hook only fires on fresh install per
CLAUDE.md rule 13d). Idempotent: only writes the password if the
ir.config_parameter key is absent.
After this hook runs, retrieve the kiosk password via:
odoo-shell -d admin -c "print(env['ir.config_parameter'].sudo().get_param(
'fp.tablet.kiosk_password'))"
Then sysadmin enters that password ONCE in the tablet browser to log
the kiosk session in. Browser cookie persists per the configured
session_db.session_lifetime.
"""
import logging
import secrets
_logger = logging.getLogger(__name__)
def migrate(cr, version):
from odoo import api, SUPERUSER_ID
env = api.Environment(cr, SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
KEY = 'fp.tablet.kiosk_password'
existing = ICP.get_param(KEY)
if existing:
_logger.info(
'fp.tablet.kiosk_password already set; leaving it alone (idempotent)'
)
return
new_pwd = secrets.token_urlsafe(32)
ICP.set_param(KEY, new_pwd)
_logger.info(
'fp.tablet.kiosk_password generated; retrieve via odoo-shell '
"env['ir.config_parameter'].sudo().get_param('%s')",
KEY,
)
# Also write the password onto the user so they can log in via /web/login.
user = env.ref(
'fusion_plating_shopfloor.user_fp_tablet_kiosk',
raise_if_not_found=False,
)
if user:
user.password = new_pwd
_logger.info('fp_tablet_kiosk user password set to generated value')
else:
_logger.warning(
'fp_tablet_kiosk user not found via xmlid; password not applied to user record'
)
- Step 2: Verify Python parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/migrations/19.0.33.0.0/post-migrate.py
git commit -m "feat(shopfloor): post-migrate hook for kiosk password init
Generates a random kiosk password on first deploy, stores in
ir.config_parameter for sysadmin retrieval. Idempotent — re-runs
on subsequent -u leave the password alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task A6: Test kiosk user ACL
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_kiosk_user_acl.py -
Step 1: Write the test file
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessError
@tagged('-at_install', 'post_install', 'fp_tablet')
class TestKioskUserAcl(TransactionCase):
"""Kiosk user can do ONLY what the lock screen needs:
read res.users (tile grid) + read ir.config_parameter (settings).
EVERYTHING else MUST raise AccessError."""
def setUp(self):
super().setUp()
kiosk = self.env.ref(
'fusion_plating_shopfloor.user_fp_tablet_kiosk',
raise_if_not_found=False,
)
if not kiosk:
self.skipTest('fp_tablet_kiosk user not yet provisioned')
self.kiosk = kiosk
def test_kiosk_can_read_users(self):
Users = self.env['res.users'].with_user(self.kiosk)
Users.check_access_rights('read') # raises if denied
def test_kiosk_can_read_config_param(self):
ICP = self.env['ir.config_parameter'].with_user(self.kiosk)
ICP.check_access_rights('read')
def test_kiosk_cannot_write_users(self):
Users = self.env['res.users'].with_user(self.kiosk)
with self.assertRaises(AccessError):
Users.check_access_rights('write')
def test_kiosk_cannot_read_jobs(self):
Jobs = self.env['fp.job'].with_user(self.kiosk)
with self.assertRaises(AccessError):
Jobs.check_access_rights('read')
def test_kiosk_cannot_read_sale_orders(self):
SO = self.env['sale.order'].with_user(self.kiosk)
with self.assertRaises(AccessError):
SO.check_access_rights('read')
def test_kiosk_cannot_read_certificates(self):
Cert = self.env['fp.certificate'].with_user(self.kiosk)
with self.assertRaises(AccessError):
Cert.check_access_rights('read')
def test_kiosk_cannot_read_part_catalog(self):
Part = self.env['fp.part.catalog'].with_user(self.kiosk)
with self.assertRaises(AccessError):
Part.check_access_rights('read')
- Step 2: Add to tests/init.py
Check fusion_plating/fusion_plating_shopfloor/tests/__init__.py and add the import line if not present:
from . import test_kiosk_user_acl
- Step 3: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/tests/test_kiosk_user_acl.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): kiosk user ACL has near-zero access
7 tests covering allowed reads (res.users, ir.config_parameter)
and forbidden everything else (fp.job, sale.order, fp.certificate,
fp.part.catalog, res.users write).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Phase B — Audit Model + Custom Auth Manager
Task B1: Define fp.tablet.session.event model
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py -
Modify:
fusion_plating/fusion_plating_shopfloor/models/__init__.py -
Step 1: Write the model
# -*- coding: utf-8 -*-
"""Append-only audit log of tablet session lifecycle events.
Every PIN unlock / lock-back / failed attempt / cron-force-lock
writes a row here. The model has read-only ACL (Owner only); even
write/unlink/edit are forbidden to anyone except root via direct SQL.
Schema lives in CLAUDE.md ALREADY would be premature — wait until
this lands and is referenced from external code.
Spec section 4: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
"""
from odoo import api, fields, models
class FpTabletSessionEvent(models.Model):
_name = 'fp.tablet.session.event'
_description = 'Tablet Session Event (audit log)'
_order = 'create_date desc'
_rec_name = 'event_type'
event_type = fields.Selection(
[
('unlock', 'Unlock (PIN success)'),
('failed_unlock', 'Failed PIN attempt'),
('manual_lock', 'Manual lock (Hand-Off button)'),
('idle_lock', 'Idle timeout lock'),
('ceiling_lock', '8-hour ceiling lock'),
('force_lock', 'Force lock (cron, stale session)'),
('admin_reset', 'Admin force-reset PIN'),
],
required=True,
readonly=True,
index=True,
)
user_id = fields.Many2one(
'res.users',
string='Tech',
readonly=True,
ondelete='restrict',
help='The tech whose session was unlocked/locked. Empty for failed '
'attempts where the tile was tapped but unlock never succeeded.',
)
attempted_user_id = fields.Many2one(
'res.users',
string='Attempted Tech',
readonly=True,
ondelete='restrict',
help='For failed_unlock: which tile was tapped. user_id stays empty.',
)
session_id_hash = fields.Char(
string='Session ID (hashed)',
readonly=True,
help='sha256 hash of the Odoo session sid. Correlates events for '
'the same session without storing the raw token.',
)
session_started_at = fields.Datetime(readonly=True)
session_ended_at = fields.Datetime(readonly=True)
duration_seconds = fields.Integer(
string='Duration (s)',
readonly=True,
help='Filled on lock events from session_started_at delta.',
)
ip_address = fields.Char(readonly=True)
user_agent = fields.Char(
readonly=True,
help='Trimmed to 256 chars to prevent oversize logs.',
)
failure_reason = fields.Selection(
[
('wrong_pin', 'Wrong PIN'),
('locked_out', 'Locked out (too many failures)'),
('no_pin_set', 'No PIN configured'),
('user_inactive', 'User archived or disabled'),
('no_role', 'User has no shop-branch role'),
],
readonly=True,
)
acting_uid = fields.Many2one(
'res.users',
string='Acting User',
readonly=True,
help='The user the SERVER saw at request time. Usually '
'fp_tablet_kiosk for unlocks; the manager for admin_reset; '
'base.user_root for cron-driven events.',
)
notes = fields.Text(readonly=True)
- Step 2: Register in init.py
Check fusion_plating/fusion_plating_shopfloor/models/__init__.py and add:
from . import fp_tablet_session_event
- Step 3: Verify Python parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py fusion_plating/fusion_plating_shopfloor/models/__init__.py
git commit -m "feat(shopfloor): fp.tablet.session.event append-only audit log
Captures unlock / failed_unlock / manual_lock / idle_lock /
ceiling_lock / force_lock / admin_reset events with session hash,
ip, user-agent, duration, failure reason, acting uid.
Read-only ACL granted to Owner in Phase A; no write/unlink anywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task B2: Test the audit model is append-only
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py -
Step 1: Write the test
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessError
@tagged('-at_install', 'post_install', 'fp_tablet')
class TestTabletSessionEventAppendOnly(TransactionCase):
def setUp(self):
super().setUp()
Users = self.env['res.users'].with_context(no_reset_password=True)
self.owner = Users.create({
'login': 'audit_owner', 'name': 'Audit Owner',
'email': 'audit_owner@example.com',
'group_ids': [(6, 0, [
self.env.ref('fusion_plating.group_fp_owner').id
])],
})
self.tech = Users.create({
'login': 'audit_tech', 'name': 'Audit Tech',
'email': 'audit_tech@example.com',
'group_ids': [(6, 0, [
self.env.ref('fusion_plating.group_fp_technician').id
])],
})
def test_owner_can_read(self):
event = self.env['fp.tablet.session.event'].sudo().create({
'event_type': 'unlock',
'user_id': self.tech.id,
})
# Owner reads via their own user
e = self.env['fp.tablet.session.event'].with_user(self.owner).browse(event.id)
self.assertEqual(e.user_id, self.tech)
def test_technician_cannot_read(self):
event = self.env['fp.tablet.session.event'].sudo().create({
'event_type': 'unlock',
'user_id': self.tech.id,
})
with self.assertRaises(AccessError):
self.env['fp.tablet.session.event'].with_user(self.tech).browse(event.id).event_type
def test_owner_cannot_write(self):
event = self.env['fp.tablet.session.event'].sudo().create({
'event_type': 'unlock',
'user_id': self.tech.id,
})
with self.assertRaises(AccessError):
self.env['fp.tablet.session.event'].with_user(self.owner).browse(event.id).write({
'event_type': 'failed_unlock',
})
def test_owner_cannot_unlink(self):
event = self.env['fp.tablet.session.event'].sudo().create({
'event_type': 'unlock',
'user_id': self.tech.id,
})
with self.assertRaises(AccessError):
self.env['fp.tablet.session.event'].with_user(self.owner).browse(event.id).unlink()
- Step 2: Register in tests/init.py + commit + push
cd 'K:/Github/Odoo-Modules'
# Add 'from . import test_tablet_session_event_model' to tests/__init__.py
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/tests/test_tablet_session_event_model.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): fp.tablet.session.event is append-only
Owner reads. Technician cannot read. Owner cannot write or unlink.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task B3: Custom auth manager — fp_tablet_pin credential type
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/models/res_users.py -
Step 1: Add the _check_credentials override at the bottom of the existing ResUsers class
Find the existing class ResUsers(models.Model) block (it already holds x_fc_tablet_pin_hash, set_tablet_pin, etc.). Add this method:
def _check_credentials(self, credential, env):
"""Custom auth manager: accept `type='fp_tablet_pin'` credential.
Validates the PIN hash, that the user is active, and that they hold
at least one shop-branch plating role. On success, returns the auth
info dict Odoo's session expects. On failure, raises AccessDenied
so the standard auth chain returns a 401.
See docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
Section 2 — Auth path.
"""
from odoo.exceptions import AccessDenied
if isinstance(credential, dict) and credential.get('type') == 'fp_tablet_pin':
login = credential.get('login')
pin = credential.get('pin')
if not login or not pin:
raise AccessDenied()
user_sudo = self.sudo().search([('login', '=', login)], limit=1)
if not user_sudo or not user_sudo.active:
raise AccessDenied()
# Must hold a shop-branch role (otherwise they can't operate the tablet)
shop_branch_xmlids = (
'fusion_plating.group_fp_technician',
'fusion_plating.group_fp_shop_manager_v2',
'fusion_plating.group_fp_manager',
'fusion_plating.group_fp_quality_manager',
'fusion_plating.group_fp_owner',
)
shop_branch_ids = {
g.id for g in (
self.env.ref(x, raise_if_not_found=False)
for x in shop_branch_xmlids
) if g
}
user_group_ids = set(user_sudo.group_ids.ids)
if not (shop_branch_ids & user_group_ids):
raise AccessDenied()
# Verify the PIN hash. verify_tablet_pin already exists.
if not user_sudo.verify_tablet_pin(pin):
raise AccessDenied()
return {
'uid': user_sudo.id,
'auth_method': 'fp_tablet_pin',
'mfa': 'default',
}
return super()._check_credentials(credential, env)
- Step 2: Verify Python parses + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/res_users.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/models/res_users.py
git commit -m "feat(shopfloor): fp_tablet_pin custom auth manager
Validates PIN hash + shop-branch role membership when the credential
type is fp_tablet_pin. Goes through Odoo's standard _check_credentials
chain so future 2FA / IP-gate modules layer cleanly on top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task B4: Test the auth manager
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py -
Step 1: Write the test
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import AccessDenied
@tagged('-at_install', 'post_install', 'fp_tablet')
class TestTabletPinAuthManager(TransactionCase):
def setUp(self):
super().setUp()
Users = self.env['res.users'].with_context(no_reset_password=True)
self.tech = Users.create({
'login': 'authmgr_tech@example.com', 'name': 'AuthMgr Tech',
'email': 'authmgr_tech@example.com',
'group_ids': [(6, 0, [
self.env.ref('fusion_plating.group_fp_technician').id
])],
})
self.tech.sudo().set_tablet_pin('1234')
self.no_role_user = Users.create({
'login': 'authmgr_norole@example.com', 'name': 'NoRole',
'email': 'authmgr_norole@example.com',
})
# Set a PIN but no shop-branch role
# (set_tablet_pin doesn't care about roles)
self.no_role_user.sudo().set_tablet_pin('5555')
def _check(self, login, pin):
return self.env['res.users'].sudo()._check_credentials(
{'type': 'fp_tablet_pin', 'login': login, 'pin': pin},
self.env,
)
def test_correct_pin_succeeds(self):
result = self._check('authmgr_tech@example.com', '1234')
self.assertEqual(result['uid'], self.tech.id)
self.assertEqual(result['auth_method'], 'fp_tablet_pin')
def test_wrong_pin_raises_access_denied(self):
with self.assertRaises(AccessDenied):
self._check('authmgr_tech@example.com', '0000')
def test_missing_pin_raises_access_denied(self):
with self.assertRaises(AccessDenied):
self._check('authmgr_tech@example.com', '')
def test_missing_login_raises_access_denied(self):
with self.assertRaises(AccessDenied):
self._check('', '1234')
def test_unknown_login_raises_access_denied(self):
with self.assertRaises(AccessDenied):
self._check('nobody@example.com', '1234')
def test_inactive_user_raises_access_denied(self):
self.tech.sudo().active = False
with self.assertRaises(AccessDenied):
self._check('authmgr_tech@example.com', '1234')
def test_no_shop_branch_role_raises_access_denied(self):
with self.assertRaises(AccessDenied):
self._check('authmgr_norole@example.com', '5555')
def test_other_credential_types_pass_through_to_super(self):
# Standard password credential should still work normally.
# We don't test the exact result here — just that our override
# doesn't intercept it.
# AccessDenied is expected because we provide no password.
# Key check: it's not OUR AccessDenied (which fires when type
# is fp_tablet_pin), so the super() chain handles it.
try:
self.env['res.users'].sudo()._check_credentials(
{'type': 'password', 'login': 'authmgr_tech@example.com',
'password': 'wrong'},
self.env,
)
except AccessDenied:
pass # expected — wrong password
except Exception as e:
self.fail(f'Standard password path broken: {e}')
- Step 2: Register + commit + push
cd 'K:/Github/Odoo-Modules'
# Add 'from . import test_tablet_pin_auth_manager' to tests/__init__.py
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin_auth_manager.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): fp_tablet_pin auth manager handles all cases
8 tests: correct/wrong/missing PIN, missing/unknown login, inactive
user, no shop-branch role, and pass-through of other credential types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Phase C — New Endpoints + Cron + Feature Flag
Task C1: Add the helper module for session-event writes
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py -
Step 1: Write the helper
# -*- coding: utf-8 -*-
"""Helper for writing fp.tablet.session.event rows from the new
unlock_session / lock_session endpoints and the force-lock cron.
Single source of truth for hashing the session sid, trimming the
user-agent, and capturing forensic ip / acting_uid.
"""
import hashlib
from odoo import fields
from odoo.http import request
def _sha256_session_sid(sid):
"""Return sha256 hex digest of the session sid. Stored in the audit
log so DB leaks can't be replayed."""
if not sid:
return ''
return hashlib.sha256(sid.encode('utf-8')).hexdigest()
def _trim_ua(ua):
"""Trim user-agent to 256 chars (Odoo's standard Char width)."""
if not ua:
return ''
return ua[:256]
def write_event(env, *, event_type, user_id=None, attempted_user_id=None,
session_id_hash=None, session_started_at=None,
session_ended_at=None, duration_seconds=None,
failure_reason=None, notes=None):
"""Append an fp.tablet.session.event row. All writes sudo'd.
The acting_uid + ip + ua are pulled from the current request
automatically so callers never forget them.
"""
vals = {
'event_type': event_type,
'user_id': user_id,
'attempted_user_id': attempted_user_id,
'session_id_hash': session_id_hash,
'session_started_at': session_started_at,
'session_ended_at': session_ended_at,
'duration_seconds': duration_seconds,
'failure_reason': failure_reason,
'notes': notes,
'acting_uid': env.uid,
}
if request:
vals['ip_address'] = request.httprequest.remote_addr or ''
vals['user_agent'] = _trim_ua(
request.httprequest.headers.get('User-Agent', '')
)
return env['fp.tablet.session.event'].sudo().create(vals)
- Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/controllers/_tablet_session_audit.py
git commit -m "feat(shopfloor): _tablet_session_audit helper for audit-log writes
Single source for sha256(session sid), ua trim, ip/acting_uid capture
from request. Used by unlock_session, lock_session, and force-lock cron.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task C2: Add /fp/tablet/unlock_session endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py -
Step 1: Add the new endpoint AFTER the existing
/fp/tablet/unlockroute
Find the existing def unlock(self, user_id, pin): method. Below it (still in the FpTabletController class), add:
# ======================================================================
# /fp/tablet/unlock_session — verify PIN + mint REAL Odoo session as tech
# ======================================================================
@http.route('/fp/tablet/unlock_session', type='jsonrpc', auth='user')
def unlock_session(self, user_id, pin):
"""Phase 1 of the tablet PIN session redesign.
Verifies the PIN, then mints a real Odoo session AS the tech
via the fp_tablet_pin custom auth manager. Browser cookie
swaps; subsequent requests carry the tech's session uid, so
create_uid / write_uid / chatter authorship attribute correctly
without any tablet_tech_id plumbing.
Spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
"""
from odoo.exceptions import AccessDenied
from ._tablet_session_audit import write_event, _sha256_session_sid
env = request.env
Users = env['res.users'].sudo()
target = Users.browse(int(user_id))
if not target.exists():
return {'ok': False, 'error': _('User not found.')}
# No PIN set yet
if not target.x_fc_tablet_pin_hash:
write_event(env,
event_type='failed_unlock',
attempted_user_id=target.id,
failure_reason='no_pin_set')
return {
'ok': False,
'error': _('No PIN set. Set one in Preferences first.'),
'needs_setup': True,
}
# Inactive
if not target.active:
write_event(env,
event_type='failed_unlock',
attempted_user_id=target.id,
failure_reason='user_inactive')
return {'ok': False, 'error': _('User is inactive.')}
# Currently locked out?
now = fields.Datetime.now()
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
write_event(env,
event_type='failed_unlock',
attempted_user_id=target.id,
failure_reason='locked_out')
return {
'ok': False,
'error': _('Account locked. Try again in a few minutes.'),
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
}
# Attempt the real Odoo session swap via the custom auth manager.
# session.authenticate validates credentials through _check_credentials,
# issues a new sid, sets the cookie, returns the user dict.
try:
auth_info = request.session.authenticate(
request.db,
{'type': 'fp_tablet_pin',
'login': target.login,
'pin': pin},
)
except AccessDenied:
# Wrong PIN — increment failure counter
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
threshold = int(env['ir.config_parameter'].sudo().get_param(
'fp.shopfloor.tablet_pin_fail_threshold', 5))
lockout_min = int(env['ir.config_parameter'].sudo().get_param(
'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5))
vals = {'x_fc_tablet_pin_failed_count': new_count}
failure_reason = 'wrong_pin'
if new_count >= threshold:
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
failure_reason = 'locked_out'
target.write(vals)
write_event(env,
event_type='failed_unlock',
attempted_user_id=target.id,
failure_reason=failure_reason)
_logger.warning(
'Tablet PIN failure for uid %s (count=%d, locked=%s)',
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
)
return {'ok': False, 'error': _('Incorrect PIN.')}
# Success path. session.authenticate already swapped the cookie.
sid = request.session.sid
target.write({
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
write_event(env,
event_type='unlock',
user_id=target.id,
session_id_hash=_sha256_session_sid(sid),
session_started_at=now)
_logger.info(
'Tablet session minted for uid %s (sid %s..)',
target.id, sid[:8] if sid else '',
)
return {
'ok': True,
'tech_id': target.id,
'tech_name': target.name,
}
-
Step 2: Make sure the top-of-file imports include
timedelta(likely already present — check) -
Step 3: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py
git commit -m "feat(shopfloor): /fp/tablet/unlock_session mints real Odoo session
PIN verify -> request.session.authenticate(type=fp_tablet_pin) -> new
session sid, cookie swap, audit event written. Failed attempts also
written to audit log (failed_unlock, failure_reason=wrong_pin or
locked_out or no_pin_set or user_inactive).
OLD /fp/tablet/unlock stays alive during the 1-week overlap window
per spec Section 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task C3: Add /fp/tablet/lock_session endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py -
Step 1: Add the lock endpoint below unlock_session
# ======================================================================
# /fp/tablet/lock_session — destroy tech session + re-auth as kiosk
# ======================================================================
@http.route('/fp/tablet/lock_session', type='jsonrpc', auth='user')
def lock_session(self, reason='manual'):
"""Lock the tablet — destroy the tech's session and re-auth the
browser as fp_tablet_kiosk. Audit-log the event.
`reason` is one of 'manual' / 'idle' / 'ceiling' — controls which
event_type gets written. The corresponding event_type names:
manual -> manual_lock
idle -> idle_lock
ceiling -> ceiling_lock
Anything else falls back to manual_lock.
"""
from ._tablet_session_audit import write_event, _sha256_session_sid
env = request.env
now = fields.Datetime.now()
sid = request.session.sid
tech_id = env.uid
# Determine the audit event_type
event_type_map = {
'manual': 'manual_lock',
'idle': 'idle_lock',
'ceiling': 'ceiling_lock',
}
event_type = event_type_map.get(reason, 'manual_lock')
# Find the matching open session_event so we can compute duration.
# We look for the most recent unlock for this user without a
# session_ended_at — that's the open one.
SessionEvent = env['fp.tablet.session.event'].sudo()
open_event = SessionEvent.search([
('event_type', '=', 'unlock'),
('user_id', '=', tech_id),
('session_ended_at', '=', False),
], order='create_date desc', limit=1)
session_started_at = (
open_event.session_started_at if open_event else False
)
duration_seconds = None
if session_started_at:
duration_seconds = int((now - session_started_at).total_seconds())
# Write the lock event BEFORE destroying the session (we lose
# env.uid after logout).
write_event(env,
event_type=event_type,
user_id=tech_id,
session_id_hash=_sha256_session_sid(sid),
session_started_at=session_started_at,
session_ended_at=now,
duration_seconds=duration_seconds)
_logger.info(
'Tablet locked (reason=%s) for uid %s (sid %s..) duration=%ss',
reason, tech_id, sid[:8] if sid else '', duration_seconds,
)
# Destroy the tech session
request.session.logout(keep_db=True)
# Re-authenticate as the kiosk user. Credential comes from
# ir.config_parameter (auto-generated on first install in
# post-migrate.py).
kiosk_login = 'fp_tablet_kiosk@enplating.local'
kiosk_password = env['ir.config_parameter'].sudo().get_param(
'fp.tablet.kiosk_password', ''
)
if not kiosk_password:
_logger.error(
'fp.tablet.kiosk_password missing from ir.config_parameter; '
'cannot re-auth tablet as kiosk. The browser will need '
'manual login.'
)
return {'ok': True, 'locked_at': now.isoformat(),
'needs_kiosk_relogin': True}
try:
request.session.authenticate(
request.db,
{'type': 'password',
'login': kiosk_login,
'password': kiosk_password},
)
except Exception as e:
_logger.exception(
'Failed to re-auth tablet as kiosk: %s', e)
return {'ok': True, 'locked_at': now.isoformat(),
'needs_kiosk_relogin': True}
return {'ok': True, 'locked_at': now.isoformat()}
- Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py
git commit -m "feat(shopfloor): /fp/tablet/lock_session destroys tech session
Writes lock event (manual/idle/ceiling) with duration computed from
the open unlock event. Then logout + re-authenticate as kiosk via
the password stored in ir.config_parameter['fp.tablet.kiosk_password'].
Falls back to 'needs_kiosk_relogin' if the kiosk password is missing
(sysadmin must log in manually). Logs every event for forensic
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task C4: Force-lock cron for stale sessions
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml -
Modify:
fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py(add the cron method) -
Step 1: Add the cron method on the model
Append this method to the FpTabletSessionEvent class:
@api.model
def _cron_force_lock_stale_sessions(self):
"""Belt-and-suspenders cron: find any active unlock event past
the 8-hour ceiling and mark it force-locked.
Handles browser crashes, tablet reboots with stale cookies,
and any path that bypasses /fp/tablet/lock_session.
Runs every 5 minutes per fp_tablet_cron.xml.
"""
from datetime import timedelta
ceiling_hours = int(self.env['ir.config_parameter'].sudo().get_param(
'fp.tablet.session_ceiling_hours', 8))
cutoff = fields.Datetime.now() - timedelta(hours=ceiling_hours)
stale = self.search([
('event_type', '=', 'unlock'),
('session_ended_at', '=', False),
('session_started_at', '<', cutoff),
])
now = fields.Datetime.now()
for event in stale:
duration = int((now - event.session_started_at).total_seconds())
self.sudo().create({
'event_type': 'force_lock',
'user_id': event.user_id.id,
'session_id_hash': event.session_id_hash,
'session_started_at': event.session_started_at,
'session_ended_at': now,
'duration_seconds': duration,
'notes': 'Cron force-lock: session exceeded %d-hour ceiling' % ceiling_hours,
})
# Mark the original unlock event closed so it's not reprocessed
# next tick. write() is blocked by ACL — use sudo + bypass.
self.env.cr.execute(
"""UPDATE fp_tablet_session_event
SET session_ended_at = %s, duration_seconds = %s
WHERE id = %s""",
(now, duration, event.id),
)
- Step 2: Write the cron XML
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_force_lock_stale_sessions" model="ir.cron">
<field name="name">Tablet: Force-lock Stale PIN Sessions</field>
<field name="model_id" ref="model_fp_tablet_session_event"/>
<field name="state">code</field>
<field name="code">model._cron_force_lock_stale_sessions()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>
- Step 3: Verify + commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml'); print('XML OK')"
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py').read()); print('Py OK')"
git add fusion_plating/fusion_plating_shopfloor/data/fp_tablet_cron.xml fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py
git commit -m "feat(shopfloor): force-lock cron for stale tablet sessions
Every 5 minutes, find active unlock events past 8-hour ceiling and
mark them force-locked. SQL bypass of the model's read-only ACL is
the only path that can update existing rows (no Python write() works).
Ceiling configurable via ir.config_parameter[fp.tablet.session_ceiling_hours].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task C5: HTTP-test the unlock + lock endpoints
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py -
Step 1: Write the HttpCase tests
import json
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fp_tablet')
class TestUnlockLockSessionEndpoints(HttpCase):
def setUp(self):
super().setUp()
Users = self.env['res.users'].with_context(no_reset_password=True)
self.tech = Users.create({
'login': 'http_tech@example.com', 'name': 'HTTP Tech',
'email': 'http_tech@example.com',
'group_ids': [(6, 0, [
self.env.ref('fusion_plating.group_fp_technician').id
])],
})
self.tech.sudo().set_tablet_pin('1234')
# Make sure the kiosk password is set so lock_session can re-auth.
ICP = self.env['ir.config_parameter'].sudo()
if not ICP.get_param('fp.tablet.kiosk_password'):
ICP.set_param('fp.tablet.kiosk_password', 'test_kiosk_pwd')
kiosk = self.env.ref('fusion_plating_shopfloor.user_fp_tablet_kiosk')
kiosk.sudo().password = 'test_kiosk_pwd'
def _jsonrpc(self, route, params):
return self.url_open(
route,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
).json()
def test_unlock_session_with_correct_pin_returns_ok(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
resp = self._jsonrpc('/fp/tablet/unlock_session', {
'user_id': self.tech.id, 'pin': '1234',
})
self.assertTrue(resp['result']['ok'])
self.assertEqual(resp['result']['tech_id'], self.tech.id)
def test_unlock_session_writes_audit_event(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
self._jsonrpc('/fp/tablet/unlock_session', {
'user_id': self.tech.id, 'pin': '1234',
})
events = self.env['fp.tablet.session.event'].sudo().search([
('event_type', '=', 'unlock'),
('user_id', '=', self.tech.id),
])
self.assertGreater(len(events), 0)
e = events[0]
self.assertTrue(e.session_id_hash)
self.assertTrue(e.session_started_at)
def test_unlock_session_with_wrong_pin_writes_failed_event(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
resp = self._jsonrpc('/fp/tablet/unlock_session', {
'user_id': self.tech.id, 'pin': '0000',
})
self.assertFalse(resp['result']['ok'])
events = self.env['fp.tablet.session.event'].sudo().search([
('event_type', '=', 'failed_unlock'),
('attempted_user_id', '=', self.tech.id),
('failure_reason', '=', 'wrong_pin'),
])
self.assertGreater(len(events), 0)
def test_lock_session_writes_manual_lock_event(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
# Unlock first
self._jsonrpc('/fp/tablet/unlock_session', {
'user_id': self.tech.id, 'pin': '1234',
})
# Then lock with reason=manual
resp = self._jsonrpc('/fp/tablet/lock_session', {'reason': 'manual'})
self.assertTrue(resp['result']['ok'])
events = self.env['fp.tablet.session.event'].sudo().search([
('event_type', '=', 'manual_lock'),
('user_id', '=', self.tech.id),
])
self.assertGreater(len(events), 0)
self.assertIsNotNone(events[0].session_ended_at)
self.assertGreaterEqual(events[0].duration_seconds, 0)
def test_lock_session_idle_reason_writes_idle_lock(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'test_kiosk_pwd')
self._jsonrpc('/fp/tablet/unlock_session', {
'user_id': self.tech.id, 'pin': '1234',
})
self._jsonrpc('/fp/tablet/lock_session', {'reason': 'idle'})
events = self.env['fp.tablet.session.event'].sudo().search([
('event_type', '=', 'idle_lock'),
('user_id', '=', self.tech.id),
])
self.assertGreater(len(events), 0)
- Step 2: Register + commit + push
cd 'K:/Github/Odoo-Modules'
# Add 'from . import test_unlock_lock_session_endpoints' to tests/__init__.py
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/tests/test_unlock_lock_session_endpoints.py fusion_plating/fusion_plating_shopfloor/tests/__init__.py
git commit -m "test(shopfloor): HTTP tests for unlock_session + lock_session
5 tests covering correct/wrong PIN, audit event writes, manual/idle
lock reasons. Uses HttpCase to actually drive the JSONRPC endpoint
end-to-end with session cookie handling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Phase D — OWL Session Manager + Lock Screen Integration
Task D1: Write the tablet_session_manager OWL service
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js -
Step 1: Write the service
/** @odoo-module **/
// =============================================================================
// Tablet Session Manager (Phase D of tablet PIN session redesign)
//
// OWL service that tracks idle time + hard ceiling for an unlocked tech
// session and fires /fp/tablet/lock_session when either threshold trips.
//
// Activity events (click / touchstart / keydown / mousemove) reset the idle
// timer. setInterval polls every 5 seconds.
//
// Spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md
// =============================================================================
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
const DEFAULT_IDLE_MS = 10 * 60 * 1000; // 10 minutes
const DEFAULT_CEILING_MS = 8 * 60 * 60 * 1000; // 8 hours
const TICK_MS = 5000; // check every 5 seconds
export const tabletSessionManager = {
dependencies: [],
start(env) {
const service = {
idleMs: DEFAULT_IDLE_MS,
ceilingMs: DEFAULT_CEILING_MS,
lastActivity: Date.now(),
sessionStartedAt: null,
_tickHandle: null,
_running: false,
beginSession(sessionStartedAtMs) {
this.sessionStartedAt = sessionStartedAtMs || Date.now();
this.lastActivity = Date.now();
this._installListeners();
this._tickHandle = setInterval(() => this._tick(), TICK_MS);
this._running = true;
},
endSession() {
if (this._tickHandle) clearInterval(this._tickHandle);
this._tickHandle = null;
this._removeListeners();
this._running = false;
this.sessionStartedAt = null;
},
_touchHandler: null,
_installListeners() {
this._touchHandler = () => { this.lastActivity = Date.now(); };
["click", "touchstart", "keydown", "mousemove"].forEach(ev =>
document.addEventListener(ev, this._touchHandler, { passive: true })
);
},
_removeListeners() {
if (!this._touchHandler) return;
["click", "touchstart", "keydown", "mousemove"].forEach(ev =>
document.removeEventListener(ev, this._touchHandler)
);
this._touchHandler = null;
},
async _tick() {
if (!this._running) return;
const now = Date.now();
const idleFor = now - this.lastActivity;
const sessionAgeMs = this.sessionStartedAt ? now - this.sessionStartedAt : 0;
let reason = null;
if (sessionAgeMs > this.ceilingMs) {
reason = "ceiling";
} else if (idleFor > this.idleMs) {
reason = "idle";
}
if (!reason) return;
this.endSession(); // stop ticking before the RPC
await this.lockBack(reason);
},
async lockBack(reason) {
try {
await rpc("/fp/tablet/lock_session", { reason });
} catch (e) {
// Even if the RPC fails, force a reload to drop the
// current session state — the cron will clean up.
console.warn("lock_session RPC failed; reloading anyway", e);
}
window.location.reload();
},
};
return service;
},
};
registry.category("services").add("fp_tablet_session_manager", tabletSessionManager);
- Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js
git commit -m "feat(shopfloor): tablet_session_manager OWL service
Tracks idle + ceiling timers for an unlocked tech session. Fires
/fp/tablet/lock_session when either trips, then reloads the page so
the browser re-bootstraps under the fresh kiosk session.
Defaults: 10min idle, 8hr ceiling, 5s tick interval. Listens for
click/touchstart/keydown/mousemove as activity signals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task D2: Wire the lock screen to call unlock_session
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js -
Step 1: Find the existing unlock flow in tablet_lock.js
Look for where the OWL component currently calls /fp/tablet/unlock. There should be an onPinSubmit or similar handler.
- Step 2: Add feature-flag-gated branch
The component must detect tablet_session_mode (from a server-bootstrapped value or from a static ir.config_parameter read at mount) and route to either the OLD or NEW endpoint:
// In setup() or onWillStart, fetch the mode:
this.sessionMode = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_session_mode", "legacy"],
kwargs: {},
});
// In the PIN-submit handler:
async _onPinSubmit() {
const pin = this.state.pinDigits.join("");
if (this.sessionMode === "session_swap") {
const resp = await rpc("/fp/tablet/unlock_session", {
user_id: this.state.selectedTileUserId,
pin: pin,
});
if (resp.ok) {
// Browser cookie has swapped — reload so the app re-bootstraps
// as the tech. The new session manager (Task D1) will kick in
// on the next page load.
window.location.reload();
return;
}
this._showError(resp.error || "Unlock failed");
return;
}
// Legacy path: existing /fp/tablet/unlock flow, unchanged
// ... existing code stays here ...
}
The exact integration depends on the current tablet_lock.js structure. The PRINCIPLE: branch on sessionMode and reload on success.
- Step 3: Bootstrap tablet_session_manager on page load
In the OWL service registry consumer (likely the shopfloor landing or job workspace component), call:
this.tabletSessionManager = useService("fp_tablet_session_manager");
onMounted(() => {
// If we're on a TECH session (uid != kiosk uid), begin the idle timer.
// Bootstrap data should provide initial_uid + kiosk_uid hints.
if (env.services.user.userId && env.services.user.userId !== KIOSK_UID) {
this.tabletSessionManager.beginSession();
}
});
onWillUnmount(() => {
this.tabletSessionManager.endSession();
});
Determining KIOSK_UID at runtime: read it once from a server endpoint or static config parameter, OR check env.services.user.userId === kiosk.id via a server-bootstrap value.
- Step 4: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js
git commit -m "feat(shopfloor): tablet_lock OWL component branches on session_mode
When ir.config_parameter[fp.shopfloor.tablet_session_mode]='session_swap',
PIN submit calls /fp/tablet/unlock_session + reloads the page. The
new session manager service kicks in on next mount.
Legacy mode unchanged — same /fp/tablet/unlock path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task D3: Add a Lock button to the unlocked UI
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml(or wherever the existing Hand-Off button lives) -
Step 1: Find the existing Hand-Off / Lock button (search for
LockorHandin tablet_lock.xml / shopfloor_landing.xml / job_workspace.xml)
The CLAUDE.md mentions a Hand-Off button already exists. Wire its t-on-click to call the new lockBack('manual') method on the session manager service:
<button t-on-click="() => this.tabletSessionManager.lockBack('manual')"
class="btn btn-warning"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off / Lock
</button>
- Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
git add fusion_plating/fusion_plating_shopfloor/static/src/xml/
git commit -m "feat(shopfloor): Hand-Off button calls lockBack('manual')
Wires the existing button to the new session manager service so it
writes a manual_lock audit event and re-authenticates as kiosk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Phase E — Audit Views + Owner Menu + Smart Button
Task E1: List + form views for the audit log
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml -
Step 1: Write the view file
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_fp_tablet_session_event_list" model="ir.ui.view">
<field name="name">fp.tablet.session.event.list</field>
<field name="model">fp.tablet.session.event</field>
<field name="arch" type="xml">
<list decoration-success="event_type == 'unlock'"
decoration-danger="event_type == 'failed_unlock'"
decoration-warning="event_type in ('ceiling_lock','force_lock')"
decoration-muted="event_type in ('manual_lock','idle_lock')"
create="false" delete="false" edit="false">
<field name="create_date" string="When"/>
<field name="event_type" widget="badge"/>
<field name="user_id"/>
<field name="attempted_user_id"
optional="hide"/>
<field name="failure_reason"
optional="hide"/>
<field name="duration_seconds"
optional="hide" string="Duration (s)"/>
<field name="acting_uid"
optional="hide" string="Acting"/>
<field name="ip_address"
optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_tablet_session_event_form" model="ir.ui.view">
<field name="name">fp.tablet.session.event.form</field>
<field name="model">fp.tablet.session.event</field>
<field name="arch" type="xml">
<form create="false" delete="false" edit="false">
<sheet>
<h1><field name="event_type" readonly="1"/></h1>
<group>
<group>
<field name="user_id" readonly="1"/>
<field name="attempted_user_id" readonly="1"/>
<field name="failure_reason" readonly="1"/>
<field name="acting_uid" readonly="1"/>
</group>
<group>
<field name="create_date" readonly="1"/>
<field name="session_started_at" readonly="1"/>
<field name="session_ended_at" readonly="1"/>
<field name="duration_seconds" readonly="1"/>
</group>
</group>
<group string="Forensic">
<field name="ip_address" readonly="1"/>
<field name="user_agent" readonly="1"/>
<field name="session_id_hash" readonly="1"/>
</group>
<group string="Notes">
<field name="notes" readonly="1" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_tablet_session_event" model="ir.actions.act_window">
<field name="name">Tablet Audit Log</field>
<field name="res_model">fp.tablet.session.event</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_groupby_event_type': 0}</field>
</record>
<menuitem id="menu_fp_tablet_session_event"
name="Tablet Audit Log"
parent="fusion_plating.menu_fp_config"
action="action_fp_tablet_session_event"
sequence="20"
groups="fusion_plating.group_fp_owner"/>
</data>
</odoo>
- Step 2: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import xml.etree.ElementTree as et; et.parse('fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml'); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml
git commit -m "feat(shopfloor): audit log list+form views, Owner-only menu
Plating > Configuration > Tablet Audit Log. Read-only list with
decoration (green=unlock, red=failed, warning=ceiling/force,
muted=manual/idle). Form shows full forensic detail incl. ip/ua.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task E2: Smart button on res.users for per-user last-7-days events
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml -
Modify:
fusion_plating/fusion_plating_shopfloor/models/res_users.py -
Step 1: Add a computed count + action method on ResUsers
In res_users.py, append to the existing ResUsers class:
x_fc_tablet_event_count_7d = fields.Integer(
string='Tablet Events (7d)',
compute='_compute_tablet_event_count_7d',
store=False,
)
def _compute_tablet_event_count_7d(self):
from datetime import timedelta
cutoff = fields.Datetime.now() - timedelta(days=7)
for user in self:
user.x_fc_tablet_event_count_7d = self.env['fp.tablet.session.event'].sudo().search_count([
'|',
('user_id', '=', user.id),
('attempted_user_id', '=', user.id),
('create_date', '>=', cutoff),
])
def action_view_tablet_events(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Tablet Events: %s') % self.name,
'res_model': 'fp.tablet.session.event',
'view_mode': 'list,form',
'domain': [
'|',
('user_id', '=', self.id),
('attempted_user_id', '=', self.id),
],
}
- Step 2: Add the smart button to res_users_views.xml
Find the existing inherited res.users.form view (or create one). Add the button:
<xpath expr="//div[@class='oe_button_box']" position="inside">
<button name="action_view_tablet_events"
type="object"
class="oe_stat_button"
icon="fa-history"
groups="fusion_plating.group_fp_owner">
<field name="x_fc_tablet_event_count_7d" widget="statinfo"
string="Tablet Events (7d)"/>
</button>
</xpath>
- Step 3: Commit + push
cd 'K:/Github/Odoo-Modules'
python -c "import ast; ast.parse(open('fusion_plating/fusion_plating_shopfloor/models/res_users.py').read()); print('OK')"
git add fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml fusion_plating/fusion_plating_shopfloor/models/res_users.py
git commit -m "feat(shopfloor): per-user 7-day tablet event smart button
Owner-only smart button on res.users form. Click opens the audit log
filtered to that user (both user_id and attempted_user_id, so
failed unlock attempts against a tile show up too).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Phase F — Entech Rollout (inline by main session)
This phase is not delegated to subagents — the main session deploys, verifies, and manually validates each tablet per the spec's two-step plan with 1-week overlap.
Task F1: Pre-deploy backup
- Step 1: pg_dump entech admin DB
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"pg_dump admin > /tmp/admin_pre_tablet_pin_session_$(date +%Y%m%d_%H%M).sql\" && ls -lh /tmp/admin_pre_tablet_pin_session_*.sql | tail -1'"
Expected: ~115MB file.
Task F2: Sync files + deploy with -u
- Step 1: Sync changed files via git diff + tar
cd 'K:/Github/Odoo-Modules'
git diff --name-only main~10..main | grep -v '^fusion_plating/CLAUDE.md$' | grep -v '^fusion_plating/docs/' > /tmp/sync_files.txt
tar czf /tmp/perms_sync.tar.gz --files-from=/tmp/sync_files.txt
base64 /tmp/perms_sync.tar.gz | ssh pve-worker5 "pct exec 111 -- bash -c 'cat | base64 -d > /tmp/perms_sync.tar.gz && cd /mnt/extra-addons/custom && tar xzf /tmp/perms_sync.tar.gz --strip-components=1 --no-same-owner --no-same-permissions && chown -R odoo:odoo /mnt/extra-addons/custom/fusion_plating_shopfloor'"
- Step 2: Update shopfloor module
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -10\" && systemctl start odoo'"
Expected: clean module load, no errors.
Task F3: Set feature flag to legacy first, confirm OLD path still works
- Step 1: Verify feature flag is 'legacy' by default
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"SELECT value FROM ir_config_parameter WHERE key = '\\''fp.shopfloor.tablet_session_mode'\\'';\\\"\"'"
Expected: empty (no row) — means default 'legacy' applies.
- Step 2: Open the tablet, PIN-unlock with the OLD path, confirm everything still works
Manual smoke test: open enplating.com on a tablet, tap your tile, enter PIN, verify the kanban loads normally.
Task F4: Flip flag to session_swap on one test tablet, validate
- Step 1: Set flag
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"INSERT INTO ir_config_parameter (key, value, create_date, write_date) VALUES ('\\''fp.shopfloor.tablet_session_mode'\\'', '\\''session_swap'\\'', NOW(), NOW()) ON CONFLICT (key) DO UPDATE SET value = '\\''session_swap'\\'', write_date = NOW();\\\"\"'"
- Step 2: Retrieve the kiosk password
ssh pve-worker5 "pct exec 111 -- bash -c \"echo 'print(env[\\\"ir.config_parameter\\\"].sudo().get_param(\\\"fp.tablet.kiosk_password\\\"))' | su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http 2>&1 | tail -2'\""
- Step 3: Re-authenticate the tablet browser as the kiosk user
In the tablet's browser: log out, log back in as fp_tablet_kiosk@enplating.local with the retrieved password. Bookmark the lock screen URL.
- Step 4: Manual full-cycle test
- Tap your tile, enter your PIN → page reloads, you're logged in as YOU
- Click a job card, do some action (start step, add note)
- Verify in DB:
SELECT create_uid FROM mail_message ORDER BY create_date DESC LIMIT 5;should show YOUR user id - Click Hand-Off / Lock button → page reloads to lock screen
- Wait 10 minutes idle on next unlock → confirm idle_lock fires automatically
Task F5: Roll out to remaining tablets + observe for 7 days
-
Step 1: Repeat F4 step 3 (re-auth as kiosk) on each remaining tablet
-
Step 2: Check audit log daily for the first week
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"SELECT event_type, COUNT(*) FROM fp_tablet_session_event WHERE create_date > NOW() - INTERVAL '\\''1 day'\\'' GROUP BY event_type ORDER BY 2 DESC;\\\"\"'"
Look for unexpected force_lock or failed_unlock patterns.
Task F6: Decision gate — keep or rollback
After 7 days of observation:
- Keep if no anomalies: proceed to Phase G cleanup.
- Rollback if issues:
Re-authenticate tablets as the legacy "shopfloor service" user. File a bug report and re-spec.
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'\\'';\\\"\"'"
Phase G — Step 3 Cleanup (post-overlap, post-decision)
ONLY run this phase after Phase F6 = "Keep" decision and at least 7 days of observation.
Task G1: Strip tablet_tech_id from all endpoints
- Step 1: Find all callers
cd 'K:/Github/Odoo-Modules'
grep -rn 'tablet_tech_id\|env_for_tablet_tech' fusion_plating/fusion_plating_shopfloor/ --include='*.py' | grep -v '\.pyc'
- Step 2: For each match, remove the kwarg +
env = env_for_tablet_tech(...)line
The endpoint body still uses env; just point env = request.env directly (no wrapping):
# BEFORE
def my_endpoint(self, foo, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
...
# AFTER
def my_endpoint(self, foo):
env = request.env
...
-
Step 3: Remove
env_for_tablet_techimport line at the top of each file -
Step 4: Commit + push
git add -A
git commit -m "cleanup(shopfloor): remove tablet_tech_id kwarg from all endpoints
Session swap (Phase F) makes attribution automatic via request.env.user.
The tablet_tech_id plumbing is dead code after the cutover.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task G2: Delete _tablet_audit.py and OLD endpoint
Files:
-
Delete:
fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py -
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py(remove /fp/tablet/unlock) -
Step 1: Delete the helper file
cd 'K:/Github/Odoo-Modules'
git rm fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py
- Step 2: Remove the legacy
/fp/tablet/unlockroute from tablet_controller.py
Find the @http.route('/fp/tablet/unlock', ...) decorator and the def unlock(self, user_id, pin): method below it. Delete the whole block.
- Step 3: Sync + restart + verify nothing imports the deleted module
# Quick sanity-check:
cd 'K:/Github/Odoo-Modules'
grep -rn '_tablet_audit\|env_for_tablet_tech' fusion_plating/ --include='*.py'
# Expected: empty
- Step 4: Commit + push
git add -A
git commit -m "cleanup(shopfloor): remove legacy /fp/tablet/unlock + _tablet_audit helper
Session-swap rollout complete and stable. Old attribution path is dead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin main
Task G3: Archive the legacy shopfloor service user
- Step 1: Identify the user
The legacy "shopfloor service" user likely has login matching shopfloor* or tablet*. Find it:
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"SELECT id, login, active FROM res_users WHERE login ILIKE '\\''%shopfloor%'\\'' OR login ILIKE '\\''%tablet%'\\'' AND login != '\\''fp_tablet_kiosk@enplating.local'\\'';\\\"\"'"
- Step 2: Archive (active=False) — DO NOT delete
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql admin -c \\\"UPDATE res_users SET active = false WHERE id = <ID_FROM_STEP_1> AND login != '\\''fp_tablet_kiosk@enplating.local'\\'';\\\"\"'"
Archiving (not deleting) keeps the audit trail of historical actions intact.
Self-Review
Spec coverage (each section → tasks):
- Section 1 architecture → Phase A (groups + ACL), Phase D (OWL state machine)
- Section 2 components & files → covered by Phase A-E task file lists
- Section 3 lifecycle → Phase C endpoints + Phase D session manager
- Section 4 audit log → Phase B (model + auth manager) + E (views + smart button)
- Section 5 migration → Phase F (rollout) + Phase G (cleanup)
- Section 6 acceptance criteria → covered by tests in Phase A-C + manual validation in Phase F
Placeholder scan: No TBD / TODO / "add validation" — all steps show code or commands.
Type consistency:
event_typeSelection values consistent (unlock,failed_unlock,manual_lock,idle_lock,ceiling_lock,force_lock,admin_reset) across model, endpoint writes, cron, testsgroup_ids(Odoo 19) used in all test files (NOTgroups_id)user_idvsattempted_user_idsemantics consistent (former for success path, latter for failed attempts)- Auth credential type
fp_tablet_pinconsistent 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?