Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Results web page for the eCW Charge Validation bot — see web/app.py."""
|
||||
+689
@@ -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))
|
||||
@@ -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
@@ -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}%"])
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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")
|
||||
Reference in new issue
Block a user