Report PDF emailing

This commit is contained in:
2026-06-18 21:07:34 +01:00
parent 7254719794
commit 364f4fe57e
16 changed files with 1428 additions and 16 deletions
+7
View File
@@ -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
View File
@@ -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")
+395
View File
@@ -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"
+50
View File
@@ -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()
+232
View File
@@ -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}"
+55
View File
@@ -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
+79
View File
@@ -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;
}
+10
View File
@@ -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>
+202
View File
@@ -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 %}
+20 -1
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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),
)