237 lines
8.3 KiB
Python
237 lines
8.3 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:
|
|
# One subfolder per upload keeps path.name the user's real filename, so
|
|
# error messages and the Data Quality sheet never show a "0_" prefix.
|
|
safe = Path(name).name.replace("/", "_") or "upload.xlsx"
|
|
slot = self.dir / f"{len(self.history)}{'p' if kind == 'perf' else ''}"
|
|
slot.mkdir(parents=True, exist_ok=True)
|
|
target = slot / 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 for p in SESSION.history],
|
|
"perf": SESSION.perf.name 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())
|