feat(reports): wire run-now, archive, test-email, last-run status into the UI
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>
This commit is contained in:
@@ -17,10 +17,12 @@ baseline-week range to populate the comparison.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
@@ -202,3 +204,79 @@ async def run_nightly_report_endpoint(
|
||||
+ (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 in New Issue
Block a user