From a82bf59fb63a8b230ee14ac3c27bdfbaa343a146 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 23:43:17 +0000 Subject: [PATCH] feat(reports): manual run/view endpoints for the night report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backend/routers/reports.py (registered in main.py): - GET /api/projects/{id}/reports/nightly/view — render the night report HTML inline (preview; no write, no email) - POST /api/projects/{id}/reports/nightly/run — build -> write report.html/report.json to disk -> dry-run email -> JSON result + view_url Same entry point the scheduled morning tick will reuse. Query params: night_date (default last night, local tz), baseline_start/end, metrics, send. Orchestrator now also returns the rendered html for inline display. Verified via FastAPI TestClient on real meter data (200 HTML with the computed numbers, files written to disk, 400/404 validation paths). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/main.py | 4 + backend/routers/reports.py | 126 ++++++++++++++++++++++++ backend/services/report_orchestrator.py | 1 + 3 files changed, 131 insertions(+) create mode 100644 backend/routers/reports.py diff --git a/backend/main.py b/backend/main.py index 54792ad..b3e6fa4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -148,6 +148,10 @@ app.include_router(deployments.router) from backend.routers import calibration app.include_router(calibration.router) +# Nightly sound-report pipeline (manual triggers; scheduled tick reuses run_nightly_report) +from backend.routers import reports +app.include_router(reports.router) + # Start scheduler service and device status monitor on application startup from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..ef255eb --- /dev/null +++ b/backend/routers/reports.py @@ -0,0 +1,126 @@ +""" +Nightly Report Router. + +Manual triggers for the night-vs-baseline sound report — the same entry point +the scheduled morning tick will reuse. Two endpoints: + + GET …/reports/nightly/view → render and return the HTML inline (preview). + No write, no email. Browser-friendly. + POST …/reports/nightly/run → full run: build → write report.html/json to + disk → (dry-run) email. Returns JSON result. + +Dates are the *evening* date of the night being reported (the 7/7 in "night of +7/7 → morning 7/8"). Defaults to last night. Baseline is optional; pass the +baseline-week range to populate the comparison. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, date +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import Project +from backend.services.report_pipeline import METRIC_REGISTRY, DEFAULT_METRICS +from backend.services.report_orchestrator import run_nightly_report +from backend.utils.timezone import utc_to_local + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/projects/{project_id}/reports", tags=["reports"]) + + +def _default_night_date() -> date: + """Last night = yesterday in the user's local timezone.""" + return (utc_to_local(datetime.utcnow()) - timedelta(days=1)).date() + + +def _parse_date(s: Optional[str], field: str) -> Optional[date]: + if not s: + return None + try: + return datetime.strptime(s, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail=f"{field} must be YYYY-MM-DD (got {s!r})") + + +def _parse_metrics(s: Optional[str]) -> list[str]: + if not s: + return list(DEFAULT_METRICS) + keys = [k.strip().lower() for k in s.split(",") if k.strip()] + unknown = [k for k in keys if k not in METRIC_REGISTRY] + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown metric(s): {unknown}. Known: {sorted(METRIC_REGISTRY)}", + ) + return keys or list(DEFAULT_METRICS) + + +def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics): + """Shared validation/parsing for both endpoints.""" + if not db.query(Project).filter_by(id=project_id).first(): + raise HTTPException(status_code=404, detail="Project not found") + nd = _parse_date(night_date, "night_date") or _default_night_date() + bs = _parse_date(baseline_start, "baseline_start") + be = _parse_date(baseline_end, "baseline_end") + if (bs and not be) or (be and not bs): + raise HTTPException(status_code=400, detail="Provide both baseline_start and baseline_end, or neither.") + if bs and be and bs > be: + raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.") + return nd, bs, be, _parse_metrics(metrics) + + +@router.get("/nightly/view", response_class=HTMLResponse) +async def view_nightly_report( + project_id: str, + night_date: Optional[str] = Query(None, description="Evening date of the night (YYYY-MM-DD). Default: last night."), + baseline_start: Optional[str] = Query(None, description="Baseline range start (YYYY-MM-DD)."), + baseline_end: Optional[str] = Query(None, description="Baseline range end (YYYY-MM-DD)."), + metrics: Optional[str] = Query(None, description="Comma list, e.g. lmax,l01,l10,l90. Default: house set."), + db: Session = Depends(get_db), +): + """Render the night report and return the HTML inline (preview — no write, no email).""" + nd, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics) + result = run_nightly_report( + db, project_id, nd, + metric_keys=metric_keys, baseline_start=bs, baseline_end=be, + send=False, # preview: no email + ) + return HTMLResponse(result["html"]) + + +@router.post("/nightly/run") +async def run_nightly_report_endpoint( + project_id: str, + night_date: Optional[str] = Query(None, description="Evening date of the night (YYYY-MM-DD). Default: last night."), + baseline_start: Optional[str] = Query(None, description="Baseline range start (YYYY-MM-DD)."), + baseline_end: Optional[str] = Query(None, description="Baseline range end (YYYY-MM-DD)."), + metrics: Optional[str] = Query(None, description="Comma list, e.g. lmax,l01,l10,l90. Default: house set."), + send: bool = Query(True, description="Attempt email (dry-run until SMTP is configured)."), + db: Session = Depends(get_db), +): + """Run the night report: build → write report.html/report.json to disk → email (best-effort). + + This is the same path the scheduled morning tick will call. The `html` field + is omitted from the JSON response (it's large and on disk); use /view to see it. + """ + nd, bs, be, metric_keys = _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics) + result = run_nightly_report( + db, project_id, nd, + metric_keys=metric_keys, baseline_start=bs, baseline_end=be, + send=send, + ) + result.pop("html", None) # keep the JSON response lean — view it via /view or the file + result["view_url"] = ( + f"/api/projects/{project_id}/reports/nightly/view" + f"?night_date={nd:%Y-%m-%d}" + + (f"&baseline_start={bs:%Y-%m-%d}&baseline_end={be:%Y-%m-%d}" if bs and be else "") + + (f"&metrics={','.join(metric_keys)}") + ) + return result diff --git a/backend/services/report_orchestrator.py b/backend/services/report_orchestrator.py index cb83eeb..eb5359b 100644 --- a/backend/services/report_orchestrator.py +++ b/backend/services/report_orchestrator.py @@ -118,6 +118,7 @@ def run_nightly_report( "location_count": len(report.locations), "html_path": str(html_path), "json_path": str(json_path), + "html": html, # for callers that want to display it inline "email": email_result, } logger.info(