7fb4ba0343
Backend (reports router):
- POST /reports/test-email — send a test email (body/config recipients; dry-run
if SMTP unset) to verify the relay.
- GET /reports/list — list generated report artifacts on disk (newest first).
- GET /reports/archive/{date} — serve a saved report.html (traversal-guarded).
Frontend (sound project header modals):
- Night Report modal: "Run & Email" button (POST /run) + a "Recent reports" list
(GET /list → opens the archived report.html in a new tab).
- Settings modal: schedule + last-run status line, and a "Send test email" button.
Verified: endpoints (run→list→archive, traversal blocked, test-email recipient
fallback) and the template renders with all four wired + gated to sound projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
12 KiB
Python
283 lines
12 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
|
|
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
|
|
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 _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_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_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):
|
|
"""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
|
|
|
|
|
|
# ============================================================================
|
|
# 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"))
|