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:
ManikandanandClaude Opus 5.5 committed 2026-10-23 18:38:17 +05:30
commit ba15a574ab
205 files changed
+56772

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Results web page for the eCW Charge Validation bot — see web/app.py."""
+689
View File
@@ -0,0 +1,689 @@
r"""
eCW Charge Validation — results portal (2026-10-08; redesigned 2026-10-19,
spec docs/superpowers/specs/2026-10-19-results-portal-redesign-design.md).
Reads `Charge_Validation.dbo.CV_ChargeSummary` (written by the bot when
RESULTS_DB=1). Four pages: the Dashboard (`/` — Bot run card, today's
counts, Find a claim, Claim status), Claim status (`/days` — one card per
day a run started), one day (`/day/YYYY-MM-DD` — every run of that day,
a short table, each claim's fields in a side panel) and a claim's history
(`/claim?claim_no=`). Old `/?run_at=` links go to that run's day.
run_web.bat
python -m uvicorn web.app:app --host 127.0.0.1 --port 8000
Bound to 127.0.0.1 only: the rows hold PHI (patient, account number), so
the page is not reachable from the network. Read-only for the results; it
can also start a VERIFY run of the bot and stop it (2026-10-14,
`bot_runner.py`), each POST checked for this page's Origin and token. JSON
for the later portal: /api/runs, /api/results.
Hardening (review, 2026-10-12): only the Host names 127.0.0.1 / localhost
are answered (a web page elsewhere whose name resolves to 127.0.0.1 — DNS
rebinding — gets a 400, not the PHI); every response is `no-store` so no
browser or proxy keeps a copy; a database error is reported by its kind
(not reachable / login / table missing / permission), never with the
driver's message.
"""
import re
import sys
import html
import json
import pathlib
from datetime import date, datetime
ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(ROOT / "src") not in sys.path:
sys.path.insert(0, str(ROOT / "src"))
from fastapi import FastAPI, Form, Request # noqa: E402
from starlette.middleware.trustedhost import TrustedHostMiddleware # noqa: E402
from fastapi.encoders import jsonable_encoder # noqa: E402
from fastapi.responses import (FileResponse, HTMLResponse, # noqa: E402
JSONResponse, RedirectResponse)
from fastapi.templating import Jinja2Templates # noqa: E402
import secrets # noqa: E402
from urllib.parse import urlencode, urlsplit # noqa: E402
from ecw_bot import settings as cfg # noqa: E402
from ecw_bot.core import results_db # noqa: E402
from ecw_bot.core.run_events import run_id_of # noqa: E402
from .repo import SqlResultsRepo # noqa: E402
from . import run_status # noqa: E402
from . import runs as run_view # noqa: E402
from .users import (Accounts, CAN_RUN, ROLES, Sessions, # noqa: E402
SqlUserStore, UserError)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
NotRunning, StopFailed)
CATEGORIES = ("Pass", "Fail", "Review")
DB_DOWN = "Results database not reachable"
ALLOWED_HOSTS = ("127.0.0.1", "localhost")
# The Sign-in cookie (Stage 2, 2026-10-22). Every page but these needs a
# signed-in Portal User.
COOKIE = "ecw_portal"
OPEN_PATHS = ("/signin",)
SIGN_IN_ERRORS = {
"bad": "Wrong username or password.",
"locked": "Too many wrong passwords — this user is locked for 15 "
"minutes.",
"disabled": "This user is disabled — ask an Admin.",
}
# The only pages allowed to Start / Stop a run: this one (2026-10-14). A
# page on another site in the same browser could otherwise POST here.
# The port must match the one in run_web.bat (8000).
ALLOWED_ORIGINS = ("http://127.0.0.1:8000", "http://localhost:8000")
# claim_no is NVARCHAR(20): a longer search can never match.
CLAIM_MAX = 20
# SQLSTATE -> (title, what to check). Anything else is "not reachable".
_DB_PROBLEMS = {
"28000": ("Results database login failed",
"The Windows login has no access to Charge_Validation yet — "
"see the GRANT lines at the end of tools/sql/create_tables.sql."),
"42S02": ("Results table is missing",
"Run tools/sql/create_tables.sql in SSMS once."),
"42S22": ("Results table needs an update",
"Run tools/sql/create_tables.sql in SSMS once more — it adds "
"the started_by column (2026-10-22)."),
"42000": ("No permission to read the results table",
"The Windows login needs db_datareader on Charge_Validation."),
}
_DB_DEFAULT = (DB_DOWN, "Check that SQL Server is running and that "
"RESULTS_DB_CONN in .env points at the "
"Charge_Validation database.")
_SECURITY_HEADERS = {
"Cache-Control": "no-store", "Pragma": "no-cache",
"X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff",
}
TEMPLATES = Jinja2Templates(
directory=str(pathlib.Path(__file__).resolve().parent / "templates"))
def _money(value):
return "" if value is None else f"{value:,.2f}"
def _us_date(value):
if isinstance(value, datetime):
return value.strftime("%m/%d/%Y %H:%M:%S")
if isinstance(value, date):
return value.strftime("%m/%d/%Y")
return "" if value is None else str(value)
def _long_date(value):
"""Thu, 16 Oct 2026 — how a day reads on its card and page."""
return value.strftime("%a, %d %b %Y") if value else ""
def _clock(value):
"""2:15 PM — a run's time of day."""
return value.strftime("%I:%M %p").lstrip("0") if value else ""
TEMPLATES.env.filters["money"] = _money
TEMPLATES.env.filters["usdate"] = _us_date
TEMPLATES.env.filters["longdate"] = _long_date
TEMPLATES.env.filters["clock"] = _clock
def _duration(seconds):
"""690 -> 11m 30s; 3725 -> 1h 2m. Blank for none."""
if seconds is None:
return ""
seconds = int(seconds)
h, rest = divmod(seconds, 3600)
m, s = divmod(rest, 60)
if h:
return f"{h}h {m}m"
return f"{m}m {s}s" if m else f"{s}s"
TEMPLATES.env.filters["dur"] = _duration
_MONEY_COLUMNS = {"charges", "pmts_adjs", "pmts_refunds", "adjustment",
"withheld", "balance"}
_DATE_COLUMNS = {"service_date", "received_date", "completed_on"}
def _shown(col, value) -> str:
"""One Charge Summary field as the page shows it."""
if value is None:
return ""
if col in _MONEY_COLUMNS:
return _money(value)
if col in _DATE_COLUMNS:
return _us_date(value)
return str(value)
class BadFilter(Exception):
pass
class Forbidden(Exception):
pass
class DbUnavailable(Exception):
def __init__(self, title=DB_DOWN, hint=_DB_DEFAULT[1]):
super().__init__(title)
self.title, self.hint = title, hint
def _filters(run_at, category, claim_no):
"""Validated (run_at datetime|None, category|None, claim digits|None)."""
when = None
if run_at:
try:
when = datetime.fromisoformat(run_at)
except ValueError:
raise BadFilter(f"run_at is not a date/time: {run_at!r}")
# run_at is saved as the PC's local time, with no zone.
when = when.replace(tzinfo=None)
category = (category or "").strip() or None
if category and category not in CATEGORIES:
raise BadFilter(f"category must be one of {', '.join(CATEGORIES)}")
claim = re.sub(r"[^0-9A-Za-z]", "", claim_no or "")[:CLAIM_MAX] or None
return when, category, claim
def _db(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except UserError:
raise # the user's to fix, not the database's
except Exception as exc:
state = exc.args[0] if exc.args and isinstance(exc.args[0], str) \
else ""
title, hint = _DB_PROBLEMS.get(state, _DB_DEFAULT)
print(f"[WEB] {title}: {results_db.safe_error(exc)}")
raise DbUnavailable(title, hint) from exc
def _details(row: dict) -> dict:
try:
return json.loads(row.get("details_json") or "{}")
except ValueError:
return {"unreadable": row.get("details_json")}
def _day(text) -> date:
try:
return date.fromisoformat(text)
except (TypeError, ValueError):
raise BadFilter(f"not a day (YYYY-MM-DD): {text!r}")
def _for_panel(row: dict) -> dict:
"""A results row with what the page's side panel shows: every Charge
Summary field as text, the run's time and mode, the details JSON."""
details = _details(row)
row["fields"] = ([[h, _shown(c, row.get(c))] for h, c in
results_db.COLUMNS]
+ [["Run", _us_date(row.get("run_at"))],
["Mode", row.get("run_mode") or ""],
["Started By", row.get("started_by") or ""]])
row["details_pretty"] = (json.dumps(details, indent=2,
ensure_ascii=False)
if details else "")
return row
def _counts(rows) -> dict:
out = {"claims": len(rows),
"runs": len({r.get("run_at") for r in rows})}
for cat in CATEGORIES:
out[cat.lower() + "_count"] = sum(r.get("category") == cat
for r in rows)
return out
def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
allowed_origins=ALLOWED_ORIGINS, logs_dir=None,
screenshots_root=None, users_store=None,
sessions=None) -> FastAPI:
app = FastAPI(title="eCW Charge Validation — Results",
docs_url=None, redoc_url=None, openapi_url=None)
runner = runner if runner is not None else BotRunner()
logs_dir = logs_dir if logs_dir is not None else cfg.LOGS
screenshots_root = (screenshots_root if screenshots_root is not None
else cfg.ROOT / "Screenshots")
# Made per server start and put in the page: a page on another site
# cannot read it, so it cannot send it.
app.state.run_token = secrets.token_urlsafe(32)
store = (users_store if users_store is not None
else SqlUserStore(cfg.RESULTS_DB_CONN))
accounts = Accounts(store)
sessions = sessions if sessions is not None else Sessions()
app.state.sessions = sessions
def _check_token(request: Request, sent: str) -> None:
origin = request.headers.get("origin")
if not origin:
ref = urlsplit(request.headers.get("referer") or "")
origin = f"{ref.scheme}://{ref.netloc}" if ref.netloc else ""
if origin not in allowed_origins:
raise Forbidden()
try:
same = secrets.compare_digest(sent or "", app.state.run_token)
except TypeError: # non-ASCII text
same = False
if not same:
raise Forbidden()
def _check_writer(request: Request) -> None:
"""A script's POST: the token in its X-Run-Token header."""
_check_token(request, request.headers.get("x-run-token"))
def _need(request: Request, roles) -> dict:
user = request.state.user
if user["role"] not in roles:
raise Forbidden()
return user
# Before _no_store, so its redirects get those headers too.
@app.middleware("http")
async def _signed_in(request: Request, call_next):
path = request.url.path
if path in OPEN_PATHS:
request.state.user = None
return await call_next(request)
user, timed_out = sessions.get(request.cookies.get(COOKIE))
if timed_out:
try:
store.record(timed_out, "timeout")
except Exception:
pass
if user is None:
if path.startswith("/api"):
return JSONResponse({"detail": "Sign in first"},
status_code=401)
back = path + (f"?{request.url.query}" if request.url.query
else "")
return RedirectResponse("/signin?" + urlencode({"next": back}),
status_code=303)
if user["must_change"] and path not in ("/account/password",
"/signout"):
if path.startswith("/api"):
return JSONResponse({"detail": "Change your password first"},
status_code=403)
return RedirectResponse("/account/password", status_code=303)
request.state.user = user
return await call_next(request)
@app.middleware("http")
async def _no_store(request: Request, call_next):
response = await call_next(request)
response.headers.update(_SECURITY_HEADERS)
return response
# Added last, so it runs first: a foreign Host gets a bare 400 before
# any route (or the database) is reached.
app.add_middleware(TrustedHostMiddleware,
allowed_hosts=list(allowed_hosts))
@app.exception_handler(BadFilter)
def _bad_filter(request: Request, exc: BadFilter):
if request.url.path.startswith("/api"):
return JSONResponse({"detail": str(exc)}, status_code=400)
# The message quotes what was typed in the URL: escaped, never
# echoed raw.
return HTMLResponse(f"<h1>Bad filter</h1><p>{html.escape(str(exc))}"
f"</p>", status_code=400)
@app.exception_handler(Forbidden)
def _forbidden(request: Request, exc: Forbidden):
if request.url.path.startswith("/api"):
return JSONResponse({"detail": "Not allowed from here"},
status_code=403)
return HTMLResponse("<h1>Not allowed</h1><p>Your role cannot do "
"this, or the request did not come from this "
"page.</p>", status_code=403)
@app.exception_handler(DbUnavailable)
def _db_down(request: Request, exc: DbUnavailable):
if request.url.path.startswith("/api"):
return JSONResponse({"detail": exc.title}, status_code=503)
return HTMLResponse(f"<h1>{html.escape(exc.title)}</h1>"
f"<p>{html.escape(exc.hint)}</p>",
status_code=503)
def _page(request, name, crumbs, **context):
"""Every page: its template, the mode pill, the run token, and the
breadcrumb (`[label, link]`, the last one without a link)."""
user = getattr(request.state, "user", None)
return TEMPLATES.TemplateResponse(request, name, {
"mode": "LIVE-WRITE" if cfg.SAVE_CORRECTIONS else "VERIFY",
"run_token": app.state.run_token, "crumbs": crumbs,
"categories": CATEGORIES, "user": user,
"can_run": bool(user and user["role"] in CAN_RUN),
"is_admin": bool(user and user["role"] == "Admin"),
**context})
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request, run_at: str = None,
category: str = None, claim_no: str = None):
when, cat, claim = _filters(run_at, category, claim_no)
if when is not None:
# An old one-run link: that run's day.
query = urlencode({k: v for k, v in (("category", cat),
("claim_no", claim)) if v})
return RedirectResponse(
f"/day/{when.date().isoformat()}"
+ (f"?{query}" if query else ""), status_code=303)
days = _db(repo.days)
today = date.today()
counts = next((d for d in days if d["day"] == today),
{"day": today, "runs": 0, **_counts([])})
return _page(request, "dashboard.html", [["Dashboard", None]],
today=counts, has_runs=bool(days),
alert=_last_run_alert())
@app.get("/days", response_class=HTMLResponse)
def days_page(request: Request):
return _page(request, "days.html",
[["Dashboard", "/"], ["Claim status", None]],
days=_db(repo.days))
@app.get("/day/{day}", response_class=HTMLResponse)
def day_page(request: Request, day: str, category: str = None,
claim_no: str = None, claim: str = None):
when = _day(day)
_, cat, search = _filters(None, category, claim_no)
_, _, opened = _filters(None, None, claim)
rows = _db(repo.day_results, when)
shown = [_for_panel(r) for r in rows
if (not cat or r.get("category") == cat)
and (not search or search in str(r.get("claim_no") or ""))]
return _page(request, "day.html",
[["Dashboard", "/"], ["Claim status", "/days"],
[_long_date(when), None]],
day=when, counts=_counts(rows), rows=shown,
category=cat or "", claim_no=search or "",
opened=opened or "")
@app.get("/claim", response_class=HTMLResponse)
def claim_page(request: Request, claim_no: str = None):
_, _, claim = _filters(None, None, claim_no)
rows = ([_for_panel(r) for r in _db(repo.claim_history, claim)]
if claim else [])
return _page(request, "claim.html",
[["Dashboard", "/"], ["Find a claim", None]],
rows=rows, claim_no=claim or "")
def _last_run_alert():
"""The latest Run, if it ended badly — the Dashboard's red banner
(EEWA-33). A Run Error, or a Run that ended without a record
(killed); never a Stop, a finished Run or one still going."""
last = run_view.latest_run(logs_dir, _live_run_id())
if last and last["status"] in ("Run Error", "Ended without a record"):
return last
return None
def _live_run_id():
"""The Run whose process is alive now, if any (Runs page)."""
try:
st = runner.status()
except Exception:
return None
if st.get("running") and st.get("log_path"):
return run_id_of(st["log_path"])
return None
@app.get("/runs", response_class=HTMLResponse)
def runs_page(request: Request, day_from: str = None, day_to: str = None,
status: str = None):
for value in (day_from, day_to):
if value:
try:
date.fromisoformat(value)
except ValueError:
raise BadFilter(f"Not a date: {value}")
if status and status not in run_view.STATUSES:
raise BadFilter(f"Not a result: {status}")
rows = run_view.list_runs(logs_dir, _live_run_id(),
day_from=day_from or None,
day_to=day_to or None,
status=status or None)
return _page(request, "runs.html",
[["Dashboard", "/"], ["Runs", None]],
runs=rows, statuses=run_view.STATUSES,
day_from=day_from or "", day_to=day_to or "",
status=status or "")
@app.get("/runs/{run_id}", response_class=HTMLResponse)
def run_page(request: Request, run_id: str):
run = run_view.load_run(logs_dir, run_id, _live_run_id())
if run is None:
return HTMLResponse("<h1>No such run</h1>", status_code=404)
return _page(request, "run.html",
[["Dashboard", "/"], ["Runs", "/runs"],
[_us_date(run["started"]), None]],
run=run)
@app.get("/runs/{run_id}/shot/{n}")
def run_shot(run_id: str, n: int):
path = run_view.screenshot_path(logs_dir, run_id, n, screenshots_root)
if path is None:
return HTMLResponse("<h1>No such screenshot</h1>",
status_code=404)
return FileResponse(path)
@app.get("/api/runs")
def api_runs():
return jsonable_encoder(_db(repo.runs))
@app.get("/api/results")
def api_results(run_at: str = None, category: str = None,
claim_no: str = None):
when, cat, claim = _filters(run_at, category, claim_no)
if when is None:
runs = _db(repo.runs)
if not runs:
return {"run_at": None, "rows": []}
when = runs[0]["run_at"]
rows = []
for row in _db(repo.results, when, cat, claim):
out = {k: v for k, v in row.items() if k != "details_json"}
out["details"] = _details(row)
rows.append(out)
return jsonable_encoder({"run_at": when, "rows": rows})
@app.post("/api/bot/start")
def bot_start(request: Request):
_check_writer(request)
user = _need(request, CAN_RUN)
try:
return run_status.summarize(
runner.start(started_by=user["username"]))
except AlreadyRunning as exc:
return JSONResponse({"detail": str(exc)}, status_code=409)
@app.post("/api/bot/stop")
def bot_stop(request: Request):
_check_writer(request)
user = _need(request, CAN_RUN)
try:
return run_status.summarize(
runner.stop(stopped_by=user["username"]))
except NotRunning as exc:
return JSONResponse({"detail": str(exc)}, status_code=409)
except StopFailed as exc:
return JSONResponse({"detail": str(exc)}, status_code=500)
@app.get("/api/bot/status")
def bot_status():
return run_status.summarize(runner.status())
# ── Sign-in (Stage 2, EEWA-32) ───────────────────────────────────────
def _signin_page(request, error="", next_url="/", status_code=200):
page = _page(request, "signin.html", [["Sign in", None]],
error=error, next_url=_safe_next(next_url),
no_users=not _db(store.list))
page.status_code = status_code
return page
@app.get("/signin", response_class=HTMLResponse)
def signin_page(request: Request, next: str = "/"):
user, _ = sessions.get(request.cookies.get(COOKIE))
if user:
return RedirectResponse(_safe_next(next), status_code=303)
return _signin_page(request, next_url=next)
@app.post("/signin", response_class=HTMLResponse)
def signin(request: Request, username: str = Form(""),
password: str = Form(""), token: str = Form(""),
next: str = Form("/")):
_check_token(request, token)
user, why = _db(accounts.sign_in, username, password)
if user is None:
return _signin_page(request, SIGN_IN_ERRORS[why], next, 401)
# A new token every Sign-in: an old cookie never comes back to life.
sessions.drop(request.cookies.get(COOKIE))
target = ("/account/password" if user["must_change"]
else _safe_next(next))
response = RedirectResponse(target, status_code=303)
response.set_cookie(COOKIE, sessions.new(user), httponly=True,
samesite="strict", path="/")
return response
@app.post("/signout")
def signout(request: Request, token: str = Form("")):
_check_token(request, token)
_db(store.record, request.state.user["username"], "sign_out")
sessions.drop(request.cookies.get(COOKIE))
response = RedirectResponse("/signin", status_code=303)
response.delete_cookie(COOKIE, path="/")
return response
def _password_page(request, error="", status_code=200):
page = _page(request, "password.html",
[["Dashboard", "/"], ["Change password", None]],
error=error)
page.status_code = status_code
return page
@app.get("/account/password", response_class=HTMLResponse)
def password_page(request: Request):
return _password_page(request)
@app.post("/account/password", response_class=HTMLResponse)
def password_change(request: Request, current: str = Form(""),
new: str = Form(""), confirm: str = Form(""),
token: str = Form("")):
_check_token(request, token)
try:
if new != confirm:
raise UserError("The two new passwords are not the same")
updated = _db(accounts.change_password,
request.state.user["username"], current, new)
except UserError as exc:
return _password_page(request, str(exc), 400)
sessions.refresh_user(updated)
return RedirectResponse("/", status_code=303)
# ── Portal Users (Admin) ─────────────────────────────────────────────
def _users_page(request, error="", message="", status_code=200):
page = _page(request, "users.html",
[["Dashboard", "/"], ["Portal Users", None]],
users=_db(store.list), roles=ROLES, error=error,
message=message)
page.status_code = status_code
return page
@app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, msg: str = ""):
_need(request, ("Admin",))
return _users_page(request, message=msg[:200])
def _admin_change(request, token, done, fn, *args):
"""One Admin change: checked, made, then back to the list with
`done` said — or the list again with the problem."""
_check_token(request, token)
me = _need(request, ("Admin",))["username"]
try:
_db(fn, *args, by=me)
except UserError as exc:
return None, _users_page(request, error=str(exc),
status_code=400)
return True, RedirectResponse(
"/admin/users?" + urlencode({"msg": done}), status_code=303)
@app.post("/admin/users", response_class=HTMLResponse)
def users_create(request: Request, username: str = Form(""),
display_name: str = Form(""), role: str = Form(""),
password: str = Form(""), token: str = Form("")):
name = username.strip().lower()
return _admin_change(
request, token, f"{name} added — they choose their own "
f"password at their first Sign-in", accounts.create_user,
username, display_name, role, password)[1]
@app.post("/admin/users/{name}/reset", response_class=HTMLResponse)
def users_reset(request: Request, name: str, password: str = Form(""),
token: str = Form("")):
ok, page = _admin_change(
request, token, f"{name}: password reset — they choose a new "
f"one at their next Sign-in", accounts.reset_password, name,
password)
if ok:
sessions.drop_user(name)
return page
@app.post("/admin/users/{name}/role", response_class=HTMLResponse)
def users_role(request: Request, name: str, role: str = Form(""),
token: str = Form("")):
ok, page = _admin_change(request, token, f"{name} is now {role}",
accounts.set_role, name, role)
if ok:
updated = _db(store.get, name)
if updated:
sessions.refresh_user(updated)
return page
@app.post("/admin/users/{name}/disable", response_class=HTMLResponse)
def users_disable(request: Request, name: str, token: str = Form("")):
ok, page = _admin_change(request, token, f"{name} disabled",
accounts.set_disabled, name, True)
if ok:
sessions.drop_user(name)
return page
@app.post("/admin/users/{name}/enable", response_class=HTMLResponse)
def users_enable(request: Request, name: str, token: str = Form("")):
return _admin_change(request, token, f"{name} enabled",
accounts.set_disabled, name, False)[1]
@app.get("/admin/signins", response_class=HTMLResponse)
def signins_page(request: Request):
_need(request, ("Admin",))
return _page(request, "signins.html",
[["Dashboard", "/"], ["Sign-in history", None]],
events=_db(store.history))
return app
def _safe_next(target) -> str:
"""Only a path on this site: never `//elsewhere`, `/\\elsewhere` or a
full URL."""
target = target or "/"
if not target.startswith("/") or target[1:2] in ("/", "\\"):
return "/"
return target
app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN),
users_store=SqlUserStore(cfg.RESULTS_DB_CONN))
+209
View File
@@ -0,0 +1,209 @@
r"""
Start, watch and stop the bot from the results page (spec
docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md).
The bot runs as its own process — `python run.py`, the same entry point as
the terminal — so a hung or crashed run never takes the web server with
it, and closing the page does not stop the run. The command and its
environment are fixed here; nothing from a request reaches them.
The run itself says whether it is live: `Logs\bot_run.json`
(`core/run_lock.py`), which the bot writes at start and finishes at the
end, terminal runs included.
"""
import os
import sys
import pathlib
import time
import threading
import subprocess
from datetime import datetime, timedelta
from ecw_bot import settings as cfg
from ecw_bot.core import run_lock, run_events
ROOT = pathlib.Path(__file__).resolve().parent.parent
# A page-started run: VERIFY whatever .env says, no "Press Enter", no
# information boxes (the page shows the run), no "Run the Lookup?" box.
FORCED_ENV = {
"SAVE_CORRECTIONS": "0",
"ECW_KEEP_OPEN": "0",
"QUIET_BOXES": "1",
"CONFIRM_BEFORE_LOOKUP": "0",
"ECW_RUN_SOURCE": "web",
}
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
class AlreadyRunning(Exception):
pass
class NotRunning(Exception):
pass
class StopFailed(Exception):
pass
class BotRunner:
# How long stop() waits for a killed process to be gone.
stop_wait = 10.0
def __init__(self, command=None, root=ROOT):
self.command = list(command or [sys.executable, str(root / "run.py")])
self.root = pathlib.Path(root)
self._proc = None
self._out_path = None
self._spawned_at = None
self._stopped_child = False
self._started_by = ""
# The page's requests run on a thread pool: a double-clicked Start
# must not pass the checks twice and spawn two children.
self._lock = threading.Lock()
def _child_running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def _kill(self, pid) -> None:
# /T: the bot's Chrome goes with it.
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True, creationflags=_NO_WINDOW)
def start(self, started_by: str = "") -> dict:
with self._lock:
rec = run_lock.read()
if run_lock.is_live(rec):
raise AlreadyRunning(
f"A run is already going (started "
f"{(rec or {}).get('started_at') or '?'})")
if self._child_running():
raise AlreadyRunning("A run is already going (still starting)")
# Without the keys this server took from .env: the child reads
# .env afresh, so an edit made since the server started is seen.
env = {k: v for k, v in os.environ.items()
if k not in cfg.ENV_FILE_KEYS}
# Except the results database: the run writes its rows where
# this page reads them, even if .env names another since.
env["RESULTS_DB_CONN"] = cfg.RESULTS_DB_CONN
env.update(FORCED_ENV)
# Started By on the Runs page: the signed-in Portal User. Only
# a username the Portal itself checked ever gets here.
env["ECW_STARTED_BY"] = started_by
self._started_by = started_by
cfg.LOGS.mkdir(parents=True, exist_ok=True)
self._out_path = (cfg.LOGS /
f"web_run_{datetime.now():%Y%m%d_%H%M%S}.out")
self._stopped_child = False
self._spawned_at = datetime.now()
with open(self._out_path, "ab") as out:
self._proc = subprocess.Popen(
self.command, cwd=str(self.root), env=env,
stdin=subprocess.DEVNULL, stdout=out,
stderr=subprocess.STDOUT, creationflags=_NO_WINDOW)
print(f"[WEB] Bot run started from the page (pid {self._proc.pid})")
return self.status()
def stop(self, stopped_by: str = "") -> dict:
with self._lock:
rec = run_lock.read()
live = run_lock.is_live(rec)
own_child = (live and self._proc is not None
and int(rec["pid"]) == self._proc.pid)
# A run started in the terminal may be LIVE-WRITE, and the page
# says VERIFY: it is stopped where it was started.
if live and rec.get("source") != "web" and not own_child:
raise NotRunning(
"This run was started in the terminal — stop it there")
pids = []
if live and int(rec["pid"]) != os.getpid():
pids.append(int(rec["pid"]))
if self._child_running():
pids.append(self._proc.pid)
pids = list(dict.fromkeys(pids))
if not pids:
raise NotRunning("No run is going")
for pid in pids:
self._kill(pid)
gone, alive = [], list(pids)
end = time.monotonic() + self.stop_wait
while alive:
alive = [p for p in alive if run_lock.pid_alive(p)]
if not alive or time.monotonic() >= end:
break
time.sleep(0.2)
gone = [p for p in pids if p not in alive]
if self._proc is not None and self._proc.pid in gone:
self._stopped_child = True
# mark_stopped() re-reads the record: a child that was still
# starting may have written it between the first read and the
# kill.
for pid in gone:
run_lock.mark_stopped(pid)
if rec and rec.get("log_path") and int(rec["pid"]) in gone:
# The killed bot cannot write its own end: the Runs page
# reads this one, with who pressed Stop.
run_events.append_to(rec["log_path"], "stopped",
stopped_by=stopped_by)
if gone:
print(f"[WEB] Bot run stopped from the page (pid "
f"{', '.join(str(p) for p in gone)})")
if alive:
raise StopFailed(
f"Could not stop the run (pid "
f"{', '.join(str(p) for p in alive)} is still running) "
f"— close it from the bot's own window or Task Manager")
return self.status()
def _record_predates_child(self, rec) -> bool:
"""No record, or one started before this runner's child was spawned
(1 s slack: started_at is cut to the second). An unreadable time
counts as older."""
if not rec:
return True
try:
started = datetime.fromisoformat(rec["started_at"])
return started < self._spawned_at - timedelta(seconds=1)
except (KeyError, TypeError, ValueError):
return True
def status(self) -> dict:
rec = run_lock.read() or {}
ours = self._proc is not None and rec.get("pid") == self._proc.pid
# A record that is not the child's but newer than it (a terminal
# run after the page's) is shown as itself.
if (self._proc is not None and not ours
and self._record_predates_child(rec)):
# The child has not written its record yet — or ended before
# it could (a crash at import, a refused second run).
if self._child_running():
return {"running": True, "starting": True, "pid": None,
"started_at": None, "ended_at": None,
"exit_code": None, "stopped_by_operator": False,
"source": "web", "started_by": self._started_by,
"log_path": None, "out_path": str(self._out_path)}
if not run_lock.is_live(rec):
return {"running": False, "starting": False, "pid": None,
"started_at": None, "ended_at": None,
"exit_code": (None if self._stopped_child
else self._proc.returncode),
"stopped_by_operator": self._stopped_child,
"source": "web", "started_by": self._started_by,
"log_path": None, "out_path": str(self._out_path)}
exit_code = rec.get("exit_code")
if (ours and exit_code is None and not self._child_running()
and not rec.get("stopped_by_operator")):
exit_code = self._proc.returncode
return {"running": run_lock.is_live(rec), "starting": False,
"pid": rec.get("pid"), "started_at": rec.get("started_at"),
"ended_at": rec.get("ended_at"), "exit_code": exit_code,
"stopped_by_operator": bool(rec.get("stopped_by_operator")),
"source": rec.get("source"),
"started_by": rec.get("started_by") or "",
"log_path": rec.get("log_path"),
"out_path": str(self._out_path) if ours and self._out_path
else None}
+121
View File
@@ -0,0 +1,121 @@
"""
Read-only access to the results table (`dbo.CV_ChargeSummary`, written by
src/ecw_bot/core/results_db.py). SELECT statements only, always
parameterized; one short connection per call.
"""
from datetime import date, datetime, timedelta
from ecw_bot.core import results_db
# started_by since 2026-10-22 (EEWA-33): a table without it is reported as
# "needs tools/sql/create_tables.sql" (web/app.py, SQLSTATE 42S22).
_SELECT_COLUMNS = ", ".join(["id", "run_at", "run_mode", "started_by"]
+ [c for _, c in results_db.COLUMNS]
+ ["details_json"])
_RUN_COUNTS = (
"run_at, MAX(run_mode) AS run_mode, COUNT(*) AS claims, "
"SUM(CASE WHEN category = 'Pass' THEN 1 ELSE 0 END) AS pass_count, "
"SUM(CASE WHEN category = 'Fail' THEN 1 ELSE 0 END) AS fail_count, "
"SUM(CASE WHEN category = 'Review' THEN 1 ELSE 0 END) AS review_count")
# The same counts per day a run STARTED: every row of a run shares its
# run_at, so a run never splits over two days (portal, 2026-10-19).
_DAY = "CAST(run_at AS DATE)"
_DAY_COUNTS = (
f"{_DAY} AS day, COUNT(*) AS claims, COUNT(DISTINCT run_at) AS runs, "
"SUM(CASE WHEN category = 'Pass' THEN 1 ELSE 0 END) AS pass_count, "
"SUM(CASE WHEN category = 'Fail' THEN 1 ELSE 0 END) AS fail_count, "
"SUM(CASE WHEN category = 'Review' THEN 1 ELSE 0 END) AS review_count")
def _coerce(row: dict) -> dict:
"""
run_at as a datetime whatever the driver: ODBC Driver 17/18 return
DATETIME2 as datetime, the legacy {SQL Server} driver as text
("2026-10-08 15:10:00.0000000"), which the page cannot compare or
format (review, 2026-10-12).
"""
value = row.get("run_at")
if isinstance(value, str):
try:
row["run_at"] = datetime.fromisoformat(value.strip()[:26])
except ValueError:
pass
# The same for a day (DATE), which that driver returns as "2026-10-16".
value = row.get("day")
if isinstance(value, str):
try:
row["day"] = date.fromisoformat(value.strip()[:10])
except ValueError:
pass
elif isinstance(value, datetime):
row["day"] = value.date()
return row
class SqlResultsRepo:
def __init__(self, conn_str: str, connect=None):
self.conn_str = conn_str
self._connect = connect
def _query(self, sql: str, params=()) -> list:
connect = self._connect
if connect is None:
import pyodbc
connect = pyodbc.connect
conn = connect(self.conn_str, timeout=10)
try:
cur = conn.cursor().execute(sql, list(params))
cols = [d[0] for d in cur.description]
return [_coerce(dict(zip(cols, row))) for row in cur.fetchall()]
finally:
conn.close()
def runs(self) -> list:
"""Every run, newest first, with its claim count per Category."""
return self._query(
f"SELECT TOP 200 {_RUN_COUNTS} FROM {results_db.TABLE} "
f"GROUP BY run_at ORDER BY run_at DESC")
def results(self, run_at, category=None, claim_no=None) -> list:
"""One run's rows, in S.No order, optionally filtered."""
sql = (f"SELECT {_SELECT_COLUMNS} FROM {results_db.TABLE} "
f"WHERE run_at = ?")
params = [run_at]
if category:
sql += " AND category = ?"
params.append(category)
if claim_no:
sql += " AND claim_no LIKE ?"
params.append(f"%{claim_no}%")
sql += " ORDER BY s_no, id"
return self._query(sql, params)
def days(self, limit: int = 90) -> list:
"""The last `limit` days with a run, newest first: claims checked
(every row — a claim checked twice counts twice), runs, and the
count per Category."""
return self._query(
f"SELECT TOP {int(limit)} {_DAY_COUNTS} FROM {results_db.TABLE} "
f"GROUP BY {_DAY} ORDER BY {_DAY} DESC")
def day_results(self, day) -> list:
"""Every row of every run that started on `day`, in run order then
S.No. The page counts and filters them itself: its tiles need the
whole day whatever filter is on."""
start = datetime(day.year, day.month, day.day)
return self._query(
f"SELECT {_SELECT_COLUMNS} FROM {results_db.TABLE} "
f"WHERE run_at >= ? AND run_at < ? ORDER BY run_at, s_no, id",
[start, start + timedelta(days=1)])
def claim_history(self, claim_no) -> list:
"""Every saved check of the claims matching `claim_no`, newest
first (at most 200)."""
return self._query(
f"SELECT TOP 200 {_SELECT_COLUMNS} FROM {results_db.TABLE} "
f"WHERE claim_no LIKE ? ORDER BY run_at DESC, s_no, id",
[f"%{claim_no}%"])
+187
View File
@@ -0,0 +1,187 @@
r"""
The bot's run log, read into the results page's live status (spec
docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md).
Everything comes from lines the bot already prints, plus three
fixed-format ones: `[CLAIMS] Queue: <id>, <id>, ...` (every claim of the
run, before the first starts), `[CLAIMS] Results run_at: <ISO time>` right
after it (the run's time in the results table; only with RESULTS_DB=1), and
one per claim, `[RESULT] claim n/N <id>: <Category>`.
The live log tail shows the log's text as it is, except the 2FA code
(`[OTP] Code ...`) and the eCW username (`[CONFIG] user=...`), which are
hidden. What is left can still hold patient data (claim numbers, form
values) — the page shows it on 127.0.0.1 only, as it shows the results
table.
"""
import re
from datetime import datetime
TAIL_LINES = 30
_QUEUE = re.compile(r"^\[CLAIMS\] Queue: (.*)$")
_RUN_AT = re.compile(r"^\[CLAIMS\] Results run_at: "
r"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)$")
_CLAIM = re.compile(r"^\[CLAIMS\] --- claim (\d+)/(\d+): (\S+) ---$")
_RESULT = re.compile(r"^\[RESULT\] claim (\d+)/(\d+) (\S+): (.+)$")
# Hidden in the tail only. The OTP line loses everything after "Code": the
# mail subject it quotes often repeats the code.
_REDACT = (
(re.compile(r"(\[OTP\] Code )\S.*$"), r"\1(hidden)"),
(re.compile(r"(\[CONFIG\] user=)\S*"), r"\1(hidden)"),
)
def _redact(line: str) -> str:
for pattern, repl in _REDACT:
line = pattern.sub(repl, line)
return line
_PHASES = (
(("[LOGIN]", "[CAPTCHA]"), "Logging in"),
(("[OTP]", "[2FA]"), "Waiting for the 2FA approval mail"),
(("[OK] Logged in",), "Logged in"),
(("[CLAIMS]", "[RESULT]"), "Checking claims"),
)
_EXIT = {
0: "Finished",
1: "The run failed (see the log)",
2: "Stopped before login — a setting needs fixing, or another run is "
"going (see the log)",
3: "Login guard refused the login — too many attempts this hour",
4: "eCW restricted the login — do not retry; check the account "
"(see the log)",
5: "The claims step failed (see the log)",
6: "The 2FA approval mail did not arrive in time",
7: "Stopped: eCW changed a screen — a selector needs fixing (see the "
"log's [PREFLIGHT] line)",
8: "Stopped: a claim stopped making progress (the page froze) — see "
"the Runs page",
}
# A claim's status once the run is over (finished, stopped, crashed):
# nothing is being checked or waiting any more.
_STATUS_ONCE_OVER = {"Checking": "Not finished", "Waiting": "Not checked"}
_PAGE_KEYS = ("running", "starting", "started_at", "ended_at", "exit_code",
"stopped_by_operator", "source", "started_by")
def parse(text: str) -> dict:
"""Phase, progress, counts, guard line, tail — and `claims`: every
claim of the run as `{"n", "id", "status", "done"}`, status = its
result (`done`), or "Checking" (started, no result yet), or "Waiting"
(in the `[CLAIMS] Queue:` line, not started). A log with no Queue line
lists the claims it has seen start. `results_run_at` is the run's time
in the results table, None when the run saves none."""
phase, progress, current, guard = "Starting", None, None, None
counts = {"Pass": 0, "Fail": 0, "Review": 0, "Not found": 0}
queue, status, done, run_at = [], {}, set(), None
lines = (text or "").splitlines()
for raw in lines:
line = raw.strip()
for prefixes, name in _PHASES:
if line.startswith(prefixes):
phase = name
m = _QUEUE.match(line)
if m:
queue = [c.strip() for c in m.group(1).split(",") if c.strip()]
status, done, run_at = {}, set(), None
m = _RUN_AT.match(line)
if m:
run_at = m.group(1)
m = _CLAIM.match(line)
if m:
progress = [int(m.group(1)), int(m.group(2))]
current = m.group(3)
if current not in queue:
queue.append(current)
status[current] = "Checking"
m = _RESULT.match(line)
if m:
cat = m.group(4).strip()
counts[cat] = counts.get(cat, 0) + 1
status[m.group(3)] = cat
done.add(m.group(3))
if line.startswith("[GUARD]"):
guard = line
claims = [{"n": n, "id": cid, "status": status.get(cid, "Waiting"),
"done": cid in done}
for n, cid in enumerate(queue, start=1)]
return {"phase": phase, "progress": progress, "current": current,
"counts": counts, "guard": guard, "claims": claims,
"results_run_at": run_at,
"tail": [_redact(ln.rstrip()) for ln in lines[-TAIL_LINES:]]}
def exit_text(code, stopped: bool = False) -> str:
if stopped:
return "Stopped by operator"
if code is None:
return "Ended (no exit code — the run was stopped or crashed)"
return _EXIT.get(code, f"Ended with code {code}")
def read_tail(path, max_bytes: int | None = 256_000) -> str:
"""The end of a text file ('' when there is none); max_bytes=None reads
the whole file."""
if not path:
return ""
try:
with open(path, "rb") as fh:
fh.seek(0, 2)
size = fh.tell()
fh.seek(0 if max_bytes is None else max(0, size - max_bytes))
text = fh.read().decode("utf-8", errors="replace")
return text.replace("\r\n", "\n")
except OSError:
return ""
def _elapsed(started, ended, running):
"""Seconds from start to end — to now while the run is going. A run
that ended with no end time written (killed outside the page, a crash)
has no duration: measured to now it grew on every poll (review,
2026-10-14)."""
if not ended and not running:
return None
try:
a = datetime.fromisoformat(started)
b = datetime.fromisoformat(ended) if ended else datetime.now()
return int((b - a).total_seconds())
except (TypeError, ValueError):
return None
def summarize(st: dict) -> dict:
"""The runner's status (web/bot_runner.py) plus what the log says.
Only an allowlist of keys reaches the page (never pid or file paths).
The whole log is read for counts/progress, so a long run is not
undercounted; `tail` is still just the last TAIL_LINES lines."""
text = (read_tail(st.get("log_path"), None)
or read_tail(st.get("out_path"), None))
p = parse(text)
running = bool(st.get("running"))
claims = p["claims"]
if running:
message = p["phase"]
else:
message = exit_text(st.get("exit_code"),
st.get("stopped_by_operator", False))
if st.get("exit_code") == 3 and p["guard"]:
message += " — " + p["guard"].removeprefix("[GUARD]").strip()
claims = [dict(c, status=_STATUS_ONCE_OVER.get(c["status"],
c["status"]))
for c in claims]
out = {k: st.get(k) for k in _PAGE_KEYS}
out.update(phase=p["phase"], progress=p["progress"],
current=p["current"], counts=p["counts"], tail=p["tail"],
message=message, claims=claims,
results_run_at=p["results_run_at"],
elapsed_s=_elapsed(st.get("started_at"), st.get("ended_at"),
running))
return out
+292
View File
@@ -0,0 +1,292 @@
r"""
The Runs page's data: every Run's `Logs\events_<run>.jsonl` (written by
`ecw_bot.core.run_events`) read into a summary for the list and a detail for
one Run (spec docs/superpowers/specs/2026-10-22-run-tracking-and-portal-
sign-in-design.md, Stage 1; terms in GLOSSARY.md).
Only Runs with an events file are listed: the run logs from before
2026-10-22 have no times on their lines and are not imported.
A Run's result:
* **Done** — `run_end` with exit 0;
* **Run Error** — `run_end` with any other exit (its Run Error's step and
message alongside);
* **Stopped** — the Portal's `stopped` event, written after its Stop;
* **Running** — no end yet and its process is the live one;
* **Ended without a record** — no end and not live: killed some other way.
Everything here is read-only and never raises on a bad file: a line a
killed Run left half-written is skipped.
"""
import re
import json
from datetime import datetime, date
from pathlib import Path
RUN_ID = re.compile(r"^\d{8}_\d{6}$")
STATUSES = ("Running", "Done", "Run Error", "Stopped",
"Ended without a record")
LOGIN_STEPS = ("page_open", "password", "captcha_shown", "captcha_solved",
"captcha_failed", "twofa_requested", "mail_received",
"approval_clicked", "home")
STEP_LABELS = {
"page_open": "Login page opened", "password": "Password typed",
"captcha_shown": "Captcha shown", "captcha_solved": "Captcha solved",
"captcha_failed": "Captcha failed", "twofa_requested":
"Log In clicked (2FA mail requested)", "mail_received":
"2FA mail received", "approval_clicked": "Approval clicked",
"home": "Home page reached",
}
LOGIN_RESULTS = {"ok": "OK", "twofa_timeout": "2FA timeout",
"captcha_failed": "Captcha failed",
"restricted": "Restricted", "failed": "Failed"}
def valid_run_id(run_id: str) -> bool:
return bool(RUN_ID.match(run_id or ""))
def _time(text):
try:
return datetime.fromisoformat(text)
except (TypeError, ValueError):
return None
def _secs(a, b):
return int((b - a).total_seconds()) if a and b else None
def read_events(logs_dir, run_id) -> list:
"""A Run's events in file order; [] for no file. Never raises."""
if not valid_run_id(run_id):
return []
path = Path(logs_dir) / f"events_{run_id}.jsonl"
out = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
for line in text.splitlines():
try:
e = json.loads(line)
except ValueError:
continue # half-written by a killed Run
if isinstance(e, dict) and _time(e.get("t")):
e["_t"] = _time(e["t"])
out.append(e)
return out
def _text(e) -> str:
"""One timeline line."""
k = e.get("kind")
if k == "run_start":
return (f"Run started by {e.get('started_by') or '?'} "
f"({e.get('source')}, {e.get('mode')})")
if k == "disk":
return f"Disk {e.get('drive')}: {e.get('free_mb')} MB free"
if k == "login_attempt_start":
return f"eCW Login Attempt {e.get('attempt')} started"
if k == "login_step":
return (f"Attempt {e.get('attempt')}: "
f"{STEP_LABELS.get(e.get('step'), e.get('step'))}")
if k == "login_attempt_end":
msg = f" — {e['message']}" if e.get("message") else ""
return (f"eCW Login Attempt {e.get('attempt')}: "
f"{LOGIN_RESULTS.get(e.get('result'), e.get('result'))}{msg}")
if k == "queue":
return f"{len(e.get('claims') or [])} claim(s) queued"
if k == "claim_start":
return f"Claim {e.get('n')}/{e.get('total')} {e.get('claim')} started"
if k == "claim_result":
return f"Claim {e.get('claim')}: {e.get('category')}"
if k == "claim_error":
return (f"Claim Error {e.get('claim')} ({e.get('step')}): "
f"{e.get('message')}")
if k == "warning":
who = f" {e['claim']}" if e.get("claim") else ""
return f"Warning{who} ({e.get('code')}): {e.get('message')}"
if k == "screenshot":
return f"Screenshot: {Path(e.get('path') or '').name}"
if k == "run_error":
return (f"Run Error ({e.get('step')}, exit {e.get('exit_code')}): "
f"{e.get('message')}")
if k == "run_end":
return f"Run ended, exit {e.get('exit_code')}"
if k == "stopped":
return f"Stopped from the Portal by {e.get('stopped_by') or '?'}"
return k or "?"
def _attempts(events) -> list:
by_n = {}
for e in events:
k = e.get("kind")
if k not in ("login_attempt_start", "login_step", "login_attempt_end"):
continue
a = by_n.setdefault(e.get("attempt"), {
"attempt": e.get("attempt"), "start": None, "end": None,
"result": None, "result_label": "Did not finish", "message": "",
"steps": {}, "_steps": {}})
if k == "login_attempt_start":
a["start"] = e["_t"]
elif k == "login_step":
a["_steps"].setdefault(e.get("step"), e["_t"])
a["steps"].setdefault(e.get("step"), e["t"])
else:
a["end"] = e["_t"]
a["result"] = e.get("result")
a["result_label"] = LOGIN_RESULTS.get(e.get("result"),
e.get("result"))
a["message"] = e.get("message") or ""
out = []
for a in sorted(by_n.values(), key=lambda a: a["attempt"] or 0):
a["duration_s"] = _secs(a["start"], a["end"])
a["twofa_wait_s"] = _secs(a["_steps"].get("twofa_requested"),
a["_steps"].get("mail_received"))
a["step_rows"] = [(STEP_LABELS[s], a["steps"][s][11:19])
for s in LOGIN_STEPS if s in a["steps"]]
del a["_steps"]
out.append(a)
return out
def summarize(run_id, events, live_run_id=None, now=None) -> dict:
now = now or datetime.now()
first = events[0] if events else {}
start_ev = next((e for e in events if e["kind"] == "run_start"), {})
end_ev = next((e for e in events if e["kind"] == "run_end"), None)
stop_ev = next((e for e in events if e["kind"] == "stopped"), None)
run_errors = [e for e in events if e["kind"] == "run_error"]
started = start_ev.get("_t") or first.get("_t") or _time_of_id(run_id)
if end_ev is not None:
code = end_ev.get("exit_code")
status = "Done" if code == 0 else "Run Error"
ended = end_ev["_t"]
elif stop_ev is not None:
code, status, ended = None, "Stopped", stop_ev["_t"]
elif run_id == live_run_id:
code, status, ended = None, "Running", None
else:
code, status = None, "Ended without a record"
ended = events[-1]["_t"] if events else None
results = [e for e in events if e["kind"] == "claim_result"]
queue = next((e.get("claims") or [] for e in events
if e["kind"] == "queue"), [])
started_claims = {e.get("claim") for e in events
if e["kind"] == "claim_start"}
not_reached = ([c for c in queue if c not in started_claims]
if status != "Running" else [])
attempts = _attempts(events)
err = run_errors[0] if run_errors else {}
return {
"run_id": run_id,
"started": started,
"ended": ended,
"duration_s": _secs(started, ended or now),
"started_by": start_ev.get("started_by") or "",
"source": start_ev.get("source") or "",
"mode": start_ev.get("mode") or "",
"test_sheet": start_ev.get("test_sheet") or "",
"status": status,
"exit_code": code,
"error_step": err.get("step") or "",
"error_message": err.get("message") or "",
"stopped_by": (stop_ev or {}).get("stopped_by") or "",
"login_attempts": attempts,
"login_total_s": sum(a["duration_s"] or 0 for a in attempts),
"pass": sum(e.get("category") == "Pass" for e in results),
"fail": sum(e.get("category") == "Fail" for e in results),
"review": sum(e.get("category") == "Review" for e in results),
"not_found": sum(e.get("category") == "Not found" for e in results),
"claims_total": len(queue),
"claims_done": len(results),
"run_errors": run_errors,
"claim_errors": [e for e in events if e["kind"] == "claim_error"],
"warnings": [e for e in events if e["kind"] == "warning"],
"screenshots": [e for e in events if e["kind"] == "screenshot"],
"not_reached": not_reached,
"timeline": [{"t": e["t"], "time": e["t"][11:19],
"kind": e["kind"], "text": _text(e)} for e in events],
}
def _time_of_id(run_id):
try:
return datetime.strptime(run_id, "%Y%m%d_%H%M%S")
except (TypeError, ValueError):
return None
def load_run(logs_dir, run_id, live_run_id=None, now=None):
"""One Run's detail, or None (bad id, no events file)."""
if not valid_run_id(run_id):
return None
if not (Path(logs_dir) / f"events_{run_id}.jsonl").is_file():
return None
return summarize(run_id, read_events(logs_dir, run_id), live_run_id, now)
def list_runs(logs_dir, live_run_id=None, now=None, day_from=None,
day_to=None, status=None) -> list:
"""Every Run with an events file, newest first, filtered. `day_from`
/ `day_to` are ISO dates (inclusive), `status` one of STATUSES."""
d_from = date.fromisoformat(day_from) if day_from else None
d_to = date.fromisoformat(day_to) if day_to else None
out = []
try:
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True)
except OSError:
files = []
for path in files:
run_id = path.stem[len("events_"):]
if not valid_run_id(run_id):
continue
day = _time_of_id(run_id).date()
if (d_from and day < d_from) or (d_to and day > d_to):
continue
r = summarize(run_id, read_events(logs_dir, run_id), live_run_id, now)
if status and r["status"] != status:
continue
out.append(r)
return out
def latest_run(logs_dir, live_run_id=None, now=None):
"""The newest Run's summary, or None — reads one file, not all."""
try:
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True)
except OSError:
return None
for path in files:
run_id = path.stem[len("events_"):]
if valid_run_id(run_id):
return summarize(run_id, read_events(logs_dir, run_id),
live_run_id, now)
return None
def screenshot_path(logs_dir, run_id, n, screenshots_root):
"""The n-th screenshot event's .png, if it is inside `screenshots_root`
and exists; else None. Only paths a Run itself recorded are served."""
shots = [e for e in read_events(logs_dir, run_id)
if e["kind"] == "screenshot"]
if not 0 <= n < len(shots):
return None
try:
root = Path(screenshots_root).resolve()
path = Path(shots[n].get("path") or "").resolve()
except (OSError, ValueError):
return None
# PNG only: a saved page's HTML served from the Portal's own origin
# would run eCW's scripts there.
if (root not in path.parents or path.suffix.lower() != ".png"
or not path.is_file()):
return None
return path
+83
View File
@@ -0,0 +1,83 @@
{# One claim's side panel — for the day page and a claim's history. A row
carries its data in data-fields (tojson: escaped for the attribute) and
data-details; the panel writes them with textContent only. #}
<aside class="drawer" id="claim-panel" hidden>
<div class="top"><h2 id="cp-title"></h2>
<button type="button" class="close">Close</button></div>
<div class="body">
<p><span id="cp-cat"></span> <span class="note" id="cp-when"></span></p>
<dl id="cp-fields"></dl>
<div id="cp-details-box" hidden><h3>Conditions and changes</h3>
<pre id="cp-details"></pre></div>
</div>
</aside>
<script>
function showClaim(tr) {
document.querySelectorAll('tr.sel').forEach(t => t.classList.remove('sel'));
tr.classList.add('sel');
$('cp-title').textContent = 'Claim ' + tr.dataset.claim;
const cat = tr.dataset.category || '';
$('cp-cat').className = CATEGORIES.includes(cat) ? 'cat ' + cat : '';
$('cp-cat').textContent = cat;
$('cp-when').textContent = tr.dataset.when || '';
const dl = $('cp-fields');
dl.replaceChildren();
// Only the fields that hold something: an empty field says nothing.
for (const [header, value] of JSON.parse(tr.dataset.fields || '[]')) {
if (!value) continue;
const dt = document.createElement('dt');
dt.textContent = header;
const dd = document.createElement('dd');
dd.textContent = value;
dl.append(dt, dd);
}
const det = tr.dataset.details || '';
$('cp-details').textContent = det;
$('cp-details-box').hidden = !det;
openPanel('claim-panel');
}
// 10 claims a page (operator, 2026-10-15), pagers above and below.
const PAGE_SIZE = 10;
const allRows = [...document.querySelectorAll('tbody tr[data-claim]')];
allRows.forEach(tr => tr.addEventListener('click', () => showClaim(tr)));
let page = 0;
const pageCount = () => Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));
function goToPage(n) {
page = Math.min(Math.max(n, 0), pageCount() - 1);
allRows.forEach((tr, i) =>
tr.hidden = Math.floor(i / PAGE_SIZE) !== page);
drawPagers();
}
function drawPagers() {
const total = allRows.length;
for (const id of ['pager-top', 'pager-bottom']) {
const box = $(id);
if (!box) continue;
box.replaceChildren();
if (pageCount() < 2) continue;
const btn = (text, to, cur) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = text;
if (cur) b.className = 'cur';
b.disabled = cur || to < 0 || to >= pageCount();
b.onclick = () => {
goToPage(to);
// From the bottom pager the new page starts above the window.
if (id === 'pager-bottom')
$('pager-top').scrollIntoView({block: 'nearest'});
};
box.append(b);
};
btn('◀ Prev', page - 1, false);
for (let i = 0; i < pageCount(); i++) btn(String(i + 1), i, i === page);
btn('Next ▶', page + 1, false);
const info = document.createElement('span');
info.className = 'info';
info.textContent = `Showing ${page * PAGE_SIZE + 1}–` +
`${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`;
box.append(info);
}
}
goToPage(0);
</script>
+192
View File
@@ -0,0 +1,192 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="run-token" content="{{ run_token }}">
<title>{% block title %}Charge Validation{% endblock %} · eCW</title>
{% block head %}{% endblock %}
<style>
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light
grey, blue buttons; Pass green, Fail red, Review amber everywhere. */
:root {
--navy: #12355b; --navy-2: #0f2a47; --bg: #f4f6fa; --panel: #ffffff;
--text: #1f2a37; --muted: #6b7785; --line: #dde3ec; --head: #e8edf4;
--accent: #1565c0; --accent-dark: #0d4f9e;
--pass: #2e7d32; --pass-bg: #e8f5e9;
--fail: #c62828; --fail-bg: #fdecea;
--review: #b45309; --review-bg: #fff3e0; --review-bar: #ef6c00;
--stripe: #f9fafc; --hilite: #eef4ff; --sel: #dbe7fb;
--shadow: 0 1px 2px rgba(18, 53, 91, .06), 0 2px 8px rgba(18, 53, 91, .05);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.45 "Segoe UI", system-ui, sans-serif; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
header.top { background: var(--navy); color: #fff; padding: 12px 24px;
display: flex; align-items: center; gap: 14px; }
header.top .brand { color: #fff; font-size: 17px; font-weight: 600;
letter-spacing: .2px; }
header.top .brand:hover { text-decoration: none; }
.pill { border-radius: 12px; padding: 2px 10px; font-size: 11px;
font-weight: 700; letter-spacing: .4px; background: var(--pass);
color: #fff; }
.pill.LIVE-WRITE { background: var(--fail); }
header.top nav.links { margin-left: auto; display: flex; gap: 18px;
align-items: center; }
header.top nav.links a { color: #e3ecf7; font-weight: 600; }
header.top .who { color: #b9c6d6; font-size: 13px; }
header.top .who b { color: #fff; }
header.top form.out { margin: 0; }
header.top form.out button { background: transparent; border-color: #6f86a3;
font-weight: 400; padding: 4px 10px; }
.msg { border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; }
.msg.bad { background: var(--fail-bg); color: var(--fail); }
.msg.good { background: var(--pass-bg); color: var(--pass); }
nav.crumbs { background: var(--navy-2); color: #b9c6d6; padding: 7px 24px;
font-size: 13px; }
nav.crumbs a { color: #e3ecf7; }
nav.crumbs .sep { margin: 0 8px; color: #7d8ea3; }
nav.crumbs .here { color: #fff; font-weight: 600; }
main { max-width: 1180px; margin: 0 auto; padding: 22px 24px 40px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 16px; }
.card { background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px 18px; box-shadow: var(--shadow);
margin-bottom: 18px; }
.card h2 { font-size: 15px; margin: 0 0 10px; }
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px; margin-bottom: 18px; }
.tile { background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 12px 16px; box-shadow: var(--shadow);
color: var(--text); display: block; }
.tile .n { font-size: 26px; font-weight: 700; display: block; }
.tile .l { color: var(--muted); font-size: 12px; text-transform: uppercase;
letter-spacing: .5px; }
.tile.Pass .n { color: var(--pass); }
.tile.Fail .n { color: var(--fail); }
.tile.Review .n { color: var(--review); }
a.tile:hover { text-decoration: none; border-color: var(--accent); }
a.tile.on { border: 2px solid var(--accent); padding: 11px 15px; }
select, input, button { font: inherit; padding: 7px 10px;
border: 1px solid #c9d2de; border-radius: 6px;
background: var(--panel); color: var(--text); }
button, .btn { background: var(--accent); color: #fff;
border: 1px solid var(--accent); border-radius: 6px;
padding: 7px 14px; cursor: pointer; font-weight: 600;
display: inline-block; }
button:hover, .btn:hover { background: var(--accent-dark);
text-decoration: none; }
button.plain { background: var(--panel); color: var(--text);
border-color: #c9d2de; font-weight: 400; }
button.plain:hover { background: var(--hilite); }
button[disabled] { opacity: .5; cursor: default; }
.cat { display: inline-block; padding: 2px 9px; border-radius: 10px;
font-weight: 600; font-size: 12px; }
.cat.Pass { background: var(--pass-bg); color: var(--pass); }
.cat.Fail { background: var(--fail-bg); color: var(--fail); }
.cat.Review { background: var(--review-bg); color: var(--review); }
.empty { background: var(--panel); border: 1px dashed #c9d2de;
border-radius: 10px; padding: 26px; color: var(--muted);
text-align: center; }
.note { color: var(--muted); font-size: 12px; }
footer.note { max-width: 1180px; margin: 0 auto; padding: 0 24px 24px; }
/* Tables: short, no sideways scroll; long text cut on one line. */
table.list { width: 100%; border-collapse: separate; border-spacing: 0;
background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; overflow: hidden; table-layout: fixed; }
table.list th, table.list td { padding: 9px 12px; text-align: left;
border-bottom: 1px solid var(--line);
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
table.list th { background: var(--head); font-size: 12px; color: #3b4757;
font-weight: 600; text-transform: uppercase;
letter-spacing: .4px; }
table.list tbody tr { cursor: pointer; }
table.list tbody tr:nth-child(even) td { background: var(--stripe); }
table.list tbody tr:hover td { background: var(--hilite); }
table.list tbody tr.sel td { background: var(--sel); }
table.list tbody tr:last-child td { border-bottom: 0; }
td.claim { font-weight: 600; }
.pager { display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
margin: 12px 0; }
.pager button { padding: 4px 10px; background: var(--panel);
color: var(--text); border-color: #c9d2de; font-weight: 400; }
.pager button.cur { background: var(--accent); color: #fff;
border-color: var(--accent); }
.pager button[disabled]:not(.cur) { opacity: .4; }
.pager .info { color: var(--muted); margin-left: 8px; }
form.search { display: flex; gap: 8px; align-items: center;
flex-wrap: wrap; }
form.search input { min-width: 220px; }
/* Side panels. The page under an open panel does not scroll: one scroll
bar, the panel's (operator, 2026-10-15; review 2026-10-19). */
body.drawer-open { overflow: hidden; }
.drawer { position: fixed; top: 0; right: 0; height: 100vh;
width: min(480px, 100vw); background: var(--panel);
border-left: 1px solid var(--line); z-index: 20;
box-shadow: -8px 0 24px rgba(18, 53, 91, .12);
padding: 0 0 30px; overflow-y: auto; }
.drawer[hidden] { display: none; }
.drawer .top { display: flex; align-items: center; gap: 10px;
background: var(--navy); color: #fff; padding: 12px 18px;
position: sticky; top: 0; }
.drawer .top h2 { font-size: 16px; margin: 0; }
.drawer .top .close { margin-left: auto; background: transparent;
border-color: #6f86a3; font-weight: 400; }
.drawer .body { padding: 14px 18px; }
.drawer dl { display: grid; grid-template-columns: 140px 1fr;
gap: 6px 12px; margin: 0; font-size: 13px; }
.drawer dt { color: var(--muted); }
.drawer dd { margin: 0; white-space: pre-wrap; word-break: break-word; }
.drawer h3 { font-size: 14px; margin: 16px 0 6px; }
.drawer pre { background: var(--head); padding: 10px; border-radius: 6px;
white-space: pre-wrap; font-size: 12px; margin: 0; }
@media (max-width: 760px) {
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
main { padding: 16px; }
header.top, nav.crumbs { padding-left: 16px; padding-right: 16px; }
}
{% block style %}{% endblock %}
</style>
<script>
// Shared by every page, defined before any page script runs: one side
// panel open at a time; Escape and every Close button shut it.
const $ = id => document.getElementById(id);
const CATEGORIES = {{ categories | list | tojson }};
function openPanel(id) {
document.querySelectorAll('.drawer').forEach(p => p.hidden = p.id !== id);
document.body.classList.toggle('drawer-open', id !== null);
}
const closePanels = () => openPanel(null);
document.addEventListener('click', e => {
if (e.target.closest('.drawer .close')) closePanels();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closePanels();
});
</script>
</head>
<body>
<header class="top">
<a class="brand" href="/">eCW Charge Validation</a>
<span class="pill {{ mode }}" title="SAVE_CORRECTIONS mode">{{ mode }}</span>
{% if user %}<nav class="links">
<a href="/days">Claim status</a><a href="/runs">Runs</a>
{% if is_admin %}<a href="/admin/users">Users</a><a href="/admin/signins">Sign-ins</a>{% endif %}
<a class="who" href="/account/password" title="Change password"><b>{{ user.display_name }}</b> · {{ user.role }}</a>
<form class="out" method="post" action="/signout"><input type="hidden" name="token" value="{{ run_token }}"><button type="submit">Sign out</button></form>
</nav>{% endif %}
</header>
<nav class="crumbs">
{% for label, link in crumbs %}{% if not loop.first %}<span class="sep">›</span>{% endif %}{% if link %}<a href="{{ link }}">{{ label }}</a>{% else %}<span class="here">{{ label }}</span>{% endif %}{% endfor %}
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer class="note">Local page (127.0.0.1) — contains patient data; do not
share screenshots outside the team.</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Find a claim{% endblock %}
{% block style %}
table.list col.date { width: 150px; }
table.list col.claim { width: 120px; }
table.list col.time { width: 100px; }
table.list col.mode { width: 96px; }
table.list col.result { width: 96px; }
/* A phone: the key columns only — the rest is in the side panel. */
@media (max-width: 760px) {
table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; }
}
{% endblock %}
{% block content %}
<h1>Find a claim</h1>
<p class="sub">Every time the bot checked a claim, newest first.</p>
<form class="search card" method="get" action="/claim">
<input type="search" name="claim_no" value="{{ claim_no }}" autofocus
placeholder="Claim #" inputmode="numeric" aria-label="Claim number">
<button type="submit">Find</button>
</form>
{% if rows %}
<div class="pager" id="pager-top"></div>
<table class="list">
<colgroup><col class="date"><col class="claim"><col class="time"><col class="mode"><col class="result"><col></colgroup>
<thead><tr><th>Date</th><th>Claim #</th><th>Time</th><th>Mode</th><th>Result</th><th>Comments</th></tr></thead>
<tbody>
{% for row in rows %}
<tr data-claim="{{ row.claim_no or '' }}" data-category="{{ row.category or '' }}"
data-when="{{ row.run_at | usdate }} · {{ row.run_mode or '' }}"
data-fields='{{ row.fields | tojson }}' data-details="{{ row.details_pretty }}">
<td><a href="/day/{{ row.run_at.date().isoformat() }}?claim={{ (row.claim_no or '') | urlencode }}">{{ row.run_at.date() | longdate }}</a></td>
<td class="claim">{{ row.claim_no or "" }}</td>
<td>{{ row.run_at | clock }}</td>
<td>{{ row.run_mode or "" }}</td>
<td>{% if row.category %}<span class="cat {{ row.category }}">{{ row.category }}</span>{% endif %}</td>
<td title="{{ row.comments or '' }}">{{ row.comments or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pager" id="pager-bottom"></div>
{% elif claim_no %}
<div class="empty">No saved check of a claim matching “{{ claim_no }}”.</div>
{% endif %}
{% include "_claim_panel.html" %}
{% endblock %}
{% block scripts %}
<script>
// The date link goes to the day; the rest of the row opens the panel.
document.querySelectorAll('tbody a')
.forEach(a => a.addEventListener('click', e => e.stopPropagation()));
</script>
{% endblock %}
+273
View File
@@ -0,0 +1,273 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block style %}
.botrun .row { display: flex; gap: 12px; align-items: center;
flex-wrap: wrap; }
.botrun .row + .row { margin-top: 10px; }
.botrun h2 { margin: 0; }
#br-message { color: var(--muted); }
.botrun button.stop { background: var(--fail); border-color: var(--fail); }
.botrun progress { width: 260px; height: 10px; accent-color: var(--accent); }
.botrun pre { background: var(--head); padding: 10px; border-radius: 6px;
max-height: 260px; overflow: auto; font-size: 12px;
white-space: pre-wrap; }
.botrun summary { cursor: pointer; color: var(--accent); margin-top: 10px; }
.chip { padding: 2px 9px; border-radius: 10px; font-size: 12px;
font-weight: 600; }
/* `.row { display: flex }` beats the browser's [hidden] rule. */
.botrun [hidden] { display: none; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 18px;
align-items: stretch; }
.actions .card { margin-bottom: 0; }
a.big { display: flex; align-items: center; justify-content: center;
gap: 10px; font-size: 18px; padding: 22px; border-radius: 10px;
height: 100%; }
.today-h { display: flex; justify-content: space-between; align-items: baseline;
margin: 4px 0 10px; }
.today-h h2 { font-size: 15px; margin: 0; }
#live-summary { color: var(--muted); margin: 0 0 10px; }
#live-list { list-style: none; margin: 0; padding: 0; }
#live-list li { display: flex; gap: 10px; align-items: center;
padding: 7px 8px; border-bottom: 1px solid var(--line); }
#live-list li.opens { cursor: pointer; }
#live-list li.opens:hover { background: var(--hilite); }
#live-list .n { color: var(--muted); width: 34px; }
#live-list .id { font-weight: 600; width: 100px; }
#live-list .st { margin-left: auto; }
#live-list li.st-checking { background: var(--hilite); }
#live-list li.st-checking .st { color: var(--accent); font-weight: 600; }
#live-list li.st-idle .st { color: var(--muted); }
@media (max-width: 760px) { .actions { grid-template-columns: 1fr; } }
/* The last Run ended badly (EEWA-33). */
.alert { background: var(--fail-bg); color: var(--fail);
border: 1px solid #f5c2bd; border-left: 5px solid var(--fail);
border-radius: 10px; padding: 12px 16px; margin-bottom: 18px; }
.alert a { color: var(--fail); font-weight: 600; text-decoration: underline; }
{% endblock %}
{% block content %}
{% if alert %}
<div class="alert" id="last-run-alert" role="alert">
<b>The last run {{ "ended in a Run Error" if alert.status == "Run Error" else "ended without a record (killed or crashed)" }}</b>
— {{ alert.started | usdate }}, started by {{ alert.started_by or "?" }}{% if alert.error_step %} · {{ alert.error_step }}{% if alert.exit_code is not none %} (exit {{ alert.exit_code }}){% endif %}: {{ alert.error_message }}{% endif %}{% if alert.not_reached %} · {{ alert.not_reached | length }} claim(s) not reached{% endif %}.
<a href="/runs/{{ alert.run_id }}">See the run</a>
</div>
{% endif %}
<section class="card botrun" id="botrun">
<div class="row">
<h2>Bot run</h2>
<span id="br-message">…</span>
<button type="button" id="br-start"{% if not can_run %} hidden{% endif %}>▶ Start run</button>
<button type="button" id="br-stop" class="stop" hidden>Stop</button>
<span id="br-terminal" hidden>Run started in the terminal — stop it
there</span>
<button type="button" class="plain" id="live-open">Live claims</button>
</div>
<p class="note" id="br-verify">VERIFY mode — nothing is saved to eCW.
CAPTCHA and 2FA boxes still come up on this PC when the login needs a
person.</p>
<div class="row" id="br-progress" hidden>
<progress id="br-bar" max="1" value="0"></progress>
<span id="br-count"></span>
<span id="br-current"></span>
<span class="chip cat Pass" id="br-pass"></span>
<span class="chip cat Fail" id="br-fail"></span>
<span class="chip cat Review" id="br-review"></span>
</div>
<details id="br-logbox" hidden><summary>Live log</summary>
<pre id="br-log"></pre></details>
</section>
<div class="today-h">
<h2>Today · {{ today.day | longdate }}</h2>
{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}">Open today's claims →</a>{% endif %}
</div>
<div class="tiles">
<a class="tile" href="/day/{{ today.day.isoformat() }}"><span class="n">{{ today.claims }}</span><span class="l">Checked today</span></a>
<a class="tile Pass" href="/day/{{ today.day.isoformat() }}?category=Pass"><span class="n">{{ today.pass_count }}</span><span class="l">Pass</span></a>
<a class="tile Fail" href="/day/{{ today.day.isoformat() }}?category=Fail"><span class="n">{{ today.fail_count }}</span><span class="l">Fail</span></a>
<a class="tile Review" href="/day/{{ today.day.isoformat() }}?category=Review"><span class="n">{{ today.review_count }}</span><span class="l">Review</span></a>
</div>
<div class="actions">
<a class="btn big" href="/days" id="claim-status">Claim status →</a>
<form class="search card" method="get" action="/claim">
<strong>Find a claim</strong>
<input type="search" name="claim_no" placeholder="Claim #"
inputmode="numeric" aria-label="Claim number">
<button type="submit">Find</button>
</form>
</div>
{% if not has_runs %}
<p class="note">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
in <code>.env</code>; each day's claims appear under Claim status.</p>
{% endif %}
<aside class="drawer" id="live-panel" hidden>
<div class="top"><h2>Live claims</h2>
<button type="button" class="close">Close</button></div>
<div class="body">
<p id="live-summary">…</p>
<ol id="live-list"></ol>
</div>
</aside>
{% endblock %}
{% block scripts %}
<script>
(() => {
const token = document.querySelector('meta[name="run-token"]').content;
// A Viewer sees the run, but gets no Start / Stop (the server refuses
// them too).
const CAN_RUN = {{ can_run | tojson }};
let wasRunning = null;
// One poll loop: a poll started later (after a Start) supersedes any
// earlier one still waiting on its fetch or its timer.
let timer = null, pollGen = 0;
const mins = s => s == null ? '' :
` in ${Math.floor(s / 60)}m ${s % 60}s`;
// Shown when the Portal server itself is gone (run_web.bat closed): the
// fetch throws, and a Start used to do nothing at all (2026-10-22).
const UNREACHABLE = 'The Portal is not reachable — is run_web.bat still ' +
'running? Start it again, then reload this page.';
async function post(path) {
let r;
try {
r = await fetch(path, {method: 'POST',
headers: {'X-Run-Token': token}});
} catch (e) {
alert(UNREACHABLE);
return false;
}
let body = {};
try { body = await r.json(); } catch (e) {}
if (!r.ok) alert(body.detail || ('Failed: HTTP ' + r.status));
return r.ok;
}
// Page text from the log goes in with textContent only, never innerHTML.
function show(s) {
// A terminal run may be LIVE-WRITE: the page neither stops it nor
// calls it VERIFY.
const terminal = s.running && s.source !== 'web';
$('br-start').hidden = !CAN_RUN || s.running;
$('br-stop').hidden = !CAN_RUN || !s.running || terminal;
$('br-terminal').hidden = !terminal;
$('br-verify').hidden = terminal;
const ended = s.started_at || s.exit_code != null ||
s.stopped_by_operator;
const who = s.started_by ? ` · started by ${s.started_by}` : '';
$('br-message').textContent = s.running ? s.message + who
: (ended ? s.message + mins(s.elapsed_s) +
(s.exit_code != null ? ` — exit ${s.exit_code}` : '') + who
: 'No run yet');
const p = s.progress;
$('br-progress').hidden = !p;
if (p) {
$('br-bar').max = p[1]; $('br-bar').value = p[0];
$('br-count').textContent = `${p[0]} / ${p[1]}`;
$('br-current').textContent = s.running && s.current
? `claim ${s.current}` : '';
$('br-pass').textContent = `Pass ${s.counts.Pass || 0}`;
$('br-fail').textContent = `Fail ${s.counts.Fail || 0}`;
$('br-review').textContent = `Review ${s.counts.Review || 0}`;
}
$('br-logbox').hidden = !(s.tail && s.tail.length);
$('br-log').textContent = (s.tail || []).join('\n');
drawClaims(s);
}
// ── every claim of the running (or last) run, from the status poll ──────
const STATUS_CLASS = {'Checking': 'st-checking', 'Waiting': 'st-idle',
'Not checked': 'st-idle', 'Not finished': 'st-idle',
'Not found': 'st-idle'};
let lastStatus = null;
function drawClaims(s) {
lastStatus = s;
const cs = s.claims || [];
const done = cs.filter(c => c.done).length;
const waiting = cs.filter(c => c.status === 'Waiting').length;
const checking = cs.find(c => c.status === 'Checking');
let summary = s.running
? `Checking ${done + (checking ? 1 : 0)} of ${cs.length} · ` +
`${done} done · ${waiting} waiting`
: `${cs.length} claims · ${done} done` +
(done < cs.length ? ` · ${cs.length - done} not finished` : '');
// A claim opens on the day its run is saved under — none when the run
// saves no results.
const day = s.results_run_at ? s.results_run_at.slice(0, 10) : null;
summary += day ? ` · saved under ${day}`
: ' · not saved to the results table';
$('live-summary').textContent = cs.length ? summary
: (s.running ? 'Starting — the claims appear once the bot reaches them'
: 'No claims in the last run');
const list = $('live-list');
list.replaceChildren();
for (const c of cs) {
const li = document.createElement('li');
li.className = STATUS_CLASS[c.status] || '';
for (const [cls, text] of [['n', '#' + c.n], ['id', c.id]]) {
const span = document.createElement('span');
span.className = cls; span.textContent = text; li.append(span);
}
// A result has a saved row to open; Not found (done too) has none.
const result = c.done && c.status !== 'Not found';
const st = document.createElement('span');
st.className = 'st' + (result && CATEGORIES.includes(c.status)
? ' cat ' + c.status : '');
st.textContent = c.status === 'Checking' ? 'Checking…' : c.status;
li.append(st);
if (result && day) {
li.classList.add('opens');
li.onclick = () => {
location.href = '/day/' + day + '?claim=' +
encodeURIComponent(c.id);
};
}
list.append(li);
}
}
$('live-open').onclick = () => {
if (lastStatus) drawClaims(lastStatus);
openPanel('live-panel');
};
async function poll() {
clearTimeout(timer);
const gen = ++pollGen;
let s = null, failed = false;
try {
const r = await fetch('/api/bot/status');
s = await r.json();
} catch (e) {
failed = true;
}
if (gen !== pollGen) return; // a newer poll took over
if (failed) $('br-message').textContent = UNREACHABLE;
else show(s);
// A run just ended: today's counts have changed.
if (s && wasRunning && !s.running) { location.reload(); return; }
if (s) wasRunning = s.running;
timer = setTimeout(poll, s && s.running ? 2000 : 10000);
}
$('br-start').onclick = async () => {
if (!confirm('Start a VERIFY run now? The bot logs in to eCW ' +
'(2FA approval mail) and checks the claims.')) return;
$('br-start').disabled = true;
let ok = false;
try { ok = await post('/api/bot/start'); }
finally { $('br-start').disabled = false; }
if (ok) { wasRunning = true; poll(); }
};
$('br-stop').onclick = async () => {
if (!confirm('Stop the run? The claim it is on is left as it is ' +
'(nothing is saved in VERIFY mode).')) return;
$('br-stop').disabled = true;
try { await post('/api/bot/stop'); }
finally { $('br-stop').disabled = false; }
};
poll();
})();
</script>
{% endblock %}
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}{{ day | longdate }}{% endblock %}
{% block style %}
table.list col.n { width: 52px; }
table.list col.claim { width: 120px; }
table.list col.time { width: 100px; }
table.list col.payer { width: 22%; }
table.list col.result { width: 96px; }
.bar { display: flex; justify-content: space-between; align-items: end;
gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
/* A phone: the key columns only — the rest is in the side panel. */
@media (max-width: 760px) {
table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; }
table.list col.n { width: 36px; }
table.list col.claim { width: 96px; }
table.list col.time { width: 84px; }
}
{% endblock %}
{% block content %}
<div class="bar">
<div>
<h1>{{ day | longdate }}</h1>
<p class="sub">{{ counts.claims }} claims checked · {{ counts.runs }}
run{{ "" if counts.runs == 1 else "s" }} — every run of the day together</p>
</div>
<form class="search" method="get" action="/day/{{ day.isoformat() }}">
{% if category %}<input type="hidden" name="category" value="{{ category }}">{% endif %}
<input type="search" name="claim_no" value="{{ claim_no }}"
placeholder="Search claim #" inputmode="numeric"
aria-label="Search claim number">
<button type="submit">Search</button>
</form>
</div>
{% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %}
<div class="tiles">
<a class="tile{{ ' on' if not category }}" href="/day/{{ day.isoformat() }}{{ ('?claim_no=' ~ claim_no) if claim_no }}"><span class="n">{{ counts.claims }}</span><span class="l">All</span></a>
<a class="tile Pass{{ ' on' if category == 'Pass' }}" href="/day/{{ day.isoformat() }}?category=Pass{{ q }}"><span class="n">{{ counts.pass_count }}</span><span class="l">Pass</span></a>
<a class="tile Fail{{ ' on' if category == 'Fail' }}" href="/day/{{ day.isoformat() }}?category=Fail{{ q }}"><span class="n">{{ counts.fail_count }}</span><span class="l">Fail</span></a>
<a class="tile Review{{ ' on' if category == 'Review' }}" href="/day/{{ day.isoformat() }}?category=Review{{ q }}"><span class="n">{{ counts.review_count }}</span><span class="l">Review</span></a>
</div>
{% if rows %}
<div class="pager" id="pager-top"></div>
<table class="list">
<colgroup><col class="n"><col class="claim"><col class="time"><col class="payer"><col class="result"><col></colgroup>
<thead><tr><th>#</th><th>Claim #</th><th>Time</th><th>Payer</th><th>Result</th><th>Comments</th></tr></thead>
<tbody>
{% for row in rows %}
<tr data-claim="{{ row.claim_no or '' }}" data-category="{{ row.category or '' }}"
data-when="{{ row.run_at | usdate }} · {{ row.run_mode or '' }}"
data-fields='{{ row.fields | tojson }}' data-details="{{ row.details_pretty }}">
<td>{{ loop.index }}</td>
<td class="claim">{{ row.claim_no or "" }}</td>
<td>{{ row.run_at | clock }}</td>
<td title="{{ row.payer or '' }}">{{ row.payer or "" }}</td>
<td>{% if row.category %}<span class="cat {{ row.category }}">{{ row.category }}</span>{% endif %}</td>
<td title="{{ row.comments or '' }}">{{ row.comments or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pager" id="pager-bottom"></div>
{% elif counts.claims %}
<div class="empty">No claims match this filter. <a href="/day/{{ day.isoformat() }}">Show all</a></div>
{% else %}
<div class="empty">No claims were checked on this day. <a href="/days">Back to Claim status</a></div>
{% endif %}
{% include "_claim_panel.html" %}
{% endblock %}
{% block scripts %}
<script>
// ?claim=<no> (from the Live claims panel or a claim's history): open
// that claim — the last check of it that day — on its page.
(() => {
const want = {{ opened | tojson }};
if (!want) return;
let i = -1;
allRows.forEach((tr, k) => { if (tr.dataset.claim === want) i = k; });
if (i < 0) return;
goToPage(Math.floor(i / PAGE_SIZE));
allRows[i].scrollIntoView({block: 'center'});
showClaim(allRows[i]);
})();
</script>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Claim status{% endblock %}
{% block style %}
.days { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px; }
a.daycard { display: block; background: var(--panel); color: var(--text);
border: 1px solid var(--line); border-left: 5px solid var(--accent);
border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); }
a.daycard:hover { text-decoration: none; border-color: var(--accent);
background: var(--hilite); }
.daycard .d { font-size: 16px; font-weight: 700; }
.daycard .m { color: var(--muted); font-size: 12px; margin: 2px 0 8px; }
.daycard .c { display: flex; gap: 12px; font-weight: 600; font-size: 13px; }
.daycard .c .Pass { color: var(--pass); }
.daycard .c .Fail { color: var(--fail); }
.daycard .c .Review { color: var(--review); }
.stack { display: flex; height: 7px; border-radius: 4px; overflow: hidden;
background: var(--head); margin-top: 10px; }
.stack i { display: block; }
.stack i.Pass { background: var(--pass); }
.stack i.Fail { background: var(--fail); }
.stack i.Review { background: var(--review-bar); }
{% endblock %}
{% block content %}
<h1>Claim status</h1>
<p class="sub">Every day the bot checked claims, newest first — all runs of a
day together. Pick a day to see its claims.</p>
{% if days %}
<div class="days">
{% for d in days %}
<a class="daycard" href="/day/{{ d.day.isoformat() }}">
<div class="d">{{ d.day | longdate }}</div>
<div class="m">{{ d.claims }} claims checked · {{ d.runs }} run{{ "" if d.runs == 1 else "s" }}</div>
<div class="c">
<span class="Pass">{{ d.pass_count }} Pass</span>
<span class="Fail">{{ d.fail_count }} Fail</span>
<span class="Review">{{ d.review_count }} Review</span>
</div>
<div class="stack" aria-hidden="true">
<i class="Pass" data-n="{{ d.pass_count }}"></i><i class="Fail" data-n="{{ d.fail_count }}"></i><i class="Review" data-n="{{ d.review_count }}"></i>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
in <code>.env</code>; each day's claims appear here.</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Each part of a day's colour bar as wide as its count.
document.querySelectorAll('.stack i').forEach(i => {
i.style.flexGrow = Number(i.dataset.n) || 0;
});
</script>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Change password{% endblock %}
{% block style %}
.pw { max-width: 420px; }
.pw label { display: block; margin: 0 0 12px; font-weight: 600; }
.pw input { display: block; width: 100%; margin-top: 4px; }
{% endblock %}
{% block content %}
<div class="pw">
<h1>Change password</h1>
{% if user.must_change %}<p class="sub">Choose your own password before
going on (an Admin set the one you signed in with).</p>
{% else %}<p class="sub">At least 10 characters, not your username.</p>{% endif %}
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
<form class="card" method="post" action="/account/password" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<label>Current password <input name="current" type="password" autocomplete="current-password" required></label>
<label>New password <input name="new" type="password" autocomplete="new-password" minlength="10" required></label>
<label>New password again <input name="confirm" type="password" autocomplete="new-password" minlength="10" required></label>
<button type="submit">Change password</button>
</form>
</div>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Run {{ run.run_id }}{% endblock %}
{% block head %}{% if run.status == "Running" %}<meta http-equiv="refresh" content="5">{% endif %}{% endblock %}
{% block style %}
.st { display: inline-block; padding: 2px 9px; border-radius: 10px;
font-weight: 600; font-size: 12px; background: var(--head); }
.st.Done { background: var(--pass-bg); color: var(--pass); }
.st.Run-Error, .st.Ended-without-a-record { background: var(--fail-bg);
color: var(--fail); }
.st.Stopped { background: var(--review-bg); color: var(--review); }
.st.Running { background: var(--hilite); color: var(--accent); }
dl.facts { display: grid; grid-template-columns: 150px 1fr; gap: 6px 12px;
margin: 0; }
dl.facts dt { color: var(--muted); }
dl.facts dd { margin: 0; }
.card.error { border-left: 5px solid var(--fail); }
.card.warn { border-left: 5px solid var(--review-bar); }
table.plain { width: 100%; border-collapse: collapse; font-size: 13px; }
table.plain th, table.plain td { text-align: left; padding: 6px 8px;
border-bottom: 1px solid var(--line);
vertical-align: top; }
table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase;
letter-spacing: .4px; background: var(--head); }
td.t { white-space: nowrap; color: var(--muted); width: 90px; }
.ok { color: var(--pass); font-weight: 600; }
.no { color: var(--fail); font-weight: 600; }
pre.tb { background: var(--head); padding: 10px; border-radius: 6px;
white-space: pre-wrap; font-size: 12px; margin: 6px 0 0; }
ul.claims { columns: 4; margin: 0; padding-left: 18px; }
tr.k-run_error td, tr.k-claim_error td { color: var(--fail); }
tr.k-warning td { color: var(--review); }
{% endblock %}
{% block content %}
<h1>Run {{ run.started | usdate }} <span class="st {{ run.status | replace(' ', '-') }}">{{ run.status }}</span></h1>
<p class="sub">{% if run.status == "Running" %}Still running — this page refreshes every 5 seconds.{% else %}Ran {{ run.duration_s | dur }}.{% endif %}</p>
<div class="card">
<dl class="facts">
<dt>Started By</dt><dd>{{ run.started_by }}</dd>
<dt>Source</dt><dd>{{ run.source | capitalize }}</dd>
<dt>Mode</dt><dd>{{ run.mode }}{% if run.test_sheet %} · claims from {{ run.test_sheet }}{% endif %}</dd>
<dt>Started</dt><dd>{{ run.started | usdate }}</dd>
<dt>Ended</dt><dd>{{ run.ended | usdate }}</dd>
<dt>Result</dt><dd>{{ run.status }}{% if run.exit_code is not none %} (exit {{ run.exit_code }}){% endif %}{% if run.error_step %} — {{ run.error_step }}: {{ run.error_message }}{% endif %}</dd>
{% if run.status == "Stopped" %}<dt>Stopped By</dt><dd>{{ run.stopped_by or "—" }}</dd>{% endif %}
<dt>Claims</dt><dd>{{ run.claims_done }} of {{ run.claims_total }} checked ·
{{ run.pass }} Pass · {{ run.fail }} Fail · {{ run.review }} Review{% if run.not_found %} · {{ run.not_found }} not found{% endif %}</dd>
</dl>
</div>
{% if run.run_errors %}
<div class="card error">
<h2>Run Error</h2>
{% for e in run.run_errors %}
<p><strong>{{ e.t[11:19] }}</strong> · {{ e.step }} · exit {{ e.exit_code }} — {{ e.message }}</p>
{% if e.traceback %}<details><summary>Traceback</summary><pre class="tb">{{ e.traceback }}</pre></details>{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="card">
<h2>eCW Login Attempts{% if run.login_attempts %} · {{ run.login_total_s | dur }} in all{% endif %}</h2>
{% if run.login_attempts %}
<table class="plain">
<thead><tr><th>#</th><th>Start</th><th>End</th><th>Took</th><th>2FA mail wait</th><th>Result</th><th>Steps</th></tr></thead>
<tbody>
{% for a in run.login_attempts %}
<tr>
<td>{{ a.attempt }}</td>
<td>{{ a.start | usdate }}</td>
<td>{{ a.end | usdate }}</td>
<td>{{ a.duration_s | dur }}</td>
<td>{{ a.twofa_wait_s | dur if a.twofa_wait_s is not none else "—" }}</td>
<td class="{{ 'ok' if a.result == 'ok' else 'no' }}">{{ a.result_label }}{% if a.message %} — {{ a.message }}{% endif %}</td>
<td>{% for label, time in a.step_rows %}{{ time }} {{ label }}{% if not loop.last %}<br>{% endif %}{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<p class="note">No eCW login in this run.</p>{% endif %}
</div>
{% if run.claim_errors %}
<div class="card error">
<h2>Claim Errors ({{ run.claim_errors | length }})</h2>
<p class="note">The bot could not check these claims; the Charge Summary shows them as Review.</p>
<table class="plain">
<thead><tr><th>Time</th><th>Claim</th><th>Step</th><th>Message</th></tr></thead>
<tbody>{% for e in run.claim_errors %}<tr><td class="t">{{ e.t[11:19] }}</td><td>{{ e.claim }}</td><td>{{ e.step }}</td><td>{{ e.message }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% endif %}
{% if run.not_reached %}
<div class="card error">
<h2>Not Reached ({{ run.not_reached | length }})</h2>
<p class="note">Queued, but the run ended before it got to them.</p>
<ul class="claims">{% for c in run.not_reached %}<li>{{ c }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if run.warnings %}
<div class="card warn">
<h2>Warnings ({{ run.warnings | length }})</h2>
<table class="plain">
<thead><tr><th>Time</th><th>Kind</th><th>Claim</th><th>Message</th></tr></thead>
<tbody>{% for e in run.warnings %}<tr><td class="t">{{ e.t[11:19] }}</td><td>{{ e.code }}</td><td>{{ e.claim or "" }}</td><td>{{ e.message }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% endif %}
{% if run.screenshots %}
<div class="card">
<h2>Failure screenshots</h2>
<ul>{% for e in run.screenshots %}<li>{{ e.t[11:19] }} <a href="/runs/{{ run.run_id }}/shot/{{ loop.index0 }}" target="_blank" rel="noopener">{{ e.tag }}</a></li>{% endfor %}</ul>
<p class="note">Each one's page HTML, when saved, is beside it in Screenshots\ (open it there, not here).</p>
</div>
{% endif %}
<div class="card">
<h2>Timeline</h2>
<table class="plain">
<tbody>{% for e in run.timeline %}<tr class="k-{{ e.kind }}"><td class="t">{{ e.time }}</td><td>{{ e.text }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Runs{% endblock %}
{% block style %}
.st { display: inline-block; padding: 2px 9px; border-radius: 10px;
font-weight: 600; font-size: 12px; background: var(--head); }
.st.Done { background: var(--pass-bg); color: var(--pass); }
.st.Run-Error, .st.Ended-without-a-record { background: var(--fail-bg);
color: var(--fail); }
.st.Stopped { background: var(--review-bg); color: var(--review); }
.st.Running { background: var(--hilite); color: var(--accent); }
td .Pass { color: var(--pass); font-weight: 600; }
td .Fail { color: var(--fail); font-weight: 600; }
td .Review { color: var(--review); font-weight: 600; }
td.bad { color: var(--fail); font-weight: 600; }
td.warn { color: var(--review); font-weight: 600; }
table.list tbody tr a { color: inherit; display: block; }
/* Fixed layout takes the header's widths: the time and the login column
must not be cut at 1180 px. */
#runs th.w-start { width: 160px; } #runs th.w-who { width: 100px; }
#runs th.w-src { width: 85px; } #runs th.w-mode { width: 80px; }
#runs th.w-dur { width: 85px; } #runs th.w-res { width: 135px; }
#runs th.w-login { width: 150px; } #runs th.w-cat { width: 125px; }
#runs th.w-err { width: 95px; } #runs th.w-warn { width: 80px; }
table.list tbody tr a:hover { text-decoration: none; }
{% endblock %}
{% block content %}
<h1>Runs</h1>
<p class="sub">Every bot run since run tracking started (2026-10-22), newest
first: who started it, how long the eCW login took, and what went wrong.
Pick a run for its login steps, errors and timeline.</p>
<form class="search card" method="get" action="/runs">
<label>From <input type="date" name="day_from" value="{{ day_from }}"></label>
<label>To <input type="date" name="day_to" value="{{ day_to }}"></label>
<label>Result
<select name="status">
<option value="">All</option>
{% for s in statuses %}<option value="{{ s }}"{% if s == status %} selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</label>
<button type="submit">Show</button>
{% if day_from or day_to or status %}<a href="/runs">Clear</a>{% endif %}
</form>
{% if runs %}
<table class="list" id="runs">
<thead><tr>
<th class="w-start">Started</th><th class="w-who">Started By</th>
<th class="w-src">Source</th><th class="w-mode">Mode</th>
<th class="w-dur">Duration</th><th class="w-res">Result</th>
<th class="w-login">eCW login</th><th class="w-cat">Pass / Fail / Review</th>
<th class="w-err">Claim Errors</th><th class="w-warn">Warnings</th>
</tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td><a href="/runs/{{ r.run_id }}">{{ r.started | usdate }}</a></td>
<td>{{ r.started_by }}</td>
<td>{{ r.source | capitalize }}</td>
<td>{{ r.mode }}</td>
<td>{{ r.duration_s | dur }}</td>
<td><span class="st {{ r.status | replace(' ', '-') }}">{{ r.status }}</span>{% if r.status == "Run Error" %} exit {{ r.exit_code }}{% endif %}</td>
<td>{{ r.login_attempts | length }} attempt{{ "" if r.login_attempts | length == 1 else "s" }}{% if r.login_attempts %} · {{ r.login_total_s | dur }}{% endif %}</td>
<td><span class="Pass">{{ r.pass }}</span> / <span class="Fail">{{ r.fail }}</span> / <span class="Review">{{ r.review }}</span></td>
<td class="{{ 'bad' if r.claim_errors else '' }}">{{ r.claim_errors | length }}</td>
<td class="{{ 'warn' if r.warnings else '' }}">{{ r.warnings | length }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">No runs{% if day_from or day_to or status %} match{% else %} tracked yet{% endif %}.
Runs from before 2026-10-22 are not tracked; every run from now on is.</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// A click anywhere on a row opens that run.
document.querySelectorAll('#runs tbody tr').forEach(tr => {
tr.addEventListener('click', () => {
const a = tr.querySelector('a'); if (a) location.href = a.href;
});
});
</script>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block style %}
.signin { max-width: 380px; margin: 40px auto; }
.signin label { display: block; margin: 0 0 12px; font-weight: 600; }
.signin input { display: block; width: 100%; margin-top: 4px; }
.signin button { width: 100%; margin-top: 6px; }
{% endblock %}
{% block content %}
<div class="signin">
<h1>Sign in</h1>
<p class="sub">The Portal shows patient data: every Portal User signs in,
and every Run records who started it.</p>
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
<form class="card" method="post" action="/signin" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<input type="hidden" name="next" value="{{ next_url }}">
<label>Username <input name="username" autocomplete="username" required autofocus></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Sign-in history{% endblock %}
{% block style %}
table.plain { width: 100%; border-collapse: collapse; font-size: 13px; }
table.plain th, table.plain td { text-align: left; padding: 7px 8px;
border-bottom: 1px solid var(--line); }
table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase;
letter-spacing: .4px; background: var(--head); }
tr.wrong_password td, tr.locked td, tr.disabled_attempt td { color: var(--fail); }
{% endblock %}
{% block content %}
<h1>Sign-in history</h1>
<p class="sub">The latest 300: Sign-ins, Sign-outs, wrong passwords, locks,
timeouts and every change an Admin made, newest first.</p>
{% if events %}
<div class="card">
<table class="plain">
<thead><tr><th>When</th><th>User</th><th>What</th><th>By</th></tr></thead>
<tbody>{% for e in events %}<tr class="{{ e.event }}"><td>{{ e.at | usdate }}</td><td>{{ e.username }}</td><td>{{ e.event | replace('_', ' ') }}</td><td>{{ e.by_user or "" }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% else %}<div class="empty">Nothing yet.</div>{% endif %}
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Portal Users{% endblock %}
{% block style %}
form.add { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
gap: 10px; align-items: end; }
form.add label { font-weight: 600; font-size: 13px; }
form.add input, form.add select { display: block; width: 100%; margin-top: 4px; }
table.plain { width: 100%; border-collapse: collapse; font-size: 13px; }
table.plain th, table.plain td { text-align: left; padding: 8px;
border-bottom: 1px solid var(--line);
vertical-align: middle; }
table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase;
letter-spacing: .4px; background: var(--head); }
td form { display: inline-flex; gap: 6px; margin: 0 8px 0 0; }
td form input { width: 150px; padding: 4px 8px; }
td form select, td form button { padding: 4px 8px; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 12px; font-weight: 600; background: var(--head); }
.tag.off { background: var(--fail-bg); color: var(--fail); }
.tag.lock, .tag.change { background: var(--review-bg); color: var(--review); }
.tag.on { background: var(--pass-bg); color: var(--pass); }
@media (max-width: 760px) { form.add { grid-template-columns: 1fr; } }
{% endblock %}
{% block content %}
<h1>Portal Users</h1>
<p class="sub">Admin: everything, and this page. Operator: see everything,
Start / Stop a Run. Viewer: see only. Users are disabled, never deleted.</p>
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
{% if message %}<div class="msg good">{{ message }}</div>{% endif %}
<div class="card">
<h2>Add a user</h2>
<form class="add" method="post" action="/admin/users" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<label>Username <input name="username" required></label>
<label>Name <input name="display_name"></label>
<label>Role
<select name="role">{% for r in roles %}<option{% if r == "Viewer" %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
</label>
<label>First password <input name="password" type="password" autocomplete="new-password" minlength="10" required></label>
<button type="submit">Add</button>
</form>
<p class="note">They change the first password at their first Sign-in.</p>
</div>
<div class="card">
<table class="plain">
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Added</th><th>Change</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td><b>{{ u.username }}</b><br><span class="note">{{ u.display_name }}</span></td>
<td>{{ u.role }}</td>
<td>
{% if u.disabled %}<span class="tag off">Disabled</span>
{% else %}<span class="tag on">Active</span>{% endif %}
{% if u.must_change %}<span class="tag change">Must change password</span>{% endif %}
{% if u.locked_until %}<span class="tag lock">Locked until {{ u.locked_until | usdate }}</span>{% endif %}
</td>
<td>{{ u.created_at | usdate }}<br><span class="note">by {{ u.created_by }}</span></td>
<td>
<form method="post" action="/admin/users/{{ u.username }}/role">
<input type="hidden" name="token" value="{{ run_token }}">
<select name="role">{% for r in roles %}<option{% if r == u.role %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
<button type="submit" class="plain">Set role</button>
</form>
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
<input type="hidden" name="token" value="{{ run_token }}">
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required>
<button type="submit" class="plain">Reset</button>
</form>
{% if u.disabled %}
<form method="post" action="/admin/users/{{ u.username }}/enable">
<input type="hidden" name="token" value="{{ run_token }}">
<button type="submit" class="plain">Enable</button>
</form>
{% elif u.username != user.username %}
<form method="post" action="/admin/users/{{ u.username }}/disable">
<input type="hidden" name="token" value="{{ run_token }}">
<button type="submit" class="plain">Disable</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+328
View File
@@ -0,0 +1,328 @@
r"""
Portal Users, Sign-in and sessions (spec docs/superpowers/specs/2026-10-22-
run-tracking-and-portal-sign-in-design.md, Stage 2, EEWA-32; terms in
GLOSSARY.md).
* **Passwords** are stored only as PBKDF2-SHA256 hashes with their own
salt and iteration count (stdlib, no new package on this locked-down
PC); never stored, logged or shown in plain. At least 10 characters,
not the username.
* **Roles**: Admin (everything, and managing Portal Users), Operator (see
everything, Start / Stop a Run), Viewer (see only).
* **Lockout**: 5 wrong passwords in a row lock the user for 15 minutes.
An unknown username is told the same as a wrong password, and costs the
same time (a hash is still computed), so the page does not reveal which
usernames exist.
* **Sessions** live in the Portal's memory: a server restart signs
everyone out. 30 minutes idle ends one. Disabling a user, resetting
their password or changing their role ends theirs at once.
* Users are disabled, never deleted; their history stays.
* Every Sign-in, Sign-out, wrong password, lock, timeout and Admin change
is a row in `CV_PortalSignIn`.
`Accounts` is the logic, against any store with the `SqlUserStore`
methods (tests use an in-memory one).
"""
import re
import hmac
import secrets
import hashlib
from datetime import datetime, timedelta
ROLES = ("Admin", "Operator", "Viewer")
CAN_RUN = ("Admin", "Operator")
MIN_PASSWORD = 10
ITERATIONS = 600_000
LOCK_AFTER = 5
LOCK_MINUTES = 15
IDLE_MINUTES = 30
USERNAME = re.compile(r"^[A-Za-z0-9._-]{2,50}$")
USER_TABLE = "dbo.CV_PortalUser"
SIGNIN_TABLE = "dbo.CV_PortalSignIn"
_USER_COLUMNS = ("username", "display_name", "role", "pw_hash", "pw_salt",
"pw_iterations", "must_change", "disabled", "failed_count",
"locked_until", "created_at", "created_by", "changed_at")
class UserError(ValueError):
"""A request an Admin or user can fix: the message says how."""
# ── Passwords ─────────────────────────────────────────────────────────────────
def hash_password(password: str, salt: str = None,
iterations: int = None) -> tuple:
"""(hash hex, salt hex, iterations)."""
salt = salt or secrets.token_hex(16)
iterations = iterations or ITERATIONS
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"),
bytes.fromhex(salt), iterations)
return digest.hex(), salt, iterations
def verify_password(password: str, pw_hash: str, salt: str,
iterations: int) -> bool:
try:
got, _, _ = hash_password(password, salt, int(iterations))
except (ValueError, TypeError):
return False
return hmac.compare_digest(got, pw_hash or "")
def password_problem(password: str, username: str = ""):
"""Why `password` is not allowed, or None."""
if len(password or "") < MIN_PASSWORD:
return f"The password needs at least {MIN_PASSWORD} characters"
if username and password.strip().lower() == username.lower():
return "The password cannot be the username"
return None
def clean_username(username: str) -> str:
return (username or "").strip().lower()
# ── The store (SQL Server) ────────────────────────────────────────────────────
class SqlUserStore:
"""`CV_PortalUser` and `CV_PortalSignIn` in Charge_Validation. Every
statement parameterized; one short connection per call."""
def __init__(self, conn_str: str, connect=None):
self.conn_str = conn_str
self._connect = connect
def _run(self, sql: str, params=(), fetch=False):
connect = self._connect
if connect is None:
import pyodbc
connect = pyodbc.connect
conn = connect(self.conn_str, timeout=10)
try:
cur = conn.cursor().execute(sql, list(params))
if fetch:
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
conn.commit()
return None
finally:
conn.close()
def get(self, username):
rows = self._run(f"SELECT {', '.join(_USER_COLUMNS)} FROM "
f"{USER_TABLE} WHERE username = ?",
[username], fetch=True)
return _bools(rows[0]) if rows else None
def list(self) -> list:
return [_bools(r) for r in self._run(
f"SELECT {', '.join(_USER_COLUMNS)} FROM {USER_TABLE} "
f"ORDER BY username", fetch=True)]
def create(self, user: dict) -> None:
cols = [c for c in _USER_COLUMNS if c in user]
self._run(f"INSERT INTO {USER_TABLE} ({', '.join(cols)}) VALUES "
f"({', '.join('?' for _ in cols)})",
[user[c] for c in cols])
def update(self, username, **fields) -> None:
cols = [c for c in fields if c in _USER_COLUMNS and c != "username"]
if not cols:
return
self._run(f"UPDATE {USER_TABLE} SET "
f"{', '.join(c + ' = ?' for c in cols)} WHERE username = ?",
[fields[c] for c in cols] + [username])
def record(self, username, event, by="") -> None:
self._run(f"INSERT INTO {SIGNIN_TABLE} (at, username, event, by_user)"
f" VALUES (?, ?, ?, ?)",
[datetime.now().replace(microsecond=0), username[:50],
event, by or None])
def history(self, limit: int = 300) -> list:
return self._run(f"SELECT TOP {int(limit)} at, username, event, "
f"by_user FROM {SIGNIN_TABLE} ORDER BY id DESC",
fetch=True)
def _bools(row: dict) -> dict:
for k in ("must_change", "disabled"):
row[k] = bool(row.get(k))
return row
# ── Sessions ──────────────────────────────────────────────────────────────────
class Sessions:
"""Signed-in users by cookie token, in memory."""
def __init__(self, now=datetime.now):
self._now = now
self._by_token = {}
def new(self, user: dict) -> str:
token = secrets.token_urlsafe(32)
self._by_token[token] = {"user": _public(user), "last": self._now()}
return token
def get(self, token):
"""(user dict, None) for a live session; (None, username) for one
that just timed out; (None, None) otherwise."""
s = self._by_token.get(token or "")
if not s:
return None, None
if self._now() - s["last"] > timedelta(minutes=IDLE_MINUTES):
del self._by_token[token]
return None, s["user"]["username"]
s["last"] = self._now()
return s["user"], None
def drop(self, token) -> None:
self._by_token.pop(token or "", None)
def drop_user(self, username) -> None:
for t in [t for t, s in self._by_token.items()
if s["user"]["username"] == username]:
del self._by_token[t]
def refresh_user(self, user: dict) -> None:
for s in self._by_token.values():
if s["user"]["username"] == user["username"]:
s["user"] = _public(user)
def _public(user: dict) -> dict:
"""What a session keeps: never the hash."""
return {"username": user["username"],
"display_name": user.get("display_name") or user["username"],
"role": user["role"], "must_change": bool(user.get("must_change"))}
# ── The rules ─────────────────────────────────────────────────────────────────
class Accounts:
def __init__(self, store, now=datetime.now):
self.store = store
self._now = now
# Spent on an unknown username so it takes as long as a known one.
self._dummy = hash_password(secrets.token_hex(8))
def sign_in(self, username: str, password: str):
"""(user, "ok") or (None, "bad" | "locked" | "disabled")."""
name = clean_username(username)
user = self.store.get(name) if USERNAME.match(name) else None
if user is None:
verify_password(password or "", *self._dummy)
if name:
self.store.record(name, "wrong_password")
return None, "bad"
now = self._now()
if user.get("locked_until") and user["locked_until"] > now:
self.store.record(name, "locked")
return None, "locked"
if not verify_password(password or "", user["pw_hash"],
user["pw_salt"], user["pw_iterations"]):
failed = int(user.get("failed_count") or 0) + 1
if failed >= LOCK_AFTER:
self.store.update(name, failed_count=0, locked_until=now
+ timedelta(minutes=LOCK_MINUTES))
self.store.record(name, "locked")
return None, "locked"
self.store.update(name, failed_count=failed)
self.store.record(name, "wrong_password")
return None, "bad"
if user.get("disabled"):
self.store.record(name, "disabled_attempt")
return None, "disabled"
self.store.update(name, failed_count=0, locked_until=None)
self.store.record(name, "sign_in")
return user, "ok"
def create_user(self, username, display_name, role, password, by=""):
name = clean_username(username)
if not USERNAME.match(name):
raise UserError("Username: 2-50 letters, digits, dot, dash or "
"underscore")
if role not in ROLES:
raise UserError(f"Role must be one of {', '.join(ROLES)}")
problem = password_problem(password, name)
if problem:
raise UserError(problem)
if self.store.get(name):
raise UserError(f"{name} already exists")
pw_hash, salt, iterations = hash_password(password)
now = self._now().replace(microsecond=0)
self.store.create({
"username": name,
"display_name": (display_name or "").strip()[:100] or name,
"role": role, "pw_hash": pw_hash, "pw_salt": salt,
"pw_iterations": iterations,
# Made by an Admin: the user picks their own at first Sign-in.
"must_change": bool(by), "disabled": False, "failed_count": 0,
"locked_until": None, "created_at": now,
"created_by": by or "add_portal_user", "changed_at": now})
self.store.record(name, "user_created", by)
return self.store.get(name)
def change_password(self, username, current, new):
user = self.store.get(username)
if not user or not verify_password(current or "", user["pw_hash"],
user["pw_salt"],
user["pw_iterations"]):
raise UserError("The current password is not right")
if current == new:
raise UserError("The new password must be different")
self._set_password(user, new, must_change=False)
self.store.record(username, "password_changed")
return self.store.get(username)
def reset_password(self, username, new, by):
user = self._existing(username)
self._set_password(user, new, must_change=True)
self.store.record(user["username"], "password_reset", by)
def set_role(self, username, role, by):
user = self._existing(username)
if role not in ROLES:
raise UserError(f"Role must be one of {', '.join(ROLES)}")
if user["role"] == "Admin" and role != "Admin":
self._keep_an_admin(user["username"])
self.store.update(user["username"], role=role,
changed_at=self._now().replace(microsecond=0))
self.store.record(user["username"], f"role_{role.lower()}", by)
def set_disabled(self, username, disabled: bool, by):
user = self._existing(username)
if disabled and user["username"] == by:
raise UserError("You cannot disable yourself")
if disabled and user["role"] == "Admin":
self._keep_an_admin(user["username"])
self.store.update(user["username"], disabled=disabled,
failed_count=0, locked_until=None,
changed_at=self._now().replace(microsecond=0))
self.store.record(user["username"],
"user_disabled" if disabled else "user_enabled", by)
def _set_password(self, user, new, must_change):
problem = password_problem(new, user["username"])
if problem:
raise UserError(problem)
pw_hash, salt, iterations = hash_password(new)
self.store.update(user["username"], pw_hash=pw_hash, pw_salt=salt,
pw_iterations=iterations, must_change=must_change,
failed_count=0, locked_until=None,
changed_at=self._now().replace(microsecond=0))
def _existing(self, username):
user = self.store.get(clean_username(username))
if not user:
raise UserError(f"No user {username}")
return user
def _keep_an_admin(self, leaving):
others = [u for u in self.store.list() if u["role"] == "Admin"
and not u["disabled"] and u["username"] != leaving]
if not others:
raise UserError("The Portal needs at least one active Admin")