Files
provenance/backend/app/services/audit.py
T
justin 0262ed3d97 Account menu + Settings (change password); per-tree home person; full-width tree
- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with
  Settings and Sign out. New /settings page: account info + change password
  (POST /auth/change-password, re-verifies current password). Export/restore/
  delete are stubbed there for the next pass.
- Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/
  Read; the tree and family views open focused on it; the person page gets a
  "Set as default" control and "Default person" badge. Cleared if that person
  is deleted. Complements the account-level "this is me" link.
- Tree visualization now fills the content area (AppShell drops the max-width
  column on the /tree route); other pages stay centered.
- Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields
  like home_person_id audits cleanly.

50 backend tests pass; migration up/down verified; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:05:04 -04:00

47 lines
1.3 KiB
Python

"""Audit logging. Every mutation records an append-only AuditEntry attributing
the change to a User (or the assistant principal acting for a User). Staged on
the session; the caller commits as part of its unit of work.
"""
import json
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditEntry
from app.models.enums import AuditActorType
def _json_safe(d: dict | None) -> dict | None:
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
it lands in the JSON audit column regardless of what the caller passed."""
if d is None:
return None
return json.loads(json.dumps(d, default=str))
def record_audit(
session: AsyncSession,
*,
action: str,
entity_type: str,
entity_id: uuid.UUID | None = None,
tree_id: uuid.UUID | None = None,
actor_user_id: uuid.UUID | None = None,
actor_type: AuditActorType = AuditActorType.user,
before: dict | None = None,
after: dict | None = None,
) -> AuditEntry:
entry = AuditEntry(
action=action,
entity_type=entity_type,
entity_id=entity_id,
tree_id=tree_id,
actor_user_id=actor_user_id,
actor_type=actor_type,
before=_json_safe(before),
after=_json_safe(after),
)
session.add(entry)
return entry