Report PDF emailing
This commit is contained in:
@@ -21,6 +21,13 @@ class Config:
|
||||
app_timezone: str = os.getenv("APP_TIMEZONE", "Europe/London")
|
||||
flask_secret_key: str = os.getenv("FLASK_SECRET_KEY", "dev-only-secret")
|
||||
healthcheck_url: str | None = os.getenv("HEALTHCHECK_URL")
|
||||
report_output_dir: str = os.getenv("REPORT_OUTPUT_DIR", "/tmp/switchbot-reports")
|
||||
report_sender_email: str = os.getenv("REPORT_SENDER_EMAIL", "")
|
||||
report_sender_name: str = os.getenv("REPORT_SENDER_NAME", "SwitchBot Temps")
|
||||
smtp2go_api_key: str = os.getenv("SMTP2GO_API_KEY", "")
|
||||
smtp2go_api_url: str = os.getenv("SMTP2GO_API_URL", "https://api.smtp2go.com/v3/email/send")
|
||||
smtp2go_timeout_seconds: int = int(os.getenv("SMTP2GO_TIMEOUT_SECONDS", "20"))
|
||||
report_scheduler_poll_seconds: int = int(os.getenv("REPORT_SCHEDULER_POLL_SECONDS", "300"))
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
+83
-2
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
@@ -26,6 +26,7 @@ class Device(Base):
|
||||
)
|
||||
|
||||
readings: Mapped[list["Reading"]] = relationship(back_populates="device")
|
||||
scheduled_reports: Mapped[list["ScheduledReport"]] = relationship(back_populates="device")
|
||||
|
||||
|
||||
class Reading(Base):
|
||||
@@ -44,3 +45,83 @@ class Reading(Base):
|
||||
version: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
device: Mapped[Device] = relationship(back_populates="readings")
|
||||
|
||||
|
||||
class ReportRecipient(Base):
|
||||
__tablename__ = "report_recipients"
|
||||
__table_args__ = (UniqueConstraint("email", name="uq_report_recipients_email"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(255))
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
scheduled_reports: Mapped[list["ScheduledReport"]] = relationship(back_populates="recipient")
|
||||
|
||||
|
||||
class ScheduledReport(Base):
|
||||
__tablename__ = "scheduled_reports"
|
||||
__table_args__ = (
|
||||
Index("ix_scheduled_reports_active_cadence", "active", "cadence"),
|
||||
UniqueConstraint(
|
||||
"recipient_id",
|
||||
"cadence",
|
||||
"send_time",
|
||||
"weekday",
|
||||
"monthday",
|
||||
"device_id",
|
||||
name="uq_scheduled_report_config",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recipient_id: Mapped[int] = mapped_column(ForeignKey("report_recipients.id"), nullable=False)
|
||||
cadence: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
send_time: Mapped[str] = mapped_column(String(5), nullable=False, default="08:00")
|
||||
weekday: Mapped[int | None] = mapped_column(Integer)
|
||||
monthday: Mapped[int | None] = mapped_column(Integer)
|
||||
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"))
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
recipient: Mapped[ReportRecipient] = relationship(back_populates="scheduled_reports")
|
||||
device: Mapped[Device | None] = relationship(back_populates="scheduled_reports")
|
||||
runs: Mapped[list["ScheduledReportRun"]] = relationship(back_populates="scheduled_report")
|
||||
|
||||
|
||||
class ScheduledReportRun(Base):
|
||||
__tablename__ = "scheduled_report_runs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"scheduled_report_id",
|
||||
"period_start",
|
||||
"period_end",
|
||||
name="uq_scheduled_report_run_period",
|
||||
),
|
||||
Index("ix_scheduled_report_runs_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
scheduled_report_id: Mapped[int] = mapped_column(ForeignKey("scheduled_reports.id"), nullable=False)
|
||||
period_start: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
period_end: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
pdf_path: Mapped[str | None] = mapped_column(String(1024))
|
||||
error: Mapped[str | None] = mapped_column(String(1024))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
scheduled_report: Mapped[ScheduledReport] = relationship(back_populates="runs")
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import (
|
||||
Image as RLImage,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models import Device, Reading
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailySummary:
|
||||
day: date
|
||||
high: float
|
||||
low: float
|
||||
average: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportDevice:
|
||||
name: str
|
||||
readings: list[Reading]
|
||||
summary: list[DailySummary]
|
||||
|
||||
|
||||
def build_temperature_pdf(
|
||||
session: Session,
|
||||
output_dir: Path,
|
||||
chart_dir: Path,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
tz: ZoneInfo,
|
||||
device_id: str = "",
|
||||
) -> Path:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
chart_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
devices = load_report_devices(session, start_utc, end_utc, tz, device_id)
|
||||
generated_at = datetime.now(tz)
|
||||
filename = f"temperature-report-{start_date:%Y%m%d}-{end_date:%Y%m%d}"
|
||||
if device_id and len(devices) == 1:
|
||||
filename += f"-{safe_filename(devices[0].name)}"
|
||||
pdf_path = output_dir / f"{filename}.pdf"
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
"RecordTitle",
|
||||
parent=styles["Title"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=20,
|
||||
leading=24,
|
||||
textColor=colors.HexColor("#1F4E78"),
|
||||
alignment=TA_CENTER,
|
||||
spaceAfter=5,
|
||||
)
|
||||
subtitle_style = ParagraphStyle(
|
||||
"Subtitle",
|
||||
parent=styles["Normal"],
|
||||
fontName="Helvetica",
|
||||
fontSize=9,
|
||||
leading=11,
|
||||
textColor=colors.HexColor("#475467"),
|
||||
alignment=TA_CENTER,
|
||||
spaceAfter=8,
|
||||
)
|
||||
section_style = ParagraphStyle(
|
||||
"Section",
|
||||
parent=styles["Heading2"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=14,
|
||||
leading=17,
|
||||
textColor=colors.HexColor("#1F2933"),
|
||||
spaceBefore=2,
|
||||
spaceAfter=5,
|
||||
)
|
||||
|
||||
period_label = format_period(start_date, end_date)
|
||||
doc = SimpleDocTemplate(
|
||||
str(pdf_path),
|
||||
pagesize=landscape(A4),
|
||||
leftMargin=12 * mm,
|
||||
rightMargin=12 * mm,
|
||||
topMargin=10 * mm,
|
||||
bottomMargin=12 * mm,
|
||||
title=f"Temperature Report - {period_label}",
|
||||
)
|
||||
story = []
|
||||
|
||||
if not devices:
|
||||
story.extend(
|
||||
[
|
||||
Paragraph(f"Temperature Records - {period_label}", title_style),
|
||||
Paragraph("No readings found for this period.", subtitle_style),
|
||||
]
|
||||
)
|
||||
else:
|
||||
first_device = True
|
||||
for device in devices:
|
||||
if not first_device:
|
||||
story.append(PageBreak())
|
||||
first_device = False
|
||||
|
||||
chart_path = chart_dir / f"{safe_filename(device.name)}-{start_date:%Y%m%d}-{end_date:%Y%m%d}.png"
|
||||
draw_chart(device.summary, f"{device.name} daily temperature trend", chart_path)
|
||||
|
||||
story.append(Paragraph(f"Temperature Records - {period_label}", title_style))
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"Daily high, minimum, and average temperature summaries. Generated {generated_at:%Y-%m-%d %H:%M %Z}.",
|
||||
subtitle_style,
|
||||
)
|
||||
)
|
||||
story.append(Paragraph(device.name, section_style))
|
||||
chart = RLImage(str(chart_path), width=141 * mm, height=41.5 * mm)
|
||||
top_row = Table(
|
||||
[[make_stat_cards(device, period_label, tz), chart]],
|
||||
colWidths=[143 * mm, 142 * mm],
|
||||
)
|
||||
top_row.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(top_row)
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
story.append(make_daily_table(device.summary))
|
||||
|
||||
doc.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||
return pdf_path
|
||||
|
||||
|
||||
def load_report_devices(
|
||||
session: Session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
tz: ZoneInfo,
|
||||
device_id: str = "",
|
||||
) -> list[ReportDevice]:
|
||||
stmt = (
|
||||
select(Reading)
|
||||
.options(joinedload(Reading.device))
|
||||
.join(Reading.device)
|
||||
.where(Reading.recorded_at >= start_utc, Reading.recorded_at < end_utc)
|
||||
.order_by(Device.name, Reading.recorded_at)
|
||||
)
|
||||
if device_id:
|
||||
stmt = stmt.where(Device.id == device_id)
|
||||
|
||||
grouped: dict[str, list[Reading]] = defaultdict(list)
|
||||
names: dict[str, str] = {}
|
||||
for reading in session.scalars(stmt):
|
||||
if reading.device.device_type != "WoIOSensor":
|
||||
continue
|
||||
grouped[reading.device_id].append(reading)
|
||||
names[reading.device_id] = reading.device.name
|
||||
|
||||
report_devices = []
|
||||
for item_device_id in sorted(grouped.keys(), key=lambda value: names[value].lower()):
|
||||
readings = grouped[item_device_id]
|
||||
summary = daily_summary(readings, tz)
|
||||
if summary:
|
||||
report_devices.append(ReportDevice(names[item_device_id], readings, summary))
|
||||
return report_devices
|
||||
|
||||
|
||||
def daily_summary(readings: list[Reading], tz: ZoneInfo) -> list[DailySummary]:
|
||||
temperatures_by_day: dict[date, list[float]] = defaultdict(list)
|
||||
for reading in readings:
|
||||
if reading.temperature is None:
|
||||
continue
|
||||
local_time = reading.recorded_at.replace(tzinfo=timezone.utc).astimezone(tz)
|
||||
temperatures_by_day[local_time.date()].append(float(reading.temperature))
|
||||
|
||||
summaries = []
|
||||
for day in sorted(temperatures_by_day):
|
||||
values = temperatures_by_day[day]
|
||||
summaries.append(DailySummary(day, max(values), min(values), sum(values) / len(values)))
|
||||
return summaries
|
||||
|
||||
|
||||
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
candidates = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf",
|
||||
"C:/Windows/Fonts/calibrib.ttf" if bold else "C:/Windows/Fonts/calibri.ttf",
|
||||
]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_chart(summary: list[DailySummary], title: str, output_path: Path) -> None:
|
||||
width, height = 1050, 310
|
||||
left, top, right, bottom = 64, 31, 28, 56
|
||||
plot_w = width - left - right
|
||||
plot_h = height - top - bottom
|
||||
image = Image.new("RGB", (width, height), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
series = [
|
||||
("High", [row.high for row in summary], (21, 101, 150)),
|
||||
("Min", [row.low for row in summary], (221, 107, 45)),
|
||||
("Average", [row.average for row in summary], (29, 111, 54)),
|
||||
]
|
||||
values = [value for _, values, _ in series for value in values]
|
||||
y_min = max(0, int(min(values) // 5 * 5))
|
||||
y_max = int((max(values) + 4.999) // 5 * 5)
|
||||
if y_max == y_min:
|
||||
y_max += 5
|
||||
|
||||
title_font = font(22, True)
|
||||
label_font = font(15)
|
||||
small_font = font(12)
|
||||
draw.text((width // 2, 8), title, fill=(30, 41, 51), font=title_font, anchor="ma")
|
||||
|
||||
def x_pos(idx: int) -> float:
|
||||
if len(summary) == 1:
|
||||
return left + plot_w / 2
|
||||
return left + idx * plot_w / (len(summary) - 1)
|
||||
|
||||
def y_pos(value: float) -> float:
|
||||
return top + (y_max - value) * plot_h / (y_max - y_min)
|
||||
|
||||
for tick in range(y_min, y_max + 1, 5):
|
||||
y = y_pos(tick)
|
||||
draw.line((left, y, width - right, y), fill=(215, 221, 226), width=1)
|
||||
draw.text((left - 12, y), f"{tick}", fill=(55, 65, 81), font=small_font, anchor="rm")
|
||||
|
||||
draw.line((left, top, left, top + plot_h), fill=(99, 110, 123), width=2)
|
||||
draw.line((left, top + plot_h, width - right, top + plot_h), fill=(99, 110, 123), width=2)
|
||||
|
||||
tick_indexes = list(range(0, len(summary), max(1, len(summary) // 7)))
|
||||
if len(summary) - 1 not in tick_indexes:
|
||||
tick_indexes.append(len(summary) - 1)
|
||||
for idx in tick_indexes:
|
||||
x = x_pos(idx)
|
||||
draw.line((x, top, x, top + plot_h), fill=(235, 238, 241), width=1)
|
||||
draw.text((x, top + plot_h + 11), summary[idx].day.strftime("%d %b"), fill=(55, 65, 81), font=small_font, anchor="ma")
|
||||
|
||||
for name, values, color in series:
|
||||
points = [(x_pos(idx), y_pos(value)) for idx, value in enumerate(values)]
|
||||
if len(points) > 1:
|
||||
draw.line(points, fill=color, width=4)
|
||||
for x, y in points:
|
||||
draw.ellipse((x - 3, y - 3, x + 3, y + 3), fill=color)
|
||||
|
||||
legend_x = left + 330
|
||||
legend_y = height - 24
|
||||
for name, _, color in series:
|
||||
draw.line((legend_x, legend_y, legend_x + 28, legend_y), fill=color, width=4)
|
||||
draw.text((legend_x + 36, legend_y), f"{name} C", fill=(30, 41, 51), font=label_font, anchor="lm")
|
||||
legend_x += 160
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(output_path)
|
||||
|
||||
|
||||
def make_stat_cards(device: ReportDevice, period_label: str, tz: ZoneInfo) -> Table:
|
||||
readings = device.readings
|
||||
summary = device.summary
|
||||
temperatures = [float(reading.temperature) for reading in readings if reading.temperature is not None]
|
||||
humidity_values = [float(reading.humidity) for reading in readings if reading.humidity is not None]
|
||||
start = readings[0].recorded_at.replace(tzinfo=timezone.utc).astimezone(tz).strftime("%d %b %Y %H:%M")
|
||||
end = readings[-1].recorded_at.replace(tzinfo=timezone.utc).astimezone(tz).strftime("%d %b %Y %H:%M")
|
||||
stats = [
|
||||
["Device", device.name, "Period", period_label],
|
||||
["Readings", f"{len(readings):,}", "Days", f"{len(summary)}"],
|
||||
["First", start, "Last", end],
|
||||
["Highest", f"{max(temperatures):.1f} C", "Lowest", f"{min(temperatures):.1f} C"],
|
||||
["Average", f"{sum(temperatures) / len(temperatures):.1f} C", "Avg RH", format_average(humidity_values, "%")],
|
||||
]
|
||||
table = Table(stats, colWidths=[22 * mm, 47 * mm, 20 * mm, 52 * mm])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#D9EAF7")),
|
||||
("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#1F2933")),
|
||||
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (2, 0), (2, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 7.5),
|
||||
("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#C9D7E5")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F6FAFD")]),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def make_daily_table(summary: list[DailySummary]) -> Table:
|
||||
data = daily_table_data(summary)
|
||||
if len(data) <= 18:
|
||||
return style_daily_table(Table(data, colWidths=[38 * mm, 28 * mm, 28 * mm, 32 * mm], repeatRows=1))
|
||||
|
||||
headers, rows = data[0], data[1:]
|
||||
split_at = (len(rows) + 1) // 2
|
||||
left = [headers] + rows[:split_at]
|
||||
right = [headers] + rows[split_at:]
|
||||
left_table = style_daily_table(Table(left, colWidths=[35 * mm, 25 * mm, 25 * mm, 29 * mm], repeatRows=1))
|
||||
right_table = style_daily_table(Table(right, colWidths=[35 * mm, 25 * mm, 25 * mm, 29 * mm], repeatRows=1))
|
||||
wrapper = Table([[left_table, right_table]], colWidths=[124 * mm, 124 * mm])
|
||||
wrapper.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
return wrapper
|
||||
|
||||
|
||||
def daily_table_data(summary: list[DailySummary]) -> list[list[str]]:
|
||||
data = [["Date", "High C", "Min C", "Average C"]]
|
||||
for row in summary:
|
||||
data.append([row.day.isoformat(), f"{row.high:.1f}", f"{row.low:.1f}", f"{row.average:.1f}"])
|
||||
return data
|
||||
|
||||
|
||||
def style_daily_table(table: Table) -> Table:
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4472C4")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 7.6),
|
||||
("ALIGN", (1, 1), (-1, -1), "RIGHT"),
|
||||
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#E0E7EF")),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#D7F0FA")]),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def footer(canvas, doc) -> None:
|
||||
canvas.saveState()
|
||||
canvas.setFont("Helvetica", 8)
|
||||
canvas.setFillColor(colors.HexColor("#667085"))
|
||||
canvas.drawRightString(285 * mm, 9 * mm, f"Page {doc.page}")
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def format_period(start_date: date, end_date: date) -> str:
|
||||
if start_date == end_date:
|
||||
return start_date.strftime("%d %B %Y")
|
||||
if start_date.year == end_date.year and start_date.month == end_date.month:
|
||||
return f"{start_date:%d} to {end_date:%d %B %Y}"
|
||||
return f"{start_date:%d %B %Y} to {end_date:%d %B %Y}"
|
||||
|
||||
|
||||
def format_average(values: list[float], suffix: str) -> str:
|
||||
if not values:
|
||||
return "n/a"
|
||||
return f"{sum(values) / len(values):.1f}{suffix}"
|
||||
|
||||
|
||||
def safe_filename(value: str) -> str:
|
||||
safe = "".join(char.lower() if char.isalnum() else "-" for char in value)
|
||||
return "-".join(part for part in safe.split("-") if part) or "device"
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import config
|
||||
from app.db import init_db
|
||||
from app.reporting import due_jobs, send_scheduled_job
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_once() -> int:
|
||||
tz = ZoneInfo(config.app_timezone)
|
||||
now = datetime.now(tz)
|
||||
jobs = due_jobs(now)
|
||||
sent = 0
|
||||
|
||||
for job in jobs:
|
||||
logger.info(
|
||||
"Sending %s report for %s to %s",
|
||||
job.cadence,
|
||||
f"{job.start_date} to {job.end_date}",
|
||||
job.recipient_email,
|
||||
)
|
||||
try:
|
||||
if send_scheduled_job(job, tz):
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception("Scheduled report failed")
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
def main() -> None:
|
||||
init_db()
|
||||
poll_seconds = max(60, config.report_scheduler_poll_seconds)
|
||||
logger.info("Report scheduler started; polling every %s seconds", poll_seconds)
|
||||
|
||||
while True:
|
||||
run_once()
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.config import config
|
||||
from app.db import SessionLocal
|
||||
from app.models import ScheduledReport, ScheduledReportRun
|
||||
from app.pdf_reports import build_temperature_pdf, format_period
|
||||
from app.smtp2go import send_pdf_report
|
||||
from app.web_time import local_date_range_to_utc
|
||||
|
||||
|
||||
CADENCES = {"daily", "weekly", "monthly"}
|
||||
WEEKDAYS = {
|
||||
0: "Monday",
|
||||
1: "Tuesday",
|
||||
2: "Wednesday",
|
||||
3: "Thursday",
|
||||
4: "Friday",
|
||||
5: "Saturday",
|
||||
6: "Sunday",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportSendResult:
|
||||
pdf_path: Path
|
||||
period_label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduledReportJob:
|
||||
schedule_id: int
|
||||
recipient_email: str
|
||||
cadence: str
|
||||
start_date: date
|
||||
end_date: date
|
||||
device_id: str
|
||||
|
||||
|
||||
def send_temperature_report(
|
||||
session: Session,
|
||||
to_email: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
tz: ZoneInfo,
|
||||
device_id: str = "",
|
||||
cadence: str | None = None,
|
||||
) -> ReportSendResult:
|
||||
start_utc, end_utc = local_date_range_to_utc(start_date, end_date, tz)
|
||||
period_label = format_period(start_date, end_date)
|
||||
pdf_path = build_temperature_pdf(
|
||||
session=session,
|
||||
output_dir=Path(config.report_output_dir),
|
||||
chart_dir=Path(config.report_output_dir) / "charts",
|
||||
start_utc=start_utc,
|
||||
end_utc=end_utc,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
tz=tz,
|
||||
device_id=device_id,
|
||||
)
|
||||
cadence_label = f"{cadence.title()} " if cadence else ""
|
||||
send_pdf_report(
|
||||
config=config,
|
||||
to_email=to_email,
|
||||
subject=f"{cadence_label}Temperature report - {period_label}",
|
||||
body=f"Attached is the SwitchBot temperature report for {period_label}.",
|
||||
pdf_path=pdf_path,
|
||||
)
|
||||
return ReportSendResult(pdf_path=pdf_path, period_label=period_label)
|
||||
|
||||
|
||||
def due_jobs(now: datetime) -> list[ScheduledReportJob]:
|
||||
with SessionLocal() as session:
|
||||
schedules = list(
|
||||
session.scalars(
|
||||
select(ScheduledReport)
|
||||
.options(joinedload(ScheduledReport.recipient))
|
||||
.where(ScheduledReport.active.is_(True))
|
||||
.order_by(ScheduledReport.id)
|
||||
)
|
||||
)
|
||||
jobs = []
|
||||
for schedule in schedules:
|
||||
if not schedule.recipient.active:
|
||||
continue
|
||||
period = due_period(schedule, now)
|
||||
if period is None:
|
||||
continue
|
||||
start_date, end_date = period
|
||||
if has_run(session, schedule.id, start_date, end_date):
|
||||
continue
|
||||
jobs.append(
|
||||
ScheduledReportJob(
|
||||
schedule_id=schedule.id,
|
||||
recipient_email=schedule.recipient.email,
|
||||
cadence=schedule.cadence,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
device_id=schedule.device_id or "",
|
||||
)
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def send_scheduled_job(job: ScheduledReportJob, tz: ZoneInfo) -> bool:
|
||||
run_id = reserve_run(job)
|
||||
if run_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
result = send_temperature_report(
|
||||
session=session,
|
||||
to_email=job.recipient_email,
|
||||
start_date=job.start_date,
|
||||
end_date=job.end_date,
|
||||
tz=tz,
|
||||
device_id=job.device_id,
|
||||
cadence=job.cadence,
|
||||
)
|
||||
except Exception as exc:
|
||||
mark_run_failed(run_id, exc)
|
||||
raise
|
||||
|
||||
mark_run_sent(run_id, result.pdf_path)
|
||||
return True
|
||||
|
||||
|
||||
def reserve_run(job: ScheduledReportJob) -> int | None:
|
||||
with SessionLocal() as session:
|
||||
run = ScheduledReportRun(
|
||||
scheduled_report_id=job.schedule_id,
|
||||
period_start=job.start_date,
|
||||
period_end=job.end_date,
|
||||
status="running",
|
||||
)
|
||||
session.add(run)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
return None
|
||||
return run.id
|
||||
|
||||
|
||||
def mark_run_sent(run_id: int, pdf_path: Path) -> None:
|
||||
with SessionLocal() as session:
|
||||
run = session.get(ScheduledReportRun, run_id)
|
||||
if run is None:
|
||||
return
|
||||
run.status = "sent"
|
||||
run.pdf_path = str(pdf_path)
|
||||
run.sent_at = datetime.utcnow()
|
||||
run.error = None
|
||||
session.commit()
|
||||
|
||||
|
||||
def mark_run_failed(run_id: int, exc: Exception) -> None:
|
||||
with SessionLocal() as session:
|
||||
run = session.get(ScheduledReportRun, run_id)
|
||||
if run is None:
|
||||
return
|
||||
run.status = "failed"
|
||||
run.error = str(exc)[:1024]
|
||||
session.commit()
|
||||
|
||||
|
||||
def has_run(session: Session, schedule_id: int, start_date: date, end_date: date) -> bool:
|
||||
return (
|
||||
session.scalar(
|
||||
select(ScheduledReportRun.id).where(
|
||||
ScheduledReportRun.scheduled_report_id == schedule_id,
|
||||
ScheduledReportRun.period_start == start_date,
|
||||
ScheduledReportRun.period_end == end_date,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def due_period(schedule: ScheduledReport, now: datetime) -> tuple[date, date] | None:
|
||||
if schedule.cadence not in CADENCES:
|
||||
return None
|
||||
if now.time() < parse_send_time(schedule.send_time):
|
||||
return None
|
||||
|
||||
today = now.date()
|
||||
if schedule.cadence == "daily":
|
||||
day = today - timedelta(days=1)
|
||||
return day, day
|
||||
|
||||
if schedule.cadence == "weekly":
|
||||
weekday = schedule.weekday if schedule.weekday is not None else 0
|
||||
if now.weekday() != weekday:
|
||||
return None
|
||||
return today - timedelta(days=7), today - timedelta(days=1)
|
||||
|
||||
monthday = schedule.monthday if schedule.monthday is not None else 1
|
||||
due_day = min(monthday, calendar.monthrange(today.year, today.month)[1])
|
||||
if today.day != due_day:
|
||||
return None
|
||||
first_this_month = today.replace(day=1)
|
||||
previous_month_end = first_this_month - timedelta(days=1)
|
||||
previous_month_start = previous_month_end.replace(day=1)
|
||||
return previous_month_start, previous_month_end
|
||||
|
||||
|
||||
def parse_send_time(value: str) -> time:
|
||||
try:
|
||||
return time.fromisoformat(value)
|
||||
except ValueError:
|
||||
return time(hour=8)
|
||||
|
||||
|
||||
def cadence_label(schedule: ScheduledReport) -> str:
|
||||
if schedule.cadence == "daily":
|
||||
return f"Daily at {schedule.send_time}"
|
||||
if schedule.cadence == "weekly":
|
||||
weekday = WEEKDAYS.get(schedule.weekday if schedule.weekday is not None else 0, "Monday")
|
||||
return f"Weekly on {weekday} at {schedule.send_time}"
|
||||
monthday = schedule.monthday if schedule.monthday is not None else 1
|
||||
return f"Monthly on day {monthday} at {schedule.send_time}"
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from app.config import Config
|
||||
|
||||
|
||||
class EmailConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def send_pdf_report(
|
||||
config: Config,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
pdf_path: Path,
|
||||
) -> dict[str, object]:
|
||||
if not config.smtp2go_api_key:
|
||||
raise EmailConfigurationError("SMTP2GO_API_KEY is not configured.")
|
||||
if not config.report_sender_email:
|
||||
raise EmailConfigurationError("REPORT_SENDER_EMAIL is not configured.")
|
||||
|
||||
sender = config.report_sender_email
|
||||
if config.report_sender_name:
|
||||
sender = f"{config.report_sender_name} <{config.report_sender_email}>"
|
||||
|
||||
payload = {
|
||||
"api_key": config.smtp2go_api_key,
|
||||
"sender": sender,
|
||||
"to": [to_email],
|
||||
"subject": subject,
|
||||
"text_body": body,
|
||||
"attachments": [
|
||||
{
|
||||
"filename": pdf_path.name,
|
||||
"fileblob": base64.b64encode(pdf_path.read_bytes()).decode("ascii"),
|
||||
"mimetype": "application/pdf",
|
||||
}
|
||||
],
|
||||
}
|
||||
response = requests.post(
|
||||
config.smtp2go_api_url,
|
||||
json=payload,
|
||||
timeout=config.smtp2go_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("data", {}).get("succeeded") == 0:
|
||||
failures = data.get("data", {}).get("failures") or []
|
||||
raise RuntimeError(f"SMTP2GO did not send the report: {failures}")
|
||||
return data
|
||||
@@ -144,6 +144,40 @@ h2 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.button.small {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.flash-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flash.success {
|
||||
border-color: #a7d7b4;
|
||||
background: #edf8f0;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.flash.error {
|
||||
border-color: #efb0a4;
|
||||
background: #fff1ee;
|
||||
color: #8a2c1b;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -275,6 +309,15 @@ h2 {
|
||||
grid-template-columns: minmax(180px, 240px) auto;
|
||||
}
|
||||
|
||||
.report-email-panel {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.email-report-form {
|
||||
grid-template-columns: minmax(240px, 360px) auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -283,6 +326,33 @@ h2 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.7fr);
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.settings-form.schedule-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
min-height: 42px;
|
||||
@@ -338,6 +408,12 @@ th {
|
||||
.device-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.settings-form,
|
||||
.settings-form.schedule-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
@@ -360,6 +436,9 @@ th {
|
||||
.cards,
|
||||
.report-form,
|
||||
.report-form.compact,
|
||||
.settings-form,
|
||||
.settings-form.schedule-form,
|
||||
.settings-grid,
|
||||
.device-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,20 @@
|
||||
<nav class="nav">
|
||||
<a href="/" {% if request.path == "/" %}aria-current="page"{% endif %}>Dashboard</a>
|
||||
<a href="/reports" {% if request.path == "/reports" %}aria-current="page"{% endif %}>Reports</a>
|
||||
<a href="/report-settings" {% if request.path == "/report-settings" %}aria-current="page"{% endif %}>Settings</a>
|
||||
</nav>
|
||||
<p class="refresh-status" id="refreshStatus" aria-live="polite"></p>
|
||||
</header>
|
||||
<main class="page">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack" aria-live="polite">
|
||||
{% for category, message in messages %}
|
||||
<p class="flash {{ category }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='auto_refresh.js') }}"></script>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Report Settings - SwitchBot Temps{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Report settings</p>
|
||||
<h1>Scheduled reports</h1>
|
||||
<p class="muted">Manage recipients and recurring PDF report schedules.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-grid">
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Recipients</h2>
|
||||
<p class="muted">Email addresses that can receive scheduled reports.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="settings-form" method="post" action="/report-settings/recipients">
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" placeholder="recipient@example.com" required>
|
||||
</label>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" placeholder="Optional">
|
||||
</label>
|
||||
<button class="button" type="submit">Add recipient</button>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for recipient in recipients %}
|
||||
<tr>
|
||||
<td>{{ recipient.email }}</td>
|
||||
<td>{{ recipient.name or "n/a" }}</td>
|
||||
<td>{{ "Active" if recipient.active else "Paused" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/report-settings/recipients/{{ recipient.id }}/toggle">
|
||||
<button class="button secondary small" type="submit">{{ "Pause" if recipient.active else "Resume" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="empty">No recipients configured.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Add schedule</h2>
|
||||
<p class="muted">Schedules send the previous complete day, week, or month.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="settings-form schedule-form" method="post" action="/report-settings/schedules">
|
||||
<label>
|
||||
Recipient
|
||||
<select name="recipient_id" required>
|
||||
<option value="">Choose recipient</option>
|
||||
{% for recipient in recipients %}
|
||||
<option value="{{ recipient.id }}">{{ recipient.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Cadence
|
||||
<select name="cadence" required>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Time
|
||||
<input type="time" name="send_time" value="08:00" required>
|
||||
</label>
|
||||
<label>
|
||||
Weekday
|
||||
<select name="weekday">
|
||||
{% for value, label in weekdays %}
|
||||
<option value="{{ value }}" {% if value == 0 %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Month day
|
||||
<input type="number" name="monthday" min="1" max="31" value="1">
|
||||
</label>
|
||||
<label>
|
||||
Device
|
||||
<select name="device_id">
|
||||
<option value="">All devices</option>
|
||||
{% for device in devices %}
|
||||
<option value="{{ device.id }}">{{ device.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Add schedule</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Active schedules</h2>
|
||||
<p class="muted">Each schedule is guarded by a database run record, so a period is sent once.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recipient</th>
|
||||
<th>Cadence</th>
|
||||
<th>Device</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for schedule in schedules %}
|
||||
<tr>
|
||||
<td>{{ schedule.recipient.email }}</td>
|
||||
<td>{{ schedule_labels[schedule.id] }}</td>
|
||||
<td>{{ schedule.device.name if schedule.device else "All devices" }}</td>
|
||||
<td>{{ "Active" if schedule.active else "Paused" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/report-settings/schedules/{{ schedule.id }}/toggle">
|
||||
<button class="button secondary small" type="submit">{{ "Pause" if schedule.active else "Resume" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="empty">No scheduled reports configured.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Recent runs</h2>
|
||||
<p class="muted">The scheduler writes one run record per schedule and report period.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>Recipient</th>
|
||||
<th>Cadence</th>
|
||||
<th>Period</th>
|
||||
<th>Status</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr>
|
||||
<td>{{ run.created_at }}</td>
|
||||
<td>{{ run.scheduled_report.recipient.email }}</td>
|
||||
<td>{{ run.scheduled_report.cadence|title }}</td>
|
||||
<td>{{ run.period_start }} to {{ run.period_end }}</td>
|
||||
<td>{{ run.status|title }}</td>
|
||||
<td>{{ run.error or "" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="empty">No scheduled report runs yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Reports</p>
|
||||
<h1>Build a temperature report</h1>
|
||||
<p class="muted">Choose a date range and export the summary as CSV.</p>
|
||||
<p class="muted">Choose a date range, export CSV, or send a PDF report.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -33,6 +33,25 @@
|
||||
<a class="button secondary" href="/reports.csv?start={{ start }}&end={{ end }}&device_id={{ device_id }}">Download CSV</a>
|
||||
</form>
|
||||
|
||||
<section class="panel report-email-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>Email PDF report</h2>
|
||||
<p class="muted">Creates a PDF using the selected range and device filter, then sends it via SMTP2GO.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="report-form email-report-form" method="post" action="/reports/email-pdf">
|
||||
<input type="hidden" name="start" value="{{ start }}">
|
||||
<input type="hidden" name="end" value="{{ end }}">
|
||||
<input type="hidden" name="device_id" value="{{ device_id }}">
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" placeholder="recipient@example.com" required>
|
||||
</label>
|
||||
<button class="button" type="submit">Create and email PDF</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
|
||||
+223
-12
@@ -3,16 +3,20 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from datetime import date, datetime, time, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Flask, Response, render_template, request
|
||||
from flask import Flask, Response, flash, redirect, render_template, request, url_for
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.config import config
|
||||
from app.db import SessionLocal, init_db
|
||||
from app.models import Device, Reading
|
||||
from app.models import Device, Reading, ReportRecipient, ScheduledReport, ScheduledReportRun
|
||||
from app.reporting import cadence_label, send_temperature_report
|
||||
from app.smtp2go import EmailConfigurationError
|
||||
from app.web_time import local_date_range_to_utc
|
||||
|
||||
SENSOR_DEVICE_TYPES = {"WoIOSensor"}
|
||||
|
||||
@@ -152,18 +156,195 @@ def create_app() -> Flask:
|
||||
headers={"Content-Disposition": "attachment; filename=switchbot-report.csv"},
|
||||
)
|
||||
|
||||
@app.post("/reports/email-pdf")
|
||||
def email_pdf_report() -> Response:
|
||||
tz = ZoneInfo(config.app_timezone)
|
||||
start_text = request.form.get("start") or datetime.now(tz).date().isoformat()
|
||||
end_text = request.form.get("end") or start_text
|
||||
device_id = request.form.get("device_id") or ""
|
||||
to_email = (request.form.get("email") or "").strip()
|
||||
|
||||
if not to_email:
|
||||
flash("Enter an email address for the PDF report.", "error")
|
||||
return redirect(url_for("reports", start=start_text, end=end_text, device_id=device_id))
|
||||
|
||||
try:
|
||||
start_date = date.fromisoformat(start_text)
|
||||
end_date = date.fromisoformat(end_text)
|
||||
except ValueError:
|
||||
flash("Choose a valid report date range.", "error")
|
||||
return redirect(url_for("reports"))
|
||||
|
||||
if end_date < start_date:
|
||||
flash("Report end date must be on or after the start date.", "error")
|
||||
return redirect(url_for("reports", start=start_text, end=end_text, device_id=device_id))
|
||||
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
result = send_temperature_report(
|
||||
session=session,
|
||||
to_email=to_email,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
tz=tz,
|
||||
device_id=device_id,
|
||||
)
|
||||
except EmailConfigurationError as exc:
|
||||
flash(str(exc), "error")
|
||||
except Exception as exc:
|
||||
flash(f"Could not create or send the PDF report: {exc}", "error")
|
||||
else:
|
||||
flash(f"Sent PDF report for {result.period_label} to {to_email}.", "success")
|
||||
|
||||
return redirect(url_for("reports", start=start_text, end=end_text, device_id=device_id))
|
||||
|
||||
@app.get("/report-settings")
|
||||
def report_settings() -> str:
|
||||
with SessionLocal() as session:
|
||||
recipients = list(session.scalars(select(ReportRecipient).order_by(ReportRecipient.email)))
|
||||
schedules = list(
|
||||
session.scalars(
|
||||
select(ScheduledReport)
|
||||
.options(joinedload(ScheduledReport.recipient), joinedload(ScheduledReport.device))
|
||||
.order_by(ScheduledReport.cadence, ScheduledReport.send_time, ScheduledReport.id)
|
||||
)
|
||||
)
|
||||
devices = list(
|
||||
session.scalars(
|
||||
select(Device)
|
||||
.where(Device.device_type.in_(SENSOR_DEVICE_TYPES))
|
||||
.order_by(Device.name)
|
||||
)
|
||||
)
|
||||
runs = list(
|
||||
session.scalars(
|
||||
select(ScheduledReportRun)
|
||||
.options(
|
||||
joinedload(ScheduledReportRun.scheduled_report).joinedload(ScheduledReport.recipient),
|
||||
joinedload(ScheduledReportRun.scheduled_report).joinedload(ScheduledReport.device),
|
||||
)
|
||||
.order_by(ScheduledReportRun.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"report_settings.html",
|
||||
recipients=recipients,
|
||||
schedules=schedules,
|
||||
devices=devices,
|
||||
runs=runs,
|
||||
schedule_labels={schedule.id: cadence_label(schedule) for schedule in schedules},
|
||||
weekdays=[
|
||||
(0, "Monday"),
|
||||
(1, "Tuesday"),
|
||||
(2, "Wednesday"),
|
||||
(3, "Thursday"),
|
||||
(4, "Friday"),
|
||||
(5, "Saturday"),
|
||||
(6, "Sunday"),
|
||||
],
|
||||
)
|
||||
|
||||
@app.post("/report-settings/recipients")
|
||||
def add_report_recipient() -> Response:
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
name = (request.form.get("name") or "").strip() or None
|
||||
if not email or "@" not in email:
|
||||
flash("Enter a valid recipient email address.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
with SessionLocal() as session:
|
||||
session.add(ReportRecipient(email=email, name=name))
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
flash("That recipient already exists.", "error")
|
||||
else:
|
||||
flash(f"Added report recipient {email}.", "success")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
@app.post("/report-settings/recipients/<int:recipient_id>/toggle")
|
||||
def toggle_report_recipient(recipient_id: int) -> Response:
|
||||
with SessionLocal() as session:
|
||||
recipient = session.get(ReportRecipient, recipient_id)
|
||||
if recipient is None:
|
||||
flash("Recipient not found.", "error")
|
||||
else:
|
||||
recipient.active = not recipient.active
|
||||
session.commit()
|
||||
flash(f"{'Enabled' if recipient.active else 'Paused'} {recipient.email}.", "success")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
@app.post("/report-settings/schedules")
|
||||
def add_scheduled_report() -> Response:
|
||||
recipient_id = request.form.get("recipient_id", type=int)
|
||||
cadence = (request.form.get("cadence") or "").strip().lower()
|
||||
send_time = (request.form.get("send_time") or "08:00").strip()
|
||||
device_id = (request.form.get("device_id") or "").strip() or None
|
||||
|
||||
if recipient_id is None or cadence not in {"daily", "weekly", "monthly"}:
|
||||
flash("Choose a recipient and cadence for the scheduled report.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
if not valid_send_time(send_time):
|
||||
flash("Use a valid send time in HH:MM format.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
weekday = None
|
||||
monthday = None
|
||||
if cadence == "weekly":
|
||||
weekday = request.form.get("weekday", type=int)
|
||||
if weekday is None or weekday < 0 or weekday > 6:
|
||||
flash("Choose a weekday for weekly reports.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
if cadence == "monthly":
|
||||
monthday = request.form.get("monthday", type=int)
|
||||
if monthday is None or monthday < 1 or monthday > 31:
|
||||
flash("Choose a day from 1 to 31 for monthly reports.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
with SessionLocal() as session:
|
||||
recipient = session.get(ReportRecipient, recipient_id)
|
||||
if recipient is None:
|
||||
flash("Recipient not found.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
if device_id and session.get(Device, device_id) is None:
|
||||
flash("Device not found.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
if scheduled_report_exists(session, recipient_id, cadence, send_time, weekday, monthday, device_id):
|
||||
flash("That scheduled report already exists.", "error")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
session.add(
|
||||
ScheduledReport(
|
||||
recipient_id=recipient_id,
|
||||
cadence=cadence,
|
||||
send_time=send_time,
|
||||
weekday=weekday,
|
||||
monthday=monthday,
|
||||
device_id=device_id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
flash("Added scheduled report.", "success")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
@app.post("/report-settings/schedules/<int:schedule_id>/toggle")
|
||||
def toggle_scheduled_report(schedule_id: int) -> Response:
|
||||
with SessionLocal() as session:
|
||||
schedule = session.get(ScheduledReport, schedule_id)
|
||||
if schedule is None:
|
||||
flash("Scheduled report not found.", "error")
|
||||
else:
|
||||
schedule.active = not schedule.active
|
||||
session.commit()
|
||||
flash(f"{'Enabled' if schedule.active else 'Paused'} scheduled report.", "success")
|
||||
return redirect(url_for("report_settings"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def local_date_range_to_utc(start: date, end: date, tz: ZoneInfo) -> tuple[datetime, datetime]:
|
||||
local_start = datetime.combine(start, time.min, tzinfo=tz)
|
||||
local_end = datetime.combine(end + timedelta(days=1), time.min, tzinfo=tz)
|
||||
return (
|
||||
local_start.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
local_end.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def load_readings(session: Session, start_utc: datetime, end_utc: datetime) -> list[Reading]:
|
||||
stmt = (
|
||||
select(Reading)
|
||||
@@ -336,6 +517,36 @@ def report_rows(
|
||||
return rows
|
||||
|
||||
|
||||
def valid_send_time(value: str) -> bool:
|
||||
try:
|
||||
time.fromisoformat(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return len(value) == 5
|
||||
|
||||
|
||||
def scheduled_report_exists(
|
||||
session: Session,
|
||||
recipient_id: int,
|
||||
cadence: str,
|
||||
send_time: str,
|
||||
weekday: int | None,
|
||||
monthday: int | None,
|
||||
device_id: str | None,
|
||||
) -> bool:
|
||||
schedules = session.scalars(
|
||||
select(ScheduledReport).where(
|
||||
ScheduledReport.recipient_id == recipient_id,
|
||||
ScheduledReport.cadence == cadence,
|
||||
ScheduledReport.send_time == send_time,
|
||||
)
|
||||
)
|
||||
for schedule in schedules:
|
||||
if schedule.weekday == weekday and schedule.monthday == monthday and schedule.device_id == device_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def rounded(value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def local_date_range_to_utc(start: date, end: date, tz: ZoneInfo) -> tuple[datetime, datetime]:
|
||||
local_start = datetime.combine(start, time.min, tzinfo=tz)
|
||||
local_end = datetime.combine(end + timedelta(days=1), time.min, tzinfo=tz)
|
||||
return (
|
||||
local_start.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
local_end.astimezone(timezone.utc).replace(tzinfo=None),
|
||||
)
|
||||
Reference in New Issue
Block a user