""" 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