OOB-Dashboard/serve.py

233 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Local dashboard for Amazon Ads out-of-budget analysis.
python3 serve.py
Opens http://localhost:8765 in your browser. Drag change-history exports onto
the page and the dashboard appears. Everything runs on this machine -- the
server binds to localhost only and nothing is uploaded anywhere.
This is the single-user local mode: no accounts, no database, no dependencies
beyond openpyxl. The hosted multi-user version is `app.main`, run under uvicorn;
both call the same pipeline in app/services, so the two can never disagree.
"""
from __future__ import annotations
import argparse
import json
import shutil
import tempfile
import threading
import traceback
import webbrowser
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from app.services import exporters
from app.services.analysis import run_analysis
HERE = Path(__file__).resolve().parent
WEB = HERE / "web"
MAX_UPLOAD = 200 * 1024 * 1024
MIME = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml",
".ico": "image/x-icon"}
class Session:
"""Uploaded files and the most recent analysis, held in memory."""
def __init__(self) -> None:
self.dir = Path(tempfile.mkdtemp(prefix="ppc-dashboard-"))
self.history: list[Path] = []
self.perf: Path | None = None
self.last: dict | None = None
self.lock = threading.Lock()
def add(self, name: str, data: bytes, kind: str) -> Path:
safe = Path(name).name.replace("/", "_") or "upload.xlsx"
target = self.dir / f"{len(self.history)}_{safe}"
target.write_bytes(data)
if kind == "perf":
self.perf = target
else:
self.history.append(target)
return target
def clear(self) -> None:
shutil.rmtree(self.dir, ignore_errors=True)
self.dir = Path(tempfile.mkdtemp(prefix="ppc-dashboard-"))
self.history.clear()
self.perf = None
self.last = None
def dispose(self) -> None:
shutil.rmtree(self.dir, ignore_errors=True)
SESSION = Session()
def analyze(settings_in: dict) -> dict:
"""Run the pipeline over everything uploaded so far."""
result = run_analysis(SESSION.history, SESSION.perf, settings_in)
SESSION.last = result.artifacts
return result.payload
class Handler(BaseHTTPRequestHandler):
server_version = "PPCDashboard/1.0"
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # quieter console
if "/api/" in str(args[0]) and "200" not in str(args):
super().log_message(fmt, *args)
# ------------------------------------------------------------- helpers
def _send(self, code, body: bytes, ctype: str, extra: dict | None = None) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _json(self, obj, code=HTTPStatus.OK) -> None:
self._send(code, json.dumps(obj).encode(), "application/json; charset=utf-8")
def _error(self, message: str, code=HTTPStatus.BAD_REQUEST) -> None:
self._json({"error": message}, code)
def _body(self) -> bytes:
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_UPLOAD:
raise ValueError("File is too large.")
return self.rfile.read(length) if length else b""
# ------------------------------------------------------------ requests
def do_GET(self) -> None:
route = urlparse(self.path)
path = route.path
if path == "/api/state":
self._json({
"history": [p.name.split("_", 1)[-1] for p in SESSION.history],
"perf": SESSION.perf.name.split("_", 1)[-1] if SESSION.perf else None,
})
return
if path == "/api/export":
self._export(parse_qs(route.query).get("format", ["xlsx"])[0])
return
rel = "index.html" if path in ("/", "") else path.lstrip("/")
target = (WEB / rel).resolve()
if not str(target).startswith(str(WEB.resolve())) or not target.is_file():
self._send(HTTPStatus.NOT_FOUND, b"Not found", "text/plain; charset=utf-8")
return
self._send(HTTPStatus.OK, target.read_bytes(),
MIME.get(target.suffix, "application/octet-stream"))
def do_POST(self) -> None:
path = urlparse(self.path).path
try:
if path == "/api/upload":
name = self.headers.get("X-Filename", "upload.xlsx")
kind = self.headers.get("X-Kind", "history")
data = self._body()
if not data:
self._error("That file was empty.")
return
with SESSION.lock:
SESSION.add(name, data, kind)
self._json({"ok": True, "name": Path(name).name})
return
if path == "/api/analyze":
raw = self._body()
settings_in = json.loads(raw) if raw else {}
with SESSION.lock:
self._json(analyze(settings_in))
return
if path == "/api/clear":
with SESSION.lock:
SESSION.clear()
self._json({"ok": True})
return
self._error("Unknown endpoint.", HTTPStatus.NOT_FOUND)
except ValueError as exc:
self._error(str(exc))
except Exception as exc: # noqa: BLE001 - surface the real cause in the UI
traceback.print_exc()
self._error(f"{type(exc).__name__}: {exc}", HTTPStatus.INTERNAL_SERVER_ERROR)
def _export(self, fmt: str) -> None:
last = SESSION.last
if not last:
self._error("Analyse some files first.")
return
stamp = exporters.export_stamp(last)
if fmt == "csv":
self._send(HTTPStatus.OK, exporters.csv_bytes(last), exporters.CSV_MIME,
{"Content-Disposition":
f'attachment; filename="ppc-budget_{stamp}.csv"'})
return
self._send(HTTPStatus.OK, exporters.xlsx_bytes(last), exporters.XLSX_MIME,
{"Content-Disposition":
f'attachment; filename="ppc-budget-report_{stamp}.xlsx"'})
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Local out-of-budget dashboard.")
p.add_argument("--port", type=int, default=8765)
p.add_argument("--no-browser", action="store_true")
p.add_argument("--preload", action="store_true",
help="Load any exports already sitting in data/ on startup.")
args = p.parse_args(argv)
if args.preload:
for f in sorted((HERE / "data").glob("*.xlsx")):
if not f.name.startswith("~$"):
SESSION.add(f.name, f.read_bytes(), "history")
if SESSION.history:
print(f" preloaded {len(SESSION.history)} file(s) from data/")
url = f"http://localhost:{args.port}"
try:
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
except OSError as exc:
print(f"Could not start on port {args.port}: {exc}")
print(f"Something else may be using it. Try: python3 serve.py --port {args.port + 1}")
return 1
print(f"\n PPC out-of-budget dashboard running at {url}")
print(" Drag your amazon-ads-history exports onto the page.")
print(" Everything stays on this machine. Press Ctrl+C to stop.\n")
if not args.no_browser:
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n stopped")
finally:
server.server_close()
SESSION.dispose()
return 0
if __name__ == "__main__":
raise SystemExit(main())