From b2c54caebdd18e8559969556f562f34fdda500af Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 17:08:58 +0000 Subject: [PATCH] feat(reports): per-project report config + automatic morning run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/models.py | 26 +++++ backend/routers/reports.py | 82 +++++++++++++- backend/services/scheduler.py | 63 +++++++++++ .../partials/projects/project_header.html | 102 ++++++++++++++++++ 4 files changed, 271 insertions(+), 2 deletions(-) diff --git a/backend/models.py b/backend/models.py index aae3039..0cfeacc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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. diff --git a/backend/routers/reports.py b/backend/routers/reports.py index ef255eb..12af4da 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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(): diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index b782280..b0aaa45 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -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) # ======================================================================== diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index 20cbd31..0b4384d 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -82,6 +82,14 @@ Night Report + {% endif %} + +
+ +
+ + +

Runs after this time for the night that just ended.

+
+
+
+ + +
+
+ + +
+
+
+ + +

Comma list. Options: lmax, l01, l10, l50, l90, l95, leq.

+
+
+ + +

Comma list. Blank → the default SMTP recipients.

+
+

+
+
+ + +
+ + + +