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 collections import Counter from datetime import date, datetime, timedelta 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 _reasons(rows, top=6) -> list: """A day's Fail and Review comments, grouped: `[{category, comment, count}]`, most frequent first, Fail before Review on a tie.""" counted = Counter((r.get("category"), (r.get("comments") or "").strip()) for r in rows if r.get("category") in ("Fail", "Review")) ordered = sorted(counted.items(), key=lambda kv: (-kv[1], kv[0][0] != "Fail", kv[0][1])) return [{"category": cat, "comment": text or "(no comment)", "count": n} for (cat, text), n in ordered[:top]] 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"
{html.escape(str(exc))}" f"
", 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("Your role cannot do " "this, or the request did not come from this " "page.
", 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"{html.escape(exc.hint)}
", 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([])}) week = sorted((d for d in days if today - timedelta(days=6) <= d["day"] <= today), key=lambda d: d["day"]) # Only a day with claims costs the second query. reasons = (_reasons(_db(repo.day_results, today)) if counts["claims"] else []) return _page(request, "dashboard.html", [["Dashboard", None]], today=counts, has_runs=bool(days), alert=_last_run_alert(), week=week, reasons=reasons) @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("