feat(audit): expose structured before/after evidence
audit_events already stored before_json/after_json, but the API and UI only ever surfaced metadata -- the audit trail could say something happened but never show what changed. Add before/after to AuditEventOut, resolve a safe entity_ref/entity_link for vehicle/booking/data-quality-issue entities (customer stays label-only; no customer detail route exists in this PoC), and render a human-readable change summary in the UI with the raw before/after/metadata JSON kept behind a <details> disclosure rather than shown by default.
This commit is contained in:
@@ -1,15 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import AuditEventOut, CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||
|
||||
# Only entity types with a stable public reference and (optionally) a real frontend route
|
||||
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
|
||||
# entity_id and are left as plain labels.
|
||||
_ENTITY_MODELS: dict[str, Any] = {
|
||||
"vehicle": Vehicle,
|
||||
"booking": Booking,
|
||||
"customer": Customer,
|
||||
"data_quality_issue": DataQualityIssue,
|
||||
}
|
||||
_ROUTE_TEMPLATES: dict[str, str] = {
|
||||
"vehicle": "/vehicles/{ref}",
|
||||
"booking": "/bookings/{ref}",
|
||||
"data_quality_issue": "/data-quality/{ref}",
|
||||
# No customer detail route exists in this proof of concept; still resolve the
|
||||
# reference for display, just without a link.
|
||||
}
|
||||
|
||||
|
||||
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
|
||||
ids_by_type: dict[str, set[uuid.UUID]] = {}
|
||||
for event in events:
|
||||
if event.entity_id is not None and event.entity_type in _ENTITY_MODELS:
|
||||
ids_by_type.setdefault(event.entity_type, set()).add(event.entity_id)
|
||||
|
||||
refs: dict[uuid.UUID, str] = {}
|
||||
for entity_type, ids in ids_by_type.items():
|
||||
model = _ENTITY_MODELS[entity_type]
|
||||
rows: Sequence[Any] = db.scalars(select(model).where(model.id.in_(ids))).all()
|
||||
for row in rows:
|
||||
refs[row.id] = row.public_ref
|
||||
return refs
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditEventOut])
|
||||
def list_audit_events(
|
||||
@@ -31,17 +71,27 @@ def list_audit_events(
|
||||
if correlation_id:
|
||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||
events = db.scalars(stmt).all()
|
||||
return [
|
||||
AuditEventOut(
|
||||
id=str(e.id),
|
||||
actor_type=e.actor_type,
|
||||
actor_label=e.actor_label,
|
||||
action=e.action,
|
||||
entity_type=e.entity_type,
|
||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||
correlation_id=str(e.correlation_id),
|
||||
occurred_at=e.occurred_at,
|
||||
metadata=e.metadata_json,
|
||||
entity_refs = _resolve_entity_refs(db, events)
|
||||
|
||||
out = []
|
||||
for e in events:
|
||||
ref = entity_refs.get(e.entity_id) if e.entity_id else None
|
||||
route = _ROUTE_TEMPLATES.get(e.entity_type)
|
||||
out.append(
|
||||
AuditEventOut(
|
||||
id=str(e.id),
|
||||
actor_type=e.actor_type,
|
||||
actor_label=e.actor_label,
|
||||
action=e.action,
|
||||
entity_type=e.entity_type,
|
||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||
entity_ref=ref,
|
||||
entity_link=route.format(ref=ref) if route and ref else None,
|
||||
correlation_id=str(e.correlation_id),
|
||||
occurred_at=e.occurred_at,
|
||||
before=e.before_json,
|
||||
after=e.after_json,
|
||||
metadata=e.metadata_json,
|
||||
)
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user