""" utils/scheduler.py — Daily completion report email scheduler. Runs a background daemon thread that wakes every minute, checks whether the configured send_time (HH:MM) has been reached today, and sends the summary report via SMTP if it hasn't been sent yet. Configuration (config.ini [email] section): enabled = true/false smtp_host = smtp.example.com smtp_port = 587 smtp_user = sender@example.com smtp_password= secret use_tls = true recipients = admin@example.com, manager@example.com send_time = 18:00 (24-hour HH:MM, local time) Call start() once after login succeeds (admin only). Call stop() on logout/shutdown. """ import configparser import datetime import logging import os import smtplib import threading from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText logger = logging.getLogger("scheduler") CONFIG_FILE = "config.ini" _scheduler_thread: "threading.Thread | None" = None _stop_event = threading.Event() # ─── Config helpers ─────────────────────────────────────────────────────────── def load_email_config() -> dict: from utils.config_crypto import decrypt_value cfg = configparser.ConfigParser() if not os.path.exists(CONFIG_FILE): return {} cfg.read(CONFIG_FILE, encoding="utf-8") if "email" not in cfg: return {} s = cfg["email"] return { "enabled": s.getboolean("enabled", fallback=False), "smtp_host": s.get("smtp_host", ""), "smtp_port": s.getint("smtp_port", fallback=587), "smtp_user": s.get("smtp_user", ""), "smtp_password": decrypt_value(s.get("smtp_password", "")), "use_tls": s.getboolean("use_tls", fallback=True), "recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()], "send_time": s.get("send_time", "18:00"), } def save_email_config(enabled: bool, smtp_host: str, smtp_port: int, smtp_user: str, smtp_password: str, use_tls: bool, recipients: str, send_time: str): from utils.config_crypto import encrypt_value cfg = configparser.ConfigParser() cfg.read(CONFIG_FILE, encoding="utf-8") cfg["email"] = { "enabled": str(enabled).lower(), "smtp_host": smtp_host, "smtp_port": str(smtp_port), "smtp_user": smtp_user, "smtp_password": encrypt_value(smtp_password), "use_tls": str(use_tls).lower(), "recipients": recipients, "send_time": send_time, } with open(CONFIG_FILE, "w", encoding="utf-8") as fh: cfg.write(fh) logger.info("Email configuration saved (password encrypted).") def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple: """ Attempt a connection without sending mail. Returns (success: bool, message: str). """ try: if use_tls: server = smtplib.SMTP(smtp_host, smtp_port, timeout=8) server.starttls() else: server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8) server.login(smtp_user, smtp_password) server.quit() return True, "Connection successful." except Exception as e: return False, str(e) # ─── Report builder ─────────────────────────────────────────────────────────── def _build_html_report() -> str: """Build a simple HTML daily summary table.""" try: from models import get_admin_dashboard_stats stats = get_admin_dashboard_stats() except Exception as e: return f"

Error generating report: {e}

" today = datetime.date.today().strftime("%A, %d %B %Y") rows = stats.get("user_stats", []) table_rows = "" for r in rows: pct = float(r.get("pct_complete") or 0) color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f" table_rows += ( f"" f"{r['username']}" f"{r['full_name'] or ''}" f"{int(r['checked_count'] or 0)}" f"{int(r['total_sites'] or 0)}" f"{pct:.0f}%" f"" ) return f"""

Website Checker — Daily Report

{today}

{table_rows}
Username Full Name Checked Total Completion

Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.

""" def _send_report(cfg: dict): """Build and send the daily report email.""" html = _build_html_report() today = datetime.date.today().strftime("%d %b %Y") subject = f"Website Checker — Daily Report {today}" msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = cfg["smtp_user"] msg["To"] = ", ".join(cfg["recipients"]) msg.attach(MIMEText(html, "html", "utf-8")) try: if cfg["use_tls"]: server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10) server.starttls() else: server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=10) server.login(cfg["smtp_user"], cfg["smtp_password"]) server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string()) server.quit() logger.info(f"Daily report emailed to: {cfg['recipients']}") except Exception as e: logger.error(f"Failed to send daily report email: {e}") def _get_last_sent_date() -> "datetime.date | None": """Read the last-sent date from config.ini [email] last_sent_date key.""" cfg = configparser.ConfigParser() if not os.path.exists(CONFIG_FILE): return None cfg.read(CONFIG_FILE, encoding="utf-8") raw = cfg.get("email", "last_sent_date", fallback="") if not raw: return None try: return datetime.date.fromisoformat(raw) except ValueError: return None def _set_last_sent_date(d: "datetime.date"): """Persist the last-sent date to config.ini [email] last_sent_date key.""" cfg = configparser.ConfigParser() if os.path.exists(CONFIG_FILE): cfg.read(CONFIG_FILE, encoding="utf-8") if "email" not in cfg: cfg["email"] = {} cfg["email"]["last_sent_date"] = d.isoformat() with open(CONFIG_FILE, "w", encoding="utf-8") as fh: cfg.write(fh) # ─── Scheduler loop ─────────────────────────────────────────────────────────── def _scheduler_loop(): # Seed from persisted value so a restart after send_time does not re-send. last_sent_date = _get_last_sent_date() while not _stop_event.is_set(): _stop_event.wait(60) # sleep 60 seconds between checks if _stop_event.is_set(): break cfg = load_email_config() if not cfg.get("enabled") or not cfg.get("smtp_host") or not cfg.get("recipients"): continue try: send_h, send_m = map(int, cfg["send_time"].split(":")) except Exception: continue now = datetime.datetime.now() today = now.date() if (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)): if last_sent_date != today: last_sent_date = today _set_last_sent_date(today) logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.") _send_report(cfg) def start(): """Start the background scheduler thread. Call once after successful login.""" global _scheduler_thread, _stop_event # Guard against double-start: if a thread is already alive (e.g. admin # logs out and back in), stop it cleanly before spawning a new one. # Without this guard, _stop_event.clear() would unblock the sleeping # thread while a second thread also starts, resulting in two scheduler # threads firing simultaneously and potentially sending duplicate emails. if _scheduler_thread is not None and _scheduler_thread.is_alive(): logger.info("Email scheduler already running - stopping before restart.") _stop_event.set() _scheduler_thread.join(timeout=5) # Create a fresh Event so there is no residual set-state from a prior stop() _stop_event = threading.Event() _scheduler_thread = threading.Thread(target=_scheduler_loop, name="EmailScheduler", daemon=True) _scheduler_thread.start() logger.info("Email scheduler started.") def stop(): """Signal the scheduler to stop cleanly.""" global _stop_event _stop_event.set() logger.info("Email scheduler stopped.")