233 lines
6.9 KiB
Python
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}"
|