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:
1 parent
a8af44e673
commit
fafa5285eb
21 files changed
+1509
-63
No files matched your search
+45
-5
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user