Report PDF emailing
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user