""" Nightly Report Orchestrator. Ties the pieces together: compute → render → write-to-disk → email. This is what the daily cycle (or a manual trigger) calls. It ALWAYS writes the rendered report to disk — `data/reports/{project_id}/{night_date}/report.html` (+ `report.json` with the raw numbers) — so there's a viewable artifact even when email is in dry-run (SMTP not configured yet). The email step is best-effort and never aborts the run. """ from __future__ import annotations import json import logging from datetime import date from pathlib import Path from typing import Optional from sqlalchemy.orm import Session from backend.services.report_pipeline import ( ProjectNightReport, build_project_night_report, Window, ) from backend.services.report_renderers import render_html_summary, render_excel from backend.services.report_email import send_report_email, Attachment, XLSX_MIME logger = logging.getLogger(__name__) DEFAULT_OUTPUT_ROOT = "data/reports" def _report_to_dict(report: ProjectNightReport) -> dict: """Serialise the report data model to plain JSON (for the on-disk record).""" return { "project_id": report.project_id, "project_name": report.project_name, "night_date": report.night_date.isoformat(), "metrics": [m.key for m in report.metrics], "locations": [ { "name": loc.location_name, "night_interval_count": loc.night_interval_count, "baseline_nights_used": loc.baseline_nights_used, "notes": loc.notes, "windows": { w.key: { "label": w.label, "metrics": { m.key: { "label": m.label, "last_night": loc.table[w.key][m.key].last_night, "baseline": loc.table[w.key][m.key].baseline, "delta": loc.table[w.key][m.key].delta, } for m in loc.metrics }, } for w in loc.windows }, } for loc in report.locations ], } def run_nightly_report( db: Session, project_id: str, night_date: date, *, metric_keys: Optional[list[str]] = None, windows: Optional[list[Window]] = None, baseline_mode: str = "captured", baseline_start: Optional[date] = None, baseline_end: Optional[date] = None, recipients: Optional[list[str]] = None, output_root: str = DEFAULT_OUTPUT_ROOT, send: bool = True, ) -> dict: """Build, persist, and (dry-run) email the night report for a project. Returns a result dict with the on-disk artifact paths and the email result. Designed to be called from the daily cycle or a manual trigger. """ report = build_project_night_report( db, project_id, night_date, metric_keys=metric_keys, windows=windows, baseline_mode=baseline_mode, baseline_start=baseline_start, baseline_end=baseline_end, ) html = render_html_summary(report) subject = f"{report.project_name} — night report {night_date:%m/%d/%y}" # --- Always persist a viewable copy --- out_dir = Path(output_root) / project_id / f"{night_date:%Y-%m-%d}" out_dir.mkdir(parents=True, exist_ok=True) html_path = out_dir / "report.html" html_path.write_text(html, encoding="utf-8") json_path = out_dir / "report.json" json_path.write_text(json.dumps(_report_to_dict(report), indent=2), encoding="utf-8") # --- Excel (the email attachment; also written to disk for the archive) --- attachments: list[Attachment] = [] xlsx_path = None try: xlsx_bytes = render_excel(report) xlsx_path = out_dir / "report.xlsx" xlsx_path.write_bytes(xlsx_bytes) safe_name = "".join(c for c in report.project_name if c.isalnum() or c in " -_").strip().replace(" ", "_") attachments.append(Attachment( f"{safe_name or 'report'}_{night_date:%Y-%m-%d}_night_report.xlsx", xlsx_bytes, *XLSX_MIME, )) except Exception as e: # noqa: BLE001 — never let the spreadsheet sink the report logger.error("Excel render failed for %s (%s): %s", project_id, night_date, e, exc_info=True) # --- Email (best-effort; dry-run until SMTP is configured) --- email_result = {"sent": False, "dry_run": False, "skipped": True, "error": None} if send: try: email_result = send_report_email( subject, html, attachments=attachments, recipients=recipients, ) except Exception as e: # noqa: BLE001 — artifacts are already written; never abort on email logger.error("send_report_email raised for %s (%s): %s", project_id, night_date, e, exc_info=True) email_result = {"sent": False, "dry_run": False, "skipped": False, "error": str(e)} result = { "project_id": project_id, "project_name": report.project_name, "night_date": night_date.isoformat(), "subject": subject, "location_count": len(report.locations), "html_path": str(html_path), "json_path": str(json_path), "xlsx_path": str(xlsx_path) if xlsx_path else None, "html": html, # for callers that want to display it inline "email": email_result, } logger.info( "Nightly report for %s (%s): %d location(s) → %s; email=%s", report.project_name, night_date, len(report.locations), html_path, "sent" if email_result.get("sent") else ("dry-run" if email_result.get("dry_run") else ("skipped" if email_result.get("skipped") else f"error: {email_result.get('error')}")), ) return result