114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
"""Upload, analyse, export -- the original dashboard API, now per user.
|
|
|
|
Every route here is `def`, not `async def`, on purpose. The pipeline is
|
|
synchronous and CPU-bound: one analysis holds a core for seconds to tens of
|
|
seconds. FastAPI runs `def` handlers in a worker thread, so a long run cannot
|
|
stall the event loop and everyone else's requests with it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
|
|
from fastapi import (APIRouter, Depends, File, Form, HTTPException, Response,
|
|
UploadFile, status)
|
|
|
|
from app.config import Settings, get_settings
|
|
from app.deps import require_user
|
|
from app.models import User
|
|
from app.schemas import Ok, SettingsIn
|
|
from app.services import exporters
|
|
from app.services.analysis import run_analysis
|
|
from app.services.ratelimit import limiter
|
|
from app.services.store import UploadTooLarge, UserWorkspace, store_of
|
|
|
|
log = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["analysis"])
|
|
|
|
# Bounds peak memory. The default worker threadpool is 40 threads; without this
|
|
# a burst of uploads could start 40 concurrent pipelines and exhaust the box.
|
|
_slots = threading.BoundedSemaphore(get_settings().MAX_CONCURRENT_ANALYSES)
|
|
|
|
|
|
def workspace(user: User = Depends(require_user)) -> UserWorkspace:
|
|
return store_of().get(user.id)
|
|
|
|
|
|
@router.get("/state")
|
|
def state(user: User = Depends(require_user),
|
|
ws: UserWorkspace = Depends(workspace)) -> dict:
|
|
with ws.lock:
|
|
payload = ws.names()
|
|
payload["user"] = {"email": user.email, "name": user.name, "is_admin": user.is_admin}
|
|
return payload
|
|
|
|
|
|
@router.post("/upload")
|
|
def upload(file: UploadFile = File(...), kind: str = Form("history"),
|
|
ws: UserWorkspace = Depends(workspace),
|
|
s: Settings = Depends(get_settings)) -> dict:
|
|
if kind not in ("history", "perf"):
|
|
raise ValueError("Unknown upload kind.")
|
|
try:
|
|
with ws.lock:
|
|
saved = ws.add(file.filename or "upload.xlsx", file.file, kind,
|
|
s.MAX_UPLOAD_BYTES, s.MAX_WORKSPACE_BYTES)
|
|
except UploadTooLarge as exc:
|
|
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, str(exc)) from exc
|
|
finally:
|
|
file.file.close()
|
|
return {"ok": True, "name": saved.name.split("_", 1)[-1]}
|
|
|
|
|
|
@router.post("/analyze")
|
|
def analyze(body: SettingsIn | None = None,
|
|
user: User = Depends(require_user),
|
|
ws: UserWorkspace = Depends(workspace)) -> dict:
|
|
wait = limiter.check("analyze:user", str(user.id))
|
|
if wait:
|
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
|
|
"That is a lot of runs in a short time. Give it a minute.",
|
|
headers={"Retry-After": str(int(wait))})
|
|
|
|
if not _slots.acquire(blocking=False):
|
|
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
"The server is analysing other reports right now. "
|
|
"Try again in a moment.",
|
|
headers={"Retry-After": "30"})
|
|
try:
|
|
settings_in = (body or SettingsIn()).model_dump()
|
|
with ws.lock:
|
|
# Released before the run so the previous result is not held
|
|
# alongside the new one at the peak of the build.
|
|
ws.last = None
|
|
result = run_analysis(ws.history, ws.perf, settings_in)
|
|
ws.last = result.artifacts
|
|
return result.payload
|
|
finally:
|
|
_slots.release()
|
|
|
|
|
|
@router.post("/clear", response_model=Ok)
|
|
def clear(ws: UserWorkspace = Depends(workspace)) -> Ok:
|
|
with ws.lock:
|
|
ws.clear()
|
|
return Ok()
|
|
|
|
|
|
@router.get("/export")
|
|
def export(format: str = "xlsx", ws: UserWorkspace = Depends(workspace)) -> Response:
|
|
with ws.lock:
|
|
last = ws.last
|
|
if not last:
|
|
raise ValueError("Analyse some files first.")
|
|
stamp = exporters.export_stamp(last)
|
|
if format == "csv":
|
|
body, mime, name = (exporters.csv_bytes(last), exporters.CSV_MIME,
|
|
f"ppc-budget_{stamp}.csv")
|
|
else:
|
|
body, mime, name = (exporters.xlsx_bytes(last), exporters.XLSX_MIME,
|
|
f"ppc-budget-report_{stamp}.xlsx")
|
|
return Response(body, media_type=mime,
|
|
headers={"Content-Disposition": f'attachment; filename="{name}"'})
|