Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,278 @@
|
||||
r"""
|
||||
Stage 3 (EEWA-33, spec 2026-10-22): the Dashboard's banner for a Run that
|
||||
ended badly, and Started By in `dbo.CV_ChargeSummary`.
|
||||
|
||||
python tests\test_run_alerts.py
|
||||
|
||||
* **Banner**: the latest Run ended in a Run Error, or without a record
|
||||
(killed) → a red banner naming when, who, the step and a link to the
|
||||
Run. Done, Stopped and Running → no banner.
|
||||
* **started_by** is written only once its column exists — a table not yet
|
||||
updated must never cost a Run its rows — and the Portal shows it in a
|
||||
claim's side panel; a Portal reading a table without it says how to fix
|
||||
that.
|
||||
|
||||
Claim numbers here are made up.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import contextlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(ROOT / "tests"))
|
||||
|
||||
from ecw_bot import settings as cfg # noqa: E402
|
||||
from ecw_bot.core import results_db as rdb # noqa: E402
|
||||
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
|
||||
from web import runs as run_view # noqa: E402
|
||||
from web.repo import SqlResultsRepo # noqa: E402
|
||||
from test_web_app import (FakeRepo, FakeRunner, signed_in, _row, # noqa: E402
|
||||
_quiet, DAY)
|
||||
from test_results_db import _row as _sheet_row, _sheet # noqa: E402
|
||||
|
||||
CASES = []
|
||||
|
||||
|
||||
class DbError(Exception):
|
||||
"""A pyodbc error: SQLSTATE first, as the driver gives it."""
|
||||
|
||||
|
||||
def case(fn):
|
||||
CASES.append(fn)
|
||||
return fn
|
||||
|
||||
|
||||
def ev(t, kind, **f):
|
||||
return {"t": f"2026-10-22T{t}.000", "kind": kind, **f}
|
||||
|
||||
|
||||
START = ev("14:00:00", "run_start", source="portal", started_by="olu",
|
||||
mode="VERIFY", claim_limit=2, test_sheet="", pid=1, log="x")
|
||||
|
||||
|
||||
def _logs(*runs):
|
||||
d = pathlib.Path(tempfile.mkdtemp())
|
||||
for run_id, events in runs:
|
||||
(d / f"events_{run_id}.jsonl").write_text(
|
||||
"".join(json.dumps(e) + "\n" for e in events), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
ERRORED = [START, ev("14:00:05", "queue", claims=["9001", "9002"]),
|
||||
ev("14:00:06", "claim_start", n=1, total=2, claim="9001"),
|
||||
ev("14:01:00", "run_error", step="claims", exit_code=5,
|
||||
message="Target closed", traceback=""),
|
||||
ev("14:01:01", "run_end", exit_code=5)]
|
||||
DONE = [{**START, "t": "2026-10-22T15:00:00.000"},
|
||||
{"t": "2026-10-22T15:01:00.000", "kind": "run_end", "exit_code": 0}]
|
||||
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dashboard(logs, runner=None):
|
||||
client, _ = signed_in(FakeRepo(), runner or FakeRunner(), logs_dir=logs)
|
||||
return client.get("/").text
|
||||
|
||||
|
||||
@case
|
||||
def a_run_error_last_shows_the_banner_with_who_what_and_a_link():
|
||||
page = _dashboard(_logs(("20261022_140000", ERRORED)))
|
||||
assert 'id="last-run-alert"' in page, page[:500]
|
||||
assert "ended in a Run Error" in page and "started by olu" in page
|
||||
assert "claims (exit 5): Target closed" in page
|
||||
assert "1 claim(s) not reached" in page
|
||||
assert 'href="/runs/20261022_140000"' in page
|
||||
|
||||
|
||||
@case
|
||||
def a_finished_run_after_the_error_clears_the_banner():
|
||||
page = _dashboard(_logs(("20261022_140000", ERRORED),
|
||||
("20261022_150000", DONE)))
|
||||
assert 'id="last-run-alert"' not in page
|
||||
|
||||
|
||||
@case
|
||||
def a_killed_run_shows_the_banner_a_stopped_or_running_one_does_not():
|
||||
killed = ERRORED[:3]
|
||||
page = _dashboard(_logs(("20261022_140000", killed)))
|
||||
assert "ended without a record" in page
|
||||
stopped = killed + [ev("14:02:00", "stopped", stopped_by="asha")]
|
||||
assert 'id="last-run-alert"' not in _dashboard(
|
||||
_logs(("20261022_140000", stopped)))
|
||||
runner = FakeRunner(running=True)
|
||||
st = runner._st
|
||||
runner._st = lambda: {**st(),
|
||||
"log_path": r"D:\x\Logs\run_20261022_140000.log"}
|
||||
assert 'id="last-run-alert"' not in _dashboard(
|
||||
_logs(("20261022_140000", killed)), runner)
|
||||
|
||||
|
||||
@case
|
||||
def no_runs_no_banner_and_the_latest_is_read_alone():
|
||||
assert 'id="last-run-alert"' not in _dashboard(_logs())
|
||||
d = _logs(("20261022_140000", ERRORED), ("20261022_150000", DONE))
|
||||
assert run_view.latest_run(d)["run_id"] == "20261022_150000"
|
||||
assert run_view.latest_run(_logs()) is None
|
||||
|
||||
|
||||
# ── started_by in the results table ───────────────────────────────────────────
|
||||
|
||||
class ColCursor:
|
||||
def __init__(self, log, has):
|
||||
self.log, self.has = log, has
|
||||
|
||||
def execute(self, sql, params):
|
||||
self.log.append(("execute", sql, list(params)))
|
||||
if self.has == "raise":
|
||||
raise RuntimeError("no permission")
|
||||
return self
|
||||
|
||||
def fetchone(self):
|
||||
return (100,) if self.has else (None,)
|
||||
|
||||
def executemany(self, sql, params):
|
||||
self.log.append(("executemany", sql, list(params)))
|
||||
|
||||
|
||||
class ColConn:
|
||||
def __init__(self, has):
|
||||
self.log, self.has = [], has
|
||||
|
||||
def cursor(self):
|
||||
return ColCursor(self.log, self.has)
|
||||
|
||||
def commit(self):
|
||||
self.log.append(("commit",))
|
||||
|
||||
def close(self):
|
||||
self.log.append(("close",))
|
||||
|
||||
|
||||
def _records():
|
||||
return [rdb.to_record(dict(zip(_sheet(_sheet_row())[0],
|
||||
_sheet(_sheet_row())[1])),
|
||||
run_at=None, run_mode="VERIFY", details=None)]
|
||||
|
||||
|
||||
def _insert(conn):
|
||||
return next(e for e in conn.log if e[0] == "executemany")
|
||||
|
||||
|
||||
@case
|
||||
def started_by_is_written_when_the_column_exists():
|
||||
conn = ColConn(has=True)
|
||||
assert rdb.insert_rows(conn, _records(), started_by="olu") == 1
|
||||
_, sql, params = _insert(conn)
|
||||
assert sql.split("(")[1].split(")")[0].endswith("details_json, "
|
||||
"started_by"), sql
|
||||
assert params[0][-1] == "olu" and len(params[0]) == len(rdb.DB_COLUMNS) + 1
|
||||
check = conn.log[0]
|
||||
assert check[1] == "SELECT COL_LENGTH(?, ?)", check
|
||||
assert check[2] == [rdb.TABLE, "started_by"], "parameterized"
|
||||
|
||||
|
||||
@case
|
||||
def a_table_without_the_column_still_gets_its_rows():
|
||||
for has in (False, "raise"):
|
||||
conn = ColConn(has=has)
|
||||
assert rdb.insert_rows(conn, _records(), started_by="olu") == 1
|
||||
_, sql, params = _insert(conn)
|
||||
assert "started_by" not in sql and len(params[0]) == \
|
||||
len(rdb.DB_COLUMNS), has
|
||||
|
||||
|
||||
@case
|
||||
def no_started_by_no_check():
|
||||
conn = ColConn(has=True)
|
||||
rdb.insert_rows(conn, _records())
|
||||
assert [e[0] for e in conn.log] == ["executemany", "commit"], conn.log
|
||||
|
||||
|
||||
@case
|
||||
def the_flow_saves_started_by_with_the_rows():
|
||||
seen = {}
|
||||
saved = (rdb.save_summary, cfg.RESULTS_DB, flow.charge_summary.build)
|
||||
os.environ["ECW_STARTED_BY"] = "olu"
|
||||
rdb.save_summary = lambda *a, **k: seen.update(k)
|
||||
flow.charge_summary.build = lambda *a: [["CLAIMS#"], ["9001"]]
|
||||
cfg.RESULTS_DB = True
|
||||
try:
|
||||
info = {"ids": ["9001"], "file": None}
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
flow._save_results_db(info, {"9001": {"Category": "Pass"}},
|
||||
{"9001": {}})
|
||||
finally:
|
||||
rdb.save_summary, cfg.RESULTS_DB, flow.charge_summary.build = saved
|
||||
del os.environ["ECW_STARTED_BY"]
|
||||
assert seen.get("started_by") == "olu", seen
|
||||
|
||||
|
||||
@case
|
||||
def the_portal_reads_and_shows_started_by():
|
||||
class Cur:
|
||||
def __init__(self, log):
|
||||
self.log, self.description = log, [("claim_no",)]
|
||||
|
||||
def execute(self, sql, params):
|
||||
self.log.append(sql)
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
class Conn:
|
||||
def __init__(self):
|
||||
self.log = []
|
||||
|
||||
def cursor(self):
|
||||
return Cur(self.log)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
conn = Conn()
|
||||
SqlResultsRepo("DSN=x", connect=lambda cs, timeout: conn).day_results(DAY)
|
||||
assert "started_by" in conn.log[0], conn.log[0]
|
||||
row = _row("9000001", "Pass", 1)
|
||||
row["started_by"] = "olu"
|
||||
client, _ = signed_in(FakeRepo(rows=[row]), FakeRunner())
|
||||
page = client.get(f"/day/{DAY.isoformat()}").text
|
||||
assert '["Started By", "olu"]' in page, "a field of the side panel"
|
||||
|
||||
|
||||
@case
|
||||
def a_table_without_the_column_says_run_the_script_again():
|
||||
client, _ = signed_in(FakeRepo(fail=DbError("42S22", "Invalid column "
|
||||
"name 'started_by'")),
|
||||
FakeRunner())
|
||||
r = _quiet(client.get, "/days")
|
||||
assert r.status_code == 503 and "needs an update" in r.text, r.text
|
||||
assert "create_tables.sql" in r.text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = 0
|
||||
for fn in CASES:
|
||||
name = fn.__name__.replace("_", " ")
|
||||
try:
|
||||
fn()
|
||||
print(f" ok {name}")
|
||||
except AssertionError as exc:
|
||||
print(f" FAIL {name}: {exc}")
|
||||
failures += 1
|
||||
except Exception as exc:
|
||||
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
failures += 1
|
||||
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user