a82bf59fb6
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>
127 lines
5.5 KiB
Python
127 lines
5.5 KiB
Python
"""
|
|
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
|