From 9f3edd60ae3c697cff41553374d319c9448ddc56 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 12:29:22 -0400 Subject: [PATCH] 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) --- .../models/__init__.py | 1 + .../models/fp_tablet_session_event.py | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py diff --git a/fusion_plating/fusion_plating_shopfloor/models/__init__.py b/fusion_plating/fusion_plating_shopfloor/models/__init__.py index 5bc602ee..286a1ecc 100644 --- a/fusion_plating/fusion_plating_shopfloor/models/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/models/__init__.py @@ -10,3 +10,4 @@ from . import fp_operator_queue from . import fp_tank from . import res_users from . import res_config_settings +from . import fp_tablet_session_event diff --git a/fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py b/fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py new file mode 100644 index 00000000..9fac6412 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/models/fp_tablet_session_event.py @@ -0,0 +1,90 @@ +# -*- 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. + +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)