feat(logging): structured audit logs for ledger CRUD with user, keys, pre/post balances\n\n- Add compute_case_totals_for_case_id and helpers to extract ledger keys\n- Instrument ledger_create/update/delete to emit ledger_audit with deltas\n- Preserve existing event logs (ledger_create/update/delete)\n- Verified in Docker; smoke tests pass

This commit is contained in:
HotSwapp
2025-10-07 17:10:36 -05:00
parent e07a4fda1c
commit d3d89c7a5f
2 changed files with 120 additions and 0 deletions

Binary file not shown.

View File

@@ -940,6 +940,87 @@ def compute_case_totals_from_case(case_obj: Case) -> Dict[str, float]:
}
def compute_case_totals_for_case_id(db: Session, case_id: int) -> Dict[str, float]:
"""
Compute billed, unbilled, and overall totals for a case by ID.
This uses a simple in-Python aggregation over the case's transactions to
avoid SQL portability issues and to keep the logic consistent with
compute_case_totals_from_case.
"""
billed_total = 0.0
unbilled_total = 0.0
overall_total = 0.0
transactions: List[Transaction] = (
db.query(Transaction).filter(Transaction.case_id == case_id).all()
)
for t in transactions:
amt = float(t.amount) if t.amount is not None else 0.0
overall_total += amt
if ((t.billed or '').upper()) == 'Y':
billed_total += amt
else:
unbilled_total += amt
return {
'billed_total': round(billed_total, 2),
'unbilled_total': round(unbilled_total, 2),
'overall_total': round(overall_total, 2),
}
def _ledger_keys_from_tx(tx: Optional["Transaction"]) -> Dict[str, Any]:
"""
Extract identifying keys for a ledger transaction for audit logs.
"""
if tx is None:
return {}
return {
'transaction_id': getattr(tx, 'id', None),
'case_id': getattr(tx, 'case_id', None),
'item_no': getattr(tx, 'item_no', None),
'transaction_date': getattr(tx, 'transaction_date', None),
't_code': getattr(tx, 't_code', None),
't_type_l': getattr(tx, 't_type_l', None),
'employee_number': getattr(tx, 'employee_number', None),
'billed': getattr(tx, 'billed', None),
'amount': getattr(tx, 'amount', None),
}
def _log_ledger_audit(
*,
action: str,
user: "User",
case_id: int,
keys: Dict[str, Any],
pre: Dict[str, float],
post: Dict[str, float],
) -> None:
"""
Emit a structured audit log line for ledger mutations including user, action,
identifiers, and pre/post balances with deltas.
"""
delta = {
'billed_total': round((post.get('billed_total', 0.0) - pre.get('billed_total', 0.0)), 2),
'unbilled_total': round((post.get('unbilled_total', 0.0) - pre.get('unbilled_total', 0.0)), 2),
'overall_total': round((post.get('overall_total', 0.0) - pre.get('overall_total', 0.0)), 2),
}
logger.info(
"ledger_audit",
action=action,
user_id=getattr(user, 'id', None),
user_username=getattr(user, 'username', None),
case_id=case_id,
keys=keys,
pre_balances=pre,
post_balances=post,
delta_balances=delta,
)
@app.post("/case/{case_id}/ledger")
async def ledger_create(
request: Request,
@@ -952,6 +1033,9 @@ async def ledger_create(
form = await request.form()
# Pre-mutation totals for audit
pre_totals = compute_case_totals_for_case_id(db, case_id)
# Validate
errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"),
@@ -1000,6 +1084,16 @@ async def ledger_create(
)
db.add(tx)
db.commit()
# Post-mutation totals and audit log
post_totals = compute_case_totals_for_case_id(db, case_id)
_log_ledger_audit(
action="create",
user=user,
case_id=case_id,
keys=_ledger_keys_from_tx(tx),
pre=pre_totals,
post=post_totals,
)
logger.info("ledger_create", case_id=case_id, transaction_id=tx.id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e:
@@ -1027,6 +1121,9 @@ async def ledger_update(
request.session["case_update_errors"] = ["Ledger entry not found"]
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
# Pre-mutation totals for audit
pre_totals = compute_case_totals_for_case_id(db, case_id)
errors, parsed = validate_ledger_fields(
transaction_date=form.get("transaction_date"),
t_code=form.get("t_code"),
@@ -1058,6 +1155,16 @@ async def ledger_update(
tx.description = (form.get("description") or "").strip() or None
db.commit()
# Post-mutation totals and audit log
post_totals = compute_case_totals_for_case_id(db, case_id)
_log_ledger_audit(
action="update",
user=user,
case_id=case_id,
keys=_ledger_keys_from_tx(tx),
pre=pre_totals,
post=post_totals,
)
logger.info("ledger_update", case_id=case_id, transaction_id=tx.id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e:
@@ -1084,8 +1191,21 @@ async def ledger_delete(
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
try:
# Capture pre-mutation totals and keys for audit before deletion
pre_totals = compute_case_totals_for_case_id(db, case_id)
tx_keys = _ledger_keys_from_tx(tx)
db.delete(tx)
db.commit()
# Post-mutation totals and audit log
post_totals = compute_case_totals_for_case_id(db, case_id)
_log_ledger_audit(
action="delete",
user=user,
case_id=case_id,
keys=tx_keys,
pre=pre_totals,
post=post_totals,
)
logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id)
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
except Exception as e: