From d3d89c7a5f6503541add271e471f8a9d7af75cec Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:10:36 -0500 Subject: [PATCH] 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 --- app/__pycache__/main.cpython-313.pyc | Bin 108690 -> 112351 bytes app/main.py | 120 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 64d47aba8ab2b8ed8ad3ca813646b988d385ed69..11e33a9d9fefd1f9b212623cd377c11a516932f4 100644 GIT binary patch delta 12792 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 diff --git a/app/main.py b/app/main.py index f6e1e82..37b4256 100644 --- a/app/main.py +++ b/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: