Self-healing agent (supervisor, recovery, login fixes, operator mail) and Portal sage redesign
Snapshot of D:\Ecw Process a11c9ff (2026-10-30). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fafa5285eb
commit
d500f750da
26 files changed
+1704
-357
No files matched your search
+91
-1
@@ -40,11 +40,50 @@ Repo: `D:\Ecw Process`, git branch `master`, **no remote** (never pushed).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python run.py # the bot (logs into LIVE eCW)
|
python run.py # the bot (logs into LIVE eCW)
|
||||||
|
python agent.py # the agent: the whole day's queue, self-healing (below)
|
||||||
python tests/test_<name>.py # one test file, prints "N/M passed"
|
python tests/test_<name>.py # one test file, prints "N/M passed"
|
||||||
run_web.bat # results portal, http://127.0.0.1:8000
|
run_web.bat # results portal, http://127.0.0.1:8000
|
||||||
start_ecw_chrome.bat # Chrome the bot attaches to (BROWSER_CDP_URL)
|
start_ecw_chrome.bat # Chrome the bot attaches to (BROWSER_CDP_URL)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The agent (2026-10-30, operator: "we are an agent, not a bot — observe,
|
||||||
|
fix, continue, and mail me what happened").** `python agent.py`
|
||||||
|
(`flows/supervisor.py`) runs the bot as a child process, pass after pass,
|
||||||
|
until every claim in the day's Medcor Ready queue has a result. Pass 1
|
||||||
|
downloads the live queue; a later pass takes only the claims still
|
||||||
|
pending — the queue file minus every `CV_ChargeSummary` row since the
|
||||||
|
agent started, a "Bot could not ..." Review not counting — through
|
||||||
|
`RESUME_CLAIM_IDS_FILE` (live mode, live Charge Summary, no TEST label,
|
||||||
|
no second export). After each pass, by exit code (`next_action`):
|
||||||
|
login failure (1, 6) → retry after 10 then 20 min, the 3rd in a row stops
|
||||||
|
the agent (the client's account must not be restricted); restricted (4),
|
||||||
|
screen changed (7), config (2) → stop at once; stuck page (9), stall (8),
|
||||||
|
claims error (5) → fresh Chrome + login in 1 min; guard wait (3) → wait;
|
||||||
|
a clean pass leaving the same claims pending → stop ("left for a
|
||||||
|
person"); 15 passes a day at most. VERIFY only: refuses to start with
|
||||||
|
`SAVE_CORRECTIONS=1`; forces `QUIET_BOXES=1`, `SHOW_EACH_CLAIM=0`,
|
||||||
|
`CLAIM_PROCESS_LIMIT=0`. Every failure and the end are mailed
|
||||||
|
(`core/notify.py`, from the 2FA mailbox's account over SMTP — `SMTP_*`
|
||||||
|
default to `IMAP_*`, port 465; **`NOTIFY_EMAIL_TO` blank = no mail, and it
|
||||||
|
is blank on this PC until the operator gives the address**). A mail
|
||||||
|
carries no claim number (6+ digit runs masked) and only login / error
|
||||||
|
page PNGs (`notify._EMAILABLE`) — never a claim screen or page HTML.
|
||||||
|
Inside a pass, `flows/recovery.py`: a claim the bot could not search or
|
||||||
|
read → login page on screen = session over (exit 9); else Escape, close
|
||||||
|
Patient Information, clear the screen, reopen the claim search if still
|
||||||
|
blocked, and try the claim once more; 2 claims in a row still failing
|
||||||
|
after recovery → exit 9 (live 2026-10-29: a Dental claim's sliding fee
|
||||||
|
check left Patient Information open and 18 claims went "Bot could not
|
||||||
|
search" one after another). Login: the security image page's Continue is
|
||||||
|
clicked by the bot (`login.continue_security_image`, a person only when no
|
||||||
|
Continue/OK is found; the page is saved as `security_image_page` the first
|
||||||
|
time); a 2FA link approved but not picked up in `OTP_PICKUP_SECONDS` (60)
|
||||||
|
→ Resend OTP + approve the new mail, `OTP_PICKUP_RESENDS` (2) times
|
||||||
|
(live 2026-10-29 night: three logins lost to mail 6 to 8.5 min late with
|
||||||
|
Resend still on screen). The "Chrome open" and security image boxes are
|
||||||
|
quiet with `QUIET_BOXES=1` (a `[BOX]` log line); CAPTCHA and manual 2FA
|
||||||
|
boxes still show — they need a person. **None of this has run live yet.**
|
||||||
|
|
||||||
**Watching a run live (2026-10-22, `8af5c14`):** `start_ecw_chrome.bat`
|
**Watching a run live (2026-10-22, `8af5c14`):** `start_ecw_chrome.bat`
|
||||||
opens Google Chrome with its own profile `D:\EcwChrome` (`CDP_PROFILE_DIR`),
|
opens Google Chrome with its own profile `D:\EcwChrome` (`CDP_PROFILE_DIR`),
|
||||||
debug port 9222 on 127.0.0.1, the eCW proxy (claude.ai / anthropic.com go
|
debug port 9222 on 127.0.0.1, the eCW proxy (claude.ai / anthropic.com go
|
||||||
@@ -305,6 +344,9 @@ that box and on the per-claim save box means Cancel.
|
|||||||
| `core/page_utils.py` | `shot()` with a FAILURE tag (`is_failure_tag`: failed / missing / not_ / no_ / unconfirmed ...) also saves the page's DOM + frames beside the png as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE=1`, at most `SAVE_DOM_LIMIT=30` a run; 2026-10-21) — no more hand-copying DevTools into Dom.txt. `visible()` = instant check; `appears()` = real wait (Playwright 1.62's `is_visible(timeout)` never waits); `click_topmost()` clicks the copy on top when dialogs stack (never `.first`) |
|
| `core/page_utils.py` | `shot()` with a FAILURE tag (`is_failure_tag`: failed / missing / not_ / no_ / unconfirmed ...) also saves the page's DOM + frames beside the png as `<HHMMSS>_<tag>.html` (`SAVE_DOM_ON_FAILURE=1`, at most `SAVE_DOM_LIMIT=30` a run; 2026-10-21) — no more hand-copying DevTools into Dom.txt. `visible()` = instant check; `appears()` = real wait (Playwright 1.62's `is_visible(timeout)` never waits); `click_topmost()` clicks the copy on top when dialogs stack (never `.first`) |
|
||||||
| `core/fallback.py` + `selectors/fallbacks.py` | Fallback selectors (2026-10-21): the run-critical selectors are CHAINS — primary first, then alternatives taken from the live DOM (ng-click / ng-model / type+value, plain CSS) — joined as a CSS list, so every call site falls back with no change: password field, Log In, Claim No, Lookup, UB Cancel (`closeUBClaimScreen()`, never the X or OK), Dental Cancel (`closeDntl()`), Prog. Notes (`#UBClaimProgressNoteBtn`, `claimProgressNoteBtn<ts>`). `report()` logs `[SELECTOR] fallback: <name> — <primary> not found, matched <alt>` once a run (pre-flight and login call it) |
|
| `core/fallback.py` + `selectors/fallbacks.py` | Fallback selectors (2026-10-21): the run-critical selectors are CHAINS — primary first, then alternatives taken from the live DOM (ng-click / ng-model / type+value, plain CSS) — joined as a CSS list, so every call site falls back with no change: password field, Log In, Claim No, Lookup, UB Cancel (`closeUBClaimScreen()`, never the X or OK), Dental Cancel (`closeDntl()`), Prog. Notes (`#UBClaimProgressNoteBtn`, `claimProgressNoteBtn<ts>`). `report()` logs `[SELECTOR] fallback: <name> — <primary> not found, matched <alt>` once a run (pre-flight and login call it) |
|
||||||
| `pages/preflight.py` | Pre-flight selector check (2026-10-21): the claims search page before the first claim, the claim form on the first claim of each screen type (UB / HCFA / Dental: Cancel, CPT tab, rows with code / fee / POS / M1). Missing → `[PREFLIGHT] FAIL <names>`, page shot + DOM saved, run stops with **exit 7** (`PREFLIGHT_STOP=0` only warns). No CPT row on a claim → its row checks wait for the next; 3 in a row = rows missing |
|
| `pages/preflight.py` | Pre-flight selector check (2026-10-21): the claims search page before the first claim, the claim form on the first claim of each screen type (UB / HCFA / Dental: Cancel, CPT tab, rows with code / fee / POS / M1). Missing → `[PREFLIGHT] FAIL <names>`, page shot + DOM saved, run stops with **exit 7** (`PREFLIGHT_STOP=0` only warns). No CPT row on a claim → its row checks wait for the next; 3 in a row = rows missing |
|
||||||
|
| `flows/supervisor.py` + root `agent.py` | The agent (2026-10-30, §2): passes of the bot as a child process until nothing is pending; `next_action(code, pending, state)` is the whole retry policy, `pending(queue, rows)` the resume list |
|
||||||
|
| `flows/recovery.py` | A stuck screen inside a pass (§2): `recover()` (logout check, Escape + Patient Information close + clear, reopen search) and `run_claim()` (one retry per claim, 2 in a row → `PageStuck`, exit 9) |
|
||||||
|
| `core/notify.py` | The agent's mail (§2): `incident()` / `summary()`; claim numbers masked, login / error PNGs only; never fatal |
|
||||||
| `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back |
|
| `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back |
|
||||||
| `core/run_events.py` | A Run's timed events → `Logs\events_<run>.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username |
|
| `core/run_events.py` | A Run's timed events → `Logs\events_<run>.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username |
|
||||||
| `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login |
|
| `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login |
|
||||||
@@ -583,6 +625,22 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
|
|||||||
header `X-Run-Token` must match the token in the page; else 403),
|
header `X-Run-Token` must match the token in the page; else 403),
|
||||||
`GET /api/bot/status`. Spec / plan:
|
`GET /api/bot/status`. Spec / plan:
|
||||||
`docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md`.
|
`docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md`.
|
||||||
|
- **Sage redesign (2026-10-30, the operator-approved mockup, live on
|
||||||
|
Dokploy that day from `portal-20261030-ui.zip`).** `base.html`: the navy
|
||||||
|
header became a sage side menu (`<aside class="side">`: brand, mode pill,
|
||||||
|
links with CSS-mask icons, the user's name over their role, Sign out);
|
||||||
|
breadcrumb and footer in the page column; Outfit + Inter from Google
|
||||||
|
Fonts; the old CSS variable names kept with sage / lime / Pass green /
|
||||||
|
Fail orange / Review blue values, so every page follows. Dashboard:
|
||||||
|
"Good morning|afternoon|evening, <first name>" + claim search, 4 tiles
|
||||||
|
with %, the lime **Agent run** card (the old Bot run card, same ids and
|
||||||
|
script) beside **Results by day**, **Top reasons today** beside **Runs
|
||||||
|
today** (`app._runs_today`: today's rows grouped by `run_at`, newest
|
||||||
|
first — no extra query). Sign in: sage panel + "Welcome back" card.
|
||||||
|
Not built: Queue / Pending tiles (the Portal cannot see the bot PC's
|
||||||
|
queue file — the agent would have to write the queue size to the DB).
|
||||||
|
The mockup canvas: claude.ai artifact "Charge Validation Portal Mockups"
|
||||||
|
(private to the operator).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -647,6 +705,13 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
|
|||||||
- 2026-10-14 #20 final (operator): ONLY the codes the operator gave — the 41 injection codes (IUD / implant, inhalation and oral J-codes included, as listed) and 96372 as the one admin code. No other code is added or counted; no client confirmation needed.
|
- 2026-10-14 #20 final (operator): ONLY the codes the operator gave — the 41 injection codes (IUD / implant, inhalation and oral J-codes included, as listed) and 96372 as the one admin code. No other code is added or counted; no client confirmation needed.
|
||||||
- 2026-10-08 Results go to SQL Server `MECARE331\MECARE331`, DB `Charge_Validation`, in the Charge Summary format (incl. PATIENT, Account Number, Guarantor Name); FastAPI page for now, the full portal later.
|
- 2026-10-08 Results go to SQL Server `MECARE331\MECARE331`, DB `Charge_Validation`, in the Charge Summary format (incl. PATIENT, Account Number, Guarantor Name); FastAPI page for now, the full portal later.
|
||||||
|
|
||||||
|
- 2026-10-30 The bot becomes an agent: technical failures (login, session, stuck screen, browser) are the agent's to recover from without a person; condition or process mistakes stay the operator's to point out. Mail the operator what happened, what the agent did and whether it worked.
|
||||||
|
- 2026-10-30 Agent mail: from the bot mailbox over SMTP; screenshots of login / error pages only, never a claim screen; no claim numbers.
|
||||||
|
- 2026-10-30 The security image page's Continue is clicked by the bot.
|
||||||
|
- 2026-10-30 Login failures: at most 3 in a row (10 / 20 min apart), then stop and mail — protect the client's eCW account.
|
||||||
|
- 2026-10-30 Bulk runs show no message boxes; the full day's queue (no `CLAIM_PROCESS_LIMIT`) must finish the same day.
|
||||||
|
- 2026-10-30 Portal look: the approved mockup — sage side menu, lime highlight, Outfit + Inter, Dashboard greeting / tiles / Agent run / Results by day / Top reasons / Runs today, two-panel Sign in (no Ecare/Portal toggle: the back end has none).
|
||||||
|
|
||||||
### Waiting on a ruling
|
### Waiting on a ruling
|
||||||
1. An admin code / G2025 that the Fee Schedule prices at **0.00** after being added (pinned in tests).
|
1. An admin code / G2025 that the Fee Schedule prices at **0.00** after being added (pinned in tests).
|
||||||
2. `medicare_telehealth` with G2025 **unconfirmed** leaves other rows; `priced_line_check` may then check an unchecked priced row (pinned in tests).
|
2. `medicare_telehealth` with G2025 **unconfirmed** leaves other rows; `priced_line_check` may then check an unchecked priced row (pinned in tests).
|
||||||
@@ -688,7 +753,11 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
|
|||||||
|
|
||||||
History: `git log --oneline`.
|
History: `git log --oneline`.
|
||||||
|
|
||||||
Last code commits: `0c2fca8` (2026-10-28, vaccine_z23_pointer #45 /
|
Last code commits: `78409f0` (2026-10-30, Portal sage redesign — §5;
|
||||||
|
deployed to Dokploy from the working tree before this commit, same
|
||||||
|
files), `24c9ce4` (2026-10-30, the self-healing agent: supervisor,
|
||||||
|
recovery, security image Continue, Resend pickup, operator mail — §2,
|
||||||
|
§10), `0c2fca8` (2026-10-28, vaccine_z23_pointer #45 /
|
||||||
t1017_non_billable #46 — §4), `d1aa6da` (2026-10-28, login waits for a late
|
t1017_non_billable #46 — §4), `d1aa6da` (2026-10-28, login waits for a late
|
||||||
security image page — §10), `97d4d74` (2026-10-28, Charge Summary notes a payer the
|
security image page — §10), `97d4d74` (2026-10-28, Charge Summary notes a payer the
|
||||||
sheet and eCW disagree on — §5), `aaab1ff` (2026-10-28, lab_codes #3/#4,
|
sheet and eCW disagree on — §5), `aaab1ff` (2026-10-28, lab_codes #3/#4,
|
||||||
@@ -825,6 +894,27 @@ test claim ids — new posts must not (§7).
|
|||||||
|
|
||||||
## 10. Open items
|
## 10. Open items
|
||||||
|
|
||||||
|
- **Agent (2026-10-30), not yet run live.** To do: `NOTIFY_EMAIL_TO` in
|
||||||
|
`.env` (the operator's address) and one test mail; a first `python
|
||||||
|
agent.py` to see the security image Continue click on the real page
|
||||||
|
(its HTML is saved as `security_image_page` — tighten the selector from
|
||||||
|
it) and a late-mail Resend pickup. Not built: the agent's status in the
|
||||||
|
Portal (it runs on the bot PC; the Dokploy Portal sees only the
|
||||||
|
database — a status / queue-size row would carry it), Queue / Pending
|
||||||
|
tiles.
|
||||||
|
- **29/10 live queue (690 claims).** 208 got a result in two runs (17:18
|
||||||
|
126, 19:29 82); the rest were not reached: Ctrl-C would not stop a
|
||||||
|
run blocked in a Playwright call (the tab was closed instead), a stuck
|
||||||
|
Patient Information dialog cost 18 claims (now `recovery.py`), and three
|
||||||
|
logins that night failed on 2FA mail 6 to 8.5 min late (now the Resend
|
||||||
|
pickup). 482 claims of that day's queue were never checked.
|
||||||
|
- **Tests:** `test_cdp_browser.py` 2 of 11 fail on this PC since
|
||||||
|
2026-10-29 (the Chrome it opens has a command line PowerShell cannot
|
||||||
|
read — the same `[BROWSER] Could not read the command line` line live
|
||||||
|
runs print); environment, not code — look at it before trusting the
|
||||||
|
proxy check. `test_msgbox.py` now pins `QUIET_BOXES` off (this PC's
|
||||||
|
`.env` has it on).
|
||||||
|
|
||||||
- **Stability (review of 25 runs, 20–26/10, 2026-10-26).** After the eCW
|
- **Stability (review of 25 runs, 20–26/10, 2026-10-26).** After the eCW
|
||||||
login the run is steady: the last five full runs read 50/50 with no
|
login the run is steady: the last five full runs read 50/50 with no
|
||||||
bot error and the same results run to run. The weak part is getting
|
bot error and the same results run to run. The weak part is getting
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
r"""
|
||||||
|
The eCW Charge Validation agent — runs the day's Medcor Ready queue to the
|
||||||
|
end on its own, restarting after technical failures and mailing what
|
||||||
|
happened (flows/supervisor.py).
|
||||||
|
|
||||||
|
python agent.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||||
|
|
||||||
|
from ecw_bot.flows.supervisor import main # noqa: E402
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -12,6 +12,7 @@ spreadsheet, one client instead of a loop, and the second factor is a mailed
|
|||||||
approval link rather than an authenticator code — see `twofactor.py`.
|
approval link rather than an authenticator code — see `twofactor.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -32,6 +33,25 @@ from .twofactor import handle_2fa
|
|||||||
|
|
||||||
|
|
||||||
SECURITY_IMAGE = "text=VERIFY YOUR SECURITY IMAGE"
|
SECURITY_IMAGE = "text=VERIFY YOUR SECURITY IMAGE"
|
||||||
|
# The page's own go-on control, by its words: its markup has never been
|
||||||
|
# saved (the first page the bot clicks through is, as security_image_page).
|
||||||
|
_CONTINUE = re.compile(r"^\s*(continue|ok)\s*$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def continue_security_image(page: Page) -> bool:
|
||||||
|
"""
|
||||||
|
Click Continue on eCW's "VERIFY YOUR SECURITY IMAGE" page (operator,
|
||||||
|
2026-10-30: the run must not wait for a person). True once clicked;
|
||||||
|
False when no visible Continue/OK control is there, so the caller falls
|
||||||
|
back to waiting for a person.
|
||||||
|
"""
|
||||||
|
shot(page, "security_image_page")
|
||||||
|
for role in ("button", "link"):
|
||||||
|
btn = page.get_by_role(role, name=_CONTINUE).filter(visible=True)
|
||||||
|
if btn.count():
|
||||||
|
btn.first.click(timeout=10_000)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def wait_after_username(page: Page, timeout_ms: int, image: bool = True) -> str:
|
def wait_after_username(page: Page, timeout_ms: int, image: bool = True) -> str:
|
||||||
@@ -125,15 +145,20 @@ def _do_login(page: Page, context, client_name: str):
|
|||||||
|
|
||||||
# Some logins interpose a security-image confirmation page — sometimes
|
# Some logins interpose a security-image confirmation page — sometimes
|
||||||
# seconds after Next (2026-10-28 17:59: missed by a one-look check).
|
# seconds after Next (2026-10-28 17:59: missed by a one-look check).
|
||||||
|
# No box with QUIET_BOXES (2026-10-29, bulk runs): the wait below is
|
||||||
|
# what holds the run, the box only ever said so.
|
||||||
if wait_after_username(page, cfg.LOGIN_TIMEOUT) == "image":
|
if wait_after_username(page, cfg.LOGIN_TIMEOUT) == "image":
|
||||||
print("[LOGIN] Security image page")
|
if continue_security_image(page):
|
||||||
msgbox.info_box(
|
print("[LOGIN] Security image page — clicked Continue")
|
||||||
f"eCW Bot — Security Image ({client_name})",
|
else:
|
||||||
"The 'VERIFY YOUR SECURITY IMAGE' page came up.\n"
|
print(f"[LOGIN] Security image page — no Continue found; click it "
|
||||||
"Click Continue/OK in the browser.\n\n"
|
f"in the browser (waiting up to {cfg.LOGIN_TIMEOUT // 1000}s)")
|
||||||
"Click OK once the password page appears.",
|
msgbox.info_box(
|
||||||
needs_person=True,
|
f"eCW Bot — Security Image ({client_name})",
|
||||||
)
|
"The 'VERIFY YOUR SECURITY IMAGE' page came up.\n"
|
||||||
|
"Click Continue/OK in the browser.\n\n"
|
||||||
|
"Click OK once the password page appears.",
|
||||||
|
)
|
||||||
wait_after_username(page, cfg.LOGIN_TIMEOUT, image=False)
|
wait_after_username(page, cfg.LOGIN_TIMEOUT, image=False)
|
||||||
# A renamed password field or Log In button still works through its
|
# A renamed password field or Log In button still works through its
|
||||||
# fallback (selectors/login.py); this says so in the log.
|
# fallback (selectors/login.py); this says so in the log.
|
||||||
|
|||||||
@@ -185,6 +185,61 @@ def _challenge_with_resend(page: Page, client_name: str,
|
|||||||
f"No eCW 2FA mail arrived (tried {cfg.OTP_RESEND_ATTEMPTS} resend(s)).")
|
f"No eCW 2FA mail arrived (tried {cfg.OTP_RESEND_ATTEMPTS} resend(s)).")
|
||||||
|
|
||||||
|
|
||||||
|
def _picked_up(page: Page, seconds: int) -> bool:
|
||||||
|
"""The login page left the push screen (home, or the notice gone)."""
|
||||||
|
for _ in range(max(1, seconds)):
|
||||||
|
if visible(page, sel.SEL_HOME, timeout=500) or \
|
||||||
|
not visible(page, sel.SEL_PUSH_NOTICE, timeout=500):
|
||||||
|
return True
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_until_picked_up(page: Page, context, client_name: str,
|
||||||
|
ch) -> None:
|
||||||
|
"""
|
||||||
|
Approve the link; if the login page does not pick it up within
|
||||||
|
OTP_PICKUP_SECONDS, press Resend and approve the new mail, up to
|
||||||
|
OTP_PICKUP_RESENDS times (live 2026-10-29: three logins lost to late
|
||||||
|
mail with Resend still on screen). Returns once picked up, or when no
|
||||||
|
resend is left — the caller's wait for home then reports it.
|
||||||
|
"""
|
||||||
|
for resend in range(cfg.OTP_PICKUP_RESENDS + 1):
|
||||||
|
if not _approve_in_new_tab(context, ch.value):
|
||||||
|
raise RuntimeError("Approval link opened but could not be confirmed")
|
||||||
|
# eCW's login page polls for the approval; it moves on by itself. But
|
||||||
|
# it may already have stopped polling while the mail was in flight —
|
||||||
|
# the link is then spent and confirming it changed nothing, so say that
|
||||||
|
# rather than letting the caller wait out LOGIN_TIMEOUT for a home
|
||||||
|
# screen that is never coming.
|
||||||
|
page.wait_for_timeout(2_000)
|
||||||
|
why = dead_end_reason(page)
|
||||||
|
if why:
|
||||||
|
shot(page, "2fa_timed_out")
|
||||||
|
raise LoginTimedOut(
|
||||||
|
f"The approval was confirmed, but the login page had already "
|
||||||
|
f"given up: {why}.\n"
|
||||||
|
f" The mail arrived too late to be useful. Starting over.")
|
||||||
|
print("[2FA] Waiting for the login page to pick up the approval")
|
||||||
|
if _picked_up(page, cfg.OTP_PICKUP_SECONDS):
|
||||||
|
return
|
||||||
|
if resend == cfg.OTP_PICKUP_RESENDS:
|
||||||
|
return
|
||||||
|
print(f"[2FA] Not picked up in {cfg.OTP_PICKUP_SECONDS}s — resending")
|
||||||
|
shot(page, "2fa_not_picked_up")
|
||||||
|
if not _resend_push(page):
|
||||||
|
return
|
||||||
|
since = datetime.now(timezone.utc)
|
||||||
|
run_events.login_step("twofa_resent")
|
||||||
|
ch = mailbox.get_challenge(since=since, client_name=client_name,
|
||||||
|
allow_manual=False)
|
||||||
|
if ch.kind != "link":
|
||||||
|
print("[2FA] No new approval mail after the resend")
|
||||||
|
return
|
||||||
|
print(f"[2FA] Resend {resend + 1}/{cfg.OTP_PICKUP_RESENDS}: "
|
||||||
|
f"approving the new mail")
|
||||||
|
|
||||||
|
|
||||||
def handle_2fa(page: Page, context, client_name: str,
|
def handle_2fa(page: Page, context, client_name: str,
|
||||||
clicked_at: datetime) -> None:
|
clicked_at: datetime) -> None:
|
||||||
"""Deal with whatever second factor eCW shows. No-op when there isn't one."""
|
"""Deal with whatever second factor eCW shows. No-op when there isn't one."""
|
||||||
@@ -206,22 +261,7 @@ def handle_2fa(page: Page, context, client_name: str,
|
|||||||
ch = _challenge_with_resend(page, client_name, clicked_at)
|
ch = _challenge_with_resend(page, client_name, clicked_at)
|
||||||
|
|
||||||
if ch.kind == "link":
|
if ch.kind == "link":
|
||||||
if not _approve_in_new_tab(context, ch.value):
|
_approve_until_picked_up(page, context, client_name, ch)
|
||||||
raise RuntimeError("Approval link opened but could not be confirmed")
|
|
||||||
# eCW's login page polls for the approval; it moves on by itself. But
|
|
||||||
# it may already have stopped polling while the mail was in flight —
|
|
||||||
# the link is then spent and confirming it changed nothing, so say that
|
|
||||||
# rather than letting the caller wait out LOGIN_TIMEOUT for a home
|
|
||||||
# screen that is never coming.
|
|
||||||
page.wait_for_timeout(2_000)
|
|
||||||
why = dead_end_reason(page)
|
|
||||||
if why:
|
|
||||||
shot(page, "2fa_timed_out")
|
|
||||||
raise LoginTimedOut(
|
|
||||||
f"The approval was confirmed, but the login page had already "
|
|
||||||
f"given up: {why}.\n"
|
|
||||||
f" The mail arrived too late to be useful. Starting over.")
|
|
||||||
print("[2FA] Waiting for the login page to pick up the approval")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if ch.kind in ("code", "totp"):
|
if ch.kind in ("code", "totp"):
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
The agent's email to the operator (operator, 2026-10-30): when something
|
||||||
|
technical goes wrong — a login that fails, a screen that sticks, a run that
|
||||||
|
stops — say what happened, what the agent did about it and whether that
|
||||||
|
worked, instead of leaving it for someone to find in the morning.
|
||||||
|
|
||||||
|
Sent from the bot's own mailbox (the 2FA mailbox's account, over SMTP). What
|
||||||
|
it may carry is fixed here, not by the caller:
|
||||||
|
* screenshots of login and error pages only (`_EMAILABLE`), PNG only — a
|
||||||
|
claim screen or a page's HTML carries PHI and never leaves the PC;
|
||||||
|
* no claim numbers: any run of 6+ digits in the subject or body is masked.
|
||||||
|
|
||||||
|
Never fatal: a mail that cannot be sent is a log line, and the run goes on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import settings as cfg
|
||||||
|
|
||||||
|
# `<HHMMSS>_<step>.png` as page_utils.shot() names them. Login and error
|
||||||
|
# pages only: not "logged_in" or "landing" (eCW's home can list patients).
|
||||||
|
_EMAILABLE = re.compile(
|
||||||
|
r"^\d{6}_(security_image_page|login_unconfirmed|username_error|"
|
||||||
|
r"username_step_disabled|no_login_form|captcha|restricted|"
|
||||||
|
r"password_expired|password_expiry_no_cancel|2fa_[a-z_]+|"
|
||||||
|
r"approval_(page|done|no_button))\.png$")
|
||||||
|
_DIGITS = re.compile(r"\d{6,}")
|
||||||
|
|
||||||
|
|
||||||
|
def _mask(text: str) -> str:
|
||||||
|
return _DIGITS.sub("<claim>", text or "")
|
||||||
|
|
||||||
|
|
||||||
|
def emailable(path) -> bool:
|
||||||
|
return bool(_EMAILABLE.match(Path(path).name))
|
||||||
|
|
||||||
|
|
||||||
|
def _transport(msg: EmailMessage) -> None:
|
||||||
|
with smtplib.SMTP_SSL(cfg.SMTP_HOST, cfg.SMTP_PORT, timeout=30,
|
||||||
|
context=ssl.create_default_context()) as smtp:
|
||||||
|
smtp.login(cfg.SMTP_USERNAME, cfg.SMTP_PASSWORD)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _send(subject: str, body: str, shots=()) -> bool:
|
||||||
|
if not cfg.NOTIFY_EMAIL_TO:
|
||||||
|
return False
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = cfg.SMTP_USERNAME
|
||||||
|
msg["To"] = cfg.NOTIFY_EMAIL_TO
|
||||||
|
msg["Subject"] = f"[eCW Agent] {_mask(subject)}"
|
||||||
|
msg.set_content(_mask(body))
|
||||||
|
for p in shots or ():
|
||||||
|
p = Path(p)
|
||||||
|
if emailable(p) and p.exists():
|
||||||
|
msg.add_attachment(p.read_bytes(), maintype="image",
|
||||||
|
subtype="png", filename=p.name)
|
||||||
|
try:
|
||||||
|
_transport(msg)
|
||||||
|
print(f"[NOTIFY] Mailed: {msg['Subject']}")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[NOTIFY] Could not mail '{msg['Subject']}': "
|
||||||
|
f"{str(exc).splitlines()[0] if str(exc) else type(exc).__name__}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def incident(title: str, what_happened: str, what_agent_did: str,
|
||||||
|
recovered: bool, shots=()) -> bool:
|
||||||
|
"""One technical problem: what, what the agent did, and the outcome."""
|
||||||
|
outcome = "Recovered - the run goes on." if recovered else \
|
||||||
|
"Not recovered - the agent stopped or is waiting to retry."
|
||||||
|
body = (f"What happened:\n {what_happened}\n\n"
|
||||||
|
f"What the agent did:\n {what_agent_did}\n\n"
|
||||||
|
f"Outcome:\n {outcome}\n\n"
|
||||||
|
f"Run log and screenshots are on the bot PC and in the Portal.")
|
||||||
|
return _send(title, body, shots)
|
||||||
|
|
||||||
|
|
||||||
|
def summary(title: str, lines) -> bool:
|
||||||
|
"""The end of a run: counts and anything left for a person."""
|
||||||
|
return _send(title, "\n".join(lines))
|
||||||
@@ -56,6 +56,8 @@ from ..pages import preflight
|
|||||||
from ..core.text import first_line
|
from ..core.text import first_line
|
||||||
from ..core.browser import cdp_ready, chrome_proxy_problem, start_chrome
|
from ..core.browser import cdp_ready, chrome_proxy_problem, start_chrome
|
||||||
from ..core import run_events, watchdog
|
from ..core import run_events, watchdog
|
||||||
|
from ..core.page_utils import shot
|
||||||
|
from . import recovery
|
||||||
from ..auth.login import login
|
from ..auth.login import login
|
||||||
from ..auth.dead_end import LoginTimedOut
|
from ..auth.dead_end import LoginTimedOut
|
||||||
from ..auth.restricted import Restricted
|
from ..auth.restricted import Restricted
|
||||||
@@ -983,6 +985,7 @@ def _check_claims(page, info) -> None:
|
|||||||
keep_pages = list(page.context.pages)
|
keep_pages = list(page.context.pages)
|
||||||
except Exception:
|
except Exception:
|
||||||
keep_pages = []
|
keep_pages = []
|
||||||
|
stuck = {} # recovery.run_claim's streak of unrecovered claims
|
||||||
for n, claim_id in enumerate(todo, start=1):
|
for n, claim_id in enumerate(todo, start=1):
|
||||||
# A claim that froze the page stops the run here, before the next
|
# A claim that froze the page stops the run here, before the next
|
||||||
# one: the rest of the queue is Not Reached, not 40 failed searches.
|
# one: the rest of the queue is Not Reached, not 40 failed searches.
|
||||||
@@ -998,29 +1001,43 @@ def _check_claims(page, info) -> None:
|
|||||||
# 2 and 3 were never searched. A page that will not come clean
|
# 2 and 3 were never searched. A page that will not come clean
|
||||||
# makes this claim a failed search — Review — and the next claim
|
# makes this claim a failed search — Review — and the next claim
|
||||||
# clears and checks again.
|
# clears and checks again.
|
||||||
claim_form.clear_screen(page, claim_id, keep_pages)
|
def attempt():
|
||||||
try:
|
claim_form.clear_screen(page, claim_id, keep_pages)
|
||||||
r = claim_results.search_claim(page, claim_id)
|
try:
|
||||||
except Exception as exc:
|
r = claim_results.search_claim(page, claim_id)
|
||||||
# One bad claim must not cost the other 540.
|
except Exception as exc:
|
||||||
print(f"[CLAIM {claim_id}] Failed: {exc}")
|
# One bad claim must not cost the other 540.
|
||||||
# Not "not found" (final review, 2026-10-05): that is a fact
|
print(f"[CLAIM {claim_id}] Failed: {exc}")
|
||||||
# about the queue, and a search that raised (browser gone, a
|
# Not "not found" (final review, 2026-10-05): that is a fact
|
||||||
# timeout) says nothing about the queue. It is counted apart
|
# about the queue, and a search that raised (browser gone, a
|
||||||
# and its row is Review, not left blank like a missing claim.
|
# timeout) says nothing about the queue. It is counted apart
|
||||||
r = {"id": claim_id, "count": "?", "found": False, "seconds": 0,
|
# and its row is Review, not left blank like a missing claim.
|
||||||
"search_error": first_line(exc)}
|
r = {"id": claim_id, "count": "?", "found": False,
|
||||||
# Built before the read, not after: the review-before-close box is
|
"seconds": 0, "search_error": first_line(exc)}
|
||||||
# shown while the form is still open and leads with it too.
|
# Built before the read, not after: the review-before-close box
|
||||||
head = _claim_head(n, len(todo), claim_id, r)
|
# is shown while the form is still open and leads with it too.
|
||||||
if r["found"]:
|
r["_head"] = _claim_head(n, len(todo), claim_id, r)
|
||||||
watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: open the form")
|
if r["found"]:
|
||||||
data = _read_claim_form(page, claim_id, head)
|
watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: "
|
||||||
elif r.get("search_error"):
|
f"open the form")
|
||||||
data = {"claim_no": claim_id,
|
data = _read_claim_form(page, claim_id, r["_head"])
|
||||||
"_search_error": r["search_error"]}
|
elif r.get("search_error"):
|
||||||
else:
|
data = {"claim_no": claim_id,
|
||||||
data = {}
|
"_search_error": r["search_error"]}
|
||||||
|
else:
|
||||||
|
data = {}
|
||||||
|
return r, data
|
||||||
|
|
||||||
|
def recover_now():
|
||||||
|
# Operator, 2026-10-30: a stuck screen is the agent's to clear.
|
||||||
|
watchdog.beat(f"claim {n}/{len(todo)} {claim_id}: recover")
|
||||||
|
how = recovery.recover(page, claim_id, keep_pages, _open_search)
|
||||||
|
print(f"[RECOVER {claim_id}] Search page back ({how}) — "
|
||||||
|
f"trying the claim again")
|
||||||
|
run_events.emit("recovered", claim=str(claim_id), how=how)
|
||||||
|
|
||||||
|
r, data = recovery.run_claim(attempt, recover_now, stuck)
|
||||||
|
head = r.pop("_head")
|
||||||
# `_error` = the form was opened (or tried) and could not be read:
|
# `_error` = the form was opened (or tried) and could not be read:
|
||||||
# not "read", whatever the dict holds (review item D2).
|
# not "read", whatever the dict holds (review item D2).
|
||||||
error = data.get("_error") if data else None
|
error = data.get("_error") if data else None
|
||||||
@@ -1206,8 +1223,9 @@ def _write_charge_summary(info, outcomes: dict) -> None:
|
|||||||
f"This run's charge summary is:\n {path.name}")
|
f"This run's charge summary is:\n {path.name}")
|
||||||
|
|
||||||
|
|
||||||
def _claims_phase(page) -> None:
|
def _open_search(page) -> None:
|
||||||
"""Everything on claimLookup.jsp, in the one order that works."""
|
"""The claim search with the run's filters — at the start, and again
|
||||||
|
when flows/recovery.py reopens it after a stuck screen."""
|
||||||
leftnav.open_claims(page)
|
leftnav.open_claims(page)
|
||||||
print(f"[OK] On: {page.url}")
|
print(f"[OK] On: {page.url}")
|
||||||
claim_filters.set_claim_status(page) # FIRST — see the module docstring
|
claim_filters.set_claim_status(page) # FIRST — see the module docstring
|
||||||
@@ -1215,6 +1233,20 @@ def _claims_phase(page) -> None:
|
|||||||
claim_filters.set_date_range(page)
|
claim_filters.set_date_range(page)
|
||||||
claim_filters.clear_balance(page)
|
claim_filters.clear_balance(page)
|
||||||
|
|
||||||
|
|
||||||
|
def _claims_phase(page) -> None:
|
||||||
|
"""Everything on claimLookup.jsp, in the one order that works."""
|
||||||
|
_open_search(page)
|
||||||
|
|
||||||
|
if cfg.RESUME_CLAIM_IDS_FILE and not cfg.TEST_CLAIM_IDS_FILE:
|
||||||
|
# The agent's later pass (flows/supervisor.py): today's live queue,
|
||||||
|
# only the claims still pending, no second export.
|
||||||
|
info = claim_file.claim_ids(Path(cfg.RESUME_CLAIM_IDS_FILE))
|
||||||
|
print(f"[RESUME] {len(info['ids'])} claim(s) of today's live queue "
|
||||||
|
f"still pending")
|
||||||
|
_process_claims(page, info)
|
||||||
|
return
|
||||||
|
|
||||||
if cfg.TEST_CLAIM_IDS_FILE:
|
if cfg.TEST_CLAIM_IDS_FILE:
|
||||||
_process_test_claim_ids_file(page)
|
_process_test_claim_ids_file(page)
|
||||||
return
|
return
|
||||||
@@ -1520,12 +1552,12 @@ def _main(log_path) -> int:
|
|||||||
return _stop("config", problem, 2)
|
return _stop("config", problem, 2)
|
||||||
if opened_chrome:
|
if opened_chrome:
|
||||||
# Operator, 2026-10-23: a moment to add the Claude extension before
|
# Operator, 2026-10-23: a moment to add the Claude extension before
|
||||||
# eCW opens. A person's box (shown on a Portal run too); after the
|
# eCW opens; after the usual timeout the run goes on. 2026-10-29:
|
||||||
# usual timeout the run goes on.
|
# bulk runs show no box at all, so QUIET_BOXES makes it a log line.
|
||||||
msgbox.info_box("eCW Bot — Chrome open",
|
msgbox.info_box("eCW Bot — Chrome open",
|
||||||
"Chrome is open. Add the Claude extension if you "
|
"Chrome is open. Add the Claude extension if you "
|
||||||
"need it, then click OK — the bot logs in to eCW "
|
"need it, then click OK — the bot logs in to eCW "
|
||||||
"after this box.", needs_person=True)
|
"after this box.")
|
||||||
if not _disk_check():
|
if not _disk_check():
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@@ -1551,6 +1583,12 @@ def _main(log_path) -> int:
|
|||||||
except watchdog.Stalled as exc:
|
except watchdog.Stalled as exc:
|
||||||
print(f"[WATCHDOG] Run stopped: {exc}")
|
print(f"[WATCHDOG] Run stopped: {exc}")
|
||||||
code = _stop("stalled", str(exc), watchdog.EXIT_CODE)
|
code = _stop("stalled", str(exc), watchdog.EXIT_CODE)
|
||||||
|
except recovery.PageStuck as exc:
|
||||||
|
# The supervisor restarts Chrome and logs in again for the
|
||||||
|
# claims still pending (flows/recovery.py).
|
||||||
|
print(f"[RECOVER] Run stopped: {exc}")
|
||||||
|
shot(page, "page_stuck")
|
||||||
|
code = _stop("stuck", first_line(exc), recovery.EXIT_CODE)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if watchdog.has_fired():
|
if watchdog.has_fired():
|
||||||
# The frozen call came back with an error somewhere
|
# The frozen call came back with an error somewhere
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Getting the run out of a stuck screen without a person (operator,
|
||||||
|
2026-10-30: close or refresh, re-login, and continue).
|
||||||
|
|
||||||
|
Live 2026-10-29: a Dental claim's sliding fee check left Patient Information
|
||||||
|
open; its overlay took every click, and the next 18 claims were each marked
|
||||||
|
"Bot could not search the claim" without being looked at.
|
||||||
|
|
||||||
|
A claim the bot could not search or read (`bot_failed`) sets off `recover`:
|
||||||
|
1. the login page on screen = eCW ended the session → PageStuck;
|
||||||
|
2. Escape, close Patient Information, clear the screen → search ready?
|
||||||
|
3. reopen the claim search page (`reopen`) → search ready?
|
||||||
|
4. still blocked → PageStuck.
|
||||||
|
`run_claim` then tries that claim once more. Two claims in a row still
|
||||||
|
failing after their recovery → PageStuck as well. PageStuck ends the run
|
||||||
|
with EXIT_CODE; the supervisor restarts Chrome, logs in again and carries
|
||||||
|
on with the claims still pending.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..core.page_utils import visible
|
||||||
|
from ..pages import claim_form, claim_results, sliding_fee
|
||||||
|
from ..selectors import login as login_sel
|
||||||
|
|
||||||
|
EXIT_CODE = 9
|
||||||
|
STREAK_LIMIT = 2
|
||||||
|
|
||||||
|
|
||||||
|
class PageStuck(RuntimeError):
|
||||||
|
"""The page cannot be brought back without a new browser and login."""
|
||||||
|
|
||||||
|
|
||||||
|
def bot_failed(r: dict, data: dict) -> bool:
|
||||||
|
"""The bot could not search or read the claim — a fault of the screen,
|
||||||
|
not a fact about the claim."""
|
||||||
|
return bool((r or {}).get("search_error") or (data or {}).get("_error"))
|
||||||
|
|
||||||
|
|
||||||
|
def _logged_out(page) -> bool:
|
||||||
|
return visible(page, login_sel.SEL_USERNAME, timeout=1_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_dialogs(page) -> None:
|
||||||
|
for _ in range(2):
|
||||||
|
try:
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sliding_fee.close_patient_info(page)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear(page, claim_id, keep_pages) -> None:
|
||||||
|
claim_form.clear_screen(page, claim_id, keep_pages)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocker(page):
|
||||||
|
return claim_results.search_blocker(page)
|
||||||
|
|
||||||
|
|
||||||
|
def recover(page, claim_id: str, keep_pages, reopen) -> str:
|
||||||
|
"""'cleared' or 'reopened' once the claim search is free; else raises."""
|
||||||
|
if _logged_out(page):
|
||||||
|
raise PageStuck("eCW showed the login page - the session ended")
|
||||||
|
_escape_dialogs(page)
|
||||||
|
_clear(page, claim_id, keep_pages)
|
||||||
|
if not _blocker(page):
|
||||||
|
return "cleared"
|
||||||
|
reopen(page)
|
||||||
|
blocker = _blocker(page)
|
||||||
|
if not blocker:
|
||||||
|
return "reopened"
|
||||||
|
raise PageStuck(f"the claim search stayed blocked after a reopen: "
|
||||||
|
f"{blocker}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_claim(attempt, recover_now, state: dict):
|
||||||
|
"""
|
||||||
|
`attempt()` → (r, data). A bot failure: `recover_now()`, then one more
|
||||||
|
attempt. `state["streak"]` counts claims in a row that still failed
|
||||||
|
after that; at STREAK_LIMIT, PageStuck.
|
||||||
|
"""
|
||||||
|
state.setdefault("streak", 0)
|
||||||
|
r, data = attempt()
|
||||||
|
if not bot_failed(r, data):
|
||||||
|
state["streak"] = 0
|
||||||
|
return r, data
|
||||||
|
recover_now()
|
||||||
|
r, data = attempt()
|
||||||
|
if not bot_failed(r, data):
|
||||||
|
state["streak"] = 0
|
||||||
|
return r, data
|
||||||
|
state["streak"] += 1
|
||||||
|
if state["streak"] >= STREAK_LIMIT:
|
||||||
|
raise PageStuck(f"{state['streak']} claims in a row could not be "
|
||||||
|
f"searched or read, even after a recovery")
|
||||||
|
return r, data
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
The agent: runs the bot over the day's Medcor Ready queue until every claim
|
||||||
|
has a result, without a person (operator, 2026-10-30: "we are an agent, not
|
||||||
|
a bot — observe, fix, continue, and mail me what happened").
|
||||||
|
|
||||||
|
python agent.py
|
||||||
|
|
||||||
|
Each pass runs the bot in a child process (a fresh Chrome and login every
|
||||||
|
time). The first pass downloads the live queue; a later pass takes only the
|
||||||
|
claims still pending — the queue minus every claim that got a result since
|
||||||
|
the agent started, a "Bot could not ..." Review not counting as a result.
|
||||||
|
|
||||||
|
What a pass's exit code leads to is `next_action`, the operator's rules of
|
||||||
|
2026-10-30:
|
||||||
|
* login failures (1, 6): retry after 10, 20 minutes; the 3rd in a row
|
||||||
|
stops the agent — the client's eCW account must not be restricted;
|
||||||
|
* restricted (4), eCW screen changed (7), config (2): stop at once;
|
||||||
|
* stuck page (9), stall (8), claims error (5): restart in a minute;
|
||||||
|
* the attempt guard said wait (3): wait, not a failure;
|
||||||
|
* nothing pending: done; a clean pass that left the same claims: stop,
|
||||||
|
those are for a person.
|
||||||
|
Every failure is mailed (core/notify.py), and so is the end.
|
||||||
|
|
||||||
|
VERIFY only: the agent refuses to start with SAVE_CORRECTIONS=1, since
|
||||||
|
LIVE-WRITE asks a person before every save.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .. import settings as cfg
|
||||||
|
from .. import claim_file
|
||||||
|
from ..core import notify, spreadsheet
|
||||||
|
|
||||||
|
LOGIN_WAITS = (600, 1200, 1800)
|
||||||
|
LOGIN_FAIL_LIMIT = 3
|
||||||
|
CRASH_LIMIT = 6
|
||||||
|
RUN_LIMIT = 15
|
||||||
|
|
||||||
|
_STOP_AT_ONCE = {
|
||||||
|
2: "the bot's settings are incomplete",
|
||||||
|
4: "eCW restricted the account",
|
||||||
|
7: "an eCW screen changed and the bot no longer knows it",
|
||||||
|
}
|
||||||
|
_LOGIN = {1: "the login failed", 6: "the 2FA approval timed out"}
|
||||||
|
_RESTART = {5: "a claim step failed", 8: "a claim stopped making progress",
|
||||||
|
9: "the eCW page got stuck and could not be cleared"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class State:
|
||||||
|
login_fails: int = 0
|
||||||
|
crashes: int = 0
|
||||||
|
runs: int = 0
|
||||||
|
last_pending: list | None = None
|
||||||
|
outcome: str = ""
|
||||||
|
history: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def next_action(code: int, pending, state: State):
|
||||||
|
"""(action, wait_seconds, why) — action is done, retry or stop."""
|
||||||
|
state.runs += 1
|
||||||
|
if code not in _LOGIN and code != 3:
|
||||||
|
state.login_fails = 0
|
||||||
|
if pending == []:
|
||||||
|
return "done", 0, "every claim in the queue has a result"
|
||||||
|
if code in _STOP_AT_ONCE:
|
||||||
|
return "stop", 0, _STOP_AT_ONCE[code]
|
||||||
|
if state.runs >= RUN_LIMIT:
|
||||||
|
return "stop", 0, f"{RUN_LIMIT} passes today without finishing"
|
||||||
|
if code in _LOGIN:
|
||||||
|
state.login_fails += 1
|
||||||
|
if state.login_fails >= LOGIN_FAIL_LIMIT:
|
||||||
|
return "stop", 0, (f"{LOGIN_FAIL_LIMIT} login failures in a row "
|
||||||
|
f"- stopping so eCW does not restrict the "
|
||||||
|
f"account")
|
||||||
|
return "retry", LOGIN_WAITS[state.login_fails - 1], _LOGIN[code]
|
||||||
|
if code == 3:
|
||||||
|
return "retry", 300, "the login guard asked to wait"
|
||||||
|
if code in _RESTART or code != 0:
|
||||||
|
state.crashes += 1
|
||||||
|
if state.crashes >= CRASH_LIMIT:
|
||||||
|
return "stop", 0, f"{CRASH_LIMIT} restarts today"
|
||||||
|
return "retry", 60, _RESTART.get(code, f"the bot ended with {code}")
|
||||||
|
if pending is None:
|
||||||
|
return "stop", 0, "the run ended but no queue file was found"
|
||||||
|
if state.last_pending is not None and \
|
||||||
|
sorted(pending) == sorted(state.last_pending):
|
||||||
|
return "stop", 0, (f"{len(pending)} claim(s) still could not be "
|
||||||
|
f"checked after a retry - left for a person")
|
||||||
|
state.last_pending = list(pending)
|
||||||
|
return "retry", 60, f"{len(pending)} claim(s) to retry"
|
||||||
|
|
||||||
|
|
||||||
|
def pending(queue_ids, rows) -> list:
|
||||||
|
"""Queue order kept; rows = (claim_no, comments) since the agent began."""
|
||||||
|
done = {str(c) for c, comments in rows
|
||||||
|
if not (comments or "").startswith("Bot could not")}
|
||||||
|
return [c for c in queue_ids if str(c) not in done]
|
||||||
|
|
||||||
|
|
||||||
|
def run_loop(run_bot, pending_now, mail, sleep) -> State:
|
||||||
|
st = State()
|
||||||
|
resume = None
|
||||||
|
while True:
|
||||||
|
code = run_bot(resume)
|
||||||
|
left = pending_now()
|
||||||
|
action, wait, why = next_action(code, left, st)
|
||||||
|
st.history.append((code, action, why))
|
||||||
|
print(f"[AGENT] Pass {st.runs}: exit {code} - {action} ({why})")
|
||||||
|
if action == "done":
|
||||||
|
st.outcome = "done"
|
||||||
|
mail("summary", "Run finished", st)
|
||||||
|
return st
|
||||||
|
if action == "stop":
|
||||||
|
st.outcome = "stopped"
|
||||||
|
mail("incident", "Agent stopped", why,
|
||||||
|
"Stopped; nothing more will run today without a person.",
|
||||||
|
False)
|
||||||
|
return st
|
||||||
|
mail("incident", "Recovering", why,
|
||||||
|
f"Waiting {wait // 60} min, then a fresh Chrome and login for "
|
||||||
|
f"the {len(left) if left else 'queued'} claim(s) still pending.",
|
||||||
|
True)
|
||||||
|
resume = left or None
|
||||||
|
sleep(wait)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the real world ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_BOOT = ("import sys; sys.path.insert(0, 'src'); "
|
||||||
|
"from ecw_bot.flows.export_medcor_ready import main; sys.exit(main())")
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_file() -> Path:
|
||||||
|
return cfg.ROOT / "Output" / f"Medcor Ready_{cfg.TODAY}.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_bot_child(resume, started_at: datetime):
|
||||||
|
env = dict(os.environ, SAVE_CORRECTIONS="0", QUIET_BOXES="1",
|
||||||
|
SHOW_EACH_CLAIM="0", SHOW_CLAIM_IDS="0", CLAIM_PROCESS_LIMIT="0",
|
||||||
|
ECW_KEEP_OPEN="0", TEST_CLAIM_IDS_FILE="",
|
||||||
|
RESUME_CLAIM_IDS_FILE="")
|
||||||
|
if resume:
|
||||||
|
path = cfg.ROOT / "Output" / f"Agent pending_{cfg.TODAY}.xlsx"
|
||||||
|
spreadsheet.write_xlsx(path, [["CLAIMS#"]] + [[c] for c in resume])
|
||||||
|
env["RESUME_CLAIM_IDS_FILE"] = str(path)
|
||||||
|
print(f"[AGENT] Resuming {len(resume)} pending claim(s)")
|
||||||
|
else:
|
||||||
|
print("[AGENT] Running the live Medcor Ready queue")
|
||||||
|
return subprocess.call([sys.executable, "-c", _BOOT], cwd=cfg.ROOT,
|
||||||
|
env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_now(started_at: datetime):
|
||||||
|
if not _queue_file().exists():
|
||||||
|
return None
|
||||||
|
queue = claim_file.claim_ids(_queue_file())["ids"]
|
||||||
|
import pyodbc
|
||||||
|
with pyodbc.connect(cfg.RESULTS_DB_CONN, timeout=15) as conn:
|
||||||
|
rows = conn.cursor().execute(
|
||||||
|
"SELECT claim_no, comments FROM dbo.CV_ChargeSummary "
|
||||||
|
"WHERE run_at >= ?", started_at).fetchall()
|
||||||
|
return pending(queue, [(r[0], r[1]) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
def _shots_since(when: datetime) -> list:
|
||||||
|
folder = Path(cfg.SCREENSHOTS)
|
||||||
|
if not folder.exists():
|
||||||
|
return []
|
||||||
|
t = when.timestamp()
|
||||||
|
return [p for p in folder.glob("*.png") if p.stat().st_mtime >= t]
|
||||||
|
|
||||||
|
|
||||||
|
def _mail(started_at):
|
||||||
|
def mail(kind, title, *args):
|
||||||
|
if kind == "summary":
|
||||||
|
st = args[0]
|
||||||
|
lines = [f"The agent finished the Medcor Ready queue for "
|
||||||
|
f"{cfg.TODAY} in {st.runs} pass(es).",
|
||||||
|
"", "Passes:"]
|
||||||
|
lines += [f" exit {c}: {a} - {w}" for c, a, w in st.history]
|
||||||
|
lines += ["", "Results are in the Portal under today's date."]
|
||||||
|
notify.summary(title, lines)
|
||||||
|
return
|
||||||
|
what, did, recovered = args
|
||||||
|
notify.incident(title, what, did, recovered,
|
||||||
|
shots=_shots_since(started_at))
|
||||||
|
return mail
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if cfg.SAVE_CORRECTIONS:
|
||||||
|
print("[AGENT] SAVE_CORRECTIONS=1 - the agent runs VERIFY only; "
|
||||||
|
"LIVE-WRITE needs a person at every save.")
|
||||||
|
return 2
|
||||||
|
started_at = datetime.now().replace(microsecond=0)
|
||||||
|
print(f"[AGENT] Started {started_at:%Y-%m-%d %H:%M:%S}")
|
||||||
|
st = run_loop(
|
||||||
|
run_bot=lambda resume: _run_bot_child(resume, started_at),
|
||||||
|
pending_now=lambda: _pending_now(started_at),
|
||||||
|
mail=_mail(started_at),
|
||||||
|
sleep=time.sleep)
|
||||||
|
return 0 if st.outcome == "done" else 1
|
||||||
@@ -192,6 +192,10 @@ def _close_patient_info(page: Page) -> None:
|
|||||||
"sliding_fee_patient_info_still_open.png")
|
"sliding_fee_patient_info_still_open.png")
|
||||||
|
|
||||||
|
|
||||||
|
# For flows/recovery.py: the same close, after a claim left the dialog up.
|
||||||
|
close_patient_info = _close_patient_info
|
||||||
|
|
||||||
|
|
||||||
def read(page: Page) -> dict:
|
def read(page: Page) -> dict:
|
||||||
"""Open Info -> Sliding Fee Schedule, read it, close both. Never raises."""
|
"""Open Info -> Sliding Fee Schedule, read it, close both. Never raises."""
|
||||||
if not appears(page, sel.SEL_CLAIM_PATIENT_INFO, 5_000):
|
if not appears(page, sel.SEL_CLAIM_PATIENT_INFO, 5_000):
|
||||||
|
|||||||
@@ -235,6 +235,15 @@ IMAP_PASSWORD = _env("IMAP_PASSWORD")
|
|||||||
IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX")
|
IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX")
|
||||||
IMAP_SSL = _env_bool("IMAP_SSL", True)
|
IMAP_SSL = _env_bool("IMAP_SSL", True)
|
||||||
|
|
||||||
|
# The agent's email to the operator (core/notify.py, operator 2026-10-30):
|
||||||
|
# sent from the 2FA mailbox's own account over SMTP, so no new credential.
|
||||||
|
# Blank NOTIFY_EMAIL_TO = no mail at all.
|
||||||
|
NOTIFY_EMAIL_TO = _env("NOTIFY_EMAIL_TO")
|
||||||
|
SMTP_HOST = _env("SMTP_HOST") or IMAP_HOST
|
||||||
|
SMTP_PORT = _env_int("SMTP_PORT", 465)
|
||||||
|
SMTP_USERNAME = _env("SMTP_USERNAME") or IMAP_USERNAME
|
||||||
|
SMTP_PASSWORD = _env("SMTP_PASSWORD") or IMAP_PASSWORD
|
||||||
|
|
||||||
# How long to keep polling the mailbox for the OTP mail, and how often.
|
# How long to keep polling the mailbox for the OTP mail, and how often.
|
||||||
#
|
#
|
||||||
# Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery
|
# Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery
|
||||||
@@ -272,6 +281,12 @@ OTP_LINK_AGE_WARN_SECONDS = _env_int("OTP_LINK_AGE_WARN_SECONDS", 540)
|
|||||||
# for that and stops with it as the stated reason.
|
# for that and stops with it as the stated reason.
|
||||||
OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1)
|
OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1)
|
||||||
OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600)
|
OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600)
|
||||||
|
# A link approved but never picked up by the login page (live 2026-10-29: mail
|
||||||
|
# 6 to 8.5 minutes late, page still on "sent a notification", Resend still
|
||||||
|
# there): wait this long for the page to move on, then Resend and approve the
|
||||||
|
# new mail, up to OTP_PICKUP_RESENDS times. eCW allows three resends a login.
|
||||||
|
OTP_PICKUP_SECONDS = _env_int("OTP_PICKUP_SECONDS", 60)
|
||||||
|
OTP_PICKUP_RESENDS = _env_int("OTP_PICKUP_RESENDS", 2)
|
||||||
# Ignore anything already sitting in the inbox from before the login attempt.
|
# Ignore anything already sitting in the inbox from before the login attempt.
|
||||||
OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300)
|
OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300)
|
||||||
|
|
||||||
@@ -516,6 +531,11 @@ NOTIFY_UNMAPPED_PAYERS = _env_bool("NOTIFY_UNMAPPED_PAYERS", True)
|
|||||||
# Accepted on purpose — a path in .env that points at nothing is a config
|
# Accepted on purpose — a path in .env that points at nothing is a config
|
||||||
# mistake whichever program reads the file (unit B fix round 1, 2026-10-05).
|
# mistake whichever program reads the file (unit B fix round 1, 2026-10-05).
|
||||||
TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "")
|
TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "")
|
||||||
|
# Set by the agent (flows/supervisor.py), not by hand: the claims of today's
|
||||||
|
# live queue still pending after an earlier pass stopped. Run like the live
|
||||||
|
# queue — no TEST label, the live Charge Summary — but from this list, with
|
||||||
|
# no fresh export.
|
||||||
|
RESUME_CLAIM_IDS_FILE = _env("RESUME_CLAIM_IDS_FILE", "")
|
||||||
|
|
||||||
|
|
||||||
# ── Per-claim processing ──────────────────────────────────────────────────────
|
# ── Per-claim processing ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -194,7 +194,14 @@ def _run(*, fires=False, review=True, show_each=True, dental=False,
|
|||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(out):
|
with contextlib.redirect_stdout(out):
|
||||||
flow._process_claims(None, {"ids": ["9103000"], "file": None})
|
# No page to recover on: flows/recovery.py's steps are
|
||||||
|
# its own tests' business (test_recovery.py).
|
||||||
|
real_recover = flow.recovery.recover
|
||||||
|
flow.recovery.recover = lambda *a: "cleared"
|
||||||
|
try:
|
||||||
|
flow._process_claims(None, {"ids": ["9103000"], "file": None})
|
||||||
|
finally:
|
||||||
|
flow.recovery.recover = real_recover
|
||||||
finally:
|
finally:
|
||||||
_restore()
|
_restore()
|
||||||
return events, out.getvalue(), written[0]
|
return events, out.getvalue(), written[0]
|
||||||
|
|||||||
@@ -321,7 +321,14 @@ def _run(ids, search, events=None):
|
|||||||
lambda page, cid, keep=(): events.append(("clear", cid)) or [])
|
lambda page, cid, keep=(): events.append(("clear", cid)) or [])
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(io.StringIO()):
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
flow._process_claims(None, {"ids": list(ids), "file": None})
|
# No page to recover on: flows/recovery.py's steps are
|
||||||
|
# its own tests' business (test_recovery.py).
|
||||||
|
real_recover = flow.recovery.recover
|
||||||
|
flow.recovery.recover = lambda *a: "cleared"
|
||||||
|
try:
|
||||||
|
flow._process_claims(None, {"ids": list(ids), "file": None})
|
||||||
|
finally:
|
||||||
|
flow.recovery.recover = real_recover
|
||||||
finally:
|
finally:
|
||||||
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
||||||
flow.claim_results.search_claim, flow._read_claim_form,
|
flow.claim_results.search_claim, flow._read_claim_form,
|
||||||
|
|||||||
@@ -79,6 +79,34 @@ def a_password_box_that_stays_disabled_is_an_error_with_a_screenshot(page):
|
|||||||
assert {p.suffix for p in shots} == {".png", ".html"}, shots
|
assert {p.suffix for p in shots} == {".png", ".html"}, shots
|
||||||
|
|
||||||
|
|
||||||
|
# The security image page with its own Continue: clicking it is what lets
|
||||||
|
# eCW enable the password box (operator, 2026-10-30: no manual click).
|
||||||
|
_IMAGE_PAGE = """
|
||||||
|
<input id="doctorID" value="someone">
|
||||||
|
<input disabled type="password" id="passwordField">
|
||||||
|
<div id="img">VERIFY YOUR SECURITY IMAGE
|
||||||
|
<input type="button" value="%s" onclick="document.getElementById('img')
|
||||||
|
.style.display = 'none'; document.getElementById('passwordField')
|
||||||
|
.disabled = false;">
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def continue_on_the_security_image_page_is_clicked_by_the_bot(page):
|
||||||
|
page.set_content(_IMAGE_PAGE % "Continue")
|
||||||
|
assert login.wait_after_username(page, 5_000) == "image"
|
||||||
|
assert login.continue_security_image(page) is True
|
||||||
|
assert login.wait_after_username(page, 5_000, image=False) == "password"
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def no_continue_button_is_left_to_a_person(page):
|
||||||
|
page.set_content(_IMAGE_PAGE % "Not me")
|
||||||
|
assert login.continue_security_image(page) is False
|
||||||
|
assert page.locator("#passwordField").is_disabled()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ def without_quiet_boxes_an_info_box_is_shown():
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
failures = 0
|
failures = 0
|
||||||
|
# This PC's .env runs bulk with QUIET_BOXES=1 (2026-10-29); the cases
|
||||||
|
# that want it on set it themselves.
|
||||||
|
msgbox.cfg.QUIET_BOXES = False
|
||||||
for fn in CASES:
|
for fn in CASES:
|
||||||
name = fn.__name__.replace("_", " ")
|
name = fn.__name__.replace("_", " ")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
r"""
|
||||||
|
core/notify.py — the agent's email to the operator (operator, 2026-10-30):
|
||||||
|
what happened, what the agent did about it, and whether it recovered.
|
||||||
|
|
||||||
|
Rules the operator set:
|
||||||
|
* sent from the bot's own mailbox (SMTP, the 2FA mailbox's account);
|
||||||
|
* screenshots of login and error pages only — never a claim screen, never
|
||||||
|
page HTML: those carry PHI;
|
||||||
|
* no claim numbers in the mail.
|
||||||
|
|
||||||
|
python tests\test_notify.py
|
||||||
|
|
||||||
|
No mail is sent: the SMTP transport is a stand-in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
||||||
|
|
||||||
|
from ecw_bot import settings as cfg # noqa: E402
|
||||||
|
from ecw_bot.core import notify # noqa: E402
|
||||||
|
|
||||||
|
CASES = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(fn):
|
||||||
|
CASES.append(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def _capture():
|
||||||
|
sent = []
|
||||||
|
notify._transport = lambda msg: sent.append(msg)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def _files(tmp, *names):
|
||||||
|
out = []
|
||||||
|
for n in names:
|
||||||
|
p = pathlib.Path(tmp) / n
|
||||||
|
p.write_bytes(b"x")
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def an_incident_says_what_happened_and_what_the_agent_did(tmp):
|
||||||
|
sent = _capture()
|
||||||
|
assert notify.incident("Login failed", "2FA mail came 7 minutes late",
|
||||||
|
"Resent the OTP, will retry in 10 minutes",
|
||||||
|
recovered=False) is True
|
||||||
|
msg = sent[0]
|
||||||
|
assert msg["To"] == cfg.NOTIFY_EMAIL_TO
|
||||||
|
assert "Login failed" in msg["Subject"]
|
||||||
|
body = msg.get_body(("plain",)).get_content()
|
||||||
|
assert "2FA mail came 7 minutes late" in body
|
||||||
|
assert "Resent the OTP" in body
|
||||||
|
assert "Not recovered" in body
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def claim_numbers_never_reach_the_mail(tmp):
|
||||||
|
sent = _capture()
|
||||||
|
notify.incident("Stuck dialog on claim 9107000",
|
||||||
|
"Claim 9107000 form would not close", "Reloaded eCW",
|
||||||
|
recovered=True)
|
||||||
|
msg = sent[0]
|
||||||
|
text = msg["Subject"] + msg.get_body(("plain",)).get_content()
|
||||||
|
assert "9107000" not in text, text
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def only_login_and_error_screenshots_are_attached(tmp):
|
||||||
|
sent = _capture()
|
||||||
|
shots = _files(tmp, "211944_login_unconfirmed.png",
|
||||||
|
"9107000_claim_form_leftover_204500.png",
|
||||||
|
"211944_login_unconfirmed.html",
|
||||||
|
"101500_security_image_page.png")
|
||||||
|
notify.incident("Login failed", "x", "y", recovered=False, shots=shots)
|
||||||
|
names = sorted(a.get_filename() for a in sent[0].iter_attachments())
|
||||||
|
assert names == ["101500_security_image_page.png",
|
||||||
|
"211944_login_unconfirmed.png"], names
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def nothing_is_sent_when_no_recipient_is_set(tmp):
|
||||||
|
sent = _capture()
|
||||||
|
old = cfg.NOTIFY_EMAIL_TO
|
||||||
|
cfg.NOTIFY_EMAIL_TO = ""
|
||||||
|
try:
|
||||||
|
assert notify.incident("x", "y", "z", recovered=True) is False
|
||||||
|
finally:
|
||||||
|
cfg.NOTIFY_EMAIL_TO = old
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_mail_server_error_never_stops_the_run(tmp):
|
||||||
|
def boom(msg):
|
||||||
|
raise OSError("connection refused")
|
||||||
|
notify._transport = boom
|
||||||
|
assert notify.incident("x", "y", "z", recovered=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
failures = 0
|
||||||
|
real = notify._transport
|
||||||
|
cfg.NOTIFY_EMAIL_TO = "operator@example.test"
|
||||||
|
for fn in CASES:
|
||||||
|
name = fn.__name__.replace("_", " ")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
try:
|
||||||
|
fn(tmp)
|
||||||
|
print(f" ok {name}")
|
||||||
|
except AssertionError as exc:
|
||||||
|
print(f" FAIL {name}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
finally:
|
||||||
|
notify._transport = real
|
||||||
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -220,7 +220,8 @@ def the_header_names_the_user_and_admin_links_are_admin_only():
|
|||||||
for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
|
for name, role in (("sam", "Super Admin"), ("asha", "Admin")):
|
||||||
a, _ = _sign_in(app, name)
|
a, _ = _sign_in(app, name)
|
||||||
page = a.get("/runs").text
|
page = a.get("/runs").text
|
||||||
assert f"<b>{name.title()}</b> · {role}" in page, name
|
# The side menu (2026-10-30): name over role.
|
||||||
|
assert f"<b>{name.title()}</b><small>{role}</small>" in page, name
|
||||||
assert 'href="/admin/users"' in page and 'href="/admin/signins"' in page
|
assert 'href="/admin/users"' in page and 'href="/admin/signins"' in page
|
||||||
v, _ = _sign_in(app, "vee")
|
v, _ = _sign_in(app, "vee")
|
||||||
page = v.get("/runs").text
|
page = v.get("/runs").text
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
r"""
|
||||||
|
flows/recovery.py — the run gets itself out of a stuck screen (operator,
|
||||||
|
2026-10-30: "you can close or refresh and re-login and continue the agent").
|
||||||
|
|
||||||
|
Live 2026-10-29: a Dental claim's sliding fee check left Patient Information
|
||||||
|
open; its overlay took every click, and the next 18 claims were each marked
|
||||||
|
"Bot could not search the claim" without being looked at.
|
||||||
|
|
||||||
|
Now a claim the bot could not search or read sets off a recovery:
|
||||||
|
1. the login page on screen = the session ended → PageStuck (re-login);
|
||||||
|
2. Escape, close Patient Information, clear the screen → search ready?
|
||||||
|
3. reopen the claim search page → search ready?
|
||||||
|
4. still blocked → PageStuck.
|
||||||
|
After a recovery the same claim is tried once more. Two claims in a row
|
||||||
|
that still fail after their recovery → PageStuck, so the supervisor
|
||||||
|
restarts Chrome and logs in again.
|
||||||
|
|
||||||
|
python tests\test_recovery.py
|
||||||
|
|
||||||
|
No browser: the page steps are stand-ins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
||||||
|
|
||||||
|
from ecw_bot.flows import recovery # noqa: E402
|
||||||
|
|
||||||
|
CASES = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(fn):
|
||||||
|
CASES.append(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
class Steps:
|
||||||
|
"""Stand-ins for the page steps; `blocked` = what search_blocker says,
|
||||||
|
in order, one per look."""
|
||||||
|
|
||||||
|
def __init__(self, blocked, logged_out=False):
|
||||||
|
self.blocked = list(blocked)
|
||||||
|
self.logged_out = logged_out
|
||||||
|
self.done = []
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
recovery._logged_out = lambda page: self.logged_out
|
||||||
|
recovery._escape_dialogs = lambda page: self.done.append("escape")
|
||||||
|
recovery._clear = lambda page, cid, keep: self.done.append("clear")
|
||||||
|
recovery._blocker = lambda page: self.blocked.pop(0)
|
||||||
|
|
||||||
|
def reopen(self, page):
|
||||||
|
self.done.append("reopen")
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_dialog_cleared_by_escape_needs_no_reopen():
|
||||||
|
s = Steps([None])
|
||||||
|
s.install()
|
||||||
|
assert recovery.recover(None, "1", (), s.reopen) == "cleared"
|
||||||
|
assert s.done == ["escape", "clear"], s.done
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_page_still_blocked_is_reopened():
|
||||||
|
s = Steps(["covered by div.modalOverlay", None])
|
||||||
|
s.install()
|
||||||
|
assert recovery.recover(None, "1", (), s.reopen) == "reopened"
|
||||||
|
assert s.done == ["escape", "clear", "reopen"], s.done
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_page_blocked_after_reopen_is_stuck():
|
||||||
|
s = Steps(["covered", "covered"])
|
||||||
|
s.install()
|
||||||
|
try:
|
||||||
|
recovery.recover(None, "1", (), s.reopen)
|
||||||
|
except recovery.PageStuck as exc:
|
||||||
|
assert "covered" in str(exc), exc
|
||||||
|
else:
|
||||||
|
raise AssertionError("no PageStuck")
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def the_login_page_means_the_session_ended():
|
||||||
|
s = Steps([], logged_out=True)
|
||||||
|
s.install()
|
||||||
|
try:
|
||||||
|
recovery.recover(None, "1", (), s.reopen)
|
||||||
|
except recovery.PageStuck as exc:
|
||||||
|
assert "login page" in str(exc), exc
|
||||||
|
else:
|
||||||
|
raise AssertionError("no PageStuck")
|
||||||
|
assert s.done == [], s.done
|
||||||
|
|
||||||
|
|
||||||
|
def _attempts(*outcomes):
|
||||||
|
"""attempt() returning each outcome in turn: 'ok' or 'bot'."""
|
||||||
|
seq = list(outcomes)
|
||||||
|
|
||||||
|
def attempt():
|
||||||
|
o = seq.pop(0)
|
||||||
|
return ({"search_error": "x"}, {}) if o == "bot" else ({}, {"cpt": []})
|
||||||
|
return attempt
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_claim_that_fails_is_recovered_and_tried_again():
|
||||||
|
tried = []
|
||||||
|
state = {}
|
||||||
|
r, data = recovery.run_claim(_attempts("bot", "ok"),
|
||||||
|
lambda: tried.append(1), state)
|
||||||
|
assert tried == [1] and not r.get("search_error"), (tried, r)
|
||||||
|
assert state["streak"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_claim_that_fails_again_is_kept_as_review():
|
||||||
|
state = {}
|
||||||
|
r, _ = recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
|
||||||
|
assert r.get("search_error") and state["streak"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def two_claims_in_a_row_failing_after_recovery_stop_the_run():
|
||||||
|
state = {}
|
||||||
|
recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
|
||||||
|
try:
|
||||||
|
recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
|
||||||
|
except recovery.PageStuck:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("no PageStuck after two in a row")
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_good_claim_resets_the_streak():
|
||||||
|
state = {}
|
||||||
|
recovery.run_claim(_attempts("bot", "bot"), lambda: None, state)
|
||||||
|
recovery.run_claim(_attempts("ok"), lambda: None, state)
|
||||||
|
assert state["streak"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
failures = 0
|
||||||
|
real = {k: getattr(recovery, k, None) for k in
|
||||||
|
("_logged_out", "_escape_dialogs", "_clear", "_blocker")}
|
||||||
|
for fn in CASES:
|
||||||
|
name = fn.__name__.replace("_", " ")
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f" ok {name}")
|
||||||
|
except AssertionError as exc:
|
||||||
|
print(f" FAIL {name}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
finally:
|
||||||
|
for k, v in real.items():
|
||||||
|
setattr(recovery, k, v)
|
||||||
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -739,7 +739,14 @@ def process_claims_counts_an_unreadable_form_as_not_read_and_reviews_it():
|
|||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(out):
|
with contextlib.redirect_stdout(out):
|
||||||
flow._process_claims(None, {"ids": ["9103000"], "file": None})
|
# No page to recover on: flows/recovery.py's steps are
|
||||||
|
# its own tests' business (test_recovery.py).
|
||||||
|
real_recover = flow.recovery.recover
|
||||||
|
flow.recovery.recover = lambda *a: "cleared"
|
||||||
|
try:
|
||||||
|
flow._process_claims(None, {"ids": ["9103000"], "file": None})
|
||||||
|
finally:
|
||||||
|
flow.recovery.recover = real_recover
|
||||||
finally:
|
finally:
|
||||||
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
|
||||||
cfg.OPEN_CLAIM_FORM, msgbox.info_box, claim_form.open_claim,
|
cfg.OPEN_CLAIM_FORM, msgbox.info_box, claim_form.open_claim,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
r"""
|
||||||
|
flows/supervisor.py — the agent loop around the bot (operator, 2026-10-30):
|
||||||
|
run the day's queue to the end without a person, restarting on technical
|
||||||
|
failures and mailing what happened.
|
||||||
|
|
||||||
|
Rules (operator's choices that day):
|
||||||
|
* login failures (exit 1, 6): retry after 10, 20, then 30 minutes; the
|
||||||
|
3rd failure in a row stops the agent and mails it — the client's eCW
|
||||||
|
account must not be restricted;
|
||||||
|
* restricted (4), eCW screen changed (7), config (2): stop at once — no
|
||||||
|
retry can fix them;
|
||||||
|
* a stuck page (9), a stall (8), a claims error (5): restart with a fresh
|
||||||
|
Chrome and login for the claims still pending;
|
||||||
|
* the attempt guard said wait (3): wait, it is not a failure;
|
||||||
|
* nothing pending: done. A clean run that left the same claims pending
|
||||||
|
as before: stop, those are for a person.
|
||||||
|
|
||||||
|
python tests\test_supervisor.py
|
||||||
|
|
||||||
|
No bot, no browser, no mail, no database: all stand-ins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
||||||
|
|
||||||
|
from ecw_bot.flows import supervisor as sv # noqa: E402
|
||||||
|
|
||||||
|
CASES = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(fn):
|
||||||
|
CASES.append(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def _decide(code, pending, state=None):
|
||||||
|
state = state if state is not None else sv.State()
|
||||||
|
return sv.next_action(code, pending, state), state
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def nothing_pending_is_done():
|
||||||
|
(action, _, _), _ = _decide(0, [])
|
||||||
|
assert action == "done"
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def login_failures_back_off_10_20_then_stop_on_the_third():
|
||||||
|
st = sv.State()
|
||||||
|
waits = []
|
||||||
|
for _ in range(2):
|
||||||
|
(action, wait, _), st = _decide(1, ["a"], st)
|
||||||
|
assert action == "retry"
|
||||||
|
waits.append(wait)
|
||||||
|
assert waits == [600, 1200], waits
|
||||||
|
(action, _, why), st = _decide(6, ["a"], st)
|
||||||
|
assert action == "stop" and "3 login" in why, why
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_good_login_resets_the_login_failures():
|
||||||
|
st = sv.State()
|
||||||
|
_decide(1, ["a"], st)
|
||||||
|
_decide(9, ["a"], st)
|
||||||
|
assert st.login_fails == 0
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def restricted_screen_changed_and_config_stop_at_once():
|
||||||
|
for code in (2, 4, 7):
|
||||||
|
(action, _, _), _ = _decide(code, ["a"])
|
||||||
|
assert action == "stop", code
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_stuck_page_restarts_soon():
|
||||||
|
(action, wait, _), _ = _decide(9, ["a", "b"])
|
||||||
|
assert action == "retry" and wait <= 120
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def the_guard_saying_wait_is_not_a_failure():
|
||||||
|
(action, wait, _), st = _decide(3, ["a"])
|
||||||
|
assert action == "retry" and wait >= 300 and st.login_fails == 0
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def a_clean_run_that_left_the_same_claims_stops():
|
||||||
|
st = sv.State()
|
||||||
|
(action, _, _), st = _decide(0, ["a", "b"], st)
|
||||||
|
assert action == "retry"
|
||||||
|
(action, _, why), st = _decide(0, ["a", "b"], st)
|
||||||
|
assert action == "stop" and "person" in why, why
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def pending_is_the_queue_minus_claims_done_since_the_agent_started():
|
||||||
|
queue = ["1", "2", "3", "4"]
|
||||||
|
rows = [("1", "Clean claims"), ("2", "Bot could not search the claim"),
|
||||||
|
("3", "Telehealth (POS 02 on T1017)")]
|
||||||
|
assert sv.pending(queue, rows) == ["2", "4"]
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def the_loop_restarts_mails_and_ends_when_nothing_is_left():
|
||||||
|
codes = [6, 9, 0]
|
||||||
|
left = [["1", "2"], ["2"], []]
|
||||||
|
runs, mails, slept = [], [], []
|
||||||
|
st = sv.run_loop(
|
||||||
|
run_bot=lambda resume: runs.append(resume) or codes.pop(0),
|
||||||
|
pending_now=lambda: left.pop(0),
|
||||||
|
mail=lambda kind, *a: mails.append(kind),
|
||||||
|
sleep=slept.append)
|
||||||
|
assert runs == [None, ["1", "2"], ["2"]], runs
|
||||||
|
assert slept == [600, 60], slept
|
||||||
|
assert mails == ["incident", "incident", "summary"], mails
|
||||||
|
assert st.outcome == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
failures = 0
|
||||||
|
for fn in CASES:
|
||||||
|
name = fn.__name__.replace("_", " ")
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f" ok {name}")
|
||||||
|
except AssertionError as exc:
|
||||||
|
print(f" FAIL {name}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
r"""
|
||||||
|
auth/twofactor.py `handle_2fa()` — an approval the login page never picks up.
|
||||||
|
|
||||||
|
Live 2026-10-29, three logins in a row: the push mail came 6 to 8.5 minutes
|
||||||
|
late, the bot opened its link and clicked "Yes, it's me", and the login page
|
||||||
|
stayed on "eClinicalWorks sent a notification to your email" with Resend OTP
|
||||||
|
still there. The bot waited out LOGIN_TIMEOUT and gave up ("Login not
|
||||||
|
confirmed"). Now it presses Resend, waits for the new mail and approves that
|
||||||
|
one, up to OTP_PICKUP_RESENDS times (operator, 2026-10-30: no manual step).
|
||||||
|
|
||||||
|
python tests\test_twofa_late_approval.py
|
||||||
|
|
||||||
|
Local headless chromium, `page.set_content()` only. The mailbox and the
|
||||||
|
approval tab are stand-ins: no eCW, no mail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
from ecw_bot import settings as cfg # noqa: E402
|
||||||
|
from ecw_bot.auth import twofactor # noqa: E402
|
||||||
|
from ecw_bot.auth.mailbox import Challenge # noqa: E402
|
||||||
|
|
||||||
|
CASES = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(fn):
|
||||||
|
CASES.append(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
# The push screen as seen live: the notice in #dispMessage, Resend enabled.
|
||||||
|
_PAGE = """
|
||||||
|
<div id="dispMessage">eClinicalWorks sent a notification to your email.
|
||||||
|
Please confirm to login.</div>
|
||||||
|
<input type="button" id="resendOtpBtn" value="Resend OTP"
|
||||||
|
onclick="window.resends = (window.resends || 0) + 1">
|
||||||
|
<a id="jellybean-panelLink4" style="display:none">Home</a>
|
||||||
|
<script>window.resends = 0</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _go_home(page):
|
||||||
|
page.evaluate("document.getElementById('jellybean-panelLink4')"
|
||||||
|
".style.display = ''; document.getElementById('dispMessage')"
|
||||||
|
".style.display = 'none'")
|
||||||
|
|
||||||
|
|
||||||
|
def _fakes(page, picked_up_on):
|
||||||
|
"""Mailbox hands out links in order; approval number `picked_up_on`
|
||||||
|
(1-based) is the one the page takes."""
|
||||||
|
seen = {"approvals": 0, "mails": 0}
|
||||||
|
|
||||||
|
def get_challenge(**kw):
|
||||||
|
seen["mails"] += 1
|
||||||
|
return Challenge("link", f"https://example.test/approve/{seen['mails']}")
|
||||||
|
|
||||||
|
def approve(context, url):
|
||||||
|
seen["approvals"] += 1
|
||||||
|
if seen["approvals"] == picked_up_on:
|
||||||
|
_go_home(page)
|
||||||
|
return True
|
||||||
|
|
||||||
|
twofactor.mailbox.get_challenge = get_challenge
|
||||||
|
twofactor._approve_in_new_tab = approve
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def an_approval_not_picked_up_is_resent_and_approved_again(page):
|
||||||
|
page.set_content(_PAGE)
|
||||||
|
seen = _fakes(page, picked_up_on=2)
|
||||||
|
twofactor.handle_2fa(page, None, "Test", None)
|
||||||
|
assert seen["approvals"] == 2, seen
|
||||||
|
assert page.evaluate("window.resends") == 1
|
||||||
|
assert page.locator("#jellybean-panelLink4").is_visible()
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def an_approval_picked_up_at_once_needs_no_resend(page):
|
||||||
|
page.set_content(_PAGE)
|
||||||
|
seen = _fakes(page, picked_up_on=1)
|
||||||
|
twofactor.handle_2fa(page, None, "Test", None)
|
||||||
|
assert seen["approvals"] == 1, seen
|
||||||
|
assert page.evaluate("window.resends || 0") == 0
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def resends_stop_at_the_limit(page):
|
||||||
|
page.set_content(_PAGE)
|
||||||
|
seen = _fakes(page, picked_up_on=99)
|
||||||
|
twofactor.handle_2fa(page, None, "Test", None)
|
||||||
|
assert page.evaluate("window.resends") == cfg.OTP_PICKUP_RESENDS
|
||||||
|
assert seen["approvals"] == cfg.OTP_PICKUP_RESENDS + 1, seen
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
real = (twofactor.mailbox.get_challenge, twofactor._approve_in_new_tab)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg.SCREENSHOTS = pathlib.Path(tmp) # never the real folder
|
||||||
|
cfg.ECW_SECRET_KEY = ""
|
||||||
|
cfg.OTP_PICKUP_SECONDS = 2
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
browser = pw.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
for fn in CASES:
|
||||||
|
name = fn.__name__.replace("_", " ")
|
||||||
|
try:
|
||||||
|
fn(page)
|
||||||
|
print(f" ok {name}")
|
||||||
|
except AssertionError as exc:
|
||||||
|
print(f" FAIL {name}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
||||||
|
failures += 1
|
||||||
|
finally:
|
||||||
|
(twofactor.mailbox.get_challenge,
|
||||||
|
twofactor._approve_in_new_tab) = real
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -232,7 +232,9 @@ def a_box_says_chrome_is_open_before_the_login():
|
|||||||
assert code == 0, code
|
assert code == 0, code
|
||||||
assert seen[-1] == "login" and len(seen) == 2, seen
|
assert seen[-1] == "login" and len(seen) == 2, seen
|
||||||
_, title, needs_person = seen[0]
|
_, title, needs_person = seen[0]
|
||||||
assert "Chrome" in title and needs_person is True, seen
|
# Operator, 2026-10-29: bulk runs show no box at all, so this one is
|
||||||
|
# quieted by QUIET_BOXES like any other (a log line instead).
|
||||||
|
assert "Chrome" in title and needs_person is False, seen
|
||||||
|
|
||||||
seen.clear()
|
seen.clear()
|
||||||
code, _ = _main_with(lambda page: None, box,
|
code, _ = _main_with(lambda page: None, box,
|
||||||
|
|||||||
+31
-3
@@ -283,6 +283,33 @@ def the_dashboard_lists_todays_top_fail_and_review_reasons():
|
|||||||
assert "No Fail or Review today" in html
|
assert "No Fail or Review today" in html
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def the_dashboard_lists_todays_runs_newest_first():
|
||||||
|
"""Operator, 2026-10-30 (the approved mockup): a Runs today card —
|
||||||
|
each run's start, its mode and its claims by result, from the same
|
||||||
|
rows the reasons card reads, so no second query."""
|
||||||
|
today = datetime.combine(date.today(), datetime.min.time())
|
||||||
|
first, second = today + timedelta(hours=16, minutes=39), \
|
||||||
|
today + timedelta(hours=17, minutes=18)
|
||||||
|
rows = ([_row("9000001", "Pass", 1, first),
|
||||||
|
_row("9000002", "Fail", 2, first)]
|
||||||
|
+ [_row(f"900001{i}", "Pass", i, second) for i in range(3)])
|
||||||
|
client, repo = _client(FakeRepo(days=[_day(date.today(), 5, 2, 4, 1, 0)],
|
||||||
|
rows=rows))
|
||||||
|
html = client.get("/").text
|
||||||
|
box = html.split('id="runs-today"', 1)[1].split("</section>", 1)[0]
|
||||||
|
assert box.index("5:18 PM") < box.index("4:39 PM"), box
|
||||||
|
assert "<b>3</b> claims" in box and "<b>2</b> claims" in box, box
|
||||||
|
assert repo.calls == [("days",), ("day_results", date.today())], \
|
||||||
|
repo.calls
|
||||||
|
|
||||||
|
|
||||||
|
@case
|
||||||
|
def the_dashboard_greets_the_signed_in_user():
|
||||||
|
html = _client()[0].get("/").text
|
||||||
|
assert re.search(r"Good (morning|afternoon|evening), ", html), html[:400]
|
||||||
|
|
||||||
|
|
||||||
@case
|
@case
|
||||||
def every_page_has_the_footer_and_marks_where_you_are():
|
def every_page_has_the_footer_and_marks_where_you_are():
|
||||||
client, _ = _client()
|
client, _ = _client()
|
||||||
@@ -440,14 +467,15 @@ def find_a_claim_with_no_number_shows_the_box_only():
|
|||||||
# ── every page ───────────────────────────────────────────────────────────────
|
# ── every page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@case
|
@case
|
||||||
def every_page_has_the_navy_header_breadcrumb_and_mode():
|
def every_page_has_the_side_menu_breadcrumb_and_mode():
|
||||||
client, _ = _client()
|
client, _ = _client()
|
||||||
for path in _PAGES:
|
for path in _PAGES:
|
||||||
html = client.get(path).text
|
html = client.get(path).text
|
||||||
assert '<header class="top">' in html, path
|
# Sage side menu since 2026-10-30 (the approved mockup).
|
||||||
|
assert '<aside class="side">' in html, path
|
||||||
assert 'class="pill VERIFY"' in html, path
|
assert 'class="pill VERIFY"' in html, path
|
||||||
assert '<nav class="crumbs">' in html, path
|
assert '<nav class="crumbs">' in html, path
|
||||||
assert "--navy: #12355b" in html, path
|
assert "--navy: #2e3f36" in html, path
|
||||||
html = client.get("/day/2026-10-16").text
|
html = client.get("/day/2026-10-16").text
|
||||||
assert '<a href="/days">Claim status</a>' in html
|
assert '<a href="/days">Claim status</a>' in html
|
||||||
assert '<span class="here">Fri, 16 Oct 2026</span>' in html
|
assert '<span class="here">Fri, 16 Oct 2026</span>' in html
|
||||||
|
|||||||
+25
-3
@@ -283,6 +283,27 @@ def _reasons(rows, top=6) -> list:
|
|||||||
for (cat, text), n in ordered[:top]]
|
for (cat, text), n in ordered[:top]]
|
||||||
|
|
||||||
|
|
||||||
|
def _runs_today(rows) -> list:
|
||||||
|
"""The Dashboard's Runs today card (operator, 2026-10-30): one entry
|
||||||
|
per run in the day's rows, newest first, with its counts."""
|
||||||
|
runs = {}
|
||||||
|
for r in rows:
|
||||||
|
run = runs.setdefault(r.get("run_at"), {
|
||||||
|
"run_at": r.get("run_at"), "run_mode": r.get("run_mode"),
|
||||||
|
"claims": 0, "Pass": 0, "Fail": 0, "Review": 0})
|
||||||
|
run["claims"] += 1
|
||||||
|
if r.get("category") in CATEGORIES:
|
||||||
|
run[r["category"]] += 1
|
||||||
|
return sorted(runs.values(), key=lambda x: x["run_at"] or datetime.min,
|
||||||
|
reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _greeting(now=None) -> str:
|
||||||
|
hour = (now or datetime.now()).hour
|
||||||
|
return ("Good morning" if hour < 12 else
|
||||||
|
"Good afternoon" if hour < 17 else "Good evening")
|
||||||
|
|
||||||
|
|
||||||
def portal_settings(urls: str, server: bool) -> dict:
|
def portal_settings(urls: str, server: bool) -> dict:
|
||||||
"""create_app's host / origin / cookie / Start settings from
|
"""create_app's host / origin / cookie / Start settings from
|
||||||
PORTAL_URLS (comma separated, as people type them in the browser) and
|
PORTAL_URLS (comma separated, as people type them in the browser) and
|
||||||
@@ -461,11 +482,12 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
|||||||
if today - timedelta(days=6) <= d["day"] <= today),
|
if today - timedelta(days=6) <= d["day"] <= today),
|
||||||
key=lambda d: d["day"])
|
key=lambda d: d["day"])
|
||||||
# Only a day with claims costs the second query.
|
# Only a day with claims costs the second query.
|
||||||
reasons = (_reasons(_db(repo.day_results, today))
|
rows = _db(repo.day_results, today) if counts["claims"] else []
|
||||||
if counts["claims"] else [])
|
|
||||||
return _page(request, "dashboard.html", [["Dashboard", None]],
|
return _page(request, "dashboard.html", [["Dashboard", None]],
|
||||||
today=counts, has_runs=bool(days),
|
today=counts, has_runs=bool(days),
|
||||||
alert=_last_run_alert(), week=week, reasons=reasons)
|
alert=_last_run_alert(), week=week,
|
||||||
|
reasons=_reasons(rows), runs_today=_runs_today(rows),
|
||||||
|
greeting=_greeting())
|
||||||
|
|
||||||
@app.get("/days", response_class=HTMLResponse)
|
@app.get("/days", response_class=HTMLResponse)
|
||||||
def days_page(request: Request):
|
def days_page(request: Request):
|
||||||
|
|||||||
+120
-94
@@ -5,75 +5,99 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="run-token" content="{{ run_token }}">
|
<meta name="run-token" content="{{ run_token }}">
|
||||||
<title>{% block title %}Charge Validation{% endblock %} · eCW</title>
|
<title>{% block title %}Charge Validation{% endblock %} · eCW</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2312355b'/%3E%3Ctext x='16' y='21' font-family='Segoe UI,Arial' font-size='13' font-weight='800' text-anchor='middle' fill='%23fff'%3ECV%3C/text%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232e3f36'/%3E%3Ctext x='16' y='21' font-family='Segoe UI,Arial' font-size='13' font-weight='800' text-anchor='middle' fill='%23fff'%3ECV%3C/text%3E%3C/svg%3E">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
<style>
|
<style>
|
||||||
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light
|
/* Sage (operator, 2026-10-30, the approved mockup): a deep sage side
|
||||||
grey, blue buttons; Pass green, Fail red, Review amber everywhere. */
|
menu, white rounded cards on soft sage-grey, a lime highlight; Pass
|
||||||
|
green, Fail orange, Review blue everywhere (told apart by lightness
|
||||||
|
too, not hue alone). The old names stay so every page follows. */
|
||||||
:root {
|
:root {
|
||||||
--navy: #12355b; --navy-2: #0f2a47; --bg: #f4f6fa; --panel: #ffffff;
|
--navy: #2e3f36; --navy-2: #24332b; --bg: #f2f4f1; --panel: #ffffff;
|
||||||
--text: #1f2a37; --muted: #6b7785; --line: #dde3ec; --head: #e8edf4;
|
--text: #1b2420; --muted: #5b6661; --line: #e2e6e2; --head: #eef2ee;
|
||||||
--accent: #1565c0; --accent-dark: #0d4f9e;
|
--accent: #4a6b58; --accent-dark: #3b5747; --lime: #d4e88a;
|
||||||
--pass: #2e7d32; --pass-bg: #e8f5e9;
|
--pass: #245c3a; --pass-bg: #e3f0e7; --pass-bar: #4a6b58;
|
||||||
--fail: #c62828; --fail-bg: #fdecea;
|
--fail: #9a4312; --fail-bg: #fbe9dd; --fail-bar: #d9772e;
|
||||||
--review: #b45309; --review-bg: #fff3e0; --review-bar: #ef6c00;
|
--review: #244f96; --review-bg: #e3ecfa; --review-bar: #3b6fc4;
|
||||||
--stripe: #f9fafc; --hilite: #eef4ff; --sel: #dbe7fb;
|
--stripe: #f8faf8; --hilite: #eef4ef; --sel: #dfe9e2;
|
||||||
--shadow: 0 1px 2px rgba(18, 53, 91, .06), 0 2px 8px rgba(18, 53, 91, .05);
|
--shadow: 0 1px 2px rgba(27, 36, 32, .05), 0 2px 10px rgba(27, 36, 32, .04);
|
||||||
|
--display: Outfit, "Segoe UI", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: var(--bg); color: var(--text);
|
body { margin: 0; background: var(--bg); color: var(--text);
|
||||||
font: 14px/1.45 "Segoe UI", system-ui, sans-serif;
|
font: 14px/1.45 Inter, "Segoe UI", system-ui, sans-serif;
|
||||||
display: flex; flex-direction: column; min-height: 100vh; }
|
min-height: 100vh; }
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
/* Header, breadcrumb, page and footer share one column (1180px), so
|
.shell { display: flex; flex-wrap: wrap; gap: 20px; padding: 20px;
|
||||||
their edges line up (operator, 2026-10-26). */
|
min-height: 100vh; align-items: stretch; }
|
||||||
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 24px; width: 100%; }
|
.page { flex: 999 1 560px; min-width: 0; display: flex;
|
||||||
header.top { background: var(--navy); color: #fff; }
|
flex-direction: column; }
|
||||||
header.top .wrap { display: flex; align-items: center; gap: 14px;
|
aside.side { flex: 1 1 232px; max-width: 260px; min-width: 220px;
|
||||||
min-height: 58px; }
|
background: var(--navy); color: #f2f4f1; border-radius: 24px;
|
||||||
header.top .brand { color: #fff; font-size: 17px; font-weight: 600;
|
padding: 22px 14px; display: flex; flex-direction: column;
|
||||||
letter-spacing: .2px; display: flex; align-items: center; }
|
gap: 24px; }
|
||||||
header.top .brand:hover { text-decoration: none; }
|
aside.side .brand { color: #f2f4f1; display: flex; align-items: center;
|
||||||
header.top .mark { display: inline-grid; place-items: center; width: 30px;
|
gap: 12px; padding: 0 8px; }
|
||||||
height: 30px; border-radius: 8px; background: #fff;
|
aside.side .brand:hover { text-decoration: none; }
|
||||||
color: var(--navy); font-size: 12px; font-weight: 800;
|
aside.side .mark { display: inline-grid; place-items: center; width: 38px;
|
||||||
margin-right: 10px; letter-spacing: .5px; }
|
height: 38px; border-radius: 12px; background: var(--lime);
|
||||||
.pill { border-radius: 12px; padding: 2px 10px; font-size: 11px;
|
color: var(--navy); font-size: 13px; font-weight: 800;
|
||||||
font-weight: 700; letter-spacing: .4px; background: var(--pass);
|
flex: none; }
|
||||||
color: #fff; }
|
aside.side .brand b { display: block; font-family: var(--display);
|
||||||
.pill.LIVE-WRITE { background: var(--fail); }
|
font-weight: 500; font-size: 17px; }
|
||||||
header.top nav.links { margin-left: auto; display: flex; gap: 4px;
|
aside.side .brand small { color: #b9c6be; font-size: 12px; }
|
||||||
align-items: center; }
|
.pill { border-radius: 999px; padding: 3px 10px; font-size: 11px;
|
||||||
header.top nav.links a { color: #e3ecf7; font-weight: 600; padding: 6px 11px;
|
font-weight: 700; letter-spacing: .4px; background: var(--lime);
|
||||||
border-radius: 6px; transition: background .15s; }
|
color: var(--navy); align-self: flex-start; margin-left: 8px; }
|
||||||
header.top nav.links a:hover { background: rgba(255, 255, 255, .08);
|
.pill.LIVE-WRITE { background: var(--fail-bar); color: #fff; }
|
||||||
|
aside.side nav.links { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
aside.side nav.links a { color: #e3e9e5; font-weight: 500; height: 44px;
|
||||||
|
padding: 0 14px; border-radius: 12px;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
transition: background .15s; }
|
||||||
|
aside.side nav.links a::before { content: ""; width: 18px; height: 18px;
|
||||||
|
flex: none; background: currentColor;
|
||||||
|
-webkit-mask: var(--ic) center / contain no-repeat;
|
||||||
|
mask: var(--ic) center / contain no-repeat; }
|
||||||
|
aside.side nav.links a:hover { background: rgba(242, 244, 241, .08);
|
||||||
text-decoration: none; }
|
text-decoration: none; }
|
||||||
header.top nav.links a.on { background: rgba(255, 255, 255, .16); color: #fff; }
|
aside.side nav.links a.on { background: var(--lime); color: var(--navy);
|
||||||
header.top .who { color: #b9c6d6; font-size: 13px; display: flex;
|
font-weight: 700; }
|
||||||
align-items: center; gap: 7px; margin-left: 10px; }
|
aside.side nav.links a[href="/"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Crect x='3' y='3' width='7' height='9' rx='1.5'/%3E%3Crect x='14' y='3' width='7' height='5' rx='1.5'/%3E%3Crect x='14' y='12' width='7' height='9' rx='1.5'/%3E%3Crect x='3' y='16' width='7' height='5' rx='1.5'/%3E%3C/svg%3E"); }
|
||||||
header.top .who b { color: #fff; }
|
aside.side nav.links a[href="/days"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Crect x='4' y='3' width='16' height='18' rx='2'/%3E%3Cpath d='M8 8h8M8 12h8M8 16h5'/%3E%3C/svg%3E"); }
|
||||||
header.top .avatar { display: inline-grid; place-items: center; width: 26px;
|
aside.side nav.links a[href="/runs"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12h4l3 8 4-16 3 8h4'/%3E%3C/svg%3E"); }
|
||||||
height: 26px; border-radius: 50%; background: var(--accent);
|
aside.side nav.links a[href$="/users"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='9' cy='8' r='3.5'/%3E%3Cpath d='M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5M16 4.5a3.5 3.5 0 0 1 0 7M18 14.8c1.8.8 3 2.6 3.5 5.2'/%3E%3C/svg%3E"); }
|
||||||
color: #fff; font-size: 12px; font-weight: 700; }
|
aside.side nav.links a[href$="/signins"] { --ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7v5l3 2'/%3E%3C/svg%3E"); }
|
||||||
header.top form.out { margin: 0; }
|
aside.side .who { margin-top: auto; display: flex; align-items: center;
|
||||||
header.top form.out button { background: transparent; border-color: #6f86a3;
|
gap: 12px; padding: 12px; border-radius: 16px;
|
||||||
font-weight: 400; padding: 4px 10px; }
|
background: rgba(242, 244, 241, .08); color: #f2f4f1; }
|
||||||
.msg { border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; }
|
aside.side .who:hover { text-decoration: none;
|
||||||
|
background: rgba(242, 244, 241, .14); }
|
||||||
|
aside.side .who b { display: block; font-size: 14px; }
|
||||||
|
aside.side .who small { color: #b9c6be; font-size: 12px; }
|
||||||
|
aside.side .avatar { display: inline-grid; place-items: center; width: 36px;
|
||||||
|
height: 36px; border-radius: 50%; background: var(--accent);
|
||||||
|
color: #fff; font-size: 13px; font-weight: 700; flex: none; }
|
||||||
|
aside.side form.out { margin: 0; padding: 0 8px; }
|
||||||
|
aside.side form.out button { width: 100%; background: transparent;
|
||||||
|
border-color: rgba(242, 244, 241, .3);
|
||||||
|
font-weight: 500; }
|
||||||
|
aside.side form.out button:hover { background: rgba(242, 244, 241, .08); }
|
||||||
|
.msg { border-radius: 12px; padding: 10px 14px; margin-bottom: 14px; }
|
||||||
.msg.bad { background: var(--fail-bg); color: var(--fail); }
|
.msg.bad { background: var(--fail-bg); color: var(--fail); }
|
||||||
.msg.good { background: var(--pass-bg); color: var(--pass); }
|
.msg.good { background: var(--pass-bg); color: var(--pass); }
|
||||||
nav.crumbs { background: var(--navy-2); color: #b9c6d6; padding: 7px 0;
|
nav.crumbs { color: var(--muted); padding: 4px 4px 0; font-size: 13px; }
|
||||||
font-size: 13px; }
|
nav.crumbs a { color: var(--muted); }
|
||||||
nav.crumbs a { color: #e3ecf7; }
|
nav.crumbs .sep { margin: 0 8px; color: #8a958f; }
|
||||||
nav.crumbs .sep { margin: 0 8px; color: #7d8ea3; }
|
nav.crumbs .here { color: var(--text); font-weight: 600; }
|
||||||
nav.crumbs .here { color: #fff; font-weight: 600; }
|
main { flex: 1; width: 100%; padding: 14px 4px 28px; }
|
||||||
main { flex: 1; width: 100%; max-width: 1180px; margin: 0 auto;
|
footer.foot { color: var(--muted); font-size: 12px; padding: 0 4px; }
|
||||||
padding: 22px 24px 40px; }
|
|
||||||
footer.foot { background: var(--panel); border-top: 1px solid var(--line);
|
|
||||||
color: var(--muted); font-size: 12px; }
|
|
||||||
footer.foot .wrap { display: flex; gap: 16px; align-items: center;
|
footer.foot .wrap { display: flex; gap: 16px; align-items: center;
|
||||||
justify-content: space-between; flex-wrap: wrap;
|
justify-content: space-between; flex-wrap: wrap;
|
||||||
padding-top: 12px; padding-bottom: 12px; }
|
padding: 12px 0; border-top: 1px solid var(--line); }
|
||||||
footer.foot b { color: var(--text); font-weight: 600; }
|
footer.foot b { color: var(--text); font-weight: 600; }
|
||||||
/* Motion: short, never in the way, off when Windows asks for less. */
|
/* Motion: short, never in the way, off when Windows asks for less. */
|
||||||
/* Movement only, never opacity: a tab the browser holds in the
|
/* Movement only, never opacity: a tab the browser holds in the
|
||||||
@@ -87,36 +111,37 @@
|
|||||||
a.tile, a.card { transition: transform .15s, box-shadow .15s,
|
a.tile, a.card { transition: transform .15s, box-shadow .15s,
|
||||||
border-color .15s; }
|
border-color .15s; }
|
||||||
a.tile:hover, a.card:hover { transform: translateY(-2px);
|
a.tile:hover, a.card:hover { transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 18px rgba(18, 53, 91, .12); }
|
box-shadow: 0 6px 18px rgba(27, 36, 32, .10); }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation: none !important;
|
*, *::before, *::after { animation: none !important;
|
||||||
transition: none !important; }
|
transition: none !important; }
|
||||||
}
|
}
|
||||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
h1 { font-family: var(--display); font-weight: 300; font-size: 30px;
|
||||||
|
letter-spacing: -.3px; margin: 0 0 4px; }
|
||||||
.sub { color: var(--muted); margin: 0 0 16px; }
|
.sub { color: var(--muted); margin: 0 0 16px; }
|
||||||
.card { background: var(--panel); border: 1px solid var(--line);
|
.card { background: var(--panel); border: 0; border-radius: 22px;
|
||||||
border-radius: 10px; padding: 16px 18px; box-shadow: var(--shadow);
|
padding: 20px 22px; box-shadow: var(--shadow); margin-bottom: 18px; }
|
||||||
margin-bottom: 18px; }
|
.card h2 { font-family: var(--display); font-weight: 500; font-size: 19px;
|
||||||
.card h2 { font-size: 15px; margin: 0 0 10px; }
|
margin: 0 0 10px; }
|
||||||
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
|
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 12px; margin-bottom: 18px; }
|
gap: 12px; margin-bottom: 18px; }
|
||||||
.tile { background: var(--panel); border: 1px solid var(--line);
|
.tile { background: var(--panel); border: 2px solid transparent;
|
||||||
border-radius: 10px; padding: 12px 16px; box-shadow: var(--shadow);
|
border-radius: 20px; padding: 16px 18px; box-shadow: var(--shadow);
|
||||||
color: var(--text); display: block; }
|
color: var(--text); display: block; }
|
||||||
.tile .n { font-size: 26px; font-weight: 700; display: block; }
|
.tile .n { font-family: var(--display); font-size: 36px; font-weight: 500;
|
||||||
.tile .l { color: var(--muted); font-size: 12px; text-transform: uppercase;
|
line-height: 1.1; display: block; }
|
||||||
letter-spacing: .5px; }
|
.tile .l { color: var(--muted); font-size: 13px; }
|
||||||
.tile.Pass .n { color: var(--pass); }
|
.tile.Pass .n { color: var(--pass); }
|
||||||
.tile.Fail .n { color: var(--fail); }
|
.tile.Fail .n { color: var(--fail); }
|
||||||
.tile.Review .n { color: var(--review); }
|
.tile.Review .n { color: var(--review); }
|
||||||
a.tile:hover { text-decoration: none; border-color: var(--accent); }
|
a.tile:hover { text-decoration: none; border-color: var(--accent); }
|
||||||
a.tile.on { border: 2px solid var(--accent); padding: 11px 15px; }
|
a.tile.on { border-color: var(--accent); }
|
||||||
select, input, button { font: inherit; padding: 7px 10px;
|
select, input, button { font: inherit; padding: 8px 12px;
|
||||||
border: 1px solid #c9d2de; border-radius: 6px;
|
border: 1px solid #d5dbd6; border-radius: 10px;
|
||||||
background: var(--panel); color: var(--text); }
|
background: var(--panel); color: var(--text); }
|
||||||
button, .btn { background: var(--accent); color: #fff;
|
button, .btn { background: var(--accent); color: #fff;
|
||||||
border: 1px solid var(--accent); border-radius: 6px;
|
border: 1px solid var(--accent); border-radius: 10px;
|
||||||
padding: 7px 14px; cursor: pointer; font-weight: 600;
|
padding: 8px 16px; cursor: pointer; font-weight: 600;
|
||||||
display: inline-block; }
|
display: inline-block; }
|
||||||
button:hover, .btn:hover { background: var(--accent-dark);
|
button:hover, .btn:hover { background: var(--accent-dark);
|
||||||
text-decoration: none; }
|
text-decoration: none; }
|
||||||
@@ -124,7 +149,7 @@
|
|||||||
border-color: #c9d2de; font-weight: 400; }
|
border-color: #c9d2de; font-weight: 400; }
|
||||||
button.plain:hover { background: var(--hilite); }
|
button.plain:hover { background: var(--hilite); }
|
||||||
button[disabled] { opacity: .5; cursor: default; }
|
button[disabled] { opacity: .5; cursor: default; }
|
||||||
.cat { display: inline-block; padding: 2px 9px; border-radius: 10px;
|
.cat { display: inline-block; padding: 2px 10px; border-radius: 999px;
|
||||||
font-weight: 600; font-size: 12px; }
|
font-weight: 600; font-size: 12px; }
|
||||||
.cat.Pass { background: var(--pass-bg); color: var(--pass); }
|
.cat.Pass { background: var(--pass-bg); color: var(--pass); }
|
||||||
.cat.Fail { background: var(--fail-bg); color: var(--fail); }
|
.cat.Fail { background: var(--fail-bg); color: var(--fail); }
|
||||||
@@ -135,15 +160,14 @@
|
|||||||
.note { color: var(--muted); font-size: 12px; }
|
.note { color: var(--muted); font-size: 12px; }
|
||||||
/* Tables: short, no sideways scroll; long text cut on one line. */
|
/* Tables: short, no sideways scroll; long text cut on one line. */
|
||||||
table.list { width: 100%; border-collapse: separate; border-spacing: 0;
|
table.list { width: 100%; border-collapse: separate; border-spacing: 0;
|
||||||
background: var(--panel); border: 1px solid var(--line);
|
background: var(--panel); border: 0; box-shadow: var(--shadow);
|
||||||
border-radius: 10px; overflow: hidden; table-layout: fixed; }
|
border-radius: 18px; overflow: hidden; table-layout: fixed; }
|
||||||
table.list th, table.list td { padding: 9px 12px; text-align: left;
|
table.list th, table.list td { padding: 9px 12px; text-align: left;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
overflow: hidden; text-overflow: ellipsis;
|
overflow: hidden; text-overflow: ellipsis;
|
||||||
white-space: nowrap; }
|
white-space: nowrap; }
|
||||||
table.list th { background: var(--head); font-size: 12px; color: #3b4757;
|
table.list th { background: var(--head); font-size: 12px;
|
||||||
font-weight: 600; text-transform: uppercase;
|
color: var(--muted); font-weight: 600; }
|
||||||
letter-spacing: .4px; }
|
|
||||||
table.list tbody tr { cursor: pointer; }
|
table.list tbody tr { cursor: pointer; }
|
||||||
table.list tbody tr:nth-child(even) td { background: var(--stripe); }
|
table.list tbody tr:nth-child(even) td { background: var(--stripe); }
|
||||||
table.list tbody tr:hover td { background: var(--hilite); }
|
table.list tbody tr:hover td { background: var(--hilite); }
|
||||||
@@ -167,7 +191,7 @@
|
|||||||
.drawer { position: fixed; top: 0; right: 0; height: 100vh;
|
.drawer { position: fixed; top: 0; right: 0; height: 100vh;
|
||||||
width: min(480px, 100vw); background: var(--panel);
|
width: min(480px, 100vw); background: var(--panel);
|
||||||
border-left: 1px solid var(--line); z-index: 20;
|
border-left: 1px solid var(--line); z-index: 20;
|
||||||
box-shadow: -8px 0 24px rgba(18, 53, 91, .12);
|
box-shadow: -8px 0 24px rgba(27, 36, 32, .12);
|
||||||
padding: 0 0 30px; overflow-y: auto; }
|
padding: 0 0 30px; overflow-y: auto; }
|
||||||
.drawer[hidden] { display: none; }
|
.drawer[hidden] { display: none; }
|
||||||
.drawer .top { display: flex; align-items: center; gap: 10px;
|
.drawer .top { display: flex; align-items: center; gap: 10px;
|
||||||
@@ -186,10 +210,10 @@
|
|||||||
white-space: pre-wrap; font-size: 12px; margin: 0; }
|
white-space: pre-wrap; font-size: 12px; margin: 0; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
main { padding: 16px; }
|
.shell { padding: 12px; gap: 12px; }
|
||||||
.wrap { padding-left: 16px; padding-right: 16px; }
|
aside.side { max-width: none; flex-basis: 100%; padding: 14px; gap: 12px; }
|
||||||
header.top .wrap { flex-wrap: wrap; padding-top: 8px; padding-bottom: 8px; }
|
aside.side nav.links { flex-direction: row; flex-wrap: wrap; }
|
||||||
header.top nav.links { margin-left: 0; flex-wrap: wrap; }
|
aside.side .who { margin-top: 0; }
|
||||||
}
|
}
|
||||||
{% block style %}{% endblock %}
|
{% block style %}{% endblock %}
|
||||||
</style>
|
</style>
|
||||||
@@ -213,17 +237,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% set section = crumbs[1][0] if crumbs | length > 1 else crumbs[0][0] %}
|
{% set section = crumbs[1][0] if crumbs | length > 1 else crumbs[0][0] %}
|
||||||
<header class="top">
|
<div class="shell">
|
||||||
<div class="wrap">
|
{% if user %}<aside class="side">
|
||||||
<a class="brand" href="/"><span class="mark" aria-hidden="true">CV</span>eCW Charge Validation</a>
|
<a class="brand" href="/"><span class="mark" aria-hidden="true">CV</span><span><b>Charge Validation</b><small>eCW · Salud</small></span></a>
|
||||||
<span class="pill {{ mode }}" title="SAVE_CORRECTIONS mode">{{ mode }}</span>
|
<span class="pill {{ mode }}" title="SAVE_CORRECTIONS mode">{{ mode }}</span>
|
||||||
{% if user %}<nav class="links">
|
<nav class="links" aria-label="Main">
|
||||||
{% for label, link, admin in [("Dashboard", "/", False), ("Claim status", "/days", False), ("Runs", "/runs", False), ("Users", "/admin/users", True), ("Sign-ins", "/admin/signins", True)] %}{% if not admin or is_admin %}{% if label == section %}<a href="{{ link }}" class="on" aria-current="page">{{ label }}</a>{% else %}<a href="{{ link }}">{{ label }}</a>{% endif %}{% endif %}{% endfor %}
|
{% for label, link, admin in [("Dashboard", "/", False), ("Claim status", "/days", False), ("Runs", "/runs", False), ("Users", "/admin/users", True), ("Sign-ins", "/admin/signins", True)] %}{% if not admin or is_admin %}{% if label == section %}<a href="{{ link }}" class="on" aria-current="page">{{ label }}</a>{% else %}<a href="{{ link }}">{{ label }}</a>{% endif %}{% endif %}{% endfor %}
|
||||||
<a class="who" href="/account/password" title="Change password"><span class="avatar" aria-hidden="true">{{ (user.display_name or "?")[:1] | upper }}</span><b>{{ user.display_name }}</b> · {{ user.role }}</a>
|
</nav>
|
||||||
<form class="out" method="post" action="/signout"><input type="hidden" name="token" value="{{ run_token }}"><button type="submit">Sign out</button></form>
|
<a class="who" href="/account/password" title="Change password"><span class="avatar" aria-hidden="true">{{ (user.display_name or "?")[:1] | upper }}</span><span><b>{{ user.display_name }}</b><small>{{ user.role }}</small></span></a>
|
||||||
</nav>{% endif %}
|
<form class="out" method="post" action="/signout"><input type="hidden" name="token" value="{{ run_token }}"><button type="submit">Sign out</button></form>
|
||||||
</div>
|
</aside>{% endif %}
|
||||||
</header>
|
<div class="page">
|
||||||
<nav class="crumbs">
|
<nav class="crumbs">
|
||||||
<div class="wrap">{% 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 %}</div>
|
<div class="wrap">{% 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 %}</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -233,11 +257,13 @@
|
|||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<span><b>eCW Charge Validation</b> · Ecare India</span>
|
<span><b>eCW Charge Validation</b> · Ecare India</span>
|
||||||
<span class="phi">Local page — contains patient data; do not share
|
<span class="phi">Contains patient data; do not share screenshots
|
||||||
screenshots outside the team.</span>
|
outside the team.</span>
|
||||||
<span>Mode <b>{{ mode }}</b></span>
|
<span>Mode <b>{{ mode }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+136
-106
@@ -1,79 +1,98 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
{% block style %}
|
{% block style %}
|
||||||
|
/* The approved mockup (operator, 2026-10-30): greeting and claim search
|
||||||
|
on top, today's tiles, the lime Agent run card beside results by day,
|
||||||
|
then today's reasons beside today's runs. */
|
||||||
|
header.hello { display: flex; flex-wrap: wrap; gap: 14px;
|
||||||
|
align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 18px; }
|
||||||
|
header.hello h1 { font-size: 34px; }
|
||||||
|
header.hello .sub { margin: 0; font-size: 15px; }
|
||||||
|
form.find { display: flex; gap: 8px; }
|
||||||
|
form.find input { width: 240px; height: 44px; background: var(--panel); }
|
||||||
|
form.find button { height: 44px; }
|
||||||
|
.tiles .tile .head { display: flex; justify-content: space-between;
|
||||||
|
align-items: center; }
|
||||||
|
.tile .pct { float: right; font-size: 12px; font-weight: 700;
|
||||||
|
padding: 2px 9px; border-radius: 999px; margin-top: 2px; }
|
||||||
|
.tile.Pass .pct { background: var(--pass-bg); color: var(--pass); }
|
||||||
|
.tile.Fail .pct { background: var(--fail-bg); color: var(--fail); }
|
||||||
|
.tile.Review .pct { background: var(--review-bg); color: var(--review); }
|
||||||
|
.tile .rate { display: block; margin-top: 6px; font-size: 12px;
|
||||||
|
color: var(--pass); font-weight: 600; }
|
||||||
|
.row2 { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 18px; }
|
||||||
|
.row2 > .card { margin-bottom: 0; flex: 1 1 340px; min-width: 0; }
|
||||||
|
.row2 > #week, .row2 > #runs-today { flex: 2 1 420px; }
|
||||||
|
.card-h { display: flex; justify-content: space-between;
|
||||||
|
align-items: baseline; margin-bottom: 14px; gap: 10px; }
|
||||||
|
.card-h h2 { margin: 0; }
|
||||||
|
|
||||||
|
/* Agent run: the lime card. */
|
||||||
|
.botrun { background: var(--lime); color: var(--text); }
|
||||||
.botrun .row { display: flex; gap: 12px; align-items: center;
|
.botrun .row { display: flex; gap: 12px; align-items: center;
|
||||||
flex-wrap: wrap; }
|
flex-wrap: wrap; }
|
||||||
.botrun .row + .row { margin-top: 10px; }
|
.botrun .row + .row { margin-top: 12px; }
|
||||||
.botrun h2 { margin: 0; }
|
.botrun h2 { margin: 0; }
|
||||||
#br-message { color: var(--muted); }
|
#br-message { color: var(--navy); font-weight: 600; }
|
||||||
.botrun button.stop { background: var(--fail); border-color: var(--fail); }
|
.botrun button { background: var(--text); border-color: var(--text); }
|
||||||
.botrun progress { width: 260px; height: 10px; accent-color: var(--accent); }
|
.botrun button:hover { background: #000; }
|
||||||
.botrun pre { background: var(--head); padding: 10px; border-radius: 6px;
|
.botrun button.plain { background: rgba(255, 255, 255, .6);
|
||||||
max-height: 260px; overflow: auto; font-size: 12px;
|
border-color: transparent; color: var(--text); }
|
||||||
white-space: pre-wrap; }
|
.botrun button.stop { background: var(--fail-bar); border-color: var(--fail-bar); }
|
||||||
.botrun summary { cursor: pointer; color: var(--accent); margin-top: 10px; }
|
.botrun .note { color: var(--navy); }
|
||||||
.chip { padding: 2px 9px; border-radius: 10px; font-size: 12px;
|
.botrun progress { width: 220px; height: 10px; accent-color: var(--navy); }
|
||||||
|
.botrun pre { background: rgba(255, 255, 255, .6); padding: 10px;
|
||||||
|
border-radius: 12px; max-height: 260px; overflow: auto;
|
||||||
|
font-size: 12px; white-space: pre-wrap; }
|
||||||
|
.botrun summary { cursor: pointer; color: var(--navy); margin-top: 10px;
|
||||||
|
font-weight: 600; }
|
||||||
|
.chip { padding: 2px 9px; border-radius: 999px; font-size: 12px;
|
||||||
font-weight: 600; }
|
font-weight: 600; }
|
||||||
/* `.row { display: flex }` beats the browser's [hidden] rule. */
|
/* `.row { display: flex }` beats the browser's [hidden] rule. */
|
||||||
.botrun [hidden] { display: none; }
|
.botrun [hidden] { display: none; }
|
||||||
.tile .rate { display: block; margin-top: 4px; font-size: 12px;
|
|
||||||
color: var(--pass); font-weight: 600; }
|
/* Results by day. */
|
||||||
/* Two columns, then three equal cards: every row the same edges. */
|
|
||||||
.split { display: grid; grid-template-columns: 3fr 2fr; gap: 18px;
|
|
||||||
margin-bottom: 18px; }
|
|
||||||
.split .card, .actions .card { margin-bottom: 0; }
|
|
||||||
.card-h { display: flex; justify-content: space-between;
|
|
||||||
align-items: baseline; margin-bottom: 12px; }
|
|
||||||
.card-h h2 { margin: 0; }
|
|
||||||
.week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
.week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 10px; align-items: end; height: 170px; }
|
gap: 12px; align-items: end; height: 190px; }
|
||||||
a.wd { display: flex; flex-direction: column; align-items: center;
|
a.wd { display: flex; flex-direction: column; align-items: center;
|
||||||
justify-content: flex-end; height: 100%; color: var(--text);
|
justify-content: flex-end; height: 100%; color: var(--text);
|
||||||
text-decoration: none; }
|
text-decoration: none; }
|
||||||
a.wd .col { display: flex; flex-direction: column; width: 70%;
|
a.wd .col { display: flex; flex-direction: column; width: 70%;
|
||||||
max-width: 42px; border-radius: 6px 6px 2px 2px;
|
max-width: 44px; border-radius: 8px; overflow: hidden;
|
||||||
overflow: hidden; background: var(--head); min-height: 4px;
|
background: var(--head); min-height: 4px; height: 0; }
|
||||||
height: 0; }
|
|
||||||
a.wd:hover .col { outline: 2px solid var(--accent); outline-offset: 2px; }
|
a.wd:hover .col { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
a.wd .col i { display: block; }
|
a.wd .col i { display: block; }
|
||||||
a.wd .col i.Pass { background: var(--pass); }
|
a.wd .col i.Pass { background: var(--pass-bar); }
|
||||||
a.wd .col i.Fail { background: var(--fail); }
|
a.wd .col i.Fail { background: var(--fail-bar); }
|
||||||
a.wd .col i.Review { background: var(--review-bar); }
|
a.wd .col i.Review { background: var(--review-bar); }
|
||||||
a.wd .cnt { font-size: 12px; font-weight: 700; margin-bottom: 4px; }
|
a.wd .cnt { font-size: 12px; font-weight: 700; margin-bottom: 4px; }
|
||||||
a.wd .lbl { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
a.wd .lbl { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||||
.legend { display: flex; gap: 14px; margin-top: 10px; font-size: 12px;
|
.legend { display: flex; gap: 14px; font-size: 13px; color: var(--muted); }
|
||||||
color: var(--muted); }
|
.legend span::before { content: ""; display: inline-block; width: 10px;
|
||||||
.legend span::before { content: ""; display: inline-block; width: 9px;
|
height: 10px; border-radius: 3px; margin-right: 6px; }
|
||||||
height: 9px; border-radius: 2px; margin-right: 5px; }
|
.legend .Pass::before { background: var(--pass-bar); }
|
||||||
.legend .Pass::before { background: var(--pass); }
|
.legend .Fail::before { background: var(--fail-bar); }
|
||||||
.legend .Fail::before { background: var(--fail); }
|
|
||||||
.legend .Review::before { background: var(--review-bar); }
|
.legend .Review::before { background: var(--review-bar); }
|
||||||
ol.reasons { list-style: none; margin: 0; padding: 0; }
|
|
||||||
ol.reasons li { display: flex; gap: 10px; align-items: baseline;
|
/* Today's reasons and runs. */
|
||||||
padding: 7px 0; border-bottom: 1px solid var(--line); }
|
ol.reasons, ol.runs { list-style: none; margin: 0; padding: 0; }
|
||||||
ol.reasons li:last-child { border-bottom: 0; }
|
ol.reasons li, ol.runs li { display: flex; gap: 12px; align-items: center;
|
||||||
ol.reasons b { min-width: 30px; text-align: center; border-radius: 10px;
|
padding: 9px 0; border-bottom: 1px solid var(--line); }
|
||||||
padding: 1px 8px; font-size: 12px; }
|
ol.reasons li:last-child, ol.runs li:last-child { border-bottom: 0; }
|
||||||
|
ol.reasons b { min-width: 34px; text-align: center; border-radius: 999px;
|
||||||
|
padding: 2px 9px; font-size: 12px; }
|
||||||
ol.reasons b.Fail { background: var(--fail-bg); color: var(--fail); }
|
ol.reasons b.Fail { background: var(--fail-bg); color: var(--fail); }
|
||||||
ol.reasons b.Review { background: var(--review-bg); color: var(--review); }
|
ol.reasons b.Review { background: var(--review-bg); color: var(--review); }
|
||||||
ol.reasons span { overflow: hidden; text-overflow: ellipsis;
|
ol.reasons span { overflow: hidden; text-overflow: ellipsis;
|
||||||
white-space: nowrap; }
|
white-space: nowrap; }
|
||||||
.actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
|
ol.runs .t { font-weight: 600; width: 72px; }
|
||||||
gap: 18px; align-items: stretch; }
|
ol.runs .m { font-size: 11px; font-weight: 700; padding: 2px 9px;
|
||||||
a.card.go { display: flex; align-items: center; gap: 14px; color: var(--text); }
|
border-radius: 999px; background: var(--head); color: var(--navy); }
|
||||||
a.card.go:hover { text-decoration: none; border-color: var(--accent); }
|
ol.runs .c { margin-left: auto; color: var(--muted); }
|
||||||
a.card.go b, form.search > b { display: block; font-size: 15px; }
|
ol.runs .c b { color: var(--text); }
|
||||||
a.card.go .ic { display: grid; place-items: center; width: 40px; height: 40px;
|
ol.runs .cat { min-width: 40px; text-align: center; }
|
||||||
border-radius: 10px; background: var(--hilite);
|
|
||||||
color: var(--accent); font-size: 20px; flex: none; }
|
|
||||||
a.card.go .arrow { margin-left: auto; color: var(--accent); font-size: 18px; }
|
|
||||||
a.card.go .note { display: block; }
|
|
||||||
form.search.card { display: block; }
|
|
||||||
form.search .find { display: flex; gap: 8px; margin-top: 8px; }
|
|
||||||
form.search .find input { flex: 1; min-width: 0; }
|
|
||||||
.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-summary { color: var(--muted); margin: 0 0 10px; }
|
||||||
#live-list { list-style: none; margin: 0; padding: 0; }
|
#live-list { list-style: none; margin: 0; padding: 0; }
|
||||||
#live-list li { display: flex; gap: 10px; align-items: center;
|
#live-list li { display: flex; gap: 10px; align-items: center;
|
||||||
@@ -87,15 +106,25 @@
|
|||||||
#live-list li.st-checking .st { color: var(--accent); font-weight: 600; }
|
#live-list li.st-checking .st { color: var(--accent); font-weight: 600; }
|
||||||
#live-list li.st-idle .st { color: var(--muted); }
|
#live-list li.st-idle .st { color: var(--muted); }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.actions, .split { grid-template-columns: 1fr; }
|
form.find, form.find input { width: 100%; }
|
||||||
}
|
}
|
||||||
/* The last Run ended badly (EEWA-33). */
|
/* The last Run ended badly (EEWA-33). */
|
||||||
.alert { background: var(--fail-bg); color: var(--fail);
|
.alert { background: var(--fail-bg); color: var(--fail);
|
||||||
border: 1px solid #f5c2bd; border-left: 5px solid var(--fail);
|
border-radius: 16px; padding: 12px 16px; margin-bottom: 18px; }
|
||||||
border-radius: 10px; padding: 12px 16px; margin-bottom: 18px; }
|
|
||||||
.alert a { color: var(--fail); font-weight: 600; text-decoration: underline; }
|
.alert a { color: var(--fail); font-weight: 600; text-decoration: underline; }
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<header class="hello">
|
||||||
|
<div>
|
||||||
|
<h1>{{ greeting }}, {{ (user.display_name or "").split(" ")[0] }}</h1>
|
||||||
|
<p class="sub">{{ today.day | longdate }} · Medcor Ready queue</p>
|
||||||
|
</div>
|
||||||
|
<form class="find" method="get" action="/claim">
|
||||||
|
<input type="search" name="claim_no" placeholder="Search claim number"
|
||||||
|
inputmode="numeric" aria-label="Claim number">
|
||||||
|
<button type="submit">Find</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
{% if alert %}
|
{% if alert %}
|
||||||
<div class="alert" id="last-run-alert" role="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>
|
<b>The last run {{ "ended in a Run Error" if alert.status == "Run Error" else "ended without a record (killed or crashed)" }}</b>
|
||||||
@@ -103,10 +132,20 @@
|
|||||||
<a href="/runs/{{ alert.run_id }}">See the run</a>
|
<a href="/runs/{{ alert.run_id }}">See the run</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="tiles">
|
||||||
|
<a class="tile" href="/day/{{ today.day.isoformat() }}"><span class="n">{{ today.claims }}</span><span class="l">Checked today</span>{% if today.claims %}<span class="rate">{{ (100 * today.pass_count / today.claims) | round | int }}% pass</span>{% endif %}</a>
|
||||||
|
<a class="tile Pass" href="/day/{{ today.day.isoformat() }}?category=Pass"><span class="n">{{ today.pass_count }}</span><span class="l">Pass</span>{% if today.claims %}<span class="pct">{{ (100 * today.pass_count / today.claims) | round | int }}%</span>{% endif %}</a>
|
||||||
|
<a class="tile Fail" href="/day/{{ today.day.isoformat() }}?category=Fail"><span class="n">{{ today.fail_count }}</span><span class="l">Fail</span>{% if today.claims %}<span class="pct">{{ (100 * today.fail_count / today.claims) | round | int }}%</span>{% endif %}</a>
|
||||||
|
<a class="tile Review" href="/day/{{ today.day.isoformat() }}?category=Review"><span class="n">{{ today.review_count }}</span><span class="l">Review</span>{% if today.claims %}<span class="pct">{{ (100 * today.review_count / today.claims) | round | int }}%</span>{% endif %}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2">
|
||||||
<section class="card botrun" id="botrun">
|
<section class="card botrun" id="botrun">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Bot run</h2>
|
<h2>Agent run</h2>
|
||||||
<span id="br-message">…</span>
|
<span id="br-message">…</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
<button type="button" id="br-start"{% if not can_run %} hidden{% endif %}>▶ Start run</button>
|
<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>
|
<button type="button" id="br-stop" class="stop" hidden>Stop</button>
|
||||||
<span id="br-terminal" hidden>Run started in the terminal — stop it
|
<span id="br-terminal" hidden>Run started in the terminal — stop it
|
||||||
@@ -128,59 +167,50 @@
|
|||||||
<pre id="br-log"></pre></details>
|
<pre id="br-log"></pre></details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="today-h">
|
<section class="card" id="week">
|
||||||
<h2>Today · {{ today.day | longdate }}</h2>
|
<div class="card-h"><h2>Results by day</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>{% if today.claims %}<span class="rate">{{ (100 * today.pass_count / today.claims) | round | int }}% pass</span>{% endif %}</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="split">
|
|
||||||
<section class="card" id="week">
|
|
||||||
<div class="card-h"><h2>Last 7 days</h2><a href="/days">All days →</a></div>
|
|
||||||
{% if week %}
|
|
||||||
<div class="week">
|
|
||||||
{% for d in week %}
|
|
||||||
<a class="wd" href="/day/{{ d.day.isoformat() }}" data-day="{{ d.day.isoformat() }}" title="{{ d.day | longdate }}: {{ d.pass_count }} Pass, {{ d.fail_count }} Fail, {{ d.review_count }} Review">
|
|
||||||
<span class="cnt">{{ d.claims }}</span>
|
|
||||||
<span class="col" data-n="{{ d.claims }}"><i class="Review" data-n="{{ d.review_count }}"></i><i class="Fail" data-n="{{ d.fail_count }}"></i><i class="Pass" data-n="{{ d.pass_count }}"></i></span>
|
|
||||||
<span class="lbl">{{ d.day.strftime("%a %d") }}</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="legend"><span class="Pass">Pass</span><span class="Fail">Fail</span><span class="Review">Review</span></div>
|
<div class="legend"><span class="Pass">Pass</span><span class="Fail">Fail</span><span class="Review">Review</span></div>
|
||||||
{% else %}
|
<a href="/days" id="claim-status">All days →</a></div>
|
||||||
<p class="note">No claims checked in the last 7 days.</p>
|
{% if week %}
|
||||||
{% endif %}
|
<div class="week">
|
||||||
</section>
|
{% for d in week %}
|
||||||
<section class="card" id="reasons">
|
<a class="wd" href="/day/{{ d.day.isoformat() }}" data-day="{{ d.day.isoformat() }}" title="{{ d.day | longdate }}: {{ d.pass_count }} Pass, {{ d.fail_count }} Fail, {{ d.review_count }} Review">
|
||||||
<div class="card-h"><h2>Today's Fail and Review reasons</h2>{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}?category=Fail">Open →</a>{% endif %}</div>
|
<span class="cnt">{{ d.claims }}</span>
|
||||||
{% if reasons %}
|
<span class="col" data-n="{{ d.claims }}"><i class="Review" data-n="{{ d.review_count }}"></i><i class="Fail" data-n="{{ d.fail_count }}"></i><i class="Pass" data-n="{{ d.pass_count }}"></i></span>
|
||||||
<ol class="reasons">
|
<span class="lbl">{{ d.day.strftime("%a %d") }}</span>
|
||||||
{% for r in reasons %}<li><b class="{{ r.category }}">{{ r.count }}</b><span>{{ r.comment }}</span></li>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="note">No Fail or Review today.</p>
|
<p class="note">No claims checked in the last 7 days.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="row2">
|
||||||
<a class="card go" href="/days" id="claim-status"><span class="ic" aria-hidden="true">▦</span><span><b>Claim status</b><span class="note">Every day's claims, by result</span></span><span class="arrow">→</span></a>
|
<section class="card" id="reasons">
|
||||||
<a class="card go" href="/runs"><span class="ic" aria-hidden="true">◷</span><span><b>Runs</b><span class="note">Logins, timings, errors</span></span><span class="arrow">→</span></a>
|
<div class="card-h"><h2>Top reasons today</h2>{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}?category=Fail">Open →</a>{% endif %}</div>
|
||||||
<form class="search card" method="get" action="/claim">
|
{% if reasons %}
|
||||||
<b>Find a claim</b>
|
<ol class="reasons">
|
||||||
<div class="find">
|
{% for r in reasons %}<li><b class="{{ r.category }}">{{ r.count }}</b><span>{{ r.comment }}</span></li>
|
||||||
<input type="search" name="claim_no" placeholder="Claim #"
|
{% endfor %}
|
||||||
inputmode="numeric" aria-label="Claim number">
|
</ol>
|
||||||
<button type="submit">Find</button>
|
{% else %}
|
||||||
</div>
|
<p class="note">No Fail or Review today.</p>
|
||||||
</form>
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="runs-today">
|
||||||
|
<div class="card-h"><h2>Runs today</h2><a href="/runs">All runs →</a></div>
|
||||||
|
{% if runs_today %}
|
||||||
|
<ol class="runs">
|
||||||
|
{% for r in runs_today %}<li><span class="t">{{ r.run_at | clock }}</span><span class="m">{{ r.run_mode }}</span><span class="c"><b>{{ r.claims }}</b> claims</span><span class="cat Pass">{{ r.Pass }}</span><span class="cat Fail">{{ r.Fail }}</span><span class="cat Review">{{ r.Review }}</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% else %}
|
||||||
|
<p class="note">No runs today yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% if not has_runs %}
|
{% if not has_runs %}
|
||||||
<p class="note">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
|
<p class="note">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
|
||||||
|
|||||||
+82
-93
@@ -1,110 +1,98 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Sign in{% endblock %}
|
{% block title %}Sign in{% endblock %}
|
||||||
{% block style %}
|
{% block style %}
|
||||||
/* Operator, 2026-10-26: the Portal's front door — what it is on the
|
/* The approved mockup (operator, 2026-10-30): a deep sage panel with
|
||||||
left, the form on the right; one column on a phone. */
|
what the Portal is, the form in a white card; one column on a phone. */
|
||||||
/* A slow-moving navy-to-blue background, this page only. */
|
footer.foot { display: none; }
|
||||||
body { background: linear-gradient(120deg, #0f2a47, #12355b 35%,
|
main { padding: 0; }
|
||||||
#1d4f86 65%, #1565c0);
|
nav.crumbs { display: none; }
|
||||||
background-size: 220% 220%; animation: drift 18s ease infinite; }
|
.auth { display: flex; flex-wrap: wrap; gap: 20px;
|
||||||
@keyframes drift { 0%, 100% { background-position: 0% 50%; }
|
min-height: calc(100vh - 40px); }
|
||||||
50% { background-position: 100% 50%; } }
|
.auth-brand { flex: 1 1 520px; background: var(--navy); color: #f2f4f1;
|
||||||
footer.foot { background: rgba(255, 255, 255, .92); }
|
border-radius: 28px; padding: 44px 52px; display: flex;
|
||||||
.auth { display: grid; grid-template-columns: 5fr 6fr; max-width: 860px;
|
flex-direction: column; justify-content: space-between;
|
||||||
margin: 40px auto; background: var(--panel); border-radius: 16px;
|
gap: 32px; }
|
||||||
overflow: hidden; box-shadow: 0 24px 60px rgba(4, 20, 40, .35);
|
.auth-brand .logo { display: flex; align-items: center; gap: 12px;
|
||||||
animation: rise .5s ease both; }
|
font-family: var(--display); font-weight: 500;
|
||||||
.auth-brand { background: var(--navy); color: #dbe5f1; padding: 40px 34px;
|
font-size: 18px; }
|
||||||
display: flex; flex-direction: column; gap: 22px;
|
.auth-brand .big { display: inline-grid; place-items: center; width: 42px;
|
||||||
position: relative; overflow: hidden; }
|
height: 42px; border-radius: 12px; background: var(--lime);
|
||||||
/* Two soft circles drifting behind the brand. */
|
color: var(--navy); font-size: 14px; font-weight: 800; }
|
||||||
.auth-brand::before, .auth-brand::after { content: ""; position: absolute;
|
.auth-brand h1 { font-family: var(--display); font-weight: 200;
|
||||||
border-radius: 50%; background: rgba(255, 255, 255, .07); }
|
font-size: clamp(52px, 7vw, 88px); line-height: .95;
|
||||||
.auth-brand::before { width: 220px; height: 220px; right: -70px; top: -60px;
|
letter-spacing: -2px; margin: 0 0 18px; color: #f2f4f1; }
|
||||||
animation: float 9s ease-in-out infinite; }
|
.auth-brand h1 span { color: var(--lime); font-weight: 300; }
|
||||||
.auth-brand::after { width: 150px; height: 150px; left: -50px; bottom: -40px;
|
.auth-brand p.lead { margin: 0; font-size: 17px; line-height: 1.55;
|
||||||
background: rgba(21, 101, 192, .35);
|
color: #c9d4cd; max-width: 520px; }
|
||||||
animation: float 11s ease-in-out infinite reverse; }
|
.auth-brand ul { list-style: none; margin: 0; padding: 0; display: grid;
|
||||||
@keyframes float { 0%, 100% { transform: translate(0, 0); }
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
50% { transform: translate(-18px, 22px); } }
|
gap: 14px; }
|
||||||
.auth-brand > * { position: relative; z-index: 1; }
|
.auth-brand li { background: rgba(242, 244, 241, .08); border-radius: 18px;
|
||||||
.auth-brand .big { display: inline-grid; place-items: center; width: 56px;
|
padding: 16px; display: flex; flex-direction: column;
|
||||||
height: 56px; border-radius: 16px; background: #fff;
|
gap: 8px; animation: rise .5s ease both; }
|
||||||
color: var(--navy); font-size: 19px; font-weight: 800;
|
.auth-brand li:nth-child(2) { animation-delay: .1s; }
|
||||||
animation: ring 2.8s ease-out infinite; }
|
.auth-brand li:nth-child(3) { animation-delay: .2s; }
|
||||||
@keyframes ring { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, .45); }
|
.auth-brand li svg { color: var(--lime); }
|
||||||
70%, 100% { box-shadow: 0 0 0 14px rgba(255, 255, 255, 0); } }
|
.auth-brand li b { color: #f2f4f1; font-size: 15px; }
|
||||||
.auth-brand h1 { color: #fff; font-size: 28px; margin: 0; line-height: 1.2; }
|
.auth-brand li small { color: #c9d4cd; font-size: 13px; line-height: 1.45; }
|
||||||
.auth-brand ul { list-style: none; margin: 0; padding: 0;
|
.auth-side { flex: 999 1 400px; display: flex; align-items: center;
|
||||||
display: grid; gap: 14px; }
|
justify-content: center; padding: 24px 8px; }
|
||||||
.auth-brand li { display: flex; gap: 12px; align-items: center;
|
.auth-form { width: 100%; max-width: 420px; background: var(--panel);
|
||||||
animation: slide .5s ease both; }
|
border-radius: 28px; padding: 40px; box-shadow: var(--shadow);
|
||||||
.auth-brand li:nth-child(1) { animation-delay: .15s; }
|
animation: rise .5s ease both; }
|
||||||
.auth-brand li:nth-child(2) { animation-delay: .3s; }
|
.auth-form h2 { font-family: var(--display); font-weight: 500;
|
||||||
.auth-brand li:nth-child(3) { animation-delay: .45s; }
|
font-size: 32px; letter-spacing: -.5px; margin: 0 0 6px; }
|
||||||
@keyframes slide { from { transform: translateX(-16px); }
|
.auth-form .intro { color: var(--muted); margin: 0 0 22px; font-size: 15px; }
|
||||||
to { transform: none; } }
|
.field { margin-bottom: 18px; }
|
||||||
.auth-brand li .dot { flex: none; display: inline-grid; place-items: center;
|
.field label { display: block; font-weight: 500; margin-bottom: 8px;
|
||||||
width: 30px; height: 30px; border-radius: 9px;
|
font-size: 14px; }
|
||||||
background: rgba(255, 255, 255, .12); color: #fff;
|
.field .box { display: flex; align-items: center; height: 48px;
|
||||||
font-size: 14px; }
|
border: 1px solid #d5dbd6; border-radius: 12px;
|
||||||
.auth-brand li b { color: #fff; }
|
background: var(--panel);
|
||||||
.auth-brand .foot-note { margin-top: auto; font-size: 12px; color: #8fa3bb; }
|
|
||||||
.auth-form { padding: 40px 38px; display: flex; flex-direction: column;
|
|
||||||
justify-content: center; }
|
|
||||||
.auth-form h2 { font-size: 24px; margin: 0 0 22px; }
|
|
||||||
.field { margin-bottom: 16px; }
|
|
||||||
.field label { display: block; font-weight: 600; margin-bottom: 6px;
|
|
||||||
font-size: 13px; }
|
|
||||||
.field .box { display: flex; align-items: center; border: 1px solid #c9d2de;
|
|
||||||
border-radius: 8px; background: var(--panel);
|
|
||||||
transition: border-color .15s, box-shadow .15s; }
|
transition: border-color .15s, box-shadow .15s; }
|
||||||
.field .box:focus-within { border-color: var(--accent);
|
.field .box:focus-within { border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(21, 101, 192, .15); }
|
box-shadow: 0 0 0 3px rgba(74, 107, 88, .18); }
|
||||||
.field .ic { padding: 0 4px 0 12px; color: var(--muted); font-size: 15px; }
|
.field .ic { padding: 0 2px 0 14px; color: var(--muted); display: flex; }
|
||||||
.field input { flex: 1; border: 0; padding: 11px 10px; min-width: 0;
|
.field input { flex: 1; border: 0; padding: 0 10px; height: 100%;
|
||||||
background: transparent; outline: none; }
|
min-width: 0; background: transparent; outline: none; }
|
||||||
.field button.eye { background: transparent; border: 0; color: var(--accent);
|
.field button.eye { background: transparent; border: 0; color: var(--accent);
|
||||||
font-weight: 600; font-size: 12px; padding: 0 12px; }
|
font-weight: 600; font-size: 13px; padding: 0 14px; }
|
||||||
.field button.eye:hover { background: transparent; text-decoration: underline; }
|
.field button.eye:hover { background: transparent; text-decoration: underline; }
|
||||||
#caps { color: var(--review); font-size: 12px; margin-top: 6px; }
|
#caps { color: var(--review); font-size: 12px; margin-top: 6px; }
|
||||||
.auth-form button[type="submit"] { width: 100%; padding: 12px 14px;
|
.auth-form button[type="submit"] { width: 100%; height: 52px;
|
||||||
font-size: 15px; border-radius: 8px;
|
font-size: 16px; border-radius: 14px;
|
||||||
margin-top: 4px; position: relative;
|
margin-top: 4px;
|
||||||
overflow: hidden;
|
|
||||||
transition: transform .15s, box-shadow .15s; }
|
transition: transform .15s, box-shadow .15s; }
|
||||||
.auth-form button[type="submit"]:hover { transform: translateY(-1px);
|
.auth-form button[type="submit"]:hover { transform: translateY(-1px);
|
||||||
box-shadow: 0 8px 18px rgba(21, 101, 192, .35); }
|
box-shadow: 0 8px 18px rgba(74, 107, 88, .3); }
|
||||||
/* A light sweep across the button on hover. */
|
.auth-form .rules { margin: 20px 0 0; padding: 12px 14px;
|
||||||
.auth-form button[type="submit"]::after { content: ""; position: absolute;
|
background: var(--bg); border-radius: 12px;
|
||||||
top: 0; left: -60%; width: 40%; height: 100%;
|
line-height: 1.5; }
|
||||||
background: linear-gradient(90deg, transparent,
|
|
||||||
rgba(255, 255, 255, .35), transparent);
|
|
||||||
transform: skewX(-20deg); }
|
|
||||||
.auth-form button[type="submit"]:hover::after { left: 120%;
|
|
||||||
transition: left .6s ease; }
|
|
||||||
.auth-form .rules { margin-top: 18px; padding-top: 14px;
|
|
||||||
border-top: 1px solid var(--line); }
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.auth { grid-template-columns: 1fr; margin: 8px 0; }
|
.auth-brand { padding: 28px 24px; }
|
||||||
.auth-brand, .auth-form { padding: 24px 20px; }
|
|
||||||
.auth-brand ul { display: none; }
|
.auth-brand ul { display: none; }
|
||||||
|
.auth-form { padding: 28px 22px; }
|
||||||
}
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth">
|
<div class="auth">
|
||||||
<section class="auth-brand">
|
<section class="auth-brand">
|
||||||
<span class="big" aria-hidden="true">CV</span>
|
<div class="logo"><span class="big" aria-hidden="true">CV</span>Ecare OTS</div>
|
||||||
<h1>Charge Validation</h1>
|
<div>
|
||||||
|
<h1>Charge<br><span>Validation</span></h1>
|
||||||
|
<p class="lead">Agent run monitoring for the eCW Medcor Ready queue:
|
||||||
|
every claim checked against the Charge Condition Document.</p>
|
||||||
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li><span class="dot" aria-hidden="true">✓</span><b>Charge conditions</b></li>
|
<li><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3 8 4-16 3 8h4"></path></svg><b>Daily run status</b><small>Live progress of the agent and each run.</small></li>
|
||||||
<li><span class="dot" aria-hidden="true">▦</span><b>Pass, Fail, Review</b></li>
|
<li><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"></rect><path d="M3 10h18M9 4v16"></path></svg><b>Claim results</b><small>Pass, Fail and Review with the reason.</small></li>
|
||||||
<li><span class="dot" aria-hidden="true">◷</span><b>Every run recorded</b></li>
|
<li><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7z"></path></svg><b>{{ mode }} mode</b><small>{% if mode == "VERIFY" %}Nothing is saved to eCW claims.{% else %}Corrections are saved after a person confirms.{% endif %}</small></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="foot-note">Ecare India · {{ mode }} mode</p>
|
|
||||||
</section>
|
</section>
|
||||||
<section class="auth-form">
|
<section class="auth-side">
|
||||||
<h2>Sign in</h2>
|
<div class="auth-form">
|
||||||
|
<h2>Welcome back</h2>
|
||||||
|
<p class="intro">Sign in to the Charge Validation portal.</p>
|
||||||
{% if ecare_on %}<p class="note">Ecare staff: your Ecare office username and password.</p>{% endif %}
|
{% if ecare_on %}<p class="note">Ecare staff: your Ecare office username and password.</p>{% endif %}
|
||||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
{% 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
|
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
|
||||||
@@ -114,19 +102,20 @@
|
|||||||
<input type="hidden" name="next" value="{{ next_url }}">
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<div class="box"><span class="ic" aria-hidden="true">👤</span><input id="username" name="username" autocomplete="username" required autofocus></div>
|
<div class="box"><span class="ic" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21c1-4 4-6 8-6s7 2 8 6"></path></svg></span><input id="username" name="username" autocomplete="username" required autofocus></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pw">Password</label>
|
<label for="pw">Password</label>
|
||||||
<div class="box"><span class="ic" aria-hidden="true">🔒</span><input id="pw" name="password" type="password" autocomplete="current-password" required><button type="button" class="eye" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button></div>
|
<div class="box"><span class="ic" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="4" y="11" width="16" height="10" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span><input id="pw" name="password" type="password" autocomplete="current-password" required><button type="button" class="eye" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button></div>
|
||||||
<div id="caps" role="status" hidden>Caps Lock is on</div>
|
<div id="caps" role="status" hidden>Caps Lock is on</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="note rules">Signed out after {{ idle_minutes }} minutes idle.
|
<p class="note rules">Ecare network or VPN only. Signed out after
|
||||||
{{ lock_after }} wrong passwords lock the account for
|
{{ idle_minutes }} minutes idle. {{ lock_after }} wrong passwords lock
|
||||||
{{ lock_minutes }} minutes. Forgot a Portal password? An Admin can
|
the account for {{ lock_minutes }} minutes. Forgot a Portal password?
|
||||||
reset it.</p>
|
An Admin can reset it.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in new issue
Block a user