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:
Binary file not shown.
120
app/main.py
120
app/main.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user