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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user