feat(reports): manual run/view endpoints for the night report
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) <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,10 @@ app.include_router(deployments.router)
|
|||||||
from backend.routers import calibration
|
from backend.routers import calibration
|
||||||
app.include_router(calibration.router)
|
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
|
# Start scheduler service and device status monitor on application startup
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -118,6 +118,7 @@ def run_nightly_report(
|
|||||||
"location_count": len(report.locations),
|
"location_count": len(report.locations),
|
||||||
"html_path": str(html_path),
|
"html_path": str(html_path),
|
||||||
"json_path": str(json_path),
|
"json_path": str(json_path),
|
||||||
|
"html": html, # for callers that want to display it inline
|
||||||
"email": email_result,
|
"email": email_result,
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user