c1b5efae56
Baseline can now come from fixed values typed per location, not just captured
data — for a spec limit ("L10 = 85") or a prior report's averages when the raw
data isn't available.
- SoundReportConfig.baseline_mode ("captured" | "reference").
- report_pipeline: _location_reference_baseline() reads per-location values from
location_metadata; build_*_night_report honor baseline_mode (reference cells
use the typed value; unset metrics compare against nothing).
- reports router: GET/PUT /reports/baseline (mode on config + per-location values
in location_metadata); config carries baseline_mode; manual view/run fall back
to the saved config's baseline when no explicit dates are given.
- orchestrator + scheduler tick thread baseline_mode through.
Verified end-to-end: PUT/GET /baseline, reference deltas (L10 66.6 vs 85 -> -18.4),
unset metrics compare against nothing, captured-mode regression intact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
402 lines
17 KiB
Python
402 lines
17 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 json
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, timedelta, date
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import get_db
|
|
from backend.models import Project, SoundReportConfig, MonitoringLocation
|
|
from backend.services.report_pipeline import (
|
|
METRIC_REGISTRY, DEFAULT_METRICS, DEFAULT_WINDOWS, _location_reference_baseline,
|
|
)
|
|
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 _validate_hhmm(s) -> str:
|
|
"""Validate a local HH:MM (24h) time string."""
|
|
try:
|
|
hh, mm = str(s).split(":")
|
|
h, m = int(hh), int(mm)
|
|
if 0 <= h < 24 and 0 <= m < 60:
|
|
return f"{h:02d}:{m:02d}"
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
raise HTTPException(status_code=400, detail=f"report_time must be HH:MM 24-hour (got {s!r})")
|
|
|
|
|
|
def _config_dict(cfg: Optional[SoundReportConfig], project_id: str) -> dict:
|
|
"""Serialise a config row (or defaults if none yet) to JSON."""
|
|
return {
|
|
"project_id": project_id,
|
|
"exists": cfg is not None,
|
|
"enabled": cfg.enabled if cfg else False,
|
|
"report_time": cfg.report_time if cfg else "08:00",
|
|
"metric_keys": cfg.metric_keys if cfg else ",".join(DEFAULT_METRICS),
|
|
"baseline_mode": cfg.baseline_mode if cfg else "captured",
|
|
"baseline_start": cfg.baseline_start.isoformat() if cfg and cfg.baseline_start else None,
|
|
"baseline_end": cfg.baseline_end.isoformat() if cfg and cfg.baseline_end else None,
|
|
"recipients": (cfg.recipients if cfg and cfg.recipients else ""),
|
|
"last_run_date": cfg.last_run_date.isoformat() if cfg and cfg.last_run_date else None,
|
|
}
|
|
|
|
|
|
@router.get("/config")
|
|
async def get_report_config(project_id: str, db: Session = Depends(get_db)):
|
|
"""Return the project's nightly-report config (or defaults if not set yet)."""
|
|
if not db.query(Project).filter_by(id=project_id).first():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
|
return _config_dict(cfg, project_id)
|
|
|
|
|
|
@router.put("/config")
|
|
async def put_report_config(project_id: str, request: Request, db: Session = Depends(get_db)):
|
|
"""Create or update the project's nightly-report config (JSON body)."""
|
|
if not db.query(Project).filter_by(id=project_id).first():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
data = await request.json()
|
|
|
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
|
created = cfg is None
|
|
if cfg is None:
|
|
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
|
|
db.add(cfg)
|
|
|
|
if "enabled" in data:
|
|
cfg.enabled = bool(data["enabled"])
|
|
if "report_time" in data:
|
|
cfg.report_time = _validate_hhmm(data["report_time"])
|
|
if "metric_keys" in data:
|
|
mk = data["metric_keys"]
|
|
mk = mk if isinstance(mk, str) else ",".join(mk or [])
|
|
cfg.metric_keys = ",".join(_parse_metrics(mk))
|
|
if "baseline_mode" in data:
|
|
bm = str(data["baseline_mode"]).lower()
|
|
if bm not in ("captured", "reference"):
|
|
raise HTTPException(status_code=400, detail="baseline_mode must be 'captured' or 'reference'")
|
|
cfg.baseline_mode = bm
|
|
if "baseline_start" in data or "baseline_end" in data:
|
|
bs = _parse_date(data.get("baseline_start") or None, "baseline_start")
|
|
be = _parse_date(data.get("baseline_end") or None, "baseline_end")
|
|
if (bs and not be) or (be and not bs):
|
|
raise HTTPException(status_code=400, detail="Provide both baseline dates, or neither.")
|
|
if bs and be and bs > be:
|
|
raise HTTPException(status_code=400, detail="baseline_start must be on or before baseline_end.")
|
|
cfg.baseline_start, cfg.baseline_end = bs, be
|
|
if "recipients" in data:
|
|
recips = data["recipients"]
|
|
if isinstance(recips, list):
|
|
recips = ",".join(recips)
|
|
cfg.recipients = (recips or "").strip() or None
|
|
|
|
db.commit()
|
|
db.refresh(cfg)
|
|
return {**_config_dict(cfg, project_id), "created": created}
|
|
|
|
|
|
def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics):
|
|
"""Validate inputs and resolve the baseline source.
|
|
|
|
Explicit baseline dates in the query override (captured mode with those
|
|
dates). Otherwise the project's saved config supplies the baseline (its
|
|
mode + dates) and the default metric set — so the manual view/run match
|
|
what the scheduled report does.
|
|
Returns (night_date, baseline_mode, baseline_start, baseline_end, metric_keys).
|
|
"""
|
|
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.")
|
|
|
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
|
if bs and be:
|
|
baseline_mode = "captured" # explicit dates win
|
|
elif cfg:
|
|
baseline_mode = cfg.baseline_mode # fall back to saved config
|
|
bs, be = cfg.baseline_start, cfg.baseline_end
|
|
else:
|
|
baseline_mode = "captured"
|
|
|
|
if metrics:
|
|
metric_keys = _parse_metrics(metrics)
|
|
elif cfg and cfg.metric_keys:
|
|
metric_keys = _parse_metrics(cfg.metric_keys)
|
|
else:
|
|
metric_keys = list(DEFAULT_METRICS)
|
|
|
|
return nd, baseline_mode, bs, be, metric_keys
|
|
|
|
|
|
@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, bmode, 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_mode=bmode, 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, bmode, 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_mode=bmode, 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
|
|
|
|
|
|
# ============================================================================
|
|
# Test email + generated-report archive
|
|
# ============================================================================
|
|
|
|
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
@router.post("/test-email")
|
|
async def send_test_email(project_id: str, request: Request, db: Session = Depends(get_db)):
|
|
"""Send a small test email to verify the SMTP relay (dry-run if unconfigured).
|
|
|
|
Recipients: JSON body {"recipients": "..."} overrides; else the project's
|
|
configured recipients; else the REPORT_SMTP_RECIPIENTS env default.
|
|
"""
|
|
project = db.query(Project).filter_by(id=project_id).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
data = {}
|
|
|
|
raw = (data or {}).get("recipients")
|
|
if not raw:
|
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
|
raw = cfg.recipients if cfg else None
|
|
recipients = None
|
|
if raw:
|
|
if isinstance(raw, list):
|
|
raw = ",".join(raw)
|
|
recipients = [r.strip() for r in raw.split(",") if r.strip()]
|
|
|
|
from backend.services.report_email import send_report_email
|
|
body = (
|
|
"<div style=\"font:14px Arial,sans-serif\">"
|
|
f"Terra-View test email for <b>{escape(project.name)}</b>.<br>"
|
|
"If you got this, the nightly sound-report email path is working.</div>"
|
|
)
|
|
return send_report_email("Terra-View — nightly report test email", body, recipients=recipients)
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_reports(project_id: str, db: Session = Depends(get_db)):
|
|
"""List the generated report artifacts on disk for this project (newest first)."""
|
|
if not db.query(Project).filter_by(id=project_id).first():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
base = Path("data/reports") / project_id
|
|
out = []
|
|
if base.exists():
|
|
for d in sorted((p for p in base.iterdir() if p.is_dir()), key=lambda p: p.name, reverse=True):
|
|
html_file = d / "report.html"
|
|
if html_file.exists():
|
|
st = html_file.stat()
|
|
out.append({
|
|
"night_date": d.name,
|
|
"view_url": f"/api/projects/{project_id}/reports/archive/{d.name}",
|
|
"size_bytes": st.st_size,
|
|
"generated_at": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
|
|
})
|
|
return {"reports": out, "count": len(out)}
|
|
|
|
|
|
@router.get("/archive/{night_date}", response_class=HTMLResponse)
|
|
async def view_archived_report(project_id: str, night_date: str, db: Session = Depends(get_db)):
|
|
"""Serve a previously generated report.html from disk (the actual artifact)."""
|
|
if not db.query(Project).filter_by(id=project_id).first():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
if not _DATE_RE.match(night_date):
|
|
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
|
|
safe = _parse_date(night_date, "night_date") # also guards path traversal
|
|
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.html"
|
|
if not path.exists():
|
|
raise HTTPException(status_code=404, detail="No saved report for that date")
|
|
return HTMLResponse(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
# ============================================================================
|
|
# Reference baseline (fixed values typed per location — limits / prior averages)
|
|
# ============================================================================
|
|
|
|
@router.get("/baseline")
|
|
async def get_baseline(project_id: str, db: Session = Depends(get_db)):
|
|
"""Return the baseline mode + per-location reference values + the metric/window
|
|
grid to render the editor."""
|
|
if not db.query(Project).filter_by(id=project_id).first():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
|
mode = cfg.baseline_mode if cfg else "captured"
|
|
metric_keys = _parse_metrics(cfg.metric_keys) if cfg and cfg.metric_keys else list(DEFAULT_METRICS)
|
|
|
|
locations = db.query(MonitoringLocation).filter_by(
|
|
project_id=project_id, location_type="sound",
|
|
).order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
|
locations = [l for l in locations if getattr(l, "removed_at", None) is None]
|
|
|
|
return {
|
|
"mode": mode,
|
|
"windows": [{"key": w.key, "label": w.label} for w in DEFAULT_WINDOWS],
|
|
"metrics": [{"key": k, "label": METRIC_REGISTRY[k].label} for k in metric_keys],
|
|
"locations": [
|
|
{"id": loc.id, "name": loc.name, "values": _location_reference_baseline(loc)}
|
|
for loc in locations
|
|
],
|
|
}
|
|
|
|
|
|
@router.put("/baseline")
|
|
async def put_baseline(project_id: str, request: Request, db: Session = Depends(get_db)):
|
|
"""Save the baseline mode (on config) and per-location reference values
|
|
(on each location's metadata). Body:
|
|
{"mode": "reference",
|
|
"locations": {"<loc_id>": {"nighttime": {"l10": 85}, "evening": {...}}}}
|
|
"""
|
|
if not db.query(Project).filter_by(id=project_id).first():
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
data = await request.json()
|
|
|
|
if "mode" in data:
|
|
bm = str(data["mode"]).lower()
|
|
if bm not in ("captured", "reference"):
|
|
raise HTTPException(status_code=400, detail="mode must be 'captured' or 'reference'")
|
|
cfg = db.query(SoundReportConfig).filter_by(project_id=project_id).first()
|
|
if cfg is None:
|
|
cfg = SoundReportConfig(id=str(uuid.uuid4()), project_id=project_id)
|
|
db.add(cfg)
|
|
cfg.baseline_mode = bm
|
|
|
|
loc_values = data.get("locations") or {}
|
|
updated = 0
|
|
for loc_id, windows in loc_values.items():
|
|
loc = db.query(MonitoringLocation).filter_by(id=loc_id, project_id=project_id).first()
|
|
if not loc or not isinstance(windows, dict):
|
|
continue
|
|
try:
|
|
meta = json.loads(loc.location_metadata or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
meta = {}
|
|
clean: dict = {}
|
|
for wkey, mvals in windows.items():
|
|
if not isinstance(mvals, dict):
|
|
continue
|
|
cm = {}
|
|
for mkey, val in mvals.items():
|
|
if val in (None, ""):
|
|
continue
|
|
try:
|
|
cm[mkey] = round(float(val), 1)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if cm:
|
|
clean[wkey] = cm
|
|
if clean:
|
|
meta["report_baseline"] = clean
|
|
else:
|
|
meta.pop("report_baseline", None)
|
|
loc.location_metadata = json.dumps(meta)
|
|
updated += 1
|
|
|
|
db.commit()
|
|
return {"ok": True, "locations_updated": updated}
|