Compare commits

...

14 Commits

Author SHA1 Message Date
gsinghpal
1d6184dd2f chore(fusion_plating_shopfloor): bump 19.0.30.0.0 for Phase 6.1 — PIN backend
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Backend foundation for the tablet PIN gate:
- res.users PIN fields + hash helpers (PBKDF2-SHA256, 200k iter, salted)
- 5 endpoints: /fp/tablet/{tiles,unlock,set_pin,reset_pin_for,ping}
- Per-user lockout (5 fails → 5 min, both configurable)
- Station roster + per-station idle override
- ir.config_parameter defaults
- Preferences Set/Change PIN button + Manager Reset button

Phase 6.2 (frontend lock screen) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:17:37 -04:00
gsinghpal
88a473e7eb feat(fusion_plating_shopfloor): Preferences Set/Change PIN + Manager Reset button (P6.1.7)
Two view inheritances on res.users:

(a) Preferences form — adds a 'Tablet PIN' group with a 'Set / Change
    Tablet PIN' button that triggers action_open_tablet_pin_setup → the
    fp_tablet_pin_setup OWL client action (Phase 6.2). Shows PIN Last
    Set as read-only context.

(b) Standard res.users form — header button 'Reset Tablet PIN' visible
    only to the fusion_plating manager group; hidden when no PIN is set
    (via the set_date invisible field reference). Confirms before clearing.
    Calls the clear_tablet_pin method from the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:17:20 -04:00
gsinghpal
08ababc2c7 feat(fusion_plating_shopfloor): station roster + idle override + tablet config defaults (P6.1.6)
Adds two fields to fusion.plating.shopfloor.station:
- x_fc_authorised_user_ids (Many2many → res.users): restricts the
  tablet lock-screen tile grid to a specific roster per station.
  Empty = all operator-group users shown.
- x_fc_idle_lock_minutes (Integer, nullable): per-station override
  for the auto-lock idle threshold; null = use system parameter.

Plus data/fp_tablet_config_data.xml registers four ir.config_parameter
defaults (noupdate=1 — manager can override via Settings → Technical
→ Parameters):
  fp.shopfloor.tablet_idle_lock_minutes = 5
  fp.shopfloor.tablet_pin_fail_threshold = 5
  fp.shopfloor.tablet_pin_fail_lockout_minutes = 5
  fp.shopfloor.tablet_warn_seconds_before_lock = 30

Form view surfaces both new fields in a dedicated 'Tablet PIN Gate'
group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:16:52 -04:00
gsinghpal
59ad77839a feat(fusion_plating_shopfloor): /fp/tablet/tiles + /fp/tablet/ping endpoints (P6.1.4-P6.1.5)
Tiles returns the lock-screen grid: operator-group users, sorted
clocked-in-first then alphabetical, with avatar URL + has_pin flag.
Honours station.x_fc_authorised_user_ids when non-empty (Phase 6.1.6
adds that field). Ping is a lightweight ack used by FpTabletLock as
a heartbeat — logs current_tech_id at DEBUG for forensic visibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:15:40 -04:00
gsinghpal
a594431eb6 feat(fusion_plating_shopfloor): /fp/tablet/unlock with per-user lockout (P6.1.3)
Verifies PIN, resets failure counter on success, increments + locks out
on 5 consecutive failures (configurable via ir.config_parameter
fp.shopfloor.tablet_pin_fail_threshold + tablet_pin_fail_lockout_minutes,
both defaulting to 5).

Returns informative payloads:
  ok=true            current_tech_id, current_tech_name
  needs_setup=true   user has no PIN yet
  locked_until       lockout in effect (rejects even correct PIN)
  attempts_remaining failed but not yet locked

Logs INFO on success, WARNING on failure (with running counter +
locked flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:15:01 -04:00
gsinghpal
58d02598da feat(fusion_plating_shopfloor): /fp/tablet/set_pin + /fp/tablet/reset_pin_for endpoints (P6.1.2)
set_pin is self-service: requires old PIN if a hash exists, validates
4-digit format. reset_pin_for is manager-only (enforced server-side
via has_group); clears the hash + posts to chatter.

Both endpoints log INFO on success and WARNING on access-control denials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:14:18 -04:00
gsinghpal
395bd4949e feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)
PBKDF2-SHA256 + 16-byte salt + 200k iterations on res.users. Format
of the stored hash string is <salt_hex>$<digest_hex>. Field is
manager-readable only (groups=group_fusion_plating_manager); helpers
that need to read or write it use .sudo() internally so operator-level
callers can still set/verify their own PIN.

Adds set_tablet_pin / verify_tablet_pin / clear_tablet_pin model
methods + action_open_tablet_pin_setup that triggers the OWL setup
modal (Phase 6.2). Tests cover hash uniqueness, verify, clear with
chatter post, and the 4-digit format guard.

Tests verified on entech: -u fusion_plating_shopfloor --test-tags fp_tablet_pin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:13:33 -04:00
gsinghpal
a6546ac858 docs(fusion_plating_shopfloor): implementation plan for Phase 6 PIN gate
3-sub-phase TDD plan executing the spec at
docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md:

