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")
|
@app.post("/case/{case_id}/ledger")
|
||||||
async def ledger_create(
|
async def ledger_create(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -952,6 +1033,9 @@ async def ledger_create(
|
|||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|
||||||
|
# Pre-mutation totals for audit
|
||||||
|
pre_totals = compute_case_totals_for_case_id(db, case_id)
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
errors, parsed = validate_ledger_fields(
|
errors, parsed = validate_ledger_fields(
|
||||||
transaction_date=form.get("transaction_date"),
|
transaction_date=form.get("transaction_date"),
|
||||||
@@ -1000,6 +1084,16 @@ async def ledger_create(
|
|||||||
)
|
)
|
||||||
db.add(tx)
|
db.add(tx)
|
||||||
db.commit()
|
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)
|
logger.info("ledger_create", case_id=case_id, transaction_id=tx.id)
|
||||||
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1027,6 +1121,9 @@ async def ledger_update(
|
|||||||
request.session["case_update_errors"] = ["Ledger entry not found"]
|
request.session["case_update_errors"] = ["Ledger entry not found"]
|
||||||
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
|
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(
|
errors, parsed = validate_ledger_fields(
|
||||||
transaction_date=form.get("transaction_date"),
|
transaction_date=form.get("transaction_date"),
|
||||||
t_code=form.get("t_code"),
|
t_code=form.get("t_code"),
|
||||||
@@ -1058,6 +1155,16 @@ async def ledger_update(
|
|||||||
tx.description = (form.get("description") or "").strip() or None
|
tx.description = (form.get("description") or "").strip() or None
|
||||||
|
|
||||||
db.commit()
|
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)
|
logger.info("ledger_update", case_id=case_id, transaction_id=tx.id)
|
||||||
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1084,8 +1191,21 @@ async def ledger_delete(
|
|||||||
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
|
return RedirectResponse(url=f"/case/{case_id}", status_code=302)
|
||||||
|
|
||||||
try:
|
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.delete(tx)
|
||||||
db.commit()
|
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)
|
logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id)
|
||||||
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user