Report PDF emailing
This commit is contained in:
@@ -13,3 +13,12 @@ FLASK_SECRET_KEY=replace-with-a-random-string
|
|||||||
# Optional: healthcheck.io-style heartbeat URL
|
# Optional: healthcheck.io-style heartbeat URL
|
||||||
# On success: GET <url> | On failure: POST <url>/fail
|
# On success: GET <url> | On failure: POST <url>/fail
|
||||||
HEALTHCHECK_URL=https://health.pattinson.org/ping/2009942a-9877-411e-89b4-3d17382c8286
|
HEALTHCHECK_URL=https://health.pattinson.org/ping/2009942a-9877-411e-89b4-3d17382c8286
|
||||||
|
|
||||||
|
# PDF report email settings
|
||||||
|
REPORT_OUTPUT_DIR=/tmp/switchbot-reports
|
||||||
|
REPORT_SENDER_EMAIL=reports@example.com
|
||||||
|
REPORT_SENDER_NAME=SwitchBot Temps
|
||||||
|
SMTP2GO_API_KEY=replace-me
|
||||||
|
SMTP2GO_API_URL=https://api.smtp2go.com/v3/email/send
|
||||||
|
SMTP2GO_TIMEOUT_SECONDS=20
|
||||||
|
REPORT_SCHEDULER_POLL_SECONDS=300
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Dockerised SwitchBot temperature monitor with:
|
|||||||
- Configurable collection interval, defaulting to 15 minutes.
|
- Configurable collection interval, defaulting to 15 minutes.
|
||||||
- Day-so-far temperature graph with high/low cards.
|
- Day-so-far temperature graph with high/low cards.
|
||||||
- Date-range reports with CSV export.
|
- Date-range reports with CSV export.
|
||||||
|
- PDF report generation and manual email sending through SMTP2GO.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -65,10 +66,28 @@ scripts/remove_zero_readings.sh --dry-run
|
|||||||
Open `/reports` in the web app to generate a date-range summary. Use the
|
Open `/reports` in the web app to generate a date-range summary. Use the
|
||||||
download button for a CSV export.
|
download button for a CSV export.
|
||||||
|
|
||||||
|
The same page can create and email a PDF report for the selected date range and
|
||||||
|
device filter. Configure SMTP2GO in `.env` before using it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
REPORT_SENDER_EMAIL=reports@example.com
|
||||||
|
REPORT_SENDER_NAME="SwitchBot Temps"
|
||||||
|
SMTP2GO_API_KEY=your-smtp2go-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated PDFs and chart images are written under `REPORT_OUTPUT_DIR`, which
|
||||||
|
defaults to `/tmp/switchbot-reports` inside the web container.
|
||||||
|
|
||||||
|
Open `/report-settings` to manage scheduled report recipients and daily,
|
||||||
|
weekly, or monthly schedules. The `report-scheduler` service sends the previous
|
||||||
|
complete day, week, or month and records each period in the database before
|
||||||
|
sending so restarts do not duplicate emails.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
- `db`: MySQL 8.4 with persistent `mysql_data` volume.
|
- `db`: MySQL 8.4 with persistent `mysql_data` volume.
|
||||||
- `web`: Flask app served by Gunicorn on port 8000.
|
- `web`: Flask app served by Gunicorn on port 8000.
|
||||||
|
- `report-scheduler`: recurring PDF report sender.
|
||||||
- `collector`: SwitchBot polling loop.
|
- `collector`: SwitchBot polling loop.
|
||||||
|
|
||||||
## POC Script
|
## POC Script
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ class Config:
|
|||||||
app_timezone: str = os.getenv("APP_TIMEZONE", "Europe/London")
|
app_timezone: str = os.getenv("APP_TIMEZONE", "Europe/London")
|
||||||
flask_secret_key: str = os.getenv("FLASK_SECRET_KEY", "dev-only-secret")
|
flask_secret_key: str = os.getenv("FLASK_SECRET_KEY", "dev-only-secret")
|
||||||
healthcheck_url: str | None = os.getenv("HEALTHCHECK_URL")
|
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()
|
config = Config()
|
||||||
|
|||||||
+83
-2
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
@@ -26,6 +26,7 @@ class Device(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
readings: Mapped[list["Reading"]] = relationship(back_populates="device")
|
readings: Mapped[list["Reading"]] = relationship(back_populates="device")
|
||||||
|
scheduled_reports: Mapped[list["ScheduledReport"]] = relationship(back_populates="device")
|
||||||
|
|
||||||
|
|
||||||
class Reading(Base):
|
class Reading(Base):
|
||||||
@@ -44,3 +45,83 @@ class Reading(Base):
|
|||||||
version: Mapped[str | None] = mapped_column(String(32))
|
version: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
|
||||||
device: Mapped[Device] = relationship(back_populates="readings")
|
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);
|
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 {
|
.cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -275,6 +309,15 @@ h2 {
|
|||||||
grid-template-columns: minmax(180px, 240px) auto;
|
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 {
|
.report-form label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -283,6 +326,33 @@ h2 {
|
|||||||
font-weight: 700;
|
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,
|
input,
|
||||||
select {
|
select {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -338,6 +408,12 @@ th {
|
|||||||
.device-summary {
|
.device-summary {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-grid,
|
||||||
|
.settings-form,
|
||||||
|
.settings-form.schedule-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
@@ -360,6 +436,9 @@ th {
|
|||||||
.cards,
|
.cards,
|
||||||
.report-form,
|
.report-form,
|
||||||
.report-form.compact,
|
.report-form.compact,
|
||||||
|
.settings-form,
|
||||||
|
.settings-form.schedule-form,
|
||||||
|
.settings-grid,
|
||||||
.device-summary {
|
.device-summary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,20 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/" {% if request.path == "/" %}aria-current="page"{% endif %}>Dashboard</a>
|
<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="/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>
|
</nav>
|
||||||
<p class="refresh-status" id="refreshStatus" aria-live="polite"></p>
|
<p class="refresh-status" id="refreshStatus" aria-live="polite"></p>
|
||||||
</header>
|
</header>
|
||||||
<main class="page">
|
<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 %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<script src="{{ url_for('static', filename='auto_refresh.js') }}"></script>
|
<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>
|
<div>
|
||||||
<p class="eyebrow">Reports</p>
|
<p class="eyebrow">Reports</p>
|
||||||
<h1>Build a temperature report</h1>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -33,6 +33,25 @@
|
|||||||
<a class="button secondary" href="/reports.csv?start={{ start }}&end={{ end }}&device_id={{ device_id }}">Download CSV</a>
|
<a class="button secondary" href="/reports.csv?start={{ start }}&end={{ end }}&device_id={{ device_id }}">Download CSV</a>
|
||||||
</form>
|
</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">
|
<section class="panel">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
+223
-12
@@ -3,16 +3,20 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, time, timezone
|
||||||
from zoneinfo import ZoneInfo
|
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 import Select, func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import SessionLocal, init_db
|
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"}
|
SENSOR_DEVICE_TYPES = {"WoIOSensor"}
|
||||||
|
|
||||||
@@ -152,18 +156,195 @@ def create_app() -> Flask:
|
|||||||
headers={"Content-Disposition": "attachment; filename=switchbot-report.csv"},
|
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
|
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]:
|
def load_readings(session: Session, start_utc: datetime, end_utc: datetime) -> list[Reading]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Reading)
|
select(Reading)
|
||||||
@@ -336,6 +517,36 @@ def report_rows(
|
|||||||
return 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:
|
def rounded(value: float | None) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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),
|
||||||
|
)
|
||||||
@@ -24,6 +24,13 @@ services:
|
|||||||
COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-900}
|
COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-900}
|
||||||
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/London}
|
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/London}
|
||||||
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-dev-only-secret}
|
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-dev-only-secret}
|
||||||
|
REPORT_OUTPUT_DIR: ${REPORT_OUTPUT_DIR:-/tmp/switchbot-reports}
|
||||||
|
REPORT_SENDER_EMAIL: ${REPORT_SENDER_EMAIL:-}
|
||||||
|
REPORT_SENDER_NAME: ${REPORT_SENDER_NAME:-SwitchBot Temps}
|
||||||
|
SMTP2GO_API_KEY: ${SMTP2GO_API_KEY:-}
|
||||||
|
SMTP2GO_API_URL: ${SMTP2GO_API_URL:-https://api.smtp2go.com/v3/email/send}
|
||||||
|
SMTP2GO_TIMEOUT_SECONDS: ${SMTP2GO_TIMEOUT_SECONDS:-20}
|
||||||
|
REPORT_SCHEDULER_POLL_SECONDS: ${REPORT_SCHEDULER_POLL_SECONDS:-300}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -31,6 +38,24 @@ services:
|
|||||||
- default
|
- default
|
||||||
- webapps
|
- webapps
|
||||||
|
|
||||||
|
report-scheduler:
|
||||||
|
build: .
|
||||||
|
command: ["python", "-m", "app.report_scheduler"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-mysql+pymysql://${MYSQL_USER:-switchbot}:${MYSQL_PASSWORD:-switchbot_password}@db:3306/${MYSQL_DATABASE:-switchbot}}
|
||||||
|
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/London}
|
||||||
|
REPORT_OUTPUT_DIR: ${REPORT_OUTPUT_DIR:-/tmp/switchbot-reports}
|
||||||
|
REPORT_SENDER_EMAIL: ${REPORT_SENDER_EMAIL:-}
|
||||||
|
REPORT_SENDER_NAME: ${REPORT_SENDER_NAME:-SwitchBot Temps}
|
||||||
|
SMTP2GO_API_KEY: ${SMTP2GO_API_KEY:-}
|
||||||
|
SMTP2GO_API_URL: ${SMTP2GO_API_URL:-https://api.smtp2go.com/v3/email/send}
|
||||||
|
SMTP2GO_TIMEOUT_SECONDS: ${SMTP2GO_TIMEOUT_SECONDS:-20}
|
||||||
|
REPORT_SCHEDULER_POLL_SECONDS: ${REPORT_SCHEDULER_POLL_SECONDS:-300}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
collector:
|
collector:
|
||||||
build: .
|
build: .
|
||||||
command: ["python", "-m", "app.collector"]
|
command: ["python", "-m", "app.collector"]
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
Flask==3.0.3
|
Flask==3.0.3
|
||||||
cryptography==43.0.0
|
cryptography==43.0.0
|
||||||
gunicorn==22.0.0
|
gunicorn==22.0.0
|
||||||
|
Pillow==10.4.0
|
||||||
PyMySQL==1.1.1
|
PyMySQL==1.1.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
reportlab==4.2.2
|
||||||
|
requests==2.32.3
|
||||||
|
chardet==5.2.0
|
||||||
|
urllib3==2.2.2
|
||||||
SQLAlchemy==2.0.32
|
SQLAlchemy==2.0.32
|
||||||
tzdata>=2024.1
|
tzdata>=2024.1
|
||||||
|
|||||||
Reference in New Issue
Block a user