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>
This commit is contained in:
gsinghpal
2026-05-24 12:29:22 -04:00
parent 0b92294586
commit 9f3edd60ae
2 changed files with 91 additions and 0 deletions

View File

@@ -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

View File

@@ -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)