"""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/") @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)))