Files
VasanthandClaude Opus 5.5 6616c91b7f Branding, Excel watermark, live run progress, and uv
- Login and Reports pages: eCare logo, Waystar and Aquilaehr logos (light
  versions for the dark pages), eCare favicon.
- Excel reports: faded eCare/Waystar/Aquila watermark as the sheet
  background on both sheets. Report writer moves to xlsxwriter, the only
  engine that can set a sheet background.
- Reports page: animated run progress (both download lanes piped into the
  Excel sheet) and a plain-language activity feed built from the run log;
  the raw log stays under "Technical log". /status now returns the
  location count.
- Dependencies move from requirements.txt to pyproject.toml + uv.lock;
  Dockerfile installs uv from PyPI and runs uv sync --frozen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:43:31 +05:30

143 lines
4.5 KiB
Python

"""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: uv run 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"],
locations=len(mek_recon.LOCATIONS),
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)))