109 lines
3.4 KiB
Python
109 lines
3.4 KiB
Python
"""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)
|