OOB-Dashboard/serve.py

310 lines
11 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 re
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", ".woff2": "font/woff2"}
def _web_file(url_path: str) -> Path | None:
"""Map a URL path to a file under web/, mirroring the hosted /static mount."""
rel = url_path.removeprefix("/static/").lstrip("/")
if url_path in ("/", ""):
rel = "index.html"
target = (WEB / rel).resolve()
if not str(target).startswith(str(WEB.resolve())) or not target.is_file():
return None
return target
_DISPOSITION = re.compile(
r'content-disposition:\s*form-data;\s*(.*)',
re.IGNORECASE,
)
_FIELD = re.compile(r'name="([^"]+)"')
_FILENAME = re.compile(r'filename="([^"]*)"')
def _parse_multipart(body: bytes, content_type: str) -> tuple[str, bytes, str]:
"""Extract (filename, file_bytes, kind) from a multipart upload."""
if "multipart/form-data" not in content_type:
raise ValueError("Expected a multipart upload.")
boundary = None
for part in content_type.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part[9:].strip().strip('"')
break
if not boundary:
raise ValueError("Upload is missing a boundary.")
filename = "upload.xlsx"
kind = "history"
file_data = b""
for section in body.split(f"--{boundary}".encode()):
if not section or section in (b"--", b"--\r\n"):
continue
chunk = section.lstrip(b"\r\n")
if not chunk:
continue
header_end = chunk.find(b"\r\n\r\n")
if header_end < 0:
continue
headers = chunk[:header_end].decode("latin-1", errors="replace")
payload = chunk[header_end + 4:]
if payload.endswith(b"\r\n"):
payload = payload[:-2]
disp = _DISPOSITION.search(headers)
if not disp:
continue
name_match = _FIELD.search(disp.group(1))
if not name_match:
continue
name = name_match.group(1)
if name == "kind":
kind = payload.decode("utf-8", errors="replace").strip() or "history"
elif name == "file":
file_data = payload
fn = _FILENAME.search(disp.group(1))
if fn and fn.group(1):
filename = fn.group(1)
if not file_data:
raise ValueError("That file was empty.")
return filename, file_data, kind
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({
"mode": "local",
"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
target = _web_file(path)
if target is None:
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":
ctype = self.headers.get("Content-Type", "")
if "multipart/form-data" in ctype:
name, data, kind = _parse_multipart(self._body(), ctype)
else:
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())