b2c54caebd
Add SoundReportConfig (one row per project) + the scheduler tick that runs the
nightly report on its own:
- model SoundReportConfig (enabled, report_time, metric_keys, baseline range,
recipients, last_run_date) — new table, auto-created by create_all (no migration).
- GET/PUT /api/projects/{id}/reports/config with validation.
- SchedulerService.run_due_reports(): each loop, for every enabled config past
its report_time, run last night's report once (dedup via last_run_date),
writing the file + emailing (dry-run until SMTP is set).
- UI: gear button beside "Night Report" opens a settings modal (enable, time,
baseline range, metrics, recipients) that GET/PUTs the config.
Verified: table registers + auto-creates, config CRUD + validation, tick
runs/dedups, templates render and gate to sound projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
8.9 KiB
Python
205 lines
8.9 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
|
|
|
|
import uuid
|
|
|
|
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
|