diff --git a/.env.example b/.env.example index 2bf952e..fb9553f 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,12 @@ FLASK_SECRET_KEY=replace-with-a-random-string # Optional: healthcheck.io-style heartbeat URL # On success: GET | On failure: POST /fail 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 diff --git a/README.md b/README.md index 54d11a9..39f1aa7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Dockerised SwitchBot temperature monitor with: - Configurable collection interval, defaulting to 15 minutes. - Day-so-far temperature graph with high/low cards. - Date-range reports with CSV export. +- PDF report generation and manual email sending through SMTP2GO. ## 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 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 - `db`: MySQL 8.4 with persistent `mysql_data` volume. - `web`: Flask app served by Gunicorn on port 8000. +- `report-scheduler`: recurring PDF report sender. - `collector`: SwitchBot polling loop. ## POC Script diff --git a/app/config.py b/app/config.py index b14f263..65b42b4 100644 --- a/app/config.py +++ b/app/config.py @@ -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() diff --git a/app/models.py b/app/models.py index b79358e..72582a6 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/pdf_reports.py b/app/pdf_reports.py new file mode 100644 index 0000000..9bb7fed --- /dev/null +++ b/app/pdf_reports.py @@ -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" diff --git a/app/report_scheduler.py b/app/report_scheduler.py new file mode 100644 index 0000000..7b40de4 --- /dev/null +++ b/app/report_scheduler.py @@ -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() diff --git a/app/reporting.py b/app/reporting.py new file mode 100644 index 0000000..afa0dcc --- /dev/null +++ b/app/reporting.py @@ -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}" diff --git a/app/smtp2go.py b/app/smtp2go.py new file mode 100644 index 0000000..4cfd6fa --- /dev/null +++ b/app/smtp2go.py @@ -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 diff --git a/app/static/styles.css b/app/static/styles.css index 52efe22..40e6402 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; } diff --git a/app/templates/base.html b/app/templates/base.html index 5c39e9d..7cc6713 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -15,10 +15,20 @@

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} {% block content %}{% endblock %}
diff --git a/app/templates/report_settings.html b/app/templates/report_settings.html new file mode 100644 index 0000000..98fbfcb --- /dev/null +++ b/app/templates/report_settings.html @@ -0,0 +1,202 @@ +{% extends "base.html" %} + +{% block title %}Report Settings - SwitchBot Temps{% endblock %} + +{% block content %} +
+
+

Report settings

+

Scheduled reports

+

Manage recipients and recurring PDF report schedules.

+
+
+ +
+
+
+
+

Recipients

+

Email addresses that can receive scheduled reports.

+
+
+ +
+ + + +
+ +
+ + + + + + + + + + + {% for recipient in recipients %} + + + + + + + {% else %} + + + + {% endfor %} + +
EmailNameStatus
{{ recipient.email }}{{ recipient.name or "n/a" }}{{ "Active" if recipient.active else "Paused" }} +
+ +
+
No recipients configured.
+
+
+ +
+
+
+

Add schedule

+

Schedules send the previous complete day, week, or month.

+
+
+ +
+ + + + + + + +
+
+
+ +
+
+
+

Active schedules

+

Each schedule is guarded by a database run record, so a period is sent once.

+
+
+
+ + + + + + + + + + + + {% for schedule in schedules %} + + + + + + + + {% else %} + + + + {% endfor %} + +
RecipientCadenceDeviceStatus
{{ schedule.recipient.email }}{{ schedule_labels[schedule.id] }}{{ schedule.device.name if schedule.device else "All devices" }}{{ "Active" if schedule.active else "Paused" }} +
+ +
+
No scheduled reports configured.
+
+
+ +
+
+
+

Recent runs

+

The scheduler writes one run record per schedule and report period.

+
+
+
+ + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
CreatedRecipientCadencePeriodStatusError
{{ run.created_at }}{{ run.scheduled_report.recipient.email }}{{ run.scheduled_report.cadence|title }}{{ run.period_start }} to {{ run.period_end }}{{ run.status|title }}{{ run.error or "" }}
No scheduled report runs yet.
+
+
+{% endblock %} diff --git a/app/templates/reports.html b/app/templates/reports.html index 147bfaa..f5c6531 100644 --- a/app/templates/reports.html +++ b/app/templates/reports.html @@ -7,7 +7,7 @@

Reports

Build a temperature report

-

Choose a date range and export the summary as CSV.

+

Choose a date range, export CSV, or send a PDF report.

@@ -33,6 +33,25 @@ Download CSV +
+
+
+

Email PDF report

+

Creates a PDF using the selected range and device filter, then sends it via SMTP2GO.

+
+
+ +
+
diff --git a/app/web.py b/app/web.py index e9b7cc9..e9bf303 100644 --- a/app/web.py +++ b/app/web.py @@ -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//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//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 diff --git a/app/web_time.py b/app/web_time.py new file mode 100644 index 0000000..b9fc91f --- /dev/null +++ b/app/web_time.py @@ -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), + ) diff --git a/docker-compose.yml b/docker-compose.yml index 401a0cb..c0901ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,13 @@ services: COLLECT_INTERVAL_SECONDS: ${COLLECT_INTERVAL_SECONDS:-900} APP_TIMEZONE: ${APP_TIMEZONE:-Europe/London} 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: db: condition: service_healthy @@ -31,6 +38,24 @@ services: - default - 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: build: . command: ["python", "-m", "app.collector"] @@ -52,4 +77,4 @@ volumes: networks: default: webapps: - external: true \ No newline at end of file + external: true diff --git a/requirements.txt b/requirements.txt index 904213d..5e924f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,12 @@ Flask==3.0.3 cryptography==43.0.0 gunicorn==22.0.0 +Pillow==10.4.0 PyMySQL==1.1.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 tzdata>=2024.1