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:
VasanthandClaude Opus 5.5 committed 2026-10-27 17:38:27 +05:30
commit fd480fa874
10 files changed
+1335

No files matched your search

+141
View File
@@ -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)))