From ccb70698ba74378bc342cf851213a5ff66caca6a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 22:49:32 +0000 Subject: [PATCH] feat(reports): Excel renderer + attachment + archive download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_excel(report): one worksheet per location — interval table, a line chart, and a Last/Base/Δ summary per window. Metric-driven, so it tracks whatever metric set is configured. - orchestrator: render report.xlsx alongside report.html, attach it to the email (dry-run until SMTP set), expose xlsx_path. Never lets a spreadsheet error sink the report. - reports router: /list includes xlsx_url when present; new GET /archive/{date}/xlsx serves the saved spreadsheet. - UI: Recent-reports rows get an "Excel" download link. Verified: real Feb data -> valid .xlsx (sheet per NRL, interval table + chart + summary with real values), attachment path runs, both archive routes registered. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/reports.py | 21 +++ backend/services/report_orchestrator.py | 19 ++- backend/services/report_renderers.py | 126 ++++++++++++++++++ .../partials/projects/project_header.html | 7 +- 4 files changed, 167 insertions(+), 6 deletions(-) diff --git a/backend/routers/reports.py b/backend/routers/reports.py index c23f816..c99cb1e 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -295,6 +295,8 @@ async def list_reports(project_id: str, db: Session = Depends(get_db)): out.append({ "night_date": d.name, "view_url": f"/api/projects/{project_id}/reports/archive/{d.name}", + "xlsx_url": (f"/api/projects/{project_id}/reports/archive/{d.name}/xlsx" + if (d / "report.xlsx").exists() else None), "size_bytes": st.st_size, "generated_at": datetime.utcfromtimestamp(st.st_mtime).isoformat(), }) @@ -315,6 +317,25 @@ async def view_archived_report(project_id: str, night_date: str, db: Session = D return HTMLResponse(path.read_text(encoding="utf-8")) +@router.get("/archive/{night_date}/xlsx") +async def download_archived_xlsx(project_id: str, night_date: str, db: Session = Depends(get_db)): + """Download a previously generated report.xlsx from disk.""" + from fastapi.responses import Response + if not db.query(Project).filter_by(id=project_id).first(): + raise HTTPException(status_code=404, detail="Project not found") + if not _DATE_RE.match(night_date): + raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)") + safe = _parse_date(night_date, "night_date") + path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.xlsx" + if not path.exists(): + raise HTTPException(status_code=404, detail="No saved spreadsheet for that date") + return Response( + content=path.read_bytes(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="night_report_{safe:%Y-%m-%d}.xlsx"'}, + ) + + # ============================================================================ # Reference baseline (fixed values typed per location — limits / prior averages) # ============================================================================ diff --git a/backend/services/report_orchestrator.py b/backend/services/report_orchestrator.py index c6b5818..9f5475f 100644 --- a/backend/services/report_orchestrator.py +++ b/backend/services/report_orchestrator.py @@ -23,8 +23,8 @@ 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 -from backend.services.report_email import send_report_email, Attachment +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__) @@ -102,8 +102,20 @@ def run_nightly_report( json_path = out_dir / "report.json" json_path.write_text(json.dumps(_report_to_dict(report), indent=2), encoding="utf-8") - # --- Excel attachment (renderer not built yet — held until metric set is final) --- + # --- 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} @@ -120,6 +132,7 @@ def run_nightly_report( "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, } diff --git a/backend/services/report_renderers.py b/backend/services/report_renderers.py index 994f500..d1063ae 100644 --- a/backend/services/report_renderers.py +++ b/backend/services/report_renderers.py @@ -112,3 +112,129 @@ def render_html_summary(report: ProjectNightReport) -> str: return (f'' f'{header}{body}{footer}') + + +# --------------------------------------------------------------------------- +# Excel renderer (the email attachment) — one sheet per location: +# interval table + line chart + a Last/Baseline/Δ summary per window. +# Metric-driven, so it adapts to whatever metric set is configured. +# --------------------------------------------------------------------------- + +def _safe_sheet_name(name: str) -> str: + bad = set('[]:*?/\\') + cleaned = "".join(c for c in (name or "Location") if c not in bad).strip() + return (cleaned or "Location")[:31] + + +def render_excel(report: ProjectNightReport) -> bytes: + """Render the night report as an .xlsx (bytes). One worksheet per location.""" + import io as _io + import openpyxl + from openpyxl.chart import LineChart, Reference + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + from openpyxl.utils import get_column_letter + + wb = openpyxl.Workbook() + wb.remove(wb.active) + + f_title = Font(name="Arial", bold=True, size=13) + f_h = Font(name="Arial", bold=True, size=10) + f_d = Font(name="Arial", size=10) + f_note = Font(name="Arial", size=9, italic=True, color="888888") + center = Alignment(horizontal="center", vertical="center") + hdr_fill = PatternFill("solid", fgColor="F2F2F2") + thin = Side(style="thin") + box = Border(left=thin, right=thin, top=thin, bottom=thin) + + if not report.locations: + ws = wb.create_sheet("No data") + ws["A1"] = f"{report.project_name} — no sound locations" + ws["A1"].font = f_title + + used_names: set = set() + for loc in report.locations: + sheet_name = _safe_sheet_name(loc.location_name) + n, base = sheet_name, sheet_name + i = 2 + while n in used_names: + n = (base[:28] + f"_{i}"); i += 1 + used_names.add(n) + ws = wb.create_sheet(n) + metrics = loc.metrics + + ws["A1"] = f"{report.project_name} — Night Report"; ws["A1"].font = f_title + ws["A2"] = loc.location_name; ws["A2"].font = f_h + ws["A3"] = f"Night of {loc.night_date:%m/%d/%y} · 7PM–7AM"; ws["A3"].font = f_d + + # --- interval table --- + hr = 5 + cols = ["Interval #", "Date", "Time"] + [m.label for m in metrics] + ["Comments"] + for ci, label in enumerate(cols, 1): + c = ws.cell(row=hr, column=ci, value=label) + c.font = f_h; c.alignment = center; c.fill = hdr_fill; c.border = box + r = hr + 1 + for idx, entry in enumerate(loc.interval_series, 1): + ws.cell(row=r, column=1, value=idx).border = box + dt = entry.get("dt") + ws.cell(row=r, column=2, value=(dt.strftime("%m/%d/%y") if dt else "")).border = box + ws.cell(row=r, column=3, value=entry.get("time", "")).border = box + for mi, m in enumerate(metrics): + v = entry.get(m.key) + cc = ws.cell(row=r, column=4 + mi, value=(v if isinstance(v, (int, float)) else None)) + cc.border = box; cc.alignment = center + ws.cell(row=r, column=4 + len(metrics), value="").border = box + r += 1 + data_end = max(r - 1, hr + 1) + + ws.column_dimensions["A"].width = 9 + ws.column_dimensions["B"].width = 10 + ws.column_dimensions["C"].width = 8 + for mi in range(len(metrics)): + ws.column_dimensions[get_column_letter(4 + mi)].width = 11 + ws.column_dimensions[get_column_letter(4 + len(metrics))].width = 22 + + # --- chart --- + if loc.interval_series and metrics: + chart = LineChart() + chart.title = f"{loc.location_name} — {loc.night_date:%m/%d/%y}" + chart.y_axis.title = "dBA"; chart.x_axis.title = "Time" + chart.height = 9; chart.width = 18 + data_ref = Reference(ws, min_col=4, max_col=3 + len(metrics), min_row=hr, max_row=data_end) + cats = Reference(ws, min_col=3, min_row=hr + 1, max_row=data_end) + chart.add_data(data_ref, titles_from_data=True) + chart.set_categories(cats) + ws.add_chart(chart, f"{get_column_letter(6 + len(metrics))}5") + + # --- summary: Metric × window (Last / Base / Δ) --- + sr = data_end + 3 + ws.cell(row=sr, column=1, value="Summary — last night vs baseline").font = f_h + sr += 1 + ws.cell(row=sr, column=1, value="Metric").font = f_h + win_col = {} + col = 2 + for w in loc.windows: + c = ws.cell(row=sr, column=col, value=w.label); c.font = f_h; c.alignment = center + ws.merge_cells(start_row=sr, start_column=col, end_row=sr, end_column=col + 2) + win_col[w.key] = col + col += 3 + sr += 1 + for w in loc.windows: + b = win_col[w.key] + for j, lbl in enumerate(["Last", "Base", "Δ"]): + cc = ws.cell(row=sr, column=b + j, value=lbl); cc.font = f_h; cc.alignment = center + sr += 1 + for m in metrics: + ws.cell(row=sr, column=1, value=m.label).font = f_d + for w in loc.windows: + cp = loc.table[w.key][m.key] + b = win_col[w.key] + ws.cell(row=sr, column=b + 0, value=cp.last_night).alignment = center + ws.cell(row=sr, column=b + 1, value=cp.baseline).alignment = center + ws.cell(row=sr, column=b + 2, value=cp.delta).alignment = center + sr += 1 + if loc.notes: + ws.cell(row=sr + 1, column=1, value="; ".join(loc.notes)).font = f_note + + out = _io.BytesIO() + wb.save(out) + return out.getvalue() diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index 0bed680..163391d 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -206,9 +206,10 @@ function loadRecentReports(projectId) { } box.innerHTML = j.reports.map(function (rp) { var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16); - return '' - + 'Night of ' + rp.night_date + '' - + '' + when + ' UTC'; + var xlsx = rp.xlsx_url ? ' · Excel' : ''; + return '
' + + 'Night of ' + rp.night_date + '' + + '' + when + ' UTC' + xlsx + '
'; }).join(''); }) .catch(function () { box.innerHTML = '
Failed to load.
'; });