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
+63
View File
@@ -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)
# ========================================================================