- Phase 6.1 (Backend): res.users PIN fields + PBKDF2-SHA256 hash
  helpers, 5 /fp/tablet/* endpoints (tiles/unlock/set_pin/reset_pin_for/
  ping), per-user lockout after 5 failures, station roster +
  idle-override fields, ir.config_parameter defaults, Preferences
  Set/Change PIN button, manager Reset PIN header button. Tests
  cover hash safety, lockout edge cases, manager-only enforcement,
  tile filtering.

- Phase 6.2 (Frontend lock screen): tech_store + activity_tracker
  OWL services, FpPinPad + FpIdleWarning + FpPinSetup components,
  FpTabletLock outer wrapper, wire into Landing/Workspace/Manager
  Dashboard with Hand-Off button injection.

- Phase 6.3 (Audit propagation): fpRpc wrapper auto-injects
  tablet_tech_id, env_for_tablet_tech server helper, all action
  endpoints (workspace + shopfloor + manager) accept the kwarg and
  rebind env via env.with_user() so writes carry the right operator.

Each sub-phase ships independently per spec §9. Plan follows the
established workflow: write tests + commit, verify on entech (local
docker doesn't have fusion_plating mounted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:05:45 -04:00
gsinghpal
233e5e6e72 docs(fusion_plating_shopfloor): Phase 6 PIN gate + auto-lock spec
Sequel to the 2026-05-22 tablet redesign (Phases 1-5). Adds a tile-grid
lock screen + 4-digit PIN per tech + 5-min auto-lock + audit propagation
so multiple techs sharing one tablet get correctly-attributed actions.

Key design choices:
- 4-digit PIN (industry norm), PBKDF2-SHA256 with 200k iterations
- Per-user lockout after 5 failures (not per-tablet)
- Single Odoo session + tablet_tech_id kwarg for audit (no JS reload on
  every tech switch)
- Manager-side reset only (no SMS/email infra)
- Server-side step timer keeps running on lock (auto-pause cron is
  the upper-bound safety net)

Three sub-phases (6.1 backend / 6.2 frontend lock / 6.3 audit kwarg
propagation), each independently deployable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:48:46 -04:00
gsinghpal
b06a5b2d12 fix(fusion_plating_jobs): delete orphan Plant Overview menu record
Phase 3 removed the menu_fp_shopfloor_plant_overview menuitem from
fp_menu.xml, but Odoo doesn't auto-delete orphan records when XML
disappears — the menu stayed in the database. Combined with P3.5's
action retarget (action_fp_plant_overview tag → fp_shopfloor_landing),
clicking it landed on the same Landing component as Workstation —
hence the duplicate menu items both opening the same screen.

Adds <delete model='ir.ui.menu' id='...'> in legacy_menu_hide.xml so
future -u runs scrub the orphan. Drops the now-defunct group_ids
block for the deleted menu. The action record stays (bookmark
back-compat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:07:34 -04:00
gsinghpal
3ef67c6beb fix(fusion_plating_shopfloor): import fields in manager_controller
The Phase 4 endpoints (/fp/manager/funnel, approval_inbox, at_risk)
all use fields.Datetime.now() but the controller only imported http
+ request. Hitting the Workflow Funnel tab on Manager Desk threw:

  NameError: name 'fields' is not defined

Funnel auto-loads on dashboard mount → infinite spinner + 'Funnel:
Odoo Server Error' notification. Same bug would have hit at_risk
and approval_inbox on first navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:01:44 -04:00
gsinghpal
4a304e02f3 fix(fusion_plating_jobs): restore quick_look_qty + instruction_attachment_ids
Same regression as the previous commit — b0070afc removed all 4 quick_look
related fields, my first fix only caught 2 of them. Restoring the remaining
2 so the quick-look view fully validates.
2026-05-22 22:47:43 -04:00
gsinghpal
0d08d2d135 fix(fusion_plating_jobs): restore quick_look_partner_id + quick_look_part_catalog_id
Commit b0070afc removed these two related fields from fp.job.step but
the view fp_job_step_quick_look_views.xml still references them. The
mismatch was dormant because entech never ran -u between b0070afc
and the 2026-05-22 deploy. Re-running -u during the Phase 1-4 deploy
caught it:

  Field "quick_look_part_catalog_id" does not exist in model
  "fp.job.step"

Restoring both as related fields (zero-cost, fixes the view without
touching XML).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:46:18 -04:00
gsinghpal
f9cb1b11ce fix(fusion_plating_jobs): drop ir.cron numbercall/doall — removed in Odoo 19
Caught during entech deploy of the Phase 2 auto-pause cron. Odoo 19
ir.cron no longer accepts numbercall or doall fields; the load fails
with:

  ValueError: Invalid field 'numbercall' in 'ir.cron'

Removed both from ir_cron_autopause_stale_steps. The other crons in
the same file (nudge stale paused / in_progress) already used the
minimal field set — matching that pattern now.

Also added a CLAUDE.md section so future-Claude doesn't reintroduce
the speculative fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:43:34 -04:00
18 changed files with 3644 additions and 8 deletions

View File

@@ -166,6 +166,20 @@ These modules have **source code in this repo** but are **intentionally NOT inst
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
## Removing menus/records — Odoo does NOT auto-delete orphans
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
```xml
<delete model="ir.ui.menu" id="module_name.menu_xmlid_to_remove"/>
```
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
```
ValueError: Invalid field 'numbercall' in 'ir.cron'
```
Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval_number`, `interval_type`, `active`. Caught during the 2026-05-22 entech deploy of the auto-pause cron.
## Critical Rules — Odoo 19
1. **NEVER code from memory** — Read reference files from the server first.
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
# Shop Floor PIN Gate + Auto-Lock — Design Spec
**Date:** 2026-05-22
**Status:** Awaiting user review
**Phase:** 6 (sequel to Phases 1-5 of 2026-05-22-shopfloor-tablet-redesign)
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
**Target client:** EN Technologies (Fusion Plating)
---
## 1. Context
Phases 1-5 of the tablet redesign shipped on 2026-05-22 (entech LXC 111). They assume a single user is "logged in" to a tablet for the duration of use. Real shop floors don't work that way:
- A single tablet sits at the **EN Plating tank** (or de-rack table, masking station, QC bench).
- Multiple technicians rotate through the station during a shift.
- A tech walks away mid-shift; the next tech walks up — and without a lock, the new tech is operating under the previous tech's identity.
- Every step start, finish, scrap, hold, signature, and milestone advance gets attributed to the wrong person.
- AS9100 / Nadcap audit trails break. Operators sign off on each other's work without knowing it.
The fix needs to be **fast** (PIN in < 2 seconds), **familiar** (matches iPad / debit-card UX techs already know), and **silent on the timer side** (locking the tablet must not pause a part in a tank).
## 2. Goals
- Each tech identifies themselves with a personal 4-digit PIN.
- Tablet auto-locks after a configurable idle period (default 5 min).
- Quick-switch UX: tap your face tile → enter PIN → unlocked. No typing usernames.
- All shop-floor actions (step start/finish, holds, sign-offs, milestone advances) carry the correct tech identity for audit.
- No interruption to in-progress step timers — the server keeps counting.
- Manager can reset a forgotten PIN; no SMS/email infrastructure required.
- Per-station roster: a tablet at EN Plating only shows techs trained on EN Plating.
## 3. Non-goals (v1)
- Multi-factor authentication (TOTP, SMS).
- Biometric unlock (Face ID, fingerprint).
- NFC badges or RFID readers.
- Self-service PIN reset via email/SMS (manager-side only).
- "Remember me" cross-device sessions.
- Camera-based presence detection / liveness checks.
## 4. UX
### 4.1 Lock screen — tile grid
Rendered first thing when the tablet boots, after any auto-lock, and after the Hand-Off button. Replaces the current Landing/Workspace/Dashboard view entirely.
- 3-5 tiles per row, sized for touch (~120×140 px each).
- Sort: clocked-in techs first, alphabetical within bucket.
- Each tile: avatar + name + small green dot if clocked in.
- A station with `x_fc_authorised_user_ids` configured shows only those techs; otherwise all techs in the operator group.
- "Other..." chip at the end opens a username search for off-roster cases (cross-trained tech covering an unfamiliar station).
- Tap a tile → PIN pad slides up as a modal.
### 4.2 PIN pad
- Numeric 0-9 in a 3×4 grid + Clear + Submit.
- 4 dot placeholders fill as digits are typed.
- Auto-submit on the 4th digit (no Enter required).
- Wrong PIN → quick shake animation (CSS keyframe), dots clear, tile grid stays.
- After 5 sequential failures for one tech, that tech is locked for 5 minutes. Other techs can still unlock the tablet.
- "Forgot?" link surfaces a friendly message: "Ask a manager to reset your PIN."
### 4.3 Hand-Off button
- Top-right corner of every authenticated view (Landing, Workspace, Manager Dashboard), next to QR scan.
- Big icon + label: 🔒 **Hand Off**.
- Tap → confirm dialog "Lock this tablet now?" → instant lock to tile grid.
- Confirm dialog prevents accidental locks; can be skipped in v2 with rapid double-tap.
### 4.4 Idle warning
- At 30 seconds before auto-lock, a yellow pulsing border appears around the entire viewport.
- A toast slides in: "Locking in 28s · tap anywhere to stay".
- Countdown decrements in 1s ticks until 0.
- Any pointer/touch event clears the warning and resets the timer.
- At 0s, the tile grid replaces the current view.
### 4.5 Session continuity (state preservation on lock)
| State | On lock | On unlock |
|---|---|---|
| In-progress step timer | Server-side timer keeps running. No pause event fired. | Resumes accurate elapsed time. |
| OWL state (scroll, expanded step) | Preserved in memory | Restored |
| HoldComposer modal open | Preserved (dialog still mounted under the lock overlay) | Available immediately |
| SignaturePad open mid-stroke | **Thrown away.** Signature flow restarts. | Fresh signature required. |
| QR scan drawer open | Preserved | Available |
| Refresh interval (15s/8s polling) | Paused | Resumed |
### 4.6 Profile preferences — set / change PIN
- New "Tablet PIN" group on `res.users` preferences (user-facing form).
- Single button: **Set Tablet PIN** or **Change PIN** (label flips depending on whether a hash exists).
- Tapping it opens a modal with 3 PIN inputs:
- Current PIN (only if a PIN is set; skipped on first-time set)
- New PIN
- Confirm new PIN
- All three use the same `FpPinPad` component as the unlock screen.
- Subtext shows "Last changed: 2026-05-22" or "Cleared by manager".
### 4.7 Manager-side reset
- New header button on `res.users` form: **Reset Tablet PIN** (visible only to `group_fusion_plating_manager` and above).
- Tap → confirm dialog → posts to user's chatter ("Tablet PIN reset by Manager X on 2026-05-22") + clears the hash.
- Tech sets a new PIN on next unlock attempt.
## 5. Backend
### 5.1 Model fields on `res.users` (in `fusion_plating_shopfloor/models/res_users.py` — new file)
| Field | Type | Notes |
|---|---|---|
| `x_fc_tablet_pin_hash` | Char | Hash (SHA-256 + per-user salt) of the PIN. Stored as `<salt>$<hash>`. `groups='fusion_plating.group_fusion_plating_manager'` — non-manager users cannot even read other users' hash field. |
| `x_fc_tablet_pin_set_date` | Datetime | When the current hash was set. NULL if PIN was cleared by manager. |
| `x_fc_tablet_pin_failed_count` | Integer (0) | Sequential failed attempts since last success. Resets to 0 on a correct PIN. |
| `x_fc_tablet_locked_until` | Datetime | Lockout expiry. NULL when not locked. |
### 5.2 Extras on `fusion.plating.shopfloor.station`
| Field | Type | Notes |
|---|---|---|
| `x_fc_authorised_user_ids` | Many2many → res.users | If non-empty, the tile grid restricts to these users. Empty = "all operators". |
| `x_fc_idle_lock_minutes` | Integer, nullable | Per-station override; null = use global default. |
### 5.3 `ir.config_parameter` defaults
| Key | Default | Purpose |
|---|---|---|
| `fp.shopfloor.tablet_idle_lock_minutes` | `5` | Global idle threshold |
| `fp.shopfloor.tablet_pin_fail_threshold` | `5` | Failures before lockout |
| `fp.shopfloor.tablet_pin_fail_lockout_minutes` | `5` | Lockout duration |
| `fp.shopfloor.tablet_warn_seconds_before_lock` | `30` | When the yellow border appears |
### 5.4 HTTP endpoints (`/fp/tablet/*`)
All `type='jsonrpc'`, `auth='user'`. Auth = the tablet's persistent Odoo session (a "shopfloor service" account or any non-locked user).
| Endpoint | Body | Returns |
|---|---|---|
| `POST /fp/tablet/tiles` | `{station_id?}` | `{ok, tiles: [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}`. Respects `station.x_fc_authorised_user_ids`. |
| `POST /fp/tablet/unlock` | `{user_id, pin}` | `{ok: true, current_tech_id, current_tech_name}` or `{ok: false, error, locked_until?, attempts_remaining}`. |
| `POST /fp/tablet/set_pin` | `{old_pin?, new_pin}` | Caller's PIN only. `old_pin` required if a hash exists. `{ok, error?}`. |
| `POST /fp/tablet/reset_pin_for` | `{user_id}` | Manager-only; manager group enforced server-side. Clears target user's hash + posts chatter. |
| `POST /fp/tablet/ping` | `{current_tech_id}` | Bumps a server-side "last active" timestamp for forensics. Called on every successful tech action. |
### 5.5 Hash algorithm
```python
import hashlib, secrets
def _hash_pin(pin: str, salt: bytes = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
return f"{salt.hex()}${digest.hex()}"
def _verify_pin(pin: str, stored: str) -> bool:
salt_hex, expected_hex = stored.split('$', 1)
salt = bytes.fromhex(salt_hex)
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
return digest.hex() == expected_hex
```
200,000 PBKDF2 iterations gives ~50ms verify time on entech-class hardware — fast enough for tech UX, slow enough to make a brute-force attack expensive even with the database stolen.
### 5.6 Audit propagation (Phase 6.3)
All existing shop-floor endpoints that take an action (`/fp/shopfloor/start_wo`, `stop_wo`, `bump_qty_done`, `bump_qty_scrapped`, `log_chemistry`, `log_thickness_reading`, `quality_hold`, `mark_gate`, `start_bake`, `end_bake`, `/fp/workspace/{hold,sign_off,advance_milestone}`) gain an **optional** `tablet_tech_id` kwarg.
When the OWL component passes `tablet_tech_id`:
- Server verifies the id corresponds to a recent successful `/fp/tablet/unlock` (within session's idle window).
- All chatter posts use that user's name instead of `env.uid`.
- All writes to records set `create_uid` / `write_uid` to that user (via `with_user(...)` context manager).
- If `tablet_tech_id` is missing or stale, server falls back to `env.uid` (the tablet's session user) for back-compat.
This keeps the audit trail honest without forcing a full Odoo session swap on every PIN unlock (which would clear all OWL state and JS bundle cache).
## 6. Frontend architecture
### 6.1 New OWL components
| Component | File | Purpose |
|---|---|---|
| `FpTabletLock` | `static/src/js/tablet_lock.js` | Top-level wrapper around Landing/Workspace/Manager. Renders tile grid when locked; renders children when unlocked. |
| `FpPinPad` | `static/src/js/components/pin_pad.js` | Numeric pad modal. Used by FpTabletLock unlock AND Profile set-PIN flow. |
| `FpPinSetup` | `static/src/js/components/pin_setup.js` | Modal for set/change PIN. Wraps 3 instances of FpPinPad (old + new + confirm). |
| `FpIdleWarning` | `static/src/js/components/idle_warning.js` | Yellow-border + countdown toast component shown at T-30s. |
### 6.2 Activity tracker service
- Registered as `fp_shopfloor_activity` in the OWL `services` registry.
- Tracks `lastActiveAt` (epoch ms).
- Listens at document level: `pointerdown`, `touchstart`, `keydown`, `visibilitychange`.
- Public API: `bumpActivity()`, `getSecondsUntilLock()`, `subscribe(cb)`, `lock()`.
- Bumps server-side on every `ping` (debounced to once per 30s).
### 6.3 Auto-lock flow
```js
// inside FpTabletLock setup()
this.activity = useService("fp_shopfloor_activity");
this._tick = setInterval(() => {
const remaining = this.activity.getSecondsUntilLock();
if (remaining <= 0) {
this.state.locked = true;
this.state.currentTechId = null;
} else if (remaining <= this.warnThresholdSec) {
this.state.idleWarning = remaining;
} else if (this.state.idleWarning) {
this.state.idleWarning = null; // user tapped, reset
}
}, 1000);
```
### 6.4 RPC plumbing
A tiny client-side helper wraps `rpc()` so every shop-floor call automatically includes `tablet_tech_id`:
```js
// fp_rpc.js
import { rpc as baseRpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const techStore = registry.category("services").get("fp_shopfloor_tech_store");
export function fpRpc(url, params = {}) {
if (techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}
```
Landing, Workspace, Manager Dashboard switch from `rpc(...)` to `fpRpc(...)` for action calls. Read-only calls (load, tiles, kanban) don't need the kwarg.
### 6.5 Component composition
```
FpTabletLock (NEW outer wrapper, mounted by every client action)
├── if locked → FpPinPad (tile grid + entry)
├── if idle warning → FpIdleWarning overlay
└── else → existing client action (Landing | Workspace | Manager Dashboard)
+ Hand-Off button injected into existing headers
```
The "tablet locked" boolean lives in a shared OWL service (`fp_shopfloor_tech_store`) — every client action checks it on mount and subscribes for changes.
## 7. Edge cases
| Case | Handling |
|---|---|
| No tech has set a PIN yet | Tile shows "PIN required" overlay. Tap tile → guided "you must set a PIN before using this tablet" → set-PIN flow → unlock. |
| Manager just reset a tech's PIN | Tile still shows; tap → "PIN was cleared by a manager — set a new one" → set-PIN flow → unlock. |
| Tablet boots with no station paired | Tile grid shows + a "Pair this station" CTA. Station QR scan works before any tech is logged in. |
| Network drop mid-unlock | Spinner + Retry button after 5s. Backend tolerates duplicate unlocks (idempotent on success — counter just stays at 0). |
| Tech mid-step when tablet locks | Step timer keeps running on server. Auto-pause cron (Phase 2) is the upper-bound safety net. |
| Tech A's PIN locked for 5 min — can tech B unlock? | Yes. Lockout is per-user, not per-tablet. |
| Tech keeps tablet active by setting a heavy weight on it | Activity = pointer/touch/key events only, not mouse-move. A weight doesn't fire those events. Still locks after 5 min. |
| Tech is mid-RPC when lock fires | RPC completes (server keeps running). Response is dropped silently — UI is already showing the tile grid. |
| Two tabs / windows on the same browser | Each tab has its own FpTabletLock state. They lock independently. Acceptable for v1; not a real shop scenario. |
| Manager wants to act AS a tech | Out of scope. Manager unlocks with their own PIN; their actions carry their own uid. |
## 8. Testing
### 8.1 Python tests (`fusion_plating_shopfloor/tests/test_tablet_pin.py`)
| Test | Verifies |
|---|---|
| `test_set_pin_first_time` | User with no hash can set PIN; resulting hash is salted and length > 32. |
| `test_set_pin_change_requires_old` | Setting a new PIN when one exists requires correct old_pin; wrong old_pin rejected. |
| `test_unlock_correct_pin_resets_failure_count` | Failed → failed → correct → counter is 0. |
| `test_unlock_5_wrong_locks_user` | 5 wrong attempts → 6th returns `locked_until`. 7th still rejected. |
| `test_lockout_expires_after_threshold` | After 5 min sim time elapsed → next attempt allowed again. |
| `test_reset_pin_for_requires_manager` | Operator → AccessError. Supervisor → AccessError. Manager → success. |
| `test_reset_pin_clears_hash_and_posts_chatter` | After reset: hash is False, set_date is False, chatter has "PIN reset by Manager X". |
| `test_tiles_filtered_by_station_roster` | Station with authorised_user_ids → tiles is subset. Empty list → all operator-group users. |
| `test_audit_kwarg_used_in_step_finish` | RPC with `tablet_tech_id=N` → step's `write_uid == N` (not env.uid). |
| `test_audit_kwarg_invalid_falls_back_to_session` | Invalid `tablet_tech_id` → write_uid == env.uid, no error. |
### 8.2 Manual QA
`docs/qa/2026-05-22-shopfloor-pin-gate-qa.md` walkthrough:
1. Tech A sets PIN via Preferences
2. Tech A unlocks tablet → starts a step
3. 5 min idle elapses → tablet locks
4. Tech B unlocks → finishes Tech A's step
5. Audit chatter shows: started by A at T+0, finished by B at T+6
6. Manager taps Reset PIN on Tech A's res.users form
7. Tech A unlocks → set-PIN flow
8. Tech A fails PIN 5 times → lockout kicks in
9. Tech A waits 5 min → unlocks successfully
## 9. Build sequence (3 sub-phases)
Each ships independently and can be rolled back independently.
| Sub-phase | Ships | Independently deployable? |
|---|---|---|
| **6.1 — Backend** | model fields on res.users + station extras + ir.config_parameter defaults + 5 `/fp/tablet/*` endpoints + Profile prefs Set/Change PIN button + Manager Reset PIN button on res.users form | Yes — works silently behind the scenes. Techs can set PINs but the lock screen doesn't render yet. |
| **6.2 — Frontend lock screen** | FpTabletLock wrapper + FpPinPad + FpIdleWarning + activity tracker service + Hand-Off button injection into existing headers | Yes — lock screen goes live. Audit credit still defaults to tablet session user without 6.3. |
| **6.3 — Audit propagation** | `tablet_tech_id` optional kwarg on all existing action endpoints + `fpRpc()` wrapper + Landing/Workspace/Manager updated to use it | Yes — refines the audit trail. Without it, actions are recorded against the tablet's session uid. |
## 10. Backwards compatibility
- Any tablet that hasn't been upgraded to Phase 6.2 continues to work unauthenticated (no lock screen). Once 6.2 lands, ALL tablets start showing the lock screen.
- Endpoints from Phases 1-5 keep their existing signatures. `tablet_tech_id` is purely additive.
- Setting / changing the PIN is opt-in per user. A tech without a PIN sees a "set one to continue" prompt; they can't dismiss it.
- No model migration required — all new fields default to NULL.
- `ir.config_parameter` defaults are read at runtime, no install-time setup needed.
## 11. Rollback strategy
| Sub-phase | Rollback |
|---|---|
| 6.1 | Disable endpoints in `controllers/__init__.py`. Model fields are additive, safe to drop. |
| 6.2 | Hide `FpTabletLock` via a feature flag (`ir.config_parameter` `fp.shopfloor.tablet_lock_enabled`, default true; set false to bypass). Existing client actions render directly again. |
| 6.3 | Stop sending `tablet_tech_id` from `fpRpc()` — server falls back to `env.uid`. |
## 12. Out of scope for v1
- Biometric (Face ID, fingerprint)
- NFC badges
- TOTP / SMS / email-based reset
- "Remember me" cross-device sessions
- Per-tech idle threshold (only per-station + global)
- Lock-screen widgets (weather, time, KPIs) — keep the tile grid focused
- Camera-based presence / liveness
- Pre-fetched tile grid (each unlock call fetches fresh)
- Different PIN lengths per tech (4 digits for everyone)
## 13. Decisions log
| Decision | Rationale |
|---|---|
| 4-digit PIN over 6-digit | Speed. Industry norm. Lockout + per-user-fail-counter makes 10,000 combos secure enough. |
| PBKDF2-SHA256, 200k iterations | ~50ms verify on entech hardware. Safe against rainbow tables; brute-force-resistant even with DB stolen. |
| Hash field is manager-readable only | Operators can't even view other users' hash. Reduces lateral attack surface. |
| Per-user lockout, not per-tablet | A bad-actor wrong-PIN'ing one user shouldn't deny service to other techs on the same tablet. |
| 5 minute idle default | Compromise: long enough for legitimate idle-watching of a tank, short enough that a walk-away is caught. Configurable per-station. |
| Server-side step timer keeps running on lock | Locking is UI; nothing should pause physical processes. Auto-pause cron is the deeper safety net. |
| Single Odoo session, PIN overlay credits via `tablet_tech_id` kwarg | No JS bundle reload, no state loss, no flicker. Audit kwarg keeps the trail honest. |
| Manager-only reset (no self-service) | Plating shops rarely have per-tech email/SMS. Manager is always present. Lower infra. |
| 30s warning before lock | Compromise: catches "I was right there" cases without being annoyingly chatty. |
| `tablet_tech_id` is opt-in additive kwarg | Lets 6.3 ship after 6.2 without breaking anything; lets older callers continue working unchanged. |
## 14. Future v2 candidates
- NFC badge tap (cheap USB readers, ~$30)
- Personal QR badge on lanyard (no hardware beyond what we already have)
- Per-tech idle threshold (long-shift senior techs vs cross-trained probationers)
- Lock-screen KPIs (shop output today, hot WOs visible without unlocking)
- "Switch tech without re-PIN" — keep both signed in for hand-off audit on the same step
- Mobile app companion with biometric unlock
---
**Next step:** user reviews this spec. Once approved, transition to `superpowers:writing-plans` to produce the phased implementation plan.

View File

@@ -44,8 +44,6 @@
<field name="code">model._cron_autopause_stale_steps()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -1200,6 +1200,33 @@ class FpJobStep(models.Model):
# quick-look modal. The modal is bound via context= on the parent
# job form's <field name="step_ids"/> — no TransientModel needed.
# Job-level context for the quick-look modal — restored after commit
# b0070afc accidentally removed these while still referencing them in
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
# the 2026-05-22 Phase 1-4 deploy).
quick_look_partner_id = fields.Many2one(
'res.partner',
string='Customer',
related='job_id.partner_id',
readonly=True,
)
quick_look_part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
related='job_id.part_catalog_id',
readonly=True,
)
quick_look_qty = fields.Float(
string='Order Qty',
related='job_id.qty',
readonly=True,
)
quick_look_instruction_attachment_ids = fields.Many2many(
'ir.attachment',
string='Instruction Images',
related='recipe_node_id.instruction_attachment_ids',
readonly=True,
)
quick_look_instructions = fields.Html(
string='Operator Instructions',
related='recipe_node_id.description',

View File

@@ -9,20 +9,26 @@
site that needs to bring legacy menus back can simply add a
user to the group. -->
<!-- Reset group_ids on the 3 shopfloor menus that used to be
<!-- Reset group_ids on the shopfloor menus that used to be
hidden — they are now the canonical UIs and should be visible
to all users (subject to the original groups= attribute on
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<!-- Phase 3 tablet redesign (2026-05-22) — the standalone Plant
Overview menu is superseded by Workstation > All Plant toggle.
The fp_menu.xml record was removed but the database row
persists (Odoo doesn't auto-delete orphan records). Force-
delete here so the menu disappears from the Shop Floor tree.
The action record (action_fp_plant_overview) is kept and
retargeted to fp_shopfloor_landing for bookmark back-compat. -->
<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
(the bridge module is uninstalled and its menu xmlid no longer
resolves). fp.job has its own priority field on the header. -->

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.29.0.0',
'version': '19.0.30.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -45,7 +45,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_cron_data.xml',
'data/fp_tablet_config_data.xml',
'views/fp_shopfloor_station_views.xml',
'views/res_users_views.xml',
'views/fp_bake_oven_views.xml',
'views/fp_bake_window_views.xml',
'views/fp_first_piece_gate_views.xml',

View File

@@ -8,3 +8,4 @@ from . import tank_status
from . import move_controller
from . import workspace_controller
from . import landing_controller
from . import tablet_controller

View File

@@ -21,7 +21,7 @@ import logging
from markupsafe import Markup
from odoo import http
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request

View File

@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSON-RPC endpoints for the tablet PIN gate (Phase 6 tablet redesign).
POST /fp/tablet/tiles — list of tiles for the lock screen
POST /fp/tablet/unlock — verify PIN + clear/increment failure counter
POST /fp/tablet/set_pin — self-service set/change PIN
POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN
POST /fp/tablet/ping — bump server-side last-active timestamp
Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md
"""
import logging
from datetime import timedelta
from odoo import _, fields, http
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
def _is_manager(env):
"""True if calling user is in the fusion_plating manager group."""
return env.user.has_group('fusion_plating.group_fusion_plating_manager')
class FpTabletController(http.Controller):
"""Tablet PIN gate endpoints. All require an authenticated Odoo
session (the tablet logs in once as a 'shopfloor service' user).
"""
# ======================================================================
# /fp/tablet/set_pin — self-service set or change
# ======================================================================
@http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user')
def set_pin(self, new_pin, old_pin=None):
env = request.env
user = env.user
existing_hash = user.sudo().x_fc_tablet_pin_hash
if existing_hash:
if not old_pin:
return {'ok': False, 'error': _('Current PIN is required to change it.')}
if not user.verify_tablet_pin(old_pin):
return {'ok': False, 'error': _('Current PIN is incorrect.')}
try:
user.set_tablet_pin(new_pin)
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
_logger.info(
"Tablet PIN set/changed for uid %s by self", user.id,
)
return {'ok': True}
# ======================================================================
# /fp/tablet/reset_pin_for — manager-only
# ======================================================================
@http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user')
def reset_pin_for(self, user_id):
env = request.env
if not _is_manager(env):
_logger.warning(
"Non-manager uid %s attempted to reset PIN for user %s",
env.uid, user_id,
)
return {'ok': False, 'error': _('Manager privilege required.')}
target = env['res.users'].browse(int(user_id))
if not target.exists():
return {'ok': False, 'error': _('User not found.')}
target.clear_tablet_pin()
_logger.info(
"Tablet PIN reset for uid %s by manager uid %s",
target.id, env.uid,
)
return {'ok': True}
# ======================================================================
# /fp/tablet/unlock — verify PIN + manage failure counter / lockout
# ======================================================================
@http.route('/fp/tablet/unlock', type='jsonrpc', auth='user')
def unlock(self, user_id, pin):
env = request.env
Users = env['res.users'].sudo() # need sudo to read hash field
target = Users.browse(int(user_id))
if not target.exists():
return {'ok': False, 'error': _('User not found.')}
# No PIN set yet — caller must set one first
if not target.x_fc_tablet_pin_hash:
return {
'ok': False,
'error': _('No PIN set. Set one in Preferences first.'),
'needs_setup': True,
}
# Currently locked out?
now = fields.Datetime.now()
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
return {
'ok': False,
'error': _('Account locked. Try again in a few minutes.'),
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
}
if target.verify_tablet_pin(pin):
# Reset failure state on success
target.write({
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
_logger.info(
"Tablet unlocked by uid %s (session uid %s)",
target.id, env.uid,
)
return {
'ok': True,
'current_tech_id': target.id,
'current_tech_name': target.name,
}
# Wrong PIN — increment and check threshold
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}
if new_count >= threshold:
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
target.write(vals)
_logger.warning(
"Tablet PIN failure for uid %s (count=%d, locked=%s)",
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
)
if vals.get('x_fc_tablet_locked_until'):
return {
'ok': False,
'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min,
'locked_until': vals['x_fc_tablet_locked_until'].isoformat(),
}
return {
'ok': False,
'error': _('Incorrect PIN.'),
'attempts_remaining': threshold - new_count,
}
# ======================================================================
# /fp/tablet/tiles — lock-screen tile grid
# ======================================================================
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
def tiles(self, station_id=None):
env = request.env
op_group = env.ref(
'fusion_plating.group_fusion_plating_operator',
raise_if_not_found=False,
)
if not op_group:
return {'ok': False, 'error': 'operator group missing'}
# Determine candidate users — station roster wins if non-empty
users = op_group.user_ids
if station_id:
Station = env['fusion.plating.shopfloor.station']
station = Station.browse(int(station_id))
if (station.exists()
and 'x_fc_authorised_user_ids' in station._fields
and station.x_fc_authorised_user_ids):
users = station.x_fc_authorised_user_ids
# has_pin needs sudo-read on the hash field
clocked_ids = set()
if 'hr.employee' in env and hasattr(
env['hr.employee'], '_fp_clocked_in_user_ids',
):
clocked_ids = env['hr.employee']._fp_clocked_in_user_ids() or set()
users_sorted = users.sorted('name')
users_sudo = users_sorted.sudo()
tiles = []
for u, u_sudo in zip(users_sorted, users_sudo):
tiles.append({
'user_id': u.id,
'name': u.name,
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
'is_clocked_in': u.id in clocked_ids,
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
})
# Clocked-in first, then alphabetical within bucket
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
return {'ok': True, 'tiles': tiles}
# ======================================================================
# /fp/tablet/ping — heartbeat used by the OWL component on every action
# ======================================================================
@http.route('/fp/tablet/ping', type='jsonrpc', auth='user')
def ping(self, current_tech_id=None):
"""Lightweight heartbeat. Used by the OWL component to confirm
the server-side session is alive AND to log the tech-of-record
every few minutes so the server has forensic visibility into
which tech was 'driving' the tablet at any moment.
"""
if current_tech_id:
_logger.debug(
"Tablet ping: session uid %s carrying tablet_tech_id=%s",
request.env.uid, current_tech_id,
)
return {'ok': True, 'server_time': fields.Datetime.now().isoformat()}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 6 tablet PIN gate — default knobs.
All overridable via Settings → Technical → Parameters → System Parameters.
-->
<odoo noupdate="1">
<record id="ir_config_param_tablet_idle_lock_minutes" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_idle_lock_minutes</field>
<field name="value">5</field>
</record>
<record id="ir_config_param_tablet_pin_fail_threshold" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_pin_fail_threshold</field>
<field name="value">5</field>
</record>
<record id="ir_config_param_tablet_pin_fail_lockout_minutes" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_pin_fail_lockout_minutes</field>
<field name="value">5</field>
</record>
<record id="ir_config_param_tablet_warn_seconds_before_lock" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_warn_seconds_before_lock</field>
<field name="value">30</field>
</record>
</odoo>

View File

@@ -8,3 +8,4 @@ from . import fp_bake_window
from . import fp_first_piece_gate
from . import fp_operator_queue
from . import fp_tank
from . import res_users

View File

@@ -73,6 +73,26 @@ class FpShopfloorStation(models.Model):
string='Notes',
)
# Phase 6 tablet PIN gate — per-station roster + idle override.
x_fc_authorised_user_ids = fields.Many2many(
'res.users',
relation='fp_shopfloor_station_authorised_user_rel',
column1='station_id',
column2='user_id',
string='Authorised Operators',
help='If set, the tablet lock screen only shows tiles for these '
'users. Empty = all operator-group users are shown. Use to '
'restrict a tablet at a specialised station (e.g. EN Plating) '
'to techs trained on that station.',
)
x_fc_idle_lock_minutes = fields.Integer(
string='Idle Lock (minutes)',
help='Per-station override for the auto-lock idle threshold. '
'Leave blank to use the global default '
'(ir.config_parameter fp.shopfloor.tablet_idle_lock_minutes, '
'default 5).',
)
_sql_constraints = [
(
'fp_shopfloor_station_code_uniq',

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
hash; never plaintext.
"""
import hashlib
import secrets
from odoo import _, fields, models
from odoo.exceptions import UserError
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
# against brute-force even if the DB leaks.
_PBKDF2_ITERATIONS = 200_000
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_tablet_pin_hash = fields.Char(
string='Tablet PIN (hashed)',
groups='fusion_plating.group_fusion_plating_manager',
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
'non-managers; never logged.',
)
x_fc_tablet_pin_set_date = fields.Datetime(
string='Tablet PIN Set Date',
help='When the current PIN was last set or changed.',
)
x_fc_tablet_pin_failed_count = fields.Integer(
string='Failed PIN Attempts',
default=0,
help='Sequential failed unlock attempts since the last success. '
'Resets to 0 on a correct PIN.',
)
x_fc_tablet_locked_until = fields.Datetime(
string='Tablet Lockout Until',
help='Wall-clock time at which the per-user lockout expires. '
'Null when not locked. Set after the configured fail '
'threshold (default 5) is reached.',
)
@staticmethod
def _hash_tablet_pin(pin, salt=None):
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
)
return f"{salt.hex()}${digest.hex()}"
@staticmethod
def _verify_tablet_pin_hash(pin, stored):
"""Constant-time verify of `pin` against a stored hash string."""
if not stored or '$' not in stored:
return False
salt_hex, expected_hex = stored.split('$', 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
digest = hashlib.pbkdf2_hmac(
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
)
return secrets.compare_digest(digest.hex(), expected_hex)
def set_tablet_pin(self, pin):
"""Set or change this user's tablet PIN. Requires sudo OR self.
Caller is responsible for verifying the OLD pin separately if a
hash already exists — this method just writes the new one.
"""
self.ensure_one()
if not pin or not pin.isdigit() or len(pin) != 4:
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
self.sudo().write({
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
return True
def verify_tablet_pin(self, pin):
"""Return True if `pin` matches this user's stored hash."""
self.ensure_one()
if not pin:
return False
# sudo: even non-manager callers may need to verify their OWN PIN.
# The hash field has manager-only read; sudo bypasses that.
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
def clear_tablet_pin(self):
"""Manager-side reset. Clears hash so target must set a new PIN.
Posts to chatter for audit.
"""
self.ensure_one()
manager_name = self.env.user.name
self.sudo().write({
'x_fc_tablet_pin_hash': False,
'x_fc_tablet_pin_set_date': False,
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
self.message_post(
body=_('Tablet PIN reset by %s. User must set a new PIN '
'on next unlock attempt.') % manager_name,
)
return True
def action_open_tablet_pin_setup(self):
"""Trigger the FpPinSetup OWL modal from the Preferences form.
The Phase 6.2 OWL component intercepts this action tag.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_tablet_pin_setup',
'name': 'Set Tablet PIN',
'target': 'new',
}

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_workspace_controller
from . import test_landing_kanban
from . import test_tablet_pin

View File

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Phase 6 — Tablet PIN gate tests."""
import json
from odoo.tests.common import HttpCase, TransactionCase, tagged
def _rpc(case, url, **params):
"""Helper for HTTP/JSON-RPC tests — wraps Odoo's url_open."""
res = case.url_open(
url,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
)
return res.json()['result']
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletPinHash(TransactionCase):
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
def setUp(self):
super().setUp()
self.user = self.env['res.users'].create({
'name': 'Pin Test',
'login': 'pintest@example.com',
})
def test_set_pin_stores_salted_hash(self):
self.user.sudo().set_tablet_pin('1234')
stored = self.user.sudo().x_fc_tablet_pin_hash
self.assertTrue(stored)
self.assertIn('$', stored, 'hash must include salt separator')
# Hash is non-deterministic — setting same PIN twice gives different stored values
self.user.sudo().set_tablet_pin('1234')
self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash)
def test_verify_correct_pin(self):
self.user.sudo().set_tablet_pin('1234')
self.assertTrue(self.user.sudo().verify_tablet_pin('1234'))
def test_verify_wrong_pin(self):
self.user.sudo().set_tablet_pin('1234')
self.assertFalse(self.user.sudo().verify_tablet_pin('0000'))
def test_verify_pin_no_hash_set(self):
self.assertFalse(self.user.sudo().verify_tablet_pin('1234'))
def test_set_pin_rejects_invalid_format(self):
from odoo.exceptions import UserError
for bad in ('123', '12345', 'abcd', '', None):
with self.assertRaises(UserError):
self.user.sudo().set_tablet_pin(bad)
def test_clear_pin_wipes_hash_and_posts_chatter(self):
self.user.sudo().set_tablet_pin('1234')
self.user.clear_tablet_pin()
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash)
self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date)
# Check chatter
last_msg = self.user.message_ids[:1]
self.assertIn('reset by', (last_msg.body or '').lower())
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletSetPin(HttpCase):
"""P6.1.2 — /fp/tablet/set_pin endpoint."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
def test_set_pin_first_time(self):
res = _rpc(self, '/fp/tablet/set_pin', new_pin='1234')
self.assertTrue(res['ok'])
def test_set_pin_change_requires_old(self):
admin = self.env.ref('base.user_admin')
admin.set_tablet_pin('1234')
# Change without old — rejected
res = _rpc(self, '/fp/tablet/set_pin', new_pin='5678')
self.assertFalse(res['ok'])
# Change with wrong old — rejected
res = _rpc(self, '/fp/tablet/set_pin', old_pin='9999', new_pin='5678')
self.assertFalse(res['ok'])
# Change with correct old — accepted
res = _rpc(self, '/fp/tablet/set_pin', old_pin='1234', new_pin='5678')
self.assertTrue(res['ok'])
def test_set_pin_rejects_non_4_digit(self):
for bad in ('123', '12345', 'abcd', ''):
res = _rpc(self, '/fp/tablet/set_pin', new_pin=bad)
self.assertFalse(res['ok'], f'PIN {bad!r} should be rejected')
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletResetPinFor(HttpCase):
"""P6.1.2 — /fp/tablet/reset_pin_for endpoint."""
def setUp(self):
super().setUp()
self.target = self.env['res.users'].create({
'name': 'Reset Target', 'login': 'reset@example.com',
})
self.target.sudo().set_tablet_pin('1234')
def test_reset_requires_manager_group(self):
# Plain operator can't reset
op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
self.env['res.users'].create({
'name': 'Op', 'login': 'op@example.com',
'password': 'op@example.com',
'group_ids': [(6, 0, [op_group.id])],
})
self.authenticate('op@example.com', 'op@example.com')
res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id)
self.assertFalse(res['ok'])
def test_reset_as_admin_clears_hash(self):
self.authenticate('admin', 'admin')
res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id)
self.assertTrue(res['ok'])
self.target.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletUnlock(HttpCase):
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.target = self.env['res.users'].create({
'name': 'Unlock Target', 'login': 'unlock@example.com',
})
self.target.sudo().set_tablet_pin('1234')
def _unlock(self, pin):
return _rpc(self, '/fp/tablet/unlock',
user_id=self.target.id, pin=pin)
def test_unlock_correct_pin(self):
res = self._unlock('1234')
self.assertTrue(res['ok'])
self.assertEqual(res['current_tech_id'], self.target.id)
self.assertEqual(res['current_tech_name'], 'Unlock Target')
def test_unlock_correct_pin_resets_fail_counter(self):
self._unlock('0000') # fail once
self._unlock('1234') # succeed
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0)
def test_unlock_wrong_pin_increments_counter(self):
self._unlock('0000')
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1)
def test_lockout_after_5_fails(self):
for _ in range(5):
self._unlock('0000')
res = self._unlock('0000') # 6th
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
self.target.invalidate_recordset(['x_fc_tablet_locked_until'])
self.assertTrue(self.target.sudo().x_fc_tablet_locked_until)
def test_lockout_blocks_even_correct_pin(self):
for _ in range(5):
self._unlock('0000')
# Even the correct PIN now rejected
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
def test_unlock_no_pin_set(self):
self.target.clear_tablet_pin()
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertTrue(res.get('needs_setup'))
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletTiles(HttpCase):
"""P6.1.4 — /fp/tablet/tiles endpoint."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
self.alice = self.env['res.users'].create({
'name': 'Alice Tile', 'login': 'alice_tile@example.com',
'group_ids': [(6, 0, [self.op_group.id])],
})
self.bob = self.env['res.users'].create({
'name': 'Bob Tile', 'login': 'bob_tile@example.com',
'group_ids': [(6, 0, [self.op_group.id])],
})
self.alice.sudo().set_tablet_pin('1111')
def test_tiles_returns_all_operators_without_station(self):
res = _rpc(self, '/fp/tablet/tiles')
self.assertTrue(res['ok'])
names = [t['name'] for t in res['tiles']]
self.assertIn('Alice Tile', names)
self.assertIn('Bob Tile', names)
def test_tile_has_pin_flag(self):
res = _rpc(self, '/fp/tablet/tiles')
alice_tile = next(t for t in res['tiles'] if t['name'] == 'Alice Tile')
bob_tile = next(t for t in res['tiles'] if t['name'] == 'Bob Tile')
self.assertTrue(alice_tile['has_pin'])
self.assertFalse(bob_tile['has_pin'])

View File

@@ -49,6 +49,13 @@
<field name="active"/>
</group>
</group>
<group string="Tablet PIN Gate (Phase 6)">
<field name="x_fc_authorised_user_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
<field name="x_fc_idle_lock_minutes"
placeholder="default 5 (system parameter)"/>
</group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 6 tablet PIN gate — surfaces:
(a) self-service "Set/Change PIN" on the user's Preferences form
(b) manager-only "Reset Tablet PIN" header button on res.users form
-->
<odoo>
<!-- =================================================================
(a) Preferences form — Set/Change Tablet PIN
The actual modal is OWL-side (FpPinSetup, Phase 6.2). Here we
just surface a status indicator + the button under a group.
================================================================= -->
<record id="view_users_form_preferences_tablet_pin" model="ir.ui.view">
<field name="name">res.users.preferences.tablet.pin</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Tablet PIN" name="fp_tablet_pin_group">
<field name="x_fc_tablet_pin_set_date" readonly="1"
string="PIN Last Set"/>
<button name="action_open_tablet_pin_setup"
type="object"
string="Set / Change Tablet PIN"
class="btn-secondary"
icon="fa-key"/>
</group>
</xpath>
</field>
</record>
<!-- =================================================================
(b) res.users form — Manager-only "Reset Tablet PIN" header
================================================================= -->
<record id="view_users_form_reset_tablet_pin" model="ir.ui.view">
<field name="name">res.users.form.reset.tablet.pin</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="clear_tablet_pin"
type="object"
string="Reset Tablet PIN"
class="btn-warning"
icon="fa-eraser"
groups="fusion_plating.group_fusion_plating_manager"
invisible="not x_fc_tablet_pin_set_date"
confirm="Reset this user's tablet PIN? They'll need to set a new one on their next unlock."/>
<field name="x_fc_tablet_pin_set_date" invisible="1"/>
</xpath>
</field>
</record>
</odoo>