feat(reports): per-project report config + automatic morning run

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>
This commit is contained in:
2026-06-11 17:08:58 +00:00
parent c5b5045603
commit b2c54caebd
4 changed files with 271 additions and 2 deletions
+26
View File
@@ -218,6 +218,32 @@ class ProjectModule(Base):
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),) __table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
class SoundReportConfig(Base):
"""
Per-project configuration for the automated nightly sound report
(FTP report pipeline). One row per project. Read by the morning tick in
SchedulerService and by the manual /reports endpoints (as defaults).
New table → created by Base.metadata.create_all() on startup; no migration
needed (only a rebuild/restart).
"""
__tablename__ = "sound_report_configs"
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
project_id = Column(String, nullable=False, index=True, unique=True) # FK to projects.id
enabled = Column(Boolean, default=False, nullable=False) # run the daily report?
report_time = Column(String, default="08:00", nullable=False) # local HH:MM to run/send
metric_keys = Column(String, default="lmax,l01,l10,l90", nullable=False) # csv of metric keys
baseline_start = Column(Date, nullable=True) # baseline-week range
baseline_end = Column(Date, nullable=True)
recipients = Column(Text, nullable=True) # csv; falls back to REPORT_SMTP_RECIPIENTS env
last_run_date = Column(Date, nullable=True) # evening-date of the last reported night (dedup)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MonitoringLocation(Base): class MonitoringLocation(Base):
""" """
Monitoring locations: generic location for monitoring activities. Monitoring locations: generic location for monitoring activities.
+80 -2
View File
@@ -20,12 +20,14 @@ import logging
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import Project from backend.models import Project, SoundReportConfig
from backend.services.report_pipeline import METRIC_REGISTRY, DEFAULT_METRICS from backend.services.report_pipeline import METRIC_REGISTRY, DEFAULT_METRICS
from backend.services.report_orchestrator import run_nightly_report from backend.services.report_orchestrator import run_nightly_report
from backend.utils.timezone import utc_to_local from backend.utils.timezone import utc_to_local
@@ -62,6 +64,82 @@ def _parse_metrics(s: Optional[str]) -> list[str]:
return keys or list(DEFAULT_METRICS) 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): def _resolve_params(project_id, db, night_date, baseline_start, baseline_end, metrics):
"""Shared validation/parsing for both endpoints.""" """Shared validation/parsing for both endpoints."""
if not db.query(Project).filter_by(id=project_id).first(): if not db.query(Project).filter_by(id=project_id).first():
+63
View File
@@ -78,6 +78,9 @@ class SchedulerService:
# Execute pending actions # Execute pending actions
await self.execute_pending_actions() await self.execute_pending_actions()
# Run any due nightly sound reports (FTP report pipeline)
await self.run_due_reports()
# Generate actions from recurring schedules (every hour) # Generate actions from recurring schedules (every hour)
now = datetime.utcnow() now = datetime.utcnow()
if (now - last_generation_check).total_seconds() >= 3600: if (now - last_generation_check).total_seconds() >= 3600:
@@ -782,6 +785,66 @@ class SchedulerService:
return cleaned return cleaned
# ========================================================================
# Nightly Sound Report (FTP report pipeline)
# ========================================================================
async def run_due_reports(self):
"""Run any project nightly sound reports that are due.
For each enabled SoundReportConfig: if local time is past report_time
and we haven't already reported last night, build the report (writes a
file always; emails if SMTP is configured, else dry-run) and stamp
last_run_date. Idempotent across restarts via last_run_date.
"""
from backend.models import SoundReportConfig
from backend.services.report_orchestrator import run_nightly_report
from backend.utils.timezone import utc_to_local
db = SessionLocal()
try:
configs = db.query(SoundReportConfig).filter_by(enabled=True).all()
if not configs:
return
local_now = utc_to_local(datetime.utcnow())
night_date = local_now.date() - timedelta(days=1) # last night's evening date
for cfg in configs:
try:
# Past the configured local report time (HH:MM)?
try:
hh, mm = (int(x) for x in cfg.report_time.split(":"))
except (ValueError, AttributeError):
hh, mm = 8, 0
due = (local_now.hour, local_now.minute) >= (hh, mm)
if not due or cfg.last_run_date == night_date:
continue
metric_keys = [m.strip() for m in (cfg.metric_keys or "").split(",") if m.strip()] or None
recipients = [r.strip() for r in (cfg.recipients or "").split(",") if r.strip()] or None
logger.info(f"[REPORT] Running nightly report for project {cfg.project_id} (night {night_date})")
result = run_nightly_report(
db, cfg.project_id, night_date,
metric_keys=metric_keys,
baseline_start=cfg.baseline_start,
baseline_end=cfg.baseline_end,
recipients=recipients,
)
cfg.last_run_date = night_date
db.commit()
email = result.get("email", {})
logger.info(
f"[REPORT] project {cfg.project_id}: {result.get('location_count')} location(s); "
f"email={'sent' if email.get('sent') else ('dry-run' if email.get('dry_run') else (email.get('error') or 'skipped'))}"
)
except Exception as e:
logger.error(f"[REPORT] Failed nightly report for project {cfg.project_id}: {e}", exc_info=True)
db.rollback()
finally:
db.close()
# ======================================================================== # ========================================================================
# Manual Execution (for testing/debugging) # Manual Execution (for testing/debugging)
# ======================================================================== # ========================================================================
@@ -82,6 +82,14 @@
</svg> </svg>
Night Report Night Report
</button> </button>
<button onclick="openReportSettings('{{ project.id }}')"
title="Nightly report settings — schedule, baseline range, recipients"
class="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
{% endif %} {% endif %}
<button onclick="openMergeModal()" <button onclick="openMergeModal()"
title="Merge this project into another (consolidates duplicates)" title="Merge this project into another (consolidates duplicates)"
@@ -154,6 +162,100 @@ function viewNightReport(projectId) {
} }
</script> </script>
<!-- Nightly Report Settings Modal -->
<div id="report-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Nightly Report Settings</h3>
<button onclick="closeReportSettings()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="px-6 py-5 space-y-4">
<label class="flex items-center gap-2 text-sm font-medium text-gray-800 dark:text-gray-200">
<input type="checkbox" id="rs-enabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Email the report automatically each morning
</label>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report time (local)</label>
<input type="time" id="rs-report-time" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<p class="text-xs text-gray-400 mt-1">Runs after this time for the night that just ended.</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline start</label>
<input type="date" id="rs-baseline-start" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baseline end</label>
<input type="date" id="rs-baseline-end" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metrics</label>
<input type="text" id="rs-metrics" placeholder="lmax,l01,l10,l90" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<p class="text-xs text-gray-400 mt-1">Comma list. Options: lmax, l01, l10, l50, l90, l95, leq.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipients</label>
<input type="text" id="rs-recipients" placeholder="brian@…, dad@…" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<p class="text-xs text-gray-400 mt-1">Comma list. Blank → the default SMTP recipients.</p>
</div>
<p id="rs-status" class="text-xs"></p>
</div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button onclick="closeReportSettings()" class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm">Cancel</button>
<button onclick="saveReportSettings('{{ project.id }}')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm">Save</button>
</div>
</div>
</div>
<script>
function openReportSettings(projectId) {
var show = function () { document.getElementById('report-settings-modal').classList.remove('hidden'); };
document.getElementById('rs-status').textContent = '';
fetch('/api/projects/' + projectId + '/reports/config')
.then(function (r) { return r.json(); })
.then(function (c) {
document.getElementById('rs-enabled').checked = !!c.enabled;
document.getElementById('rs-report-time').value = c.report_time || '08:00';
document.getElementById('rs-baseline-start').value = c.baseline_start || '';
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 || '';
show();
})
.catch(show);
}
function closeReportSettings() {
document.getElementById('report-settings-modal').classList.add('hidden');
}
function saveReportSettings(projectId) {
var st = document.getElementById('rs-status');
var bs = document.getElementById('rs-baseline-start').value;
var be = document.getElementById('rs-baseline-end').value;
if ((bs && !be) || (be && !bs)) {
st.style.color = '#b00020'; st.textContent = 'Provide both baseline dates, or neither.'; return;
}
var body = {
enabled: document.getElementById('rs-enabled').checked,
report_time: document.getElementById('rs-report-time').value || '08:00',
metric_keys: document.getElementById('rs-metrics').value || 'lmax,l01,l10,l90',
baseline_start: bs || null,
baseline_end: be || null,
recipients: document.getElementById('rs-recipients').value || ''
};
st.style.color = ''; st.textContent = 'Saving…';
fetch('/api/projects/' + projectId + '/reports/config', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (res.ok) { st.style.color = '#1a7f37'; st.textContent = 'Saved.'; setTimeout(closeReportSettings, 700); }
else { st.style.color = '#b00020'; st.textContent = 'Error: ' + (res.j.detail || 'save failed'); }
})
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
}
</script>
<!-- Merge Modal — <!-- Merge Modal —
min-h on the body ensures the typeahead dropdown has room to render min-h on the body ensures the typeahead dropdown has room to render
below the input without forcing the operator to scroll inside the below the input without forcing the operator to scroll inside the