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 @@ +
+
+ + +
+
+
Loading…
+
+
+

+
@@ -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; }); +}