Release v4928.1.4.2 stable
This commit is contained in:
108
app/operator_audit_service.py
Normal file
108
app/operator_audit_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Lightweight operator audit helpers.
|
||||
|
||||
v4.8 records sensitive/manual operator actions without adding a new table.
|
||||
The durable audit stream is stored in business_events with event_type
|
||||
``operator_action`` and, when a task_id is present, mirrored to task_events.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False)
|
||||
|
||||
|
||||
def record_operator_action(
|
||||
*,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: Optional[str] = None,
|
||||
actor: str = "operator",
|
||||
task_id: Optional[str] = None,
|
||||
opportunity_id: Optional[str] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
contact_id: Optional[str] = None,
|
||||
before: Optional[Dict[str, Any]] = None,
|
||||
after: Optional[Dict[str, Any]] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Record a manual/sensitive operator action.
|
||||
|
||||
Audit SQL invariant: event_type = 'operator_action'.
|
||||
|
||||
This intentionally avoids a schema migration. ``business_events.task_id`` is
|
||||
nullable, so it can hold system/outbox/admin events as a generic audit log.
|
||||
"""
|
||||
action = str(action or "operator_action").strip() or "operator_action"
|
||||
entity_type = str(entity_type or "unknown").strip() or "unknown"
|
||||
audit_payload = {
|
||||
"action": action,
|
||||
"entity_type": entity_type,
|
||||
"entity_id": str(entity_id or task_id or opportunity_id or ""),
|
||||
"actor": str(actor or "operator"),
|
||||
"opportunity_id": opportunity_id,
|
||||
"before": before or {},
|
||||
"after": after or {},
|
||||
**(payload or {}),
|
||||
}
|
||||
|
||||
task_uuid = task_id if task_id else None
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO business_events (
|
||||
event_type,
|
||||
task_id,
|
||||
conversation_id,
|
||||
contact_id,
|
||||
payload,
|
||||
created_by
|
||||
)
|
||||
VALUES (
|
||||
'operator_action',
|
||||
CAST(:task_id AS UUID),
|
||||
:conversation_id,
|
||||
:contact_id,
|
||||
CAST(:payload AS JSONB),
|
||||
:created_by
|
||||
)
|
||||
"""), {
|
||||
"task_id": task_uuid,
|
||||
"conversation_id": conversation_id,
|
||||
"contact_id": contact_id,
|
||||
"payload": _json(audit_payload),
|
||||
"created_by": str(actor or "operator"),
|
||||
})
|
||||
|
||||
if task_id:
|
||||
conn.execute(text("""
|
||||
INSERT INTO task_events (
|
||||
task_id,
|
||||
event_type,
|
||||
payload,
|
||||
created_by
|
||||
)
|
||||
VALUES (
|
||||
CAST(:task_id AS UUID),
|
||||
'operator_action',
|
||||
CAST(:payload AS JSONB),
|
||||
:created_by
|
||||
)
|
||||
"""), {
|
||||
"task_id": task_id,
|
||||
"payload": _json(audit_payload),
|
||||
"created_by": str(actor or "operator"),
|
||||
})
|
||||
|
||||
|
||||
def record_operator_action_best_effort(**kwargs: Any) -> None:
|
||||
"""Audit helper that must never break the operational action itself."""
|
||||
try:
|
||||
record_operator_action(**kwargs)
|
||||
except Exception as exc: # pragma: no cover - defensive logging only
|
||||
print(f"ClientFlow operator audit failed: {exc}", flush=True)
|
||||
Reference in New Issue
Block a user