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
+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