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