ccb70698ba
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>
241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""
|
||
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}">Δ</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} · 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'Δ = 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} · 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()
|