MEK Charge Reconciliation: Waystar + Aquilaehr download, per-location reconciliation, web app
- mek_recon.py: Playwright download for both sites in parallel (headless on server) - consolidate.py: Patient Name + DOS match, per-location and All Locations Excel - app.py: sign-in (server-side password), Run button, per-file and ZIP downloads - Dockerfile for Dokploy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd480fa874
10 files changed
+1335
No files matched your search
@@ -0,0 +1,141 @@
|
||||
"""Web front end: sign in -> Run -> download per-location reports or a ZIP of all.
|
||||
|
||||
Env: APP_PASSWORD (sign-in password), SECRET_KEY (session signing; random if unset),
|
||||
COOKIE_SECURE=1 behind HTTPS, plus the Waystar/Aquilaehr logins mek_recon.py reads.
|
||||
Run locally: python app.py -> http://127.0.0.1:8000
|
||||
"""
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, jsonify, redirect, render_template, request, send_file, session
|
||||
|
||||
import consolidate
|
||||
import mek_recon
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
OUT = ROOT / "output"
|
||||
LOG = ROOT / "logs" / "run.log"
|
||||
|
||||
mek_recon.load_env() # local .env; on the server the values come from the environment
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE") == "1",
|
||||
PERMANENT_SESSION_LIFETIME=12 * 3600,
|
||||
)
|
||||
|
||||
# ponytail: one run at a time, state in memory - fine for one gunicorn worker; restart forgets the last run
|
||||
run = {"proc": None, "started": None, "finished": None, "code": None}
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def signed_in(view):
|
||||
@wraps(view)
|
||||
def wrapper(*a, **kw):
|
||||
if not session.get("ok"):
|
||||
return (jsonify(error="sign in"), 401) if request.path != "/" else render_template("login.html")
|
||||
return view(*a, **kw)
|
||||
return wrapper
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login():
|
||||
expected = os.environ.get("APP_PASSWORD", "")
|
||||
given = request.form.get("password", "")
|
||||
if not expected or not hmac.compare_digest(given.encode(), expected.encode()):
|
||||
time.sleep(1) # slow down guessing
|
||||
return jsonify(error="wrong password"), 401
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["ok"] = True
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect("/")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@signed_in
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
def reports():
|
||||
return sorted(OUT.glob("*.xlsx"), key=lambda f: (f.name != consolidate.ALL_FILE, f.name.lower()))
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
@signed_in
|
||||
def start_run():
|
||||
with lock:
|
||||
if run["proc"] and run["proc"].poll() is None:
|
||||
return jsonify(error="already running"), 409
|
||||
# start clean: a location that fails must not leave last run's file looking current
|
||||
for f in [*ROOT.glob("processing/*/*.csv"), *OUT.glob("*.xlsx")]:
|
||||
f.unlink()
|
||||
LOG.parent.mkdir(exist_ok=True)
|
||||
env = {**os.environ, "HEADLESS": os.environ.get("HEADLESS", "1"),
|
||||
"PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
|
||||
with LOG.open("w", encoding="utf-8") as log:
|
||||
run["proc"] = subprocess.Popen([sys.executable, "mek_recon.py"], cwd=ROOT, env=env,
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
run.update(started=time.time(), finished=None, code=None)
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
@signed_in
|
||||
def status():
|
||||
proc = run["proc"]
|
||||
running = bool(proc and proc.poll() is None)
|
||||
if proc and not running and run["finished"] is None:
|
||||
run.update(finished=time.time(), code=proc.returncode)
|
||||
log = LOG.read_text(encoding="utf-8", errors="replace") if LOG.exists() else ""
|
||||
return jsonify(
|
||||
running=running, started=run["started"], finished=run["finished"], code=run["code"],
|
||||
log="\n".join(log.splitlines()[-300:]),
|
||||
files=[{"name": f.name, "size": f.stat().st_size, "mtime": f.stat().st_mtime} for f in reports()],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/download/<name>")
|
||||
@signed_in
|
||||
def download(name):
|
||||
match = next((f for f in reports() if f.name == name), None) # only files we list
|
||||
if not match:
|
||||
abort(404)
|
||||
return send_file(match, as_attachment=True, download_name=match.name)
|
||||
|
||||
|
||||
@app.get("/download-all")
|
||||
@signed_in
|
||||
def download_all():
|
||||
files = reports()
|
||||
if not files:
|
||||
abort(404)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for f in files:
|
||||
z.write(f, f.name)
|
||||
buf.seek(0)
|
||||
stamp = time.strftime("%Y-%m-%d_%H%M", time.localtime(max(f.stat().st_mtime for f in files)))
|
||||
return send_file(buf, as_attachment=True, download_name=f"MEK_Charge_Reconciliation_{stamp}.zip",
|
||||
mimetype="application/zip")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 8000)))
|
||||
Reference in new issue
Block a user