Files
terra-view/backend/services/report_renderers.py
T
serversdown ccb70698ba feat(reports): Excel renderer + attachment + archive download
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) <noreply@anthropic.com>
2026-06-11 22:49:32 +00:00

241 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Nightly Report Renderers.
Pluggable renderers over the `report_pipeline` data model. v1 ships the HTML
email body + the Excel attachment; PDF and an inline chart image are v1.1
(each needs a new dependency). Keeping renderers separate from the compute
core means a future report wizard just toggles metrics/renderers — the data
model is unchanged.
Email-client constraints: the HTML uses a table layout with **inline styles
only** (no <style> blocks, no external CSS, no fl/grid), which is the reliable
common denominator across Outlook / Gmail / Apple Mail.
"""
from __future__ import annotations
from html import escape
from backend.services.report_pipeline import ProjectNightReport, LocationNightReport
# Colours: louder-than-baseline reads as a concern (red), quieter as fine (green).
_RED = "#b00020"
_GREEN = "#1a7f37"
_GREY = "#888888"
def _fmt_value(v) -> str:
return f"{v:.1f}" if isinstance(v, (int, float)) else ""
def _fmt_delta(v) -> str:
"""Signed delta with colour; positive (louder) = red, negative (quieter) = green."""
if not isinstance(v, (int, float)):
return f'<span style="color:{_GREY}">—</span>'
if v > 0:
return f'<span style="color:{_RED}">+{v:.1f}</span>'
if v < 0:
return f'<span style="color:{_GREEN}">{v:.1f}</span>'
return f'<span style="color:{_GREY}">0.0</span>'
def _location_table(loc: LocationNightReport) -> str:
"""One location block: heading + Metric × (window: Last / Base / Δ) table."""
th = ('padding:5px 9px;border:1px solid #ccc;background:#f2f2f2;'
'font:bold 12px Arial,sans-serif;text-align:center')
sub = ('padding:4px 8px;border:1px solid #ccc;background:#fafafa;'
'font:11px Arial,sans-serif;text-align:center;color:#555')
td = 'padding:4px 9px;border:1px solid #ccc;font:12px Arial,sans-serif;text-align:center'
td_l = 'padding:4px 9px;border:1px solid #ccc;font:bold 12px Arial,sans-serif;text-align:left'
# Top header: blank label cell + each window spanning Last/Base/Δ
top = f'<th rowspan="2" style="{th}">Metric (dBA)</th>'
for w in loc.windows:
top += f'<th colspan="3" style="{th}">{escape(w.label)}</th>'
sub_row = ''.join(
f'<th style="{sub}">Last</th><th style="{sub}">Base</th><th style="{sub}">&Delta;</th>'
for _ in loc.windows
)
body = ''
for m in loc.metrics:
cells = ''
for w in loc.windows:
cp = loc.table[w.key][m.key]
cells += (f'<td style="{td}">{_fmt_value(cp.last_night)}</td>'
f'<td style="{td}">{_fmt_value(cp.baseline)}</td>'
f'<td style="{td}">{_fmt_delta(cp.delta)}</td>')
body += f'<tr><td style="{td_l}">{escape(m.label)}</td>{cells}</tr>'
meta = (f'{loc.night_interval_count} intervals'
+ (f' · baseline = {loc.baseline_nights_used} night(s)'
if loc.baseline_nights_used else ' · no baseline yet'))
notes = ''
if loc.notes:
notes = ('<div style="font:11px Arial,sans-serif;color:#b00020;margin:2px 0 0">'
+ '<br>'.join(escape(n) for n in loc.notes) + '</div>')
return (
f'<h3 style="font:bold 15px Arial,sans-serif;margin:18px 0 4px">{escape(loc.location_name)}</h3>'
f'<div style="font:11px Arial,sans-serif;color:#666;margin:0 0 6px">{escape(meta)}</div>'
f'<table style="border-collapse:collapse;border:1px solid #ccc">'
f'<thead><tr>{top}</tr><tr>{sub_row}</tr></thead>'
f'<tbody>{body}</tbody></table>{notes}'
)
def render_html_summary(report: ProjectNightReport) -> str:
"""Render the full email-body HTML for a project's night report."""
windows_desc = ", ".join(w.label for w in (report.locations[0].windows if report.locations else []))
header = (
f'<h2 style="font:bold 18px Arial,sans-serif;margin:0 0 2px">'
f'{escape(report.project_name)} — Night Report</h2>'
f'<div style="font:13px Arial,sans-serif;color:#444;margin:0 0 4px">'
f'Night of {report.night_date:%a %m/%d/%y} &nbsp;·&nbsp; last night vs. baseline</div>'
f'<div style="font:11px Arial,sans-serif;color:#888;margin:0 0 10px">'
f'Windows: {escape(windows_desc)}. '
f'&Delta; = last night minus baseline (<span style="color:{_RED}">+ louder</span>, '
f'<span style="color:{_GREEN}"> quieter</span>). '
f'LAmax = loudest interval; L-values are arithmetic averages; '
f'baseline = typical night.</div>'
)
if not report.locations:
body = ('<div style="font:13px Arial,sans-serif;color:#b00020">'
'No sound locations found for this project.</div>')
else:
body = ''.join(_location_table(loc) for loc in report.locations)
footer = ('<div style="font:10px Arial,sans-serif;color:#aaa;margin-top:18px">'
'Automated report — Terra-View. Full interval data in the attached spreadsheet.</div>')
return (f'<!DOCTYPE html><html><body style="margin:0;padding:16px;background:#fff">'
f'{header}{body}{footer}</body></html>')
# ---------------------------------------------------------------------------
# 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} · 7PM7AM"; 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()