Files
2026-06-18 21:07:34 +01:00

233 lines
6.9 KiB
Python

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}"