Portal Delete run (Super Admin), lab codes #3/#4, Z23 pointer #45, T1017 #46, payer note, login security-image wait

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-29 12:30:38 +05:30
1 parent a8af44e673
commit fafa5285eb
21 files changed
+1509 -63

No files matched your search

+45 -5
View File
@@ -56,7 +56,7 @@ from ecw_bot.core.run_events import run_id_of # noqa: E402
from .repo import SqlResultsRepo # noqa: E402
from . import run_status # noqa: E402
from . import runs as run_view # noqa: E402
from .users import (Accounts, CAN_ADMIN, CAN_RUN, # noqa: E402
from .users import (Accounts, CAN_ADMIN, CAN_DELETE, CAN_RUN, # noqa: E402
EcareSignIn, IDLE_MINUTES, LOCK_AFTER, LOCK_MINUTES,
MANAGES, Sessions, SqlUserStore, UserError, is_ecare)
from .bot_runner import (AlreadyRunning, BotRunner, # noqa: E402
@@ -109,6 +109,13 @@ _DB_PROBLEMS = {
"42000": ("No permission to read the results table",
"The Windows login needs db_datareader on Charge_Validation."),
}
# The Portal's SQL login reads; deleting a run needs one more grant.
_DELETE_PROBLEMS = {
"42000": ("No permission to delete runs",
"The Portal's SQL login needs GRANT DELETE ON "
"dbo.CV_ChargeSummary — see the end of "
"tools/sql/create_tables.sql."),
}
_DB_DEFAULT = (DB_DOWN, "Check that SQL Server is running and that "
"RESULTS_DB_CONN in .env points at the "
"Charge_Validation database.")
@@ -211,7 +218,9 @@ def _filters(run_at, category, claim_no):
return when, category, claim
def _db(fn, *args, **kwargs):
def _db(fn, *args, problems=None, **kwargs):
"""`fn(*args, **kwargs)`; a database error becomes DbUnavailable, named
by its SQLSTATE (`problems` adds or overrides names for one call)."""
try:
return fn(*args, **kwargs)
except UserError:
@@ -219,7 +228,8 @@ def _db(fn, *args, **kwargs):
except Exception as exc:
state = exc.args[0] if exc.args and isinstance(exc.args[0], str) \
else ""
title, hint = _DB_PROBLEMS.get(state, _DB_DEFAULT)
title, hint = {**_DB_PROBLEMS, **(problems or {})}.get(state,
_DB_DEFAULT)
print(f"[WEB] {title}: {results_db.safe_error(exc)}")
raise DbUnavailable(title, hint) from exc
@@ -429,6 +439,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
"can_run": bool(can_start and user
and user["role"] in CAN_RUN),
"is_admin": bool(user and user["role"] in CAN_ADMIN),
"can_delete": bool(user and user["role"] in CAN_DELETE),
**context})
@app.get("/", response_class=HTMLResponse)
@@ -464,11 +475,17 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.get("/day/{day}", response_class=HTMLResponse)
def day_page(request: Request, day: str, category: str = None,
claim_no: str = None, claim: str = None):
claim_no: str = None, claim: str = None, msg: str = ""):
when = _day(day)
_, cat, search = _filters(None, category, claim_no)
_, _, opened = _filters(None, None, claim)
rows = _db(repo.day_results, when)
runs = {}
for r in rows:
run = runs.setdefault(r["run_at"], {
"run_at": r["run_at"], "run_mode": r.get("run_mode"),
"started_by": r.get("started_by"), "claims": 0})
run["claims"] += 1
shown = [_for_panel(r) for r in rows
if (not cat or r.get("category") == cat)
and (not search or search in str(r.get("claim_no") or ""))]
@@ -477,7 +494,30 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
[_long_date(when), None]],
day=when, counts=_counts(rows), rows=shown,
category=cat or "", claim_no=search or "",
opened=opened or "")
opened=opened or "", runs=list(runs.values()),
message=msg[:200])
@app.post("/admin/runs/delete", response_class=HTMLResponse)
def run_delete(request: Request, run_at: str = Form(""),
token: str = Form("")):
"""One run's rows out of the results table — the Super Admin's
only (operator, 2026-10-28). Our own data, never eCW's; the run's
Logs files stay. Recorded in the Sign-in history."""
_check_token(request, token)
me = _need(request, CAN_DELETE)["username"]
try:
when = datetime.fromisoformat(run_at)
except ValueError:
raise BadFilter(f"Not a run time: {run_at}")
gone = _db(repo.delete_run, when, problems=_DELETE_PROBLEMS)
if gone:
_db(store.record, f"run {when:%m/%d/%Y} {_clock(when)}",
"run_deleted", by=me)
claims = f"{gone} claim{'' if gone == 1 else 's'}"
return RedirectResponse(
f"/day/{when.date().isoformat()}?" + urlencode(
{"msg": f"The {_clock(when)} run is deleted: {claims} "
f"deleted"}), status_code=303)
@app.get("/claim", response_class=HTMLResponse)
def claim_page(request: Request, claim_no: str = None):
+22 -5
View File
@@ -1,7 +1,8 @@
"""
Read-only access to the results table (`dbo.CV_ChargeSummary`, written by
src/ecw_bot/core/results_db.py). SELECT statements only, always
parameterized; one short connection per call.
Access to the results table (`dbo.CV_ChargeSummary`, written by
src/ecw_bot/core/results_db.py): SELECTs, and one DELETE — a whole run, the
Super Admin's Delete run (2026-10-28). Always parameterized; one short
connection per call.
"""
from datetime import date, datetime, timedelta
@@ -61,12 +62,15 @@ class SqlResultsRepo:
self.conn_str = conn_str
self._connect = connect
def _query(self, sql: str, params=()) -> list:
def _open(self):
connect = self._connect
if connect is None:
import pyodbc
connect = pyodbc.connect
conn = connect(self.conn_str, timeout=10)
return connect(self.conn_str, timeout=10)
def _query(self, sql: str, params=()) -> list:
conn = self._open()
try:
cur = conn.cursor().execute(sql, list(params))
cols = [d[0] for d in cur.description]
@@ -74,6 +78,19 @@ class SqlResultsRepo:
finally:
conn.close()
def delete_run(self, run_at) -> int:
"""Every row of the run that started at `run_at` (every row of a
run shares it); how many went."""
conn = self._open()
try:
cur = conn.cursor().execute(
f"DELETE FROM {results_db.TABLE} WHERE run_at = ?", [run_at])
gone = cur.rowcount
conn.commit()
return gone
finally:
conn.close()
def runs(self) -> list:
"""Every run, newest first, with its claim count per Category."""
return self._query(
+22
View File
@@ -8,6 +8,13 @@
table.list col.result { width: 96px; }
.bar { display: flex; justify-content: space-between; align-items: end;
gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
/* Super Admin only: one line per run of the day, with its Delete. */
.runs-of-day { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.runs-of-day form { display: flex; align-items: center; gap: 10px;
border: 1px solid var(--line); border-radius: 8px;
padding: 6px 6px 6px 12px; }
.runs-of-day button.danger { background: var(--fail-bg); color: var(--fail);
border: 1px solid var(--fail); }
/* A phone: the key columns only — the rest is in the side panel. */
@media (max-width: 760px) {
table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; }
@@ -32,6 +39,21 @@
</form>
</div>
{% if message %}<div class="msg good">{{ message }}</div>{% endif %}
{% if can_delete and runs %}
<div class="runs-of-day">
{% for run in runs %}
<form method="post" action="/admin/runs/delete"
onsubmit='return confirm({{ ("Delete the " ~ (run.run_at | clock) ~ " run — " ~ run.claims ~ (" claim" if run.claims == 1 else " claims") ~ "? They go from the Portal for everyone. This cannot be undone.") | tojson }})'>
<input type="hidden" name="token" value="{{ run_token }}">
<input type="hidden" name="run_at" value="{{ run.run_at.isoformat() }}">
<span>{{ run.run_at | clock }} · {{ run.claims }} claim{{ "" if run.claims == 1 else "s" }}{% if run.started_by %} · {{ run.started_by }}{% endif %}</span>
<button type="submit" class="danger">Delete run</button>
</form>
{% endfor %}
</div>
{% endif %}
{% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %}
<div class="tiles">
<a class="tile{{ ' on' if not category }}" href="/day/{{ day.isoformat() }}{{ ('?claim_no=' ~ claim_no) if claim_no }}"><span class="n">{{ counts.claims }}</span><span class="l">All</span></a>
+1
View File
@@ -46,6 +46,7 @@ from datetime import datetime, timedelta
ROLES = ("Super Admin", "Admin", "Viewer")
CAN_RUN = ("Super Admin", "Admin")
CAN_ADMIN = ("Super Admin", "Admin") # Portal Users, Sign-in history
CAN_DELETE = ("Super Admin",) # Delete run (operator, 2026-10-28)
# The roles each role may add, give, reset, disable or enable.
MANAGES = {"Super Admin": ("Admin", "Viewer"), "Admin": ("Viewer",)}
MIN_PASSWORD = 10