diff --git a/backend/routers/reports.py b/backend/routers/reports.py
index 12af4da..ade275f 100644
--- a/backend/routers/reports.py
+++ b/backend/routers/reports.py
@@ -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 = (
+ "
"
+ f"Terra-View test email for {escape(project.name)}.
"
+ "If you got this, the nightly sound-report email path is working.
"
+ )
+ 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"))
diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html
index 0b4384d..860dd70 100644
--- a/templates/partials/projects/project_header.html
+++ b/templates/partials/projects/project_header.html
@@ -128,14 +128,26 @@
+
+
+
+
+
+
+
+
+
@@ -172,6 +225,7 @@ function viewNightReport(projectId) {
+
+
+
+
@@ -222,6 +280,14 @@ function openReportSettings(projectId) {
document.getElementById('rs-baseline-end').value = c.baseline_end || '';
document.getElementById('rs-metrics').value = c.metric_keys || 'lmax,l01,l10,l90';
document.getElementById('rs-recipients').value = c.recipients || '';
+ var ss = document.getElementById('rs-schedule-status');
+ var last = c.last_run_date || '—';
+ if (c.enabled) {
+ ss.innerHTML = '● Automatic — runs daily at ' + (c.report_time || '08:00') + '. Last reported night: ' + last + '.';
+ } else {
+ ss.innerHTML = '● Automatic sending is off. Last reported night: ' + last + '.';
+ }
+ document.getElementById('rs-test-status').textContent = '';
show();
})
.catch(show);
@@ -254,6 +320,21 @@ function saveReportSettings(projectId) {
})
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
}
+function sendTestEmail(projectId) {
+ var st = document.getElementById('rs-test-status');
+ st.style.color = ''; st.textContent = 'Sending…';
+ var recips = document.getElementById('rs-recipients').value;
+ fetch('/api/projects/' + projectId + '/reports/test-email', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(recips ? { recipients: recips } : {})
+ }).then(function (r) { return r.json(); })
+ .then(function (j) {
+ if (j.sent) { st.style.color = '#1a7f37'; st.textContent = 'Sent to ' + (j.recipients || []).join(', '); }
+ else if (j.dry_run) { st.style.color = '#b8860b'; st.textContent = 'Dry-run (SMTP not set) — would send to ' + (j.recipients || []).join(', '); }
+ else { st.style.color = '#b00020'; st.textContent = 'Error: ' + (j.error || 'failed'); }
+ })
+ .catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
+}