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