- 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>
143 lines
4.5 KiB
Python
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)))
|