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:
@@ -218,6 +218,32 @@ class ProjectModule(Base):
|
||||
__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):
|
||||
"""
|
||||
Monitoring locations: generic location for monitoring activities.
|
||||
|
||||
@@ -20,12 +20,14 @@ import logging
|
||||
from datetime import datetime, timedelta, date
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
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_orchestrator import run_nightly_report
|
||||
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)
|
||||
|
||||
|
||||
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():
|
||||
|
||||
@@ -78,6 +78,9 @@ class SchedulerService:
|
||||
# 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)
|
||||
now = datetime.utcnow()
|
||||
if (now - last_generation_check).total_seconds() >= 3600:
|
||||
@@ -782,6 +785,66 @@ class SchedulerService:
|
||||
|
||||
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)
|
||||
# ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